From 3a560f149eaff99e693b722df8ac51c81359da02 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Wed, 23 Sep 2026 18:45:30 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(resource/broker):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=8F=97=E4=BF=9D=E6=8A=A4=E5=87=AD=E8=AF=81=E4=BB=A3=E5=8A=9E?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea、webhook 与 API Key 以受保护引用替代常态明文输出;旧值先备份并校验后可恢复迁移,MCP 仅提供项目只读状态。 Rules-Applied: R01,R05,R06,R07,R10 Feature-Confirmed: true --- src-tauri/src/commands/api_keys.rs | 256 ++++- src-tauri/src/commands/git_ops.rs | 69 +- src-tauri/src/commands/gitea.rs | 1100 ++++++++++++++++++--- src-tauri/src/credential_store.rs | 674 +++++++++++++ src-tauri/src/db.rs | 28 + src-tauri/src/lib.rs | 3 + src-tauri/src/mcp_server.rs | 121 ++- src-tauri/src/mcp_tools.rs | 45 + src-tauri/src/mcp_webhook.rs | 88 +- src/components/dashboard/ApiKeysPanel.tsx | 49 +- src/lib/commands.ts | 20 +- 11 files changed, 2204 insertions(+), 249 deletions(-) create mode 100644 src-tauri/src/credential_store.rs diff --git a/src-tauri/src/commands/api_keys.rs b/src-tauri/src/commands/api_keys.rs index 4f636f2..587f290 100644 --- a/src-tauri/src/commands/api_keys.rs +++ b/src-tauri/src/commands/api_keys.rs @@ -6,7 +6,6 @@ pub struct ApiKey { pub id: String, pub name: String, pub provider: String, - pub api_key: String, pub base_url: Option, pub model: Option, pub notes: Option, @@ -29,17 +28,15 @@ fn row_to_api_key(row: &rusqlite::Row<'_>) -> rusqlite::Result { id: row.get(0)?, name: row.get(1)?, provider: row.get(2)?, - api_key: row.get(3)?, - base_url: row.get(4)?, - model: row.get(5)?, - notes: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, + base_url: row.get(3)?, + model: row.get(4)?, + notes: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, }) } -const SELECT_COLS: &str = - "id, name, provider, api_key, base_url, model, notes, created_at, updated_at"; +const SELECT_COLS: &str = "id, name, provider, base_url, model, notes, created_at, updated_at"; pub fn list_api_keys(conn: &Connection) -> Vec { let sql = format!("SELECT {SELECT_COLS} FROM api_keys ORDER BY updated_at DESC"); @@ -52,23 +49,34 @@ pub fn list_api_keys(conn: &Connection) -> Vec { .unwrap_or_default() } -pub fn create_api_key(conn: &Connection, input: &SaveApiKeyInput) -> Result { +pub fn create_api_key( + conn: &Connection, + store: &impl crate::credential_store::CredentialStore, + input: &SaveApiKeyInput, +) -> Result { let id = format!("ak-{}", uuid::Uuid::new_v4()); - conn.execute( - "INSERT INTO api_keys (id, name, provider, api_key, base_url, model, notes) - VALUES (?1,?2,?3,?4,?5,?6,?7)", - params![ - id, - input.name, - input.provider, - input.api_key, - input.base_url, - input.model, - input.notes - ], - ) - .map_err(|e| e.to_string())?; - get_api_key(conn, &id).ok_or_else(|| "创建后查询失败".into()) + let reference = crate::credential_store::versioned_reference( + crate::credential_store::LegacyCredentialKind::ApiKey, + &id, + ); + crate::credential_store::put_verified(store, &reference, input.api_key.as_bytes())?; + let result = (|| { + let tx = conn + .unchecked_transaction() + .map_err(|_| "创建 API Key 失败".to_string())?; + tx.execute( + "INSERT INTO api_keys (id, name, provider, api_key, credential_ref, base_url, model, notes) + VALUES (?1,?2,?3,'',?4,?5,?6,?7)", + params![id, input.name, input.provider, reference, input.base_url, input.model, input.notes], + ).map_err(|_| "创建 API Key 失败".to_string())?; + let key = get_api_key(&tx, &id).ok_or_else(|| "创建后查询失败".to_string())?; + tx.commit().map_err(|_| "创建 API Key 失败".to_string())?; + Ok(key) + })(); + if result.is_err() { + let _ = store.delete(&reference); + } + result } pub fn get_api_key(conn: &Connection, id: &str) -> Option { @@ -78,24 +86,63 @@ pub fn get_api_key(conn: &Connection, id: &str) -> Option { pub fn update_api_key( conn: &Connection, + store: &impl crate::credential_store::CredentialStore, id: &str, input: &SaveApiKeyInput, ) -> Result { - let rows = conn.execute( - "UPDATE api_keys SET name=?1, provider=?2, api_key=?3, base_url=?4, model=?5, notes=?6, updated_at=datetime('now') - WHERE id=?7", - params![input.name, input.provider, input.api_key, input.base_url, input.model, input.notes, id], - ) - .map_err(|e| e.to_string())?; - if rows == 0 { - return Err(format!("API Key {} 不存在", id)); + let old_reference: Option = conn + .query_row( + "SELECT credential_ref FROM api_keys WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .map_err(|_| "API Key 不存在".to_string())?; + let reference = crate::credential_store::versioned_reference( + crate::credential_store::LegacyCredentialKind::ApiKey, + id, + ); + crate::credential_store::put_verified(store, &reference, input.api_key.as_bytes())?; + let result = (|| { + let tx = conn + .unchecked_transaction() + .map_err(|_| "更新 API Key 失败".to_string())?; + let rows = tx.execute( + "UPDATE api_keys SET name=?1, provider=?2, api_key='', credential_ref=?3, base_url=?4, model=?5, notes=?6, updated_at=datetime('now') + WHERE id=?7 AND credential_ref IS ?8", + params![input.name, input.provider, reference, input.base_url, input.model, input.notes, id, old_reference], + ).map_err(|_| "更新 API Key 失败".to_string())?; + if rows != 1 { + return Err("更新 API Key 失败:凭证引用已变化".into()); + } + let key = get_api_key(&tx, id).ok_or_else(|| "更新后查询失败".to_string())?; + tx.commit().map_err(|_| "更新 API Key 失败".to_string())?; + Ok(key) + })(); + if result.is_err() { + let _ = store.delete(&reference); + } else if let Some(old) = old_reference { + let _ = store.delete(&old); } - get_api_key(conn, id).ok_or_else(|| "更新后查询失败".into()) + result } -pub fn delete_api_key_fn(conn: &Connection, id: &str) -> Result<(), String> { +pub fn delete_api_key_fn( + conn: &Connection, + store: &impl crate::credential_store::CredentialStore, + id: &str, +) -> Result<(), String> { + let reference: Option = conn + .query_row( + "SELECT credential_ref FROM api_keys WHERE id=?1", + params![id], + |row| row.get(0), + ) + .map_err(|_| "API Key 不存在".to_string())?; conn.execute("DELETE FROM api_keys WHERE id=?1", params![id]) - .map_err(|e| e.to_string())?; + .map_err(|_| "删除 API Key 失败".to_string())?; + if let Some(reference) = reference { + let _ = store.delete(&reference); + } Ok(()) } @@ -110,17 +157,148 @@ pub fn get_api_keys() -> Result, String> { #[tauri::command] pub fn add_api_key(input: SaveApiKeyInput) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - create_api_key(&conn, &input) + create_api_key( + &conn, + &crate::credential_store::WindowsCredentialStore, + &input, + ) } #[tauri::command] pub fn edit_api_key(id: String, input: SaveApiKeyInput) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - update_api_key(&conn, &id, &input) + update_api_key( + &conn, + &crate::credential_store::WindowsCredentialStore, + &id, + &input, + ) } #[tauri::command] pub fn delete_api_key(id: String) -> Result<(), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - delete_api_key_fn(&conn, &id) + delete_api_key_fn(&conn, &crate::credential_store::WindowsCredentialStore, &id) +} + +pub fn reveal_api_key_for_conn( + conn: &Connection, + store: &impl crate::credential_store::CredentialStore, + id: &str, +) -> Result { + let bytes = crate::credential_store::get_or_migrate_legacy( + conn, + store, + crate::credential_store::LegacyCredentialKind::ApiKey, + id, + )?; + String::from_utf8(bytes).map_err(|_| "凭证状态 needs_repair:API Key 编码无效".to_string()) +} + +/// 仅供本机 UI 在用户显式请求查看或复制时调用;MCP 不公开此 command。 +#[tauri::command] +pub fn reveal_api_key(id: String) -> Result { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + reveal_api_key_for_conn(&conn, &crate::credential_store::WindowsCredentialStore, &id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::credential_store::CredentialStore; + + #[test] + fn list_hides_key_and_explicit_reveal_requires_existing_id() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('k1', 'key', 'test', 'secret')", []).unwrap(); + let listed = list_api_keys(&conn); + assert!(serde_json::to_string(&listed).unwrap().contains("secret") == false); + assert_eq!( + reveal_api_key_for_conn(&conn, &store, "k1").unwrap(), + "secret" + ); + assert!(reveal_api_key_for_conn(&conn, &store, "missing").is_err()); + let (legacy, reference): (String, String) = conn + .query_row( + "SELECT api_key, credential_ref FROM api_keys WHERE id='k1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert!(legacy.is_empty()); + assert!(store.get(&reference).unwrap().is_some()); + } + + #[test] + fn api_key_write_failure_preserves_legacy_and_metadata() { + let conn = crate::db::conn_with_schema(); + conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('k1', 'old', 'test', 'synthetic-old-key')", []).unwrap(); + let failing = crate::credential_store::MemoryCredentialStore::failing(); + let input = SaveApiKeyInput { + name: "new".into(), + provider: "test".into(), + api_key: "synthetic-new-key".into(), + base_url: None, + model: None, + notes: None, + }; + let error = update_api_key(&conn, &failing, "k1", &input).unwrap_err(); + assert!(error.contains("needs_repair")); + assert!(!error.contains("synthetic-new-key")); + let (name, old, reference): (String, String, Option) = conn + .query_row( + "SELECT name, api_key, credential_ref FROM api_keys WHERE id='k1'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(name, "old"); + assert_eq!(old, "synthetic-old-key"); + assert!(reference.is_none()); + + let store = crate::credential_store::MemoryCredentialStore::new(); + update_api_key(&conn, &store, "k1", &input).unwrap(); + let listed = list_api_keys(&conn); + assert!(!serde_json::to_string(&listed) + .unwrap() + .contains("synthetic-new-key")); + assert_eq!( + reveal_api_key_for_conn(&conn, &store, "k1").unwrap(), + "synthetic-new-key" + ); + } + + #[test] + fn new_api_key_keeps_only_reference_in_database_and_delete_cleans_store() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let input = SaveApiKeyInput { + name: "test".into(), + provider: "test".into(), + api_key: "synthetic-new-key".into(), + base_url: None, + model: None, + notes: None, + }; + let key = create_api_key(&conn, &store, &input).unwrap(); + let (legacy, reference): (String, String) = conn + .query_row( + "SELECT api_key, credential_ref FROM api_keys WHERE id=?1", + params![key.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert!(legacy.is_empty()); + assert_eq!( + store.get(&reference).unwrap().as_deref(), + Some(b"synthetic-new-key".as_slice()) + ); + assert_eq!( + reveal_api_key_for_conn(&conn, &store, &key.id).unwrap(), + "synthetic-new-key" + ); + delete_api_key_fn(&conn, &store, &key.id).unwrap(); + assert!(store.get(&reference).unwrap().is_none()); + } } diff --git a/src-tauri/src/commands/git_ops.rs b/src-tauri/src/commands/git_ops.rs index 6979962..bdb3764 100644 --- a/src-tauri/src/commands/git_ops.rs +++ b/src-tauri/src/commands/git_ops.rs @@ -15,22 +15,69 @@ pub struct GitStatus { /// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token fn gitea_token_for_url(url: &str) -> Option { let conn = db::pool().get().ok()?; - let mut stmt = conn - .prepare("SELECT url, access_token FROM gitea_instances") - .ok()?; - let rows = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) - .ok()?; - for row in rows.flatten() { - let (base, token) = row; - let base = base.trim_end_matches('/'); - if !base.is_empty() && url.starts_with(base) { - return Some(token); + let instances: Vec<(String, String)> = { + let mut stmt = conn.prepare("SELECT id, url FROM gitea_instances").ok()?; + let rows = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .ok()? + .filter_map(Result::ok) + .collect(); + rows + }; + for (instance_id, base) in instances { + if gitea_url_matches(&base, url) { + return crate::commands::gitea::instance_auth(&conn, &instance_id) + .ok() + .map(|(_, token)| token); } } None } +fn gitea_url_matches(base: &str, remote: &str) -> bool { + let (Ok(base), Ok(remote)) = (tauri::Url::parse(base), tauri::Url::parse(remote)) else { + return false; + }; + if !matches!(base.scheme(), "http" | "https") + || base.scheme() != remote.scheme() + || base.host_str() != remote.host_str() + || base.port_or_known_default() != remote.port_or_known_default() + { + return false; + } + let prefix = base.path().trim_end_matches('/'); + prefix.is_empty() || remote.path() == prefix || remote.path().starts_with(&format!("{prefix}/")) +} + +#[cfg(test)] +mod credential_match_tests { + use super::gitea_url_matches; + + #[test] + fn gitea_token_url_match_requires_same_origin_and_path_boundary() { + assert!(gitea_url_matches( + "https://gitea.example.com", + "https://gitea.example.com/o/r.git" + )); + assert!(gitea_url_matches( + "https://gitea.example.com/git", + "https://gitea.example.com/git/o/r.git" + )); + assert!(!gitea_url_matches( + "https://gitea.example.com", + "https://gitea.example.com.evil/o/r.git" + )); + assert!(!gitea_url_matches( + "https://gitea.example.com/git", + "https://gitea.example.com/github/o/r.git" + )); + assert!(!gitea_url_matches( + "https://gitea.example.com", + "http://gitea.example.com/o/r.git" + )); + } +} + /// 凭据链:ssh agent → git credential helper(与 CLI 同一凭据源)→ Gitea 实例 token → default /// 不再依赖 GitHub 登录,任何 remote(Gitea / GitHub / 其他)走系统已有凭据 fn make_callbacks() -> RemoteCallbacks<'static> { diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 21ade35..3ebf195 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -2,7 +2,7 @@ //! //! 观测面(webhook 接收端)在 mcp_server.rs 实现。 -use rusqlite::params; +use rusqlite::{params, OptionalExtension}; use serde::{Deserialize, Serialize}; // ── 数据结构 ────────────────────────────────────────────────────────────────── @@ -12,7 +12,6 @@ pub struct GiteaInstance { pub id: String, pub name: String, pub url: String, - pub access_token: String, pub is_default: bool, pub created_at: Option, } @@ -33,7 +32,6 @@ pub struct GiteaRepo { pub gitea_owner: String, pub gitea_repo_name: String, pub clone_url: Option, - pub webhook_secret: Option, pub webhook_id: Option, pub linked_at: Option, } @@ -52,9 +50,8 @@ fn row_to_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result { id: row.get(0)?, name: row.get(1)?, url: row.get(2)?, - access_token: row.get(3)?, - is_default: row.get::<_, i64>(4)? != 0, - created_at: row.get(5)?, + is_default: row.get::<_, i64>(3)? != 0, + created_at: row.get(4)?, }) } @@ -66,9 +63,8 @@ fn row_to_repo(row: &rusqlite::Row<'_>) -> rusqlite::Result { gitea_owner: row.get(3)?, gitea_repo_name: row.get(4)?, clone_url: row.get(5)?, - webhook_secret: row.get(6)?, - webhook_id: row.get(7)?, - linked_at: row.get(8)?, + webhook_id: row.get(6)?, + linked_at: row.get(7)?, }) } @@ -77,33 +73,167 @@ fn normalize_url(url: &str) -> String { url.trim_end_matches('/').to_string() } +#[derive(Debug, Serialize)] +pub struct ResourceReadiness { + pub state: &'static str, + pub source: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct ProjectResourceStatus { + pub project_id: String, + pub gitea_pr: ResourceReadiness, + pub gitea_webhook: ResourceReadiness, +} + +fn credential_readiness( + store: &impl crate::credential_store::CredentialStore, + reference: Option, + has_legacy: bool, +) -> ResourceReadiness { + if let Some(reference) = reference.filter(|reference| !reference.is_empty()) { + return match store.get(&reference) { + Ok(Some(value)) if !value.is_empty() => ResourceReadiness { + state: "configured", + source: "protected_store", + }, + Ok(_) => ResourceReadiness { + state: "needs_repair", + source: "protected_store", + }, + Err(_) => ResourceReadiness { + state: "unavailable", + source: "protected_store", + }, + }; + } + if has_legacy { + match store.get("resource-readiness/probe") { + Ok(_) => ResourceReadiness { + state: "configured", + source: "legacy_pending_migration", + }, + Err(_) => ResourceReadiness { + state: "unavailable", + source: "protected_store", + }, + } + } else { + ResourceReadiness { + state: "not_configured", + source: "gitea_binding", + } + } +} + +pub fn project_resource_status_for_conn( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, +) -> Result { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = ?1)", + params![project_id], + |row| row.get(0), + ) + .map_err(|_| "项目状态不可用".to_string())?; + if !exists { + return Err("项目不存在".into()); + } + let binding: Option<(Option, bool, Option, bool)> = conn.query_row( + "SELECT gi.access_token_ref, length(gi.access_token) > 0, gr.webhook_secret_ref, coalesce(length(gr.webhook_secret), 0) > 0 + FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id WHERE gr.project_id = ?1", + params![project_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ).optional().map_err(|_| "项目资源状态不可用".to_string())?; + let (gitea_pr, gitea_webhook) = match binding { + Some((token_ref, token_legacy, webhook_ref, webhook_legacy)) => ( + credential_readiness(store, token_ref, token_legacy), + credential_readiness(store, webhook_ref, webhook_legacy), + ), + None => ( + ResourceReadiness { + state: "not_configured", + source: "gitea_binding", + }, + ResourceReadiness { + state: "not_configured", + source: "gitea_binding", + }, + ), + }; + Ok(ProjectResourceStatus { + project_id: project_id.into(), + gitea_pr, + gitea_webhook, + }) +} + +#[tauri::command] +pub fn get_project_resource_status(project_id: String) -> Result { + let conn = crate::db::pool() + .get() + .map_err(|_| "项目资源状态不可用".to_string())?; + project_resource_status_for_conn( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + ) +} + +fn save_gitea_instance_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + input: SaveGiteaInstanceInput, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let url = normalize_url(&input.url); + let is_default = input.is_default.unwrap_or(false); + let reference = crate::credential_store::versioned_reference( + crate::credential_store::LegacyCredentialKind::GiteaAccessToken, + &id, + ); + crate::credential_store::put_verified(store, &reference, input.access_token.as_bytes())?; + + let result = (|| { + let tx = conn + .unchecked_transaction() + .map_err(|_| "保存 Gitea 实例失败".to_string())?; + if is_default { + tx.execute("UPDATE gitea_instances SET is_default = 0", []) + .map_err(|_| "保存 Gitea 实例失败".to_string())?; + } + tx.execute( + "INSERT INTO gitea_instances (id, name, url, access_token, is_default, access_token_ref) + VALUES (?1, ?2, ?3, '', ?4, ?5)", + params![id, input.name, url, is_default as i64, reference], + ) + .map_err(|_| "保存 Gitea 实例失败".to_string())?; + let instance = tx + .query_row( + "SELECT id, name, url, is_default, created_at FROM gitea_instances WHERE id = ?1", + params![id], + row_to_instance, + ) + .map_err(|_| "保存 Gitea 实例后读取失败".to_string())?; + tx.commit().map_err(|_| "保存 Gitea 实例失败".to_string())?; + Ok(instance) + })(); + if result.is_err() { + let _ = store.delete(&reference); + } + result +} + // ── 实例管理命令 ────────────────────────────────────────────────────────────── /// 保存 Gitea 实例(新增或覆盖同名实例) #[tauri::command] pub fn gitea_save_instance(input: SaveGiteaInstanceInput) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - let id = uuid::Uuid::new_v4().to_string(); - let url = normalize_url(&input.url); - let is_default = input.is_default.unwrap_or(false) as i64; - - // 若设为默认,先取消其他默认 - if is_default != 0 { - let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []); - } - - conn.execute( - "INSERT INTO gitea_instances (id, name, url, access_token, is_default) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![id, input.name, url, input.access_token, is_default], - ) - .map_err(|e| e.to_string())?; - - conn.query_row( - "SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1", - params![id], - row_to_instance, - ).map_err(|e| e.to_string()) + let store = crate::credential_store::WindowsCredentialStore; + save_gitea_instance_with_store(&conn, &store, input) } /// 列出所有 Gitea 实例 @@ -111,7 +241,7 @@ pub fn gitea_save_instance(input: SaveGiteaInstanceInput) -> Result Result, String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( - "SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances ORDER BY is_default DESC, created_at ASC" + "SELECT id, name, url, is_default, created_at FROM gitea_instances ORDER BY is_default DESC, created_at ASC" ).map_err(|e| e.to_string())?; let rows = stmt .query_map([], row_to_instance) @@ -125,8 +255,33 @@ pub fn gitea_list_instances() -> Result, String> { #[tauri::command] pub fn gitea_delete_instance(id: String) -> Result<(), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + delete_gitea_instance_with_store(&conn, &crate::credential_store::WindowsCredentialStore, &id) +} + +fn delete_gitea_instance_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + id: &str, +) -> Result<(), String> { + let instance_reference: Option = conn + .query_row( + "SELECT access_token_ref FROM gitea_instances WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .map_err(|_| "Gitea 实例不存在".to_string())?; + let repo_references: Vec = conn + .prepare("SELECT webhook_secret_ref FROM gitea_repos WHERE instance_id = ?1 AND webhook_secret_ref IS NOT NULL") + .map_err(|_| "删除 Gitea 实例失败".to_string())? + .query_map(params![id], |row| row.get(0)) + .map_err(|_| "删除 Gitea 实例失败".to_string())? + .collect::>() + .map_err(|_| "删除 Gitea 实例失败".to_string())?; conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id]) - .map_err(|e| e.to_string())?; + .map_err(|_| "删除 Gitea 实例失败".to_string())?; + for reference in instance_reference.into_iter().chain(repo_references) { + let _ = store.delete(&reference); + } Ok(()) } @@ -140,58 +295,107 @@ pub fn gitea_update_instance( is_default: Option, ) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + update_gitea_instance_with_store( + &conn, + &crate::credential_store::WindowsCredentialStore, + &id, + name, + url, + access_token, + is_default, + ) +} - if let Some(ref _n) = name { - conn.execute( - "UPDATE gitea_instances SET name = ?1 WHERE id = ?2", - params![_n, id], +fn update_gitea_instance_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + id: &str, + name: Option, + url: Option, + access_token: Option, + is_default: Option, +) -> Result { + let old_reference: Option = conn + .query_row( + "SELECT access_token_ref FROM gitea_instances WHERE id = ?1", + params![id], + |row| row.get(0), ) - .map_err(|e| e.to_string())?; - } - if let Some(ref u) = url { - let u = normalize_url(u); - conn.execute( - "UPDATE gitea_instances SET url = ?1 WHERE id = ?2", - params![u, id], + .map_err(|_| "Gitea 实例不存在".to_string())?; + let new_reference = access_token.as_ref().map(|_| { + crate::credential_store::versioned_reference( + crate::credential_store::LegacyCredentialKind::GiteaAccessToken, + id, ) - .map_err(|e| e.to_string())?; + }); + if let (Some(token), Some(reference)) = (&access_token, &new_reference) { + crate::credential_store::put_verified(store, reference, token.as_bytes())?; } - if let Some(ref t) = access_token { - conn.execute( - "UPDATE gitea_instances SET access_token = ?1 WHERE id = ?2", - params![t, id], - ) - .map_err(|e| e.to_string())?; - } - if let Some(def) = is_default { - if def { - let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []); + + let result = (|| { + let tx = conn + .unchecked_transaction() + .map_err(|_| "更新 Gitea 实例失败".to_string())?; + if let Some(ref n) = name { + tx.execute( + "UPDATE gitea_instances SET name = ?1 WHERE id = ?2", + params![n, id], + ) + .map_err(|_| "更新 Gitea 实例失败".to_string())?; } - conn.execute( - "UPDATE gitea_instances SET is_default = ?1 WHERE id = ?2", - params![def as i64, id], - ) - .map_err(|e| e.to_string())?; + if let Some(ref u) = url { + tx.execute( + "UPDATE gitea_instances SET url = ?1 WHERE id = ?2", + params![normalize_url(u), id], + ) + .map_err(|_| "更新 Gitea 实例失败".to_string())?; + } + if let Some(ref reference) = new_reference { + let changed = tx.execute( + "UPDATE gitea_instances SET access_token = '', access_token_ref = ?1 WHERE id = ?2 AND access_token_ref IS ?3", + params![reference, id, old_reference], + ) + .map_err(|_| "更新 Gitea 实例失败".to_string())?; + if changed != 1 { + return Err("更新 Gitea 实例失败:凭证引用已变化".to_string()); + } + } + if let Some(def) = is_default { + if def { + tx.execute("UPDATE gitea_instances SET is_default = 0", []) + .map_err(|_| "更新 Gitea 实例失败".to_string())?; + } + tx.execute( + "UPDATE gitea_instances SET is_default = ?1 WHERE id = ?2", + params![def as i64, id], + ) + .map_err(|_| "更新 Gitea 实例失败".to_string())?; + } + let instance = tx + .query_row( + "SELECT id, name, url, is_default, created_at FROM gitea_instances WHERE id = ?1", + params![id], + row_to_instance, + ) + .map_err(|_| "Gitea 实例不存在".to_string())?; + tx.commit().map_err(|_| "更新 Gitea 实例失败".to_string())?; + Ok(instance) + })(); + if result.is_err() { + if let Some(reference) = &new_reference { + let _ = store.delete(reference); + } + } else if let Some(old) = old_reference.filter(|_| new_reference.is_some()) { + let _ = store.delete(&old); } - - conn.query_row( - "SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1", - params![id], - row_to_instance, - ).map_err(|e| e.to_string()) + result } /// 测试 Gitea 连接:调 GET /api/v1/user,返回用户信息验证 token 有效性 #[tauri::command] pub fn gitea_test_connection(instance_id: String) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - let (url, token): (String, String) = conn - .query_row( - "SELECT url, access_token FROM gitea_instances WHERE id = ?1", - params![instance_id], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .map_err(|_| "实例不存在".to_string())?; + let (url, token) = instance_auth(&conn, &instance_id)?; gitea_api_get_user(&url, &token) } @@ -199,21 +403,43 @@ pub fn gitea_test_connection(instance_id: String) -> Result { // ── Gitea API 辅助 ──────────────────────────────────────────────────────────── /// 读取实例的 (url, token) -fn instance_auth( +pub(crate) fn instance_auth( conn: &rusqlite::Connection, instance_id: &str, ) -> Result<(String, String), String> { - conn.query_row( - "SELECT url, access_token FROM gitea_instances WHERE id = ?1", - params![instance_id], - |r| Ok((r.get(0)?, r.get(1)?)), + instance_auth_with_store( + conn, + &crate::credential_store::WindowsCredentialStore, + instance_id, ) - .map_err(|_| "Gitea 实例不存在".to_string()) +} + +fn instance_auth_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + instance_id: &str, +) -> Result<(String, String), String> { + let url: String = conn + .query_row( + "SELECT url FROM gitea_instances WHERE id = ?1", + params![instance_id], + |r| r.get(0), + ) + .map_err(|_| "Gitea 实例不存在".to_string())?; + let token = crate::credential_store::get_or_migrate_legacy( + conn, + store, + crate::credential_store::LegacyCredentialKind::GiteaAccessToken, + instance_id, + )?; + let token = String::from_utf8(token) + .map_err(|_| "凭证状态 needs_repair:Gitea token 编码无效".to_string())?; + Ok((url, token)) } /// 把 ureq 错误转成可读信息(尽量提取 Gitea 返回的 message 字段) -fn api_err(prefix: &str, e: ureq::Error) -> String { - match e { +fn api_err(prefix: &str, e: ureq::Error, token: &str) -> String { + let detail = match e { ureq::Error::Status(code, resp) => { let msg = resp .into_json::() @@ -227,6 +453,11 @@ fn api_err(prefix: &str, e: ureq::Error) -> String { } } other => format!("{prefix}: {other}"), + }; + if token.is_empty() { + detail + } else { + detail.replace(token, "[redacted]") } } @@ -268,7 +499,7 @@ fn gitea_get_json( ureq::get(&format!("{base_url}{path}")) .set("Authorization", &format!("token {token}")) .call() - .map_err(|e| api_err(label, e))? + .map_err(|e| api_err(label, e, token))? .into_json() .map_err(|e| format!("{label}: 解析响应失败: {e}")) } @@ -613,6 +844,20 @@ pub fn get_production_context_signals( conn: &rusqlite::Connection, project_id: &str, contexts: &[String], +) -> Result<(String, std::collections::BTreeMap), DeliverySignal> { + get_production_context_signals_with_store( + conn, + &crate::credential_store::WindowsCredentialStore, + project_id, + contexts, + ) +} + +fn get_production_context_signals_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, + contexts: &[String], ) -> Result<(String, std::collections::BTreeMap), DeliverySignal> { let binding: rusqlite::Result<(String, String, String)> = conn.query_row( "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", @@ -631,9 +876,10 @@ pub fn get_production_context_signals( serde_json::json!({}), ) })?; - let (base_url, token) = instance_auth(conn, &instance_id).map_err(|error| { - DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) - })?; + let (base_url, token) = + instance_auth_with_store(conn, store, &instance_id).map_err(|error| { + DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) + })?; let agent = ureq::builder() .timeout(std::time::Duration::from_secs(5)) .build(); @@ -642,7 +888,7 @@ pub fn get_production_context_signals( .get(&format!("{base_url}{path}")) .set("Authorization", &format!("token {token}")) .call() - .map_err(|error| api_err(label, error)) + .map_err(|error| api_err(label, error, &token)) .and_then(|response| { response .into_json::() @@ -774,24 +1020,41 @@ fn upsert_repo_link( repo_name: &str, clone_url: Option<&str>, ) -> Result { + let old_reference: Option = conn + .query_row( + "SELECT webhook_secret_ref FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |row| row.get(0), + ) + .optional() + .map_err(|_| "读取旧 webhook 凭证引用失败".to_string())? + .flatten(); let id = uuid::Uuid::new_v4().to_string(); conn.execute( "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(project_id) DO UPDATE SET instance_id = ?2, gitea_owner = ?4, gitea_repo_name = ?5, clone_url = ?6, - webhook_secret = NULL, webhook_id = NULL", + webhook_secret = NULL, webhook_secret_ref = NULL, webhook_id = NULL", params![id, instance_id, project_id, owner, repo_name, clone_url], ).map_err(|e| e.to_string())?; - conn.query_row( - "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, - clone_url, webhook_secret, webhook_id, linked_at + let repo = conn + .query_row( + "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, + clone_url, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", - params![project_id], - row_to_repo, - ) - .map_err(|e| e.to_string()) + params![project_id], + row_to_repo, + ) + .map_err(|e| e.to_string())?; + if let Some(reference) = old_reference { + let _ = crate::credential_store::CredentialStore::delete( + &crate::credential_store::WindowsCredentialStore, + &reference, + ); + } + Ok(repo) } /// GET /api/v1/user — 验证 token 并返回用户信息 @@ -828,7 +1091,7 @@ pub fn gitea_api_get_repo( let resp = ureq::get(&url) .set("Authorization", &format!("token {token}")) .call() - .map_err(|e| api_err("获取仓库信息失败", e))?; + .map_err(|e| api_err("获取仓库信息失败", e, token))?; resp.into_json().map_err(|e| format!("解析失败: {e}")) } @@ -854,7 +1117,7 @@ pub fn gitea_create_repo( "private": private.unwrap_or(true), "auto_init": false, })) - .map_err(|e| api_err("创建仓库失败", e))?; + .map_err(|e| api_err("创建仓库失败", e, &token))?; let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let owner = json["owner"]["login"] @@ -900,7 +1163,7 @@ pub fn gitea_migrate_repo( let resp = ureq::post(&format!("{base_url}/api/v1/repos/migrate")) .set("Authorization", &format!("token {token}")) .send_json(body) - .map_err(|e| api_err("迁移仓库失败", e))?; + .map_err(|e| api_err("迁移仓库失败", e, &token))?; let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let owner = json["owner"]["login"] @@ -947,30 +1210,34 @@ pub fn gitea_link_repo( /// 为项目绑定的仓库注册 push webhook。 /// callback_base 形如 http://192.168.1.5:27190(Gitea 服务器视角可达的宿主机地址)。 -/// 已有 webhook 时先删旧再建新(幂等重设)。 +/// 先建新 hook 并持久化凭证,再清理旧 hook;失败时旧 hook 保持可用。 #[tauri::command] pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + setup_webhook_with_store( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + &callback_base, + ) +} + +fn setup_webhook_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, + callback_base: &str, +) -> Result { let link = conn .query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, - clone_url, webhook_secret, webhook_id, linked_at + clone_url, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, ) .map_err(|_| "项目尚未绑定 Gitea 仓库".to_string())?; - let (base_url, token) = instance_auth(&conn, &link.instance_id)?; - - // 旧 hook 存在则先删(失败不阻断,可能已在 Gitea 侧被手动删除) - if let Some(old_id) = link.webhook_id { - let _ = ureq::delete(&format!( - "{base_url}/api/v1/repos/{}/{}/hooks/{old_id}", - link.gitea_owner, link.gitea_repo_name - )) - .set("Authorization", &format!("token {token}")) - .call(); - } + let (base_url, token) = instance_auth_with_store(conn, store, &link.instance_id)?; let secret = format!( "{}{}", @@ -982,8 +1249,13 @@ pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result< callback_base.trim_end_matches('/'), project_id ); + let reference = crate::credential_store::versioned_reference( + crate::credential_store::LegacyCredentialKind::GiteaWebhookSecret, + &project_id, + ); + crate::credential_store::put_verified(store, &reference, secret.as_bytes())?; - let resp = ureq::post(&format!( + let create_result = ureq::post(&format!( "{base_url}/api/v1/repos/{}/{}/hooks", link.gitea_owner, link.gitea_repo_name )) @@ -1000,20 +1272,50 @@ pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result< "http_method": "post", }, })) - .map_err(|e| api_err("注册 webhook 失败", e))?; - - let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; - let hook_id = json["id"].as_i64().ok_or("响应缺少 hook id")?; - - conn.execute( - "UPDATE gitea_repos SET webhook_secret = ?1, webhook_id = ?2 WHERE project_id = ?3", - params![secret, hook_id, project_id], - ) - .map_err(|e| e.to_string())?; + .map_err(|e| api_err("注册 webhook 失败", e, &token).replace(&secret, "[redacted]")); + let resp = match create_result { + Ok(resp) => resp, + Err(error) => { + let _ = store.delete(&reference); + return Err(error); + } + }; + let json: serde_json::Value = match resp.into_json() { + Ok(json) => json, + Err(_) => { + let _ = store.delete(&reference); + return Err("webhook 状态 needs_repair:远端已接收注册,但响应无法解析;旧 webhook 保持不变,请检查远端重复 hook".into()); + } + }; + let hook_id = match json["id"].as_i64() { + Some(id) => id, + None => { + let _ = store.delete(&reference); + return Err("webhook 状态 needs_repair:远端未返回 hook id;旧 webhook 保持不变,请检查远端重复 hook".into()); + } + }; + if let Err(error) = persist_webhook_secret(conn, store, project_id, &reference, hook_id) { + let _ = ureq::delete(&format!( + "{base_url}/api/v1/repos/{}/{}/hooks/{hook_id}", + link.gitea_owner, link.gitea_repo_name + )) + .set("Authorization", &format!("token {token}")) + .call(); + let _ = store.delete(&reference); + return Err(error); + } + if let Some(old_id) = link.webhook_id { + let _ = ureq::delete(&format!( + "{base_url}/api/v1/repos/{}/{}/hooks/{old_id}", + link.gitea_owner, link.gitea_repo_name + )) + .set("Authorization", &format!("token {token}")) + .call(); + } conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, - clone_url, webhook_secret, webhook_id, linked_at + clone_url, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, @@ -1021,13 +1323,49 @@ pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result< .map_err(|e| e.to_string()) } +fn persist_webhook_secret( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, + reference: &str, + hook_id: i64, +) -> Result<(), String> { + let old_reference: Option = conn + .query_row( + "SELECT webhook_secret_ref FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |row| row.get(0), + ) + .map_err(|_| "webhook 绑定不存在".to_string())?; + store + .get(reference) + .map_err(|_| "凭证状态 needs_repair:受保护存储不可用".to_string())? + .ok_or_else(|| "凭证状态 needs_repair:新 webhook 凭证不存在".to_string())?; + let tx = conn + .unchecked_transaction() + .map_err(|_| "webhook 状态 needs_repair:数据库切换失败".to_string())?; + let changed = tx.execute( + "UPDATE gitea_repos SET webhook_secret = NULL, webhook_secret_ref = ?1, webhook_id = ?2 WHERE project_id = ?3 AND webhook_secret_ref IS ?4", + params![reference, hook_id, project_id, old_reference], + ).map_err(|_| "webhook 状态 needs_repair:数据库切换失败".to_string())?; + if changed != 1 { + return Err("webhook 状态 needs_repair:绑定已变化".into()); + } + tx.commit() + .map_err(|_| "webhook 状态 needs_repair:数据库切换失败".to_string())?; + if let Some(old) = old_reference { + let _ = store.delete(&old); + } + Ok(()) +} + /// 解绑项目与 Gitea 仓库(顺带删除 Gitea 侧 webhook,失败不阻断) #[tauri::command] pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let link = match conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, - clone_url, webhook_secret, webhook_id, linked_at + clone_url, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, @@ -1036,6 +1374,13 @@ pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(()), Err(e) => return Err(e.to_string()), }; + let old_reference: Option = conn + .query_row( + "SELECT webhook_secret_ref FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |row| row.get(0), + ) + .map_err(|_| "读取 webhook 凭证引用失败".to_string())?; if let Some(hook_id) = link.webhook_id { if let Ok((base_url, token)) = instance_auth(&conn, &link.instance_id) { @@ -1053,6 +1398,12 @@ pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { params![project_id], ) .map_err(|e| e.to_string())?; + if let Some(reference) = old_reference { + let _ = crate::credential_store::CredentialStore::delete( + &crate::credential_store::WindowsCredentialStore, + &reference, + ); + } Ok(()) } @@ -1081,24 +1432,24 @@ pub fn parse_gitea_commits(json: &serde_json::Value) -> Vec<(String, String, Opt /// 返回 (轮询仓库数, 新写入 commit 数)。纯出站 HTTPS,适配公网 Gitea + 内网炼境拓扑。 pub fn poll_all_repos() -> Result<(usize, usize), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - let repos: Vec<(String, String, String, String, String)> = - { - let mut stmt = conn.prepare( - "SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.url, gi.access_token + let repos: Vec<(String, String, String, String)> = { + let mut stmt = conn + .prepare( + "SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.id FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id", - ).map_err(|e| e.to_string())?; - let rows = stmt - .query_map([], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) - }) - .map_err(|e| e.to_string())?; - rows.collect::>>() - .map_err(|e| e.to_string())? - }; + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .map_err(|e| e.to_string())?; + rows.collect::>>() + .map_err(|e| e.to_string())? + }; let mut ingested = 0usize; let mut polled = 0usize; - for (project_id, owner, repo, base_url, token) in &repos { + for (project_id, owner, repo, instance_id) in &repos { + let (base_url, token) = instance_auth(&conn, instance_id)?; let url = format!( "{base_url}/api/v1/repos/{owner}/{repo}/commits?limit=50&stat=false&verification=false" ); @@ -1111,7 +1462,10 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> { crate::db::log_event( "gitea_poll", "warn", - &format!("轮询 {owner}/{repo} 失败: {}", api_err("拉取 commits", e)), + &format!( + "轮询 {owner}/{repo} 失败: {}", + api_err("拉取 commits", e, &token) + ), None, ); continue; @@ -1160,8 +1514,9 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> { // PR 状态轮询:弥补公网 Gitea + 内网炼境时 webhook 不可达的场景 let mut pr_ingested = 0usize; - for (project_id, owner, repo, base_url, token) in &repos { - pr_ingested += poll_pr_events(&conn, project_id, owner, repo, base_url, token); + for (project_id, owner, repo, instance_id) in &repos { + let (base_url, token) = instance_auth(&conn, instance_id)?; + pr_ingested += poll_pr_events(&conn, project_id, owner, repo, &base_url, &token); } if pr_ingested > 0 { crate::db::log_event( @@ -1268,7 +1623,7 @@ pub fn gitea_list_linked_repos() -> Result, String> { .prepare( "SELECT gr.id, gr.instance_id, gi.name as instance_name, gi.url as instance_url, gr.project_id, gr.gitea_owner, gr.gitea_repo_name, - gr.clone_url, gr.webhook_secret, gr.webhook_id, gr.linked_at + gr.clone_url, (gr.webhook_secret IS NOT NULL OR gr.webhook_secret_ref IS NOT NULL), gr.webhook_id, gr.linked_at FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id ORDER BY gr.linked_at DESC", @@ -1286,7 +1641,7 @@ pub fn gitea_list_linked_repos() -> Result, String> { "gitea_owner": row.get::<_, String>(5)?, "gitea_repo_name": row.get::<_, String>(6)?, "clone_url": row.get::<_, Option>(7)?, - "has_webhook": row.get::<_, Option>(8)?.is_some(), + "has_webhook": row.get::<_, bool>(8)?, "webhook_id": row.get::<_, Option>(9)?, "linked_at": row.get::<_, Option>(10)?, })) @@ -1308,14 +1663,33 @@ pub fn gitea_api_create_pull_request( body: &str, ) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + create_pull_request_with_store( + &conn, + &crate::credential_store::WindowsCredentialStore, + project_id, + head, + base, + title, + body, + ) +} +fn create_pull_request_with_store( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, + head: &str, + base: &str, + title: &str, + body: &str, +) -> Result { let (instance_id, owner, repo_name): (String, String, String) = conn.query_row( "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", params![project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?; - let (base_url, token) = instance_auth(&conn, &instance_id)?; + let (base_url, token) = instance_auth_with_store(conn, store, &instance_id)?; let create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls"); let (json, already_exists) = match ureq::post(&create_url) @@ -1335,7 +1709,7 @@ pub fn gitea_api_create_pull_request( let prs: serde_json::Value = ureq::get(&list_url) .set("Authorization", &format!("token {token}")) .call() - .map_err(|e| api_err("获取已有 PR 列表失败", e))? + .map_err(|e| api_err("获取已有 PR 列表失败", e, &token))? .into_json() .map_err(|e| format!("解析 PR 列表失败: {e}"))?; let arr = prs.as_array().ok_or("PR 列表格式错误")?; @@ -1352,7 +1726,7 @@ pub fn gitea_api_create_pull_request( .ok_or_else(|| format!("PR 已存在但找不到(head={head})"))?; (existing, true) } - Err(e) => return Err(api_err("创建 PR 失败", e)), + Err(e) => return Err(api_err("创建 PR 失败", e, &token)), }; let pr_number = json["number"].as_u64().unwrap_or(0); @@ -1376,7 +1750,7 @@ pub fn gitea_get_repo_link(project_id: String) -> Result, Stri let conn = crate::db::pool().get().map_err(|e| e.to_string())?; match conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, - clone_url, webhook_secret, webhook_id, linked_at + clone_url, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, @@ -1406,7 +1780,7 @@ pub fn gitea_api_get_ci_status(project_id: &str, commit_sha: &str) -> Result return Err(api_err("创建仓库失败", e)), + Err(e) => return Err(api_err("创建仓库失败", e, &token)), }; // 写入炼境 DB 绑定关系 @@ -1757,6 +2132,7 @@ fn configure_git_remote(win_path: Option<&str>, clone_url: &str) -> Result, String, i64) = conn.query_row("SELECT webhook_secret, webhook_secret_ref, webhook_id FROM gitea_repos WHERE project_id='p'", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap(); + assert!(legacy.is_none()); + assert_eq!(saved_reference, reference); + assert_eq!(hook_id, 2); + } + + #[test] + fn webhook_setup_registers_mock_remote_and_stores_signature_secret() { + use std::io::{BufRead, BufReader, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.starts_with("POST /api/v1/repos/owner/repo/hooks ")); + let mut content_length = 0; + let mut authorized = false; + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap(); + } + if line.eq_ignore_ascii_case("authorization: token synthetic-hook-token\r\n") { + authorized = true; + } + } + assert!(authorized); + let mut body = vec![0; content_length]; + reader.read_exact(&mut body).unwrap(); + let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + request["config"]["url"], + "http://127.0.0.1:12345/webhook/gitea/p" + ); + sender + .send(request["config"]["secret"].as_str().unwrap().to_string()) + .unwrap(); + let response = r#"{"id":9}"#; + write!(stream, "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap(); + }); + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let instance = save_gitea_instance_with_store( + &conn, + &store, + SaveGiteaInstanceInput { + name: "test".into(), + url: format!("http://{address}"), + access_token: "synthetic-hook-token".into(), + is_default: None, + }, + ) + .unwrap(); + conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r', ?1, 'p', 'owner', 'repo')", params![instance.id]).unwrap(); + let repo = setup_webhook_with_store(&conn, &store, "p", "http://127.0.0.1:12345").unwrap(); + server.join().unwrap(); + assert_eq!(repo.webhook_id, Some(9)); + let sent_secret = receiver.recv().unwrap(); + let (legacy, reference): (Option, String) = conn + .query_row( + "SELECT webhook_secret, webhook_secret_ref FROM gitea_repos WHERE project_id='p'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert!(legacy.is_none()); + assert_eq!( + store.get(&reference).unwrap().as_deref(), + Some(sent_secret.as_bytes()) + ); + assert!(!serde_json::to_string(&repo).unwrap().contains(&sent_secret)); + } + + #[test] + fn project_resource_status_reports_only_scoped_non_secret_readiness() { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('profile', 'test')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES ('p1', 'profile')", + [], + ) + .unwrap(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let empty = project_resource_status_for_conn(&conn, &store, "p1").unwrap(); + assert_eq!(empty.gitea_pr.state, "not_configured"); + assert!(project_resource_status_for_conn(&conn, &store, "p2").is_err()); + + conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://secret-host.invalid', 'synthetic-token')", []).unwrap(); + conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p1', 'sensitive-owner', 'sensitive-repo', 'synthetic-webhook-secret')", []).unwrap(); + let legacy = project_resource_status_for_conn(&conn, &store, "p1").unwrap(); + assert_eq!(legacy.gitea_pr.state, "configured"); + assert_eq!(legacy.gitea_pr.source, "legacy_pending_migration"); + let unavailable = project_resource_status_for_conn( + &conn, + &crate::credential_store::MemoryCredentialStore::failing(), + "p1", + ) + .unwrap(); + assert_eq!(unavailable.gitea_pr.state, "unavailable"); + let reference = crate::credential_store::versioned_reference( + crate::credential_store::LegacyCredentialKind::GiteaAccessToken, + "i", + ); + conn.execute( + "UPDATE gitea_instances SET access_token='', access_token_ref=?1 WHERE id='i'", + params![reference], + ) + .unwrap(); + let broken = project_resource_status_for_conn(&conn, &store, "p1").unwrap(); + assert_eq!(broken.gitea_pr.state, "needs_repair"); + let json = serde_json::to_string(&broken).unwrap(); + for sensitive in [ + "synthetic-token", + "synthetic-webhook-secret", + "secret-host", + "sensitive-owner", + "sensitive-repo", + &reference, + ] { + assert!(!json.contains(sensitive)); + } + } + #[test] fn upsert_repo_link_resets_webhook_on_rebind() { let conn = crate::db::conn_with_schema(); @@ -1834,8 +2593,16 @@ mod tests { // 换绑到另一个仓库 → webhook 字段应被重置 let r2 = upsert_repo_link(&conn, "i1", "proj-1", "alice", "repo-b", None).unwrap(); assert_eq!(r2.gitea_repo_name, "repo-b"); - assert!(r2.webhook_secret.is_none(), "换绑后 webhook_secret 未重置"); assert!(r2.webhook_id.is_none(), "换绑后 webhook_id 未重置"); + let (secret, reference): (Option, Option) = conn.query_row( + "SELECT webhook_secret, webhook_secret_ref FROM gitea_repos WHERE project_id = 'proj-1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap(); + assert!( + secret.is_none() && reference.is_none(), + "换绑后 webhook 凭证引用未重置" + ); // 仍然只有一行 let count: i64 = conn @@ -2070,8 +2837,10 @@ mod tests { ) .unwrap(); let context = "CI — DeepSeek Agent / deploy-api (push)".to_string(); + let store = crate::credential_store::MemoryCredentialStore::new(); let (target, signals) = - get_production_context_signals(&conn, "p1", &[context.clone()]).unwrap(); + get_production_context_signals_with_store(&conn, &store, "p1", &[context.clone()]) + .unwrap(); assert_eq!(target, sha); assert_eq!(signals[&context].state, "success"); server.join().unwrap(); @@ -2279,4 +3048,29 @@ mod tests { assert_eq!(body["Do"], "merge"); assert_eq!(body["head_commit_id"], sha); } + + #[test] + fn public_gitea_dtos_never_serialize_credentials() { + let instance = GiteaInstance { + id: "i1".into(), + name: "test".into(), + url: "https://g.example".into(), + is_default: true, + created_at: None, + }; + let repo = GiteaRepo { + id: "r1".into(), + instance_id: "i1".into(), + project_id: "p1".into(), + gitea_owner: "owner".into(), + gitea_repo_name: "repo".into(), + clone_url: None, + webhook_id: Some(1), + linked_at: None, + }; + let instance_json = serde_json::to_value(instance).unwrap(); + let repo_json = serde_json::to_value(repo).unwrap(); + assert!(instance_json.get("access_token").is_none()); + assert!(repo_json.get("webhook_secret").is_none()); + } } diff --git a/src-tauri/src/credential_store.rs b/src-tauri/src/credential_store.rs new file mode 100644 index 0000000..63a6855 --- /dev/null +++ b/src-tauri/src/credential_store.rs @@ -0,0 +1,674 @@ +//! 本机用户级凭证存储。生产 Windows 构建使用 Credential Manager;其他平台 fail closed。 + +#[cfg(test)] +use std::collections::HashMap; +#[cfg(test)] +use std::sync::Mutex; + +fn ensure_legacy_backup( + conn: &rusqlite::Connection, + table: &str, + id_column: &str, + secret_column: &str, + id: &str, + legacy: &str, +) -> Result<(), String> { + let database_path: String = conn + .query_row("PRAGMA database_list", [], |row| row.get(2)) + .map_err(|_| "凭证状态 needs_repair:无法确定数据库备份位置".to_string())?; + if database_path.is_empty() { + // 仅测试用的内存数据库无需磁盘备份。 + return Ok(()); + } + let path = std::path::Path::new(&database_path); + let backup = path.with_extension("pre-credential-migration.db"); + if !backup.exists() { + conn.execute("VACUUM main INTO ?1", [backup.to_string_lossy().as_ref()]) + .map_err(|_| "凭证状态 needs_repair:迁移前备份失败".to_string())?; + } + let backup_conn = + rusqlite::Connection::open_with_flags(&backup, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|_| "凭证状态 needs_repair:迁移前备份不可读取".to_string())?; + let check: String = backup_conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .map_err(|_| "凭证状态 needs_repair:迁移前备份校验失败".to_string())?; + if check != "ok" { + return Err("凭证状态 needs_repair:迁移前备份完整性检查失败".into()); + } + // 防止恢复过旧数据库后误把一份不含当前旧值的备份当作可恢复证据。 + let check_sql = format!( + "SELECT EXISTS(SELECT 1 FROM {table} WHERE {id_column} = ?1 AND {secret_column} = ?2)" + ); + let contains_value: bool = backup_conn + .query_row(&check_sql, rusqlite::params![id, legacy], |row| row.get(0)) + .map_err(|_| "凭证状态 needs_repair:迁移前备份内容无法验证".to_string())?; + if !contains_value { + return Err("凭证状态 needs_repair:现有备份不含当前旧值;请先安排新的可恢复备份".into()); + } + Ok(()) +} + +pub trait CredentialStore { + fn put(&self, reference: &str, value: &[u8]) -> Result<(), String>; + fn get(&self, reference: &str) -> Result>, String>; + fn delete(&self, reference: &str) -> Result<(), String>; +} + +#[derive(Debug, Clone, Copy)] +pub enum LegacyCredentialKind { + GiteaAccessToken, + GiteaWebhookSecret, + ApiKey, +} + +pub fn reference_for(kind: LegacyCredentialKind, id: &str) -> String { + match kind { + LegacyCredentialKind::GiteaAccessToken => format!("gitea/access-token/{id}"), + LegacyCredentialKind::GiteaWebhookSecret => format!("gitea/webhook-secret/{id}"), + LegacyCredentialKind::ApiKey => format!("api-key/{id}"), + } +} + +pub fn versioned_reference(kind: LegacyCredentialKind, id: &str) -> String { + format!("{}/{}", reference_for(kind, id), uuid::Uuid::new_v4()) +} + +pub fn put_verified( + store: &impl CredentialStore, + reference: &str, + value: &[u8], +) -> Result<(), String> { + store + .put(reference, value) + .map_err(|_| "凭证状态 needs_repair:受保护存储写入失败".to_string())?; + let verified = store.get(reference); + match verified { + Ok(Some(bytes)) if bytes == value => Ok(()), + other => { + let _ = store.delete(reference); + match other { + Err(_) => Err("凭证状态 needs_repair:写入后读取校验失败".into()), + Ok(None) => Err("凭证状态 needs_repair:写入后无法读取校验".into()), + Ok(Some(_)) => Err("凭证状态 needs_repair:写入后校验不匹配".into()), + } + } + } +} + +impl LegacyCredentialKind { + fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) { + match self { + Self::GiteaAccessToken => ( + "gitea_instances", + "id", + "access_token", + "access_token_ref", + false, + ), + Self::GiteaWebhookSecret => ( + "gitea_repos", + "project_id", + "webhook_secret", + "webhook_secret_ref", + true, + ), + Self::ApiKey => ("api_keys", "id", "api_key", "credential_ref", false), + } + } +} + +/// 读取已迁移凭证,或以 write→verify→transactional switch→clear 顺序升级旧记录。 +/// 仅接受枚举指定的表/列;错误文本固定为非秘密描述。 +pub fn get_or_migrate_legacy( + conn: &rusqlite::Connection, + store: &impl CredentialStore, + kind: LegacyCredentialKind, + id: &str, +) -> Result, String> { + let (table, id_column, secret_column, reference_column, nullable) = kind.columns(); + let query = + format!("SELECT {secret_column}, {reference_column} FROM {table} WHERE {id_column} = ?1"); + let (legacy, reference): (Option, Option) = conn + .query_row(&query, [id], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|_| "凭证记录不存在或无法读取".to_string())?; + + if let Some(reference) = reference.filter(|value| !value.is_empty()) { + let value = store + .get(&reference) + .map_err(|_| "凭证状态 needs_repair:受保护存储不可用".to_string())? + .ok_or_else(|| "凭证状态 needs_repair:受保护存储中缺少引用值".to_string())?; + return if value.is_empty() { + Err("凭证状态 needs_repair:受保护存储中的凭证为空".into()) + } else { + Ok(value) + }; + } + + let legacy = legacy + .filter(|value| !value.is_empty()) + .ok_or_else(|| "凭证状态 not_configured:没有可迁移的凭证".to_string())?; + ensure_legacy_backup(conn, table, id_column, secret_column, id, &legacy)?; + let reference = versioned_reference(kind, id); + let bytes = legacy.as_bytes(); + put_verified(store, &reference, bytes)?; + let result = (|| { + let tx = conn + .unchecked_transaction() + .map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())?; + let clear = if nullable { "NULL" } else { "''" }; + let update = format!( + "UPDATE {table} SET {reference_column} = ?1, {secret_column} = {clear} \ + WHERE {id_column} = ?2 AND {secret_column} = ?3 AND \ + ({reference_column} IS NULL OR {reference_column} = '')" + ); + let changed = tx + .execute(&update, rusqlite::params![reference, id, legacy]) + .map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())?; + if changed != 1 { + return Err("凭证状态 needs_repair:记录在迁移期间发生变化".into()); + } + tx.commit() + .map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string()) + })(); + if result.is_err() { + let _ = store.delete(&reference); + } + result?; + Ok(bytes.to_vec()) +} + +#[cfg(windows)] +#[derive(Debug, Default, Clone, Copy)] +pub struct WindowsCredentialStore; + +#[cfg(windows)] +impl CredentialStore for WindowsCredentialStore { + fn put(&self, reference: &str, value: &[u8]) -> Result<(), String> { + windows_store::put(reference, value) + } + + fn get(&self, reference: &str) -> Result>, String> { + windows_store::get(reference) + } + + fn delete(&self, reference: &str) -> Result<(), String> { + windows_store::delete(reference) + } +} + +#[cfg(not(windows))] +#[derive(Debug, Default, Clone, Copy)] +pub struct WindowsCredentialStore; + +#[cfg(not(windows))] +impl CredentialStore for WindowsCredentialStore { + fn put(&self, _reference: &str, _value: &[u8]) -> Result<(), String> { + Err("Windows Credential Manager 不可用".into()) + } + fn get(&self, _reference: &str) -> Result>, String> { + Err("Windows Credential Manager 不可用".into()) + } + fn delete(&self, _reference: &str) -> Result<(), String> { + Err("Windows Credential Manager 不可用".into()) + } +} + +#[cfg(windows)] +mod windows_store { + use std::ffi::c_void; + use std::ptr::null_mut; + + const CRED_TYPE_GENERIC: u32 = 1; + const CRED_PERSIST_LOCAL_MACHINE: u32 = 2; + const CRED_MAX_CREDENTIAL_BLOB_SIZE: usize = 5 * 512; + const ERROR_NOT_FOUND: i32 = 1168; + + #[repr(C)] + struct FileTime { + low: u32, + high: u32, + } + + #[repr(C)] + struct CredentialAttributeW { + keyword: *mut u16, + flags: u32, + value_size: u32, + value: *mut u8, + } + + #[repr(C)] + struct CredentialW { + flags: u32, + kind: u32, + target_name: *mut u16, + comment: *mut u16, + last_written: FileTime, + credential_blob_size: u32, + credential_blob: *mut u8, + persist: u32, + attribute_count: u32, + attributes: *mut CredentialAttributeW, + target_alias: *mut u16, + user_name: *mut u16, + } + + #[link(name = "Advapi32")] + unsafe extern "system" { + fn CredWriteW(credential: *const CredentialW, flags: u32) -> i32; + fn CredReadW( + target: *const u16, + kind: u32, + flags: u32, + output: *mut *mut CredentialW, + ) -> i32; + fn CredDeleteW(target: *const u16, kind: u32, flags: u32) -> i32; + fn CredFree(buffer: *mut c_void); + } + + fn target(reference: &str) -> Result, String> { + if reference.is_empty() || reference.len() > 240 || reference.chars().any(char::is_control) + { + return Err("凭证引用格式无效".into()); + } + Ok(format!("dev-manager-tauri/{reference}") + .encode_utf16() + .chain(Some(0)) + .collect()) + } + + pub fn put(reference: &str, value: &[u8]) -> Result<(), String> { + if value.is_empty() || value.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE { + return Err("凭证值长度超出 Credential Manager 限制".into()); + } + let target = target(reference)?; + let mut blob = value.to_vec(); + let credential = CredentialW { + flags: 0, + kind: CRED_TYPE_GENERIC, + target_name: target.as_ptr() as *mut u16, + comment: null_mut(), + last_written: FileTime { low: 0, high: 0 }, + credential_blob_size: blob.len() as u32, + credential_blob: blob.as_mut_ptr(), + persist: CRED_PERSIST_LOCAL_MACHINE, + attribute_count: 0, + attributes: null_mut(), + target_alias: null_mut(), + user_name: null_mut(), + }; + let ok = unsafe { CredWriteW(&credential, 0) }; + blob.fill(0); + if ok == 0 { + Err("Credential Manager 写入失败".into()) + } else { + Ok(()) + } + } + + pub fn get(reference: &str) -> Result>, String> { + let target = target(reference)?; + let mut raw = null_mut(); + let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) }; + if ok == 0 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(ERROR_NOT_FOUND) { + Ok(None) + } else { + Err("Credential Manager 读取失败".into()) + }; + } + if raw.is_null() { + return Err("Credential Manager 返回无效凭证".into()); + } + let credential = unsafe { &*raw }; + let result = if credential.credential_blob_size as usize > CRED_MAX_CREDENTIAL_BLOB_SIZE { + Err("Credential Manager 凭证长度无效".into()) + } else if credential.credential_blob_size > 0 && credential.credential_blob.is_null() { + Err("Credential Manager 返回无效凭证".into()) + } else if credential.credential_blob_size == 0 { + Ok(Some(Vec::new())) + } else { + let bytes = unsafe { + std::slice::from_raw_parts( + credential.credential_blob, + credential.credential_blob_size as usize, + ) + }; + Ok(Some(bytes.to_vec())) + }; + unsafe { + CredFree(raw.cast()); + } + result + } + + pub fn delete(reference: &str) -> Result<(), String> { + let target = target(reference)?; + let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) }; + if ok != 0 { + return Ok(()); + } + if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_NOT_FOUND) { + Ok(()) + } else { + Err("Credential Manager 删除失败".into()) + } + } +} + +#[cfg(test)] +pub(crate) struct MemoryCredentialStore { + values: Mutex>>, + fail_writes: bool, + fail_reads: bool, +} + +#[cfg(test)] +impl MemoryCredentialStore { + pub(crate) fn new() -> Self { + Self { + values: Mutex::new(HashMap::new()), + fail_writes: false, + fail_reads: false, + } + } + pub(crate) fn failing() -> Self { + Self { + values: Mutex::new(HashMap::new()), + fail_writes: true, + fail_reads: true, + } + } + pub(crate) fn contains_value(&self, value: &[u8]) -> bool { + self.values + .lock() + .map(|values| values.values().any(|stored| stored == value)) + .unwrap_or(false) + } +} + +#[cfg(test)] +impl CredentialStore for MemoryCredentialStore { + fn put(&self, reference: &str, value: &[u8]) -> Result<(), String> { + if self.fail_writes { + return Err("模拟凭证存储写入失败".into()); + } + self.values + .lock() + .map_err(|_| "凭证存储锁不可用".to_string())? + .insert(reference.into(), value.into()); + Ok(()) + } + fn get(&self, reference: &str) -> Result>, String> { + if self.fail_reads { + return Err("模拟凭证存储读取失败".into()); + } + Ok(self + .values + .lock() + .map_err(|_| "凭证存储锁不可用".to_string())? + .get(reference) + .cloned()) + } + fn delete(&self, reference: &str) -> Result<(), String> { + self.values + .lock() + .map_err(|_| "凭证存储锁不可用".to_string())? + .remove(reference); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ + get_or_migrate_legacy, CredentialStore, LegacyCredentialKind, MemoryCredentialStore, + }; + use rusqlite::Connection; + + fn migration_db() -> Connection { + let conn = crate::db::conn_with_schema(); + conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('instance-test', 'fake', 'https://example.invalid', 'synthetic-test-token')", []).unwrap(); + conn.execute( + "INSERT INTO projects (id, name) VALUES ('project-test', 'fake')", + [], + ) + .unwrap(); + conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('repo-test', 'instance-test', 'project-test', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap(); + conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('key-test', 'fake', 'test', 'synthetic-api-key')", []).unwrap(); + conn + } + + #[test] + fn memory_adapter_supports_put_replace_get_delete_and_failures() { + let store = MemoryCredentialStore::new(); + store.put("test/ref", b"first").unwrap(); + assert_eq!( + store.get("test/ref").unwrap().as_deref(), + Some(b"first".as_slice()) + ); + store.put("test/ref", b"second").unwrap(); + assert_eq!( + store.get("test/ref").unwrap().as_deref(), + Some(b"second".as_slice()) + ); + store.delete("test/ref").unwrap(); + assert_eq!(store.get("test/ref").unwrap(), None); + let failing = MemoryCredentialStore::failing(); + let error = failing.put("test/ref", b"do-not-print-this").unwrap_err(); + assert!(!error.contains("do-not-print-this")); + } + + #[test] + fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() { + let conn = migration_db(); + let store = MemoryCredentialStore::new(); + let value = get_or_migrate_legacy( + &conn, + &store, + LegacyCredentialKind::GiteaAccessToken, + "instance-test", + ) + .unwrap(); + assert_eq!(value, b"synthetic-test-token"); + let (legacy, reference): (String, String) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert!(legacy.is_empty()); + assert_eq!( + store.get(&reference).unwrap().as_deref(), + Some(b"synthetic-test-token".as_slice()) + ); + + let webhook = get_or_migrate_legacy( + &conn, + &store, + LegacyCredentialKind::GiteaWebhookSecret, + "project-test", + ) + .unwrap(); + assert_eq!(webhook, b"synthetic-webhook-secret"); + let old_webhook: Option = conn + .query_row( + "SELECT webhook_secret FROM gitea_repos WHERE project_id='project-test'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(old_webhook.is_none()); + let key = + get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "key-test").unwrap(); + assert_eq!(key, b"synthetic-api-key"); + } + + #[test] + fn failed_legacy_migration_keeps_old_value_and_returns_repair_state_without_secret() { + let conn = migration_db(); + let store = MemoryCredentialStore::failing(); + let error = get_or_migrate_legacy( + &conn, + &store, + LegacyCredentialKind::GiteaAccessToken, + "instance-test", + ) + .unwrap_err(); + assert!(error.contains("needs_repair")); + assert!(!error.contains("synthetic-test-token")); + let (legacy, reference): (String, Option) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert_eq!(legacy, "synthetic-test-token"); + assert!(reference.is_none()); + } + + #[test] + fn migration_database_failure_keeps_legacy_secret_and_allows_retry() { + let conn = migration_db(); + let store = MemoryCredentialStore::new(); + conn.execute_batch("CREATE TRIGGER fail_credential_switch BEFORE UPDATE OF access_token_ref ON gitea_instances BEGIN SELECT RAISE(ABORT, 'injected database failure'); END;").unwrap(); + let error = get_or_migrate_legacy( + &conn, + &store, + LegacyCredentialKind::GiteaAccessToken, + "instance-test", + ) + .unwrap_err(); + assert!(error.contains("needs_repair")); + let (legacy, reference): (String, Option) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert_eq!(legacy, "synthetic-test-token"); + assert!(reference.is_none()); + conn.execute_batch("DROP TRIGGER fail_credential_switch;") + .unwrap(); + assert_eq!( + get_or_migrate_legacy( + &conn, + &store, + LegacyCredentialKind::GiteaAccessToken, + "instance-test" + ) + .unwrap(), + b"synthetic-test-token" + ); + let (legacy, reference): (String, String) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert!(legacy.is_empty()); + assert_eq!( + store.get(&reference).unwrap().as_deref(), + Some(b"synthetic-test-token".as_slice()) + ); + } + + #[test] + fn disk_migration_requires_verified_backup_and_backup_restores_old_value() { + let directory = std::env::temp_dir().join(format!( + "lian-jing-credential-backup-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("synthetic.db"); + let backup = path.with_extension("pre-credential-migration.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("CREATE TABLE api_keys (id TEXT PRIMARY KEY, api_key TEXT NOT NULL, credential_ref TEXT);").unwrap(); + conn.execute( + "INSERT INTO api_keys (id, api_key) VALUES ('k', 'synthetic-backup-key')", + [], + ) + .unwrap(); + let store = MemoryCredentialStore::new(); + + std::fs::create_dir(&backup).unwrap(); + let error = + get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "k").unwrap_err(); + assert!(error.contains("needs_repair")); + let old: String = conn + .query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(old, "synthetic-backup-key"); + std::fs::remove_dir(&backup).unwrap(); + + assert_eq!( + get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "k").unwrap(), + b"synthetic-backup-key" + ); + let live: String = conn + .query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| { + row.get(0) + }) + .unwrap(); + assert!(live.is_empty()); + let restored = + Connection::open_with_flags(&backup, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + let backup_value: String = restored + .query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(backup_value, "synthetic-backup-key"); + conn.execute( + "INSERT INTO api_keys (id, api_key) VALUES ('later', 'synthetic-later-key')", + [], + ) + .unwrap(); + let stale = get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "later") + .unwrap_err(); + assert!(stale.contains("needs_repair")); + let still_legacy: String = conn + .query_row("SELECT api_key FROM api_keys WHERE id='later'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(still_legacy, "synthetic-later-key"); + drop(restored); + drop(conn); + std::fs::remove_file(&backup).unwrap(); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&directory).unwrap(); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::{windows_store, CredentialStore, WindowsCredentialStore}; + use std::process::Command; + + const CHILD_ENV: &str = "DEV_MANAGER_CREDENTIAL_STORE_CHILD_TEST"; + + #[test] + fn windows_credential_manager_persists_across_processes() { + let reference = format!("isolated-test/{}", uuid::Uuid::new_v4()); + let value = format!("credential-store-test-{}", uuid::Uuid::new_v4()).into_bytes(); + let store = WindowsCredentialStore; + store.put(&reference, &value).unwrap(); + let child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "credential_store::windows_tests::credential_child_probe", + "--ignored", + "--nocapture", + ]) + .env(CHILD_ENV, &reference) + .env( + "DEV_MANAGER_CREDENTIAL_STORE_EXPECTED", + String::from_utf8(value.clone()).unwrap(), + ) + .status(); + let cleanup = store.delete(&reference); + assert!(cleanup.is_ok(), "测试凭证清理失败"); + assert!( + child.is_ok_and(|status| status.success()), + "Credential Manager 子进程读取测试失败" + ); + } + + #[test] + #[ignore] + fn credential_child_probe() { + let Ok(reference) = std::env::var(CHILD_ENV) else { + return; + }; + let expected = std::env::var("DEV_MANAGER_CREDENTIAL_STORE_EXPECTED").unwrap(); + assert_eq!( + windows_store::get(&reference).unwrap().as_deref(), + Some(expected.as_bytes()) + ); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index e904f52..1a16e16 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -860,6 +860,7 @@ fn migrate(conn: &rusqlite::Connection) { ", ) .expect("create api_keys table"); + let _ = conn.execute("ALTER TABLE api_keys ADD COLUMN credential_ref TEXT", []); // 增量迁移:宿主机部署应用 conn.execute_batch( @@ -1048,6 +1049,33 @@ fn migrate(conn: &rusqlite::Connection) { ", ) .expect("create gitea tables"); + let _ = conn.execute( + "ALTER TABLE gitea_instances ADD COLUMN access_token_ref TEXT", + [], + ); + let _ = conn.execute( + "ALTER TABLE gitea_repos ADD COLUMN webhook_secret_ref TEXT", + [], + ); +} + +#[cfg(test)] +pub(crate) fn init_test_pool() { + if POOL.get().is_some() { + return; + } + let manager = SqliteConnectionManager::memory(); + let pool = Pool::builder() + .max_size(1) + .build(manager) + .expect("create in-memory test pool"); + { + let conn = pool.get().expect("get in-memory test connection"); + conn.execute_batch("PRAGMA foreign_keys=ON;") + .expect("pragma"); + migrate(&conn); + } + let _ = POOL.set(pool); } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7bca88..64b7750 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod credential_store; mod db; mod mcp_inject; mod mcp_server; @@ -390,6 +391,7 @@ pub fn run() { commands::api_keys::add_api_key, commands::api_keys::edit_api_key, commands::api_keys::delete_api_key, + commands::api_keys::reveal_api_key, // host apps commands::host_apps::get_host_apps, commands::host_apps::add_host_app, @@ -406,6 +408,7 @@ pub fn run() { commands::gitea::gitea_delete_instance, commands::gitea::gitea_update_instance, commands::gitea::gitea_test_connection, + commands::gitea::get_project_resource_status, commands::gitea::gitea_list_linked_repos, commands::gitea::gitea_get_repo_link, commands::gitea::gitea_create_repo, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 13b9eed..707062f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -122,27 +122,7 @@ pub fn read_port() -> u16 { /// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输) pub async fn start(port: u16) { let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); - - let app = Router::new() - .route("/health", get(health)) - // 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id - .route("/mcp", post(handle_mcp).get(handle_sse_connect)) - .route( - "/mcp/projects/:project_id", - post(handle_scoped_mcp).get(handle_scoped_sse_connect), - ) - // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 - .route("/mcp/messages", post(handle_sse_message)) - .route( - "/mcp/projects/:project_id/messages", - post(handle_scoped_sse_message), - ) - // Gitea webhook:接收 push/pull_request 事件写入飞轮 - .route( - "/webhook/gitea/:project_id", - post(crate::mcp_webhook::handle_gitea_webhook), - ) - .with_state(sessions); + let app = mcp_router(sessions); let addr = mcp_bind_addr(port); match tokio::net::TcpListener::bind(addr).await { @@ -171,6 +151,29 @@ pub async fn start(port: u16) { } } +fn mcp_router(sessions: Sessions) -> Router { + Router::new() + .route("/health", get(health)) + // 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id + .route("/mcp", post(handle_mcp).get(handle_sse_connect)) + .route( + "/mcp/projects/:project_id", + post(handle_scoped_mcp).get(handle_scoped_sse_connect), + ) + // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 + .route("/mcp/messages", post(handle_sse_message)) + .route( + "/mcp/projects/:project_id/messages", + post(handle_scoped_sse_message), + ) + // Gitea webhook:接收 push/pull_request 事件写入飞轮 + .route( + "/webhook/gitea/:project_id", + post(crate::mcp_webhook::handle_gitea_webhook), + ) + .with_state(sessions) +} + async fn health() -> &'static str { "炼境 MCP Server running" } @@ -366,6 +369,82 @@ mod tests { assert!(!list.iter().any(|tool| tool["name"] == "unregister_project")); } + #[tokio::test(flavor = "multi_thread")] + async fn scoped_enterprise_fixture_reads_non_secret_resource_status_over_mcp() { + db::init_test_pool(); + let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + { + let conn = db::pool().get().unwrap(); + conn.execute("INSERT OR IGNORE INTO project_profiles (id, name) VALUES ('enterprise-fixture-profile', 'Enterprise fixture')", []).unwrap(); + conn.execute("INSERT OR IGNORE INTO project_workspaces (id, profile_id) VALUES ('enterprise-fixture', 'enterprise-fixture-profile')", []).unwrap(); + conn.execute( + "UPDATE settings SET value=?1 WHERE key='mcp_port'", + [port.to_string()], + ) + .unwrap(); + } + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + let server = tokio::spawn(async move { + axum::serve(listener, mcp_router(sessions)).await.unwrap(); + }); + let url = format!("http://127.0.0.1:{port}/mcp/projects/enterprise-fixture"); + let call = |method: &str, params: Value| -> Value { + ureq::post(&url) + .send_json(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})) + .unwrap() + .into_json() + .unwrap() + }; + let init = call("initialize", json!({})); + assert_eq!( + init["result"]["lianJing"]["project_id"], + "enterprise-fixture" + ); + let catalog = call("tools/list", json!({})); + assert!(catalog["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "get_project_resource_status")); + let result = call( + "tools/call", + json!({"name":"get_project_resource_status","arguments":{}}), + ); + assert_eq!( + result["result"]["structuredContent"]["project_id"], + "enterprise-fixture" + ); + assert_eq!( + result["result"]["structuredContent"]["gitea_pr"]["state"], + "not_configured" + ); + assert!(!result.to_string().contains("access_token")); + let denied = call( + "tools/call", + json!({"name":"get_project_resource_status","arguments":{"project_id":"other-project"}}), + ); + assert_eq!( + denied["result"]["structuredContent"]["code"], + "scope_violation" + ); + + use std::io::BufRead; + let sse = ureq::get(&url).call().unwrap(); + assert!(sse + .header("Content-Type") + .unwrap_or("") + .contains("text/event-stream")); + let mut line = String::new(); + std::io::BufReader::new(sse.into_reader()) + .read_line(&mut line) + .unwrap(); + assert!(line.contains("endpoint")); + server.abort(); + } + #[tokio::test] async fn readiness_probe_does_not_satisfy_client_reload_evidence() { let project_id = "reload-evidence-fixture"; diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 63ee029..7ad38ee 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -134,6 +134,17 @@ pub fn tools_list_result() -> Value { "required": ["project_id", "head", "base", "title"] } }, + { + "name": "get_project_resource_status", + "description": "只读查询项目 Gitea PR 与 webhook readiness;仅返回状态和来源,不返回凭证、URL 或连接参数。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID" } + }, + "required": ["project_id"] + } + }, { "name": "get_project_snapshots", "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", @@ -534,6 +545,14 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { value["resolver"]["status"].as_str().unwrap_or("unknown"), value["conflicts"].as_array().map(Vec::len).unwrap_or(0) ), + "get_project_resource_status" => format!( + "项目资源:project_id={},Gitea PR={},webhook={}", + value["project_id"].as_str().unwrap_or("?"), + value["gitea_pr"]["state"].as_str().unwrap_or("unknown"), + value["gitea_webhook"]["state"] + .as_str() + .unwrap_or("unknown") + ), "verify_onboarding" => value["summary"] .as_str() .unwrap_or("接入验收结构化结果已返回") @@ -546,6 +565,7 @@ fn tool_success_response(tool_name: &str, text: String) -> Value { const STRUCTURED_TOOLS: &[&str] = &[ "get_delivery_status", "get_toolchain_status", + "get_project_resource_status", "verify_onboarding", ]; if STRUCTURED_TOOLS.contains(&tool_name) { @@ -669,6 +689,11 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + crate::commands::gitea::get_project_resource_status(pid.to_string()) + .and_then(|status| serde_json::to_string(&status).map_err(|_| "项目资源状态序列化失败".to_string())) + } "poll_now" => crate::commands::gitea::poll_all_repos() .map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")), "setup_webhook" => { @@ -2042,10 +2067,30 @@ mod tests { .iter() .any(|tool| tool["name"] == "unregister_project")); assert!(!tools.iter().any(|tool| tool["name"] == "list_all_projects")); + let readiness = tools + .iter() + .find(|tool| tool["name"] == "get_project_resource_status") + .unwrap(); + assert_eq!( + readiness["inputSchema"]["properties"]["project_id"]["const"], + "p1" + ); + for forbidden in ["get_secret", "ssh_exec", "run_sql", "http_proxy", "deploy"] { + assert!(!tools.iter().any(|tool| tool["name"] == forbidden)); + } } #[tokio::test] async fn scoped_calls_reject_cross_project_and_global_write_before_io() { + let readiness_mismatch = tools_call_for_project( + Some(&json!({"name":"get_project_resource_status","arguments":{"project_id":"p2"}})), + "p1", + ) + .await; + assert_eq!( + readiness_mismatch["structuredContent"]["code"], + "scope_violation" + ); let mismatch = tools_call_for_project( Some(&json!({ "name": "get_delivery_status", diff --git a/src-tauri/src/mcp_webhook.rs b/src-tauri/src/mcp_webhook.rs index d21f35c..0dd1d62 100644 --- a/src-tauri/src/mcp_webhook.rs +++ b/src-tauri/src/mcp_webhook.rs @@ -76,23 +76,36 @@ pub async fn handle_gitea_webhook( headers: HeaderMap, body: Bytes, ) -> Response { - let secret: Option = db::pool().get().ok().and_then(|c| { - c.query_row( - "SELECT webhook_secret FROM gitea_repos WHERE project_id = ?1", - [&project_id], - |r| r.get(0), - ) - .ok() - }); - let secret = match secret { - Some(s) => s, - None => { + let conn = match db::pool().get() { + Ok(conn) => conn, + Err(_) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "webhook 状态 unavailable"})), + ) + .into_response() + } + }; + let secret = match webhook_secret_for_conn( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + ) { + Ok(secret) => secret, + Err(error) if error.contains("not_configured") || error.contains("不存在") => { return ( StatusCode::NOT_FOUND, Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})), ) .into_response() } + Err(_) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "webhook 状态 needs_repair"})), + ) + .into_response() + } }; let sig = extract_signature(&headers); @@ -129,6 +142,20 @@ pub async fn handle_gitea_webhook( } } +fn webhook_secret_for_conn( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, +) -> Result { + let bytes = crate::credential_store::get_or_migrate_legacy( + conn, + store, + crate::credential_store::LegacyCredentialKind::GiteaWebhookSecret, + project_id, + )?; + String::from_utf8(bytes).map_err(|_| "webhook 状态 needs_repair:凭证编码无效".to_string()) +} + async fn handle_push(project_id: &str, body: &Bytes) -> Response { let commits = match parse_push_payload(body) { Ok(c) => c, @@ -285,6 +312,45 @@ mod tests { assert!(!verify_hmac_sha256("my-secret", body, "deadbeef")); } + #[test] + fn legacy_webhook_migration_keeps_signature_valid_and_failure_recoverable() { + use crate::credential_store::MemoryCredentialStore; + let conn = crate::db::conn_with_schema(); + conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://example.invalid', 'synthetic-token')", []).unwrap(); + conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap(); + let failing = MemoryCredentialStore::failing(); + let error = webhook_secret_for_conn(&conn, &failing, "p").unwrap_err(); + assert!(error.contains("needs_repair")); + assert!(!error.contains("synthetic-webhook-secret")); + let legacy: String = conn + .query_row( + "SELECT webhook_secret FROM gitea_repos WHERE project_id='p'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(legacy, "synthetic-webhook-secret"); + + let store = MemoryCredentialStore::new(); + let secret = webhook_secret_for_conn(&conn, &store, "p").unwrap(); + let body = br#"{"commits":[]}"#; + assert!(verify_hmac_sha256( + &secret, + body, + &sign("synthetic-webhook-secret", body) + )); + let (legacy, reference): (Option, String) = conn + .query_row( + "SELECT webhook_secret, webhook_secret_ref FROM gitea_repos WHERE project_id='p'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!(legacy.is_none()); + assert!(!reference.is_empty()); + assert_eq!(webhook_secret_for_conn(&conn, &store, "p").unwrap(), secret); + } + #[test] fn parse_push_payload_extracts_commits() { let body = br#"{ diff --git a/src/components/dashboard/ApiKeysPanel.tsx b/src/components/dashboard/ApiKeysPanel.tsx index 2266d5b..68b675e 100644 --- a/src/components/dashboard/ApiKeysPanel.tsx +++ b/src/components/dashboard/ApiKeysPanel.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { - getApiKeys, addApiKey, editApiKey, deleteApiKey, + getApiKeys, addApiKey, editApiKey, deleteApiKey, revealApiKey, type ApiKey, type SaveApiKeyInput, } from "../../lib/commands"; @@ -43,7 +43,7 @@ function CopyBtn({ value }: { value: string }) { // ── 模态框表单 ──────────────────────────────────────────────────────────────── function ApiKeyModal({ initial, onSave, onClose, saving }: { - initial?: ApiKey; + initial?: ApiKey & { api_key?: string }; onSave: (input: SaveApiKeyInput) => void; onClose: () => void; saving: boolean; @@ -99,7 +99,7 @@ function ApiKeyModal({ initial, onSave, onClose, saving }: {