From 47ff656083d533c16af0771b54d3249cf2ca823d Mon Sep 17 00:00:00 2001 From: lanrtop Date: Wed, 23 Sep 2026 21:16:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(resource/credentials):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=A4=B1=E8=B4=A5=E5=BC=95=E7=94=A8=E5=B9=B6?= =?UTF-8?q?=E7=BA=A0=E6=AD=A3=E8=BF=81=E7=A7=BB=E5=89=8D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除受保护凭证失败时回滚数据库删除,并恢复已删引用。旧凭证明确标为 pending_migration,避免误报 configured。 --- docs/ai-context/agent-button-system.md | 2 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/api_keys.rs | 33 ++++++++-- src-tauri/src/commands/gitea.rs | 85 ++++++++++++++++++++++++-- src-tauri/src/credential_store.rs | 63 +++++++++++++++++++ src/lib/commands.ts | 2 +- 6 files changed, 175 insertions(+), 12 deletions(-) diff --git a/docs/ai-context/agent-button-system.md b/docs/ai-context/agent-button-system.md index c9574f3..76e04e0 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -141,7 +141,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言: ## 项目资源与凭证代办边界 -`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。 +`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`pending_migration`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。旧 SQLite 凭证未迁移时只报告 `pending_migration`,不提前宣称可执行;scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。 Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机代办凭证由 Credential Manager 保护,SQLite 只存引用及非秘密元数据。旧值在显式操作时,先在数据库同目录以 SQLite `VACUUM INTO` 生成 `dev-manager.pre-credential-migration.db`,校验完整性和当前旧值确在备份中,再按写入、读取校验、事务切换引用并清除旧列的顺序迁移;备份不可用或失败则保留旧值并报告 `needs_repair`。此备份可能含历史明文,须按原数据库同等敏感级别保护,不能因当前列清空就宣称历史明文已消失。普通 Gitea/API Key DTO 不带原始值;人的 API Key 查看与复制仍通过本机 Tauri 的显式 reveal,MCP 不暴露该入口。Gitea Actions secrets、项目运行时密钥与服务器凭证仍留在各自原用途位置;炼境不提供 `get_secret` 或任意 SSH、SQL、HTTP、部署代理。同一 Windows 用户下的进程隔离不是本方案提供的安全保证。 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index d7f6e07..b5594fc 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -15,7 +15,7 @@ contracts: - "src-tauri/src/commands/toolchain.rs" - "src-tauri/src/commands/cicd.rs" - "src-tauri/src/commands/gitea.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" + doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/src/commands/api_keys.rs b/src-tauri/src/commands/api_keys.rs index 587f290..375e2f2 100644 --- a/src-tauri/src/commands/api_keys.rs +++ b/src-tauri/src/commands/api_keys.rs @@ -131,17 +131,23 @@ pub fn delete_api_key_fn( store: &impl crate::credential_store::CredentialStore, id: &str, ) -> Result<(), String> { - let reference: Option = conn + let tx = conn + .unchecked_transaction() + .map_err(|_| "删除 API Key 失败".to_string())?; + let reference: Option = tx .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]) + tx.execute("DELETE FROM api_keys WHERE id=?1", params![id]) .map_err(|_| "删除 API Key 失败".to_string())?; - if let Some(reference) = reference { - let _ = store.delete(&reference); + let references: Vec = reference.into_iter().collect(); + let snapshots = crate::credential_store::delete_references(store, &references)?; + if tx.commit().is_err() { + crate::credential_store::restore_references(store, &snapshots)?; + return Err("删除 API Key 失败:数据库提交失败".into()); } Ok(()) } @@ -301,4 +307,23 @@ mod tests { delete_api_key_fn(&conn, &store, &key.id).unwrap(); assert!(store.get(&reference).unwrap().is_none()); } + + #[test] + fn delete_failure_keeps_api_key_and_credential_reference() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::failing_deletes(); + store.put("api-key/k1/v1", b"synthetic-key").unwrap(); + conn.execute( + "INSERT INTO api_keys (id, name, provider, api_key, credential_ref) VALUES ('k1', 'key', 'test', '', 'api-key/k1/v1')", + [], + ).unwrap(); + assert!(delete_api_key_fn(&conn, &store, "k1") + .unwrap_err() + .contains("needs_repair")); + assert!(get_api_key(&conn, "k1").is_some()); + assert_eq!( + store.get("api-key/k1/v1").unwrap().as_deref(), + Some(b"synthetic-key".as_slice()) + ); + } } diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 3ebf195..93e6a01 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -110,7 +110,7 @@ fn credential_readiness( if has_legacy { match store.get("resource-readiness/probe") { Ok(_) => ResourceReadiness { - state: "configured", + state: "pending_migration", source: "legacy_pending_migration", }, Err(_) => ResourceReadiness { @@ -277,10 +277,19 @@ fn delete_gitea_instance_with_store( .map_err(|_| "删除 Gitea 实例失败".to_string())? .collect::>() .map_err(|_| "删除 Gitea 实例失败".to_string())?; - conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id]) + let tx = conn + .unchecked_transaction() .map_err(|_| "删除 Gitea 实例失败".to_string())?; - for reference in instance_reference.into_iter().chain(repo_references) { - let _ = store.delete(&reference); + tx.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id]) + .map_err(|_| "删除 Gitea 实例失败".to_string())?; + let references: Vec = instance_reference + .into_iter() + .chain(repo_references) + .collect(); + let snapshots = crate::credential_store::delete_references(store, &references)?; + if tx.commit().is_err() { + crate::credential_store::restore_references(store, &snapshots)?; + return Err("删除 Gitea 实例失败:数据库提交失败".into()); } Ok(()) } @@ -2237,6 +2246,72 @@ mod tests { assert_eq!(default_count, 1, "失败的保存不应改变已有默认实例"); } + #[test] + fn deleting_instance_keeps_instance_and_repo_when_protected_store_delete_fails() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::failing_deletes(); + store + .put("gitea/access-token/i/v1", b"synthetic-token") + .unwrap(); + conn.execute( + "INSERT INTO gitea_instances (id, name, url, access_token, access_token_ref) VALUES ('i', 'test', 'https://example.invalid', '', 'gitea/access-token/i/v1')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r', 'i', 'p', 'owner', 'repo')", + [], + ).unwrap(); + assert!(delete_gitea_instance_with_store(&conn, &store, "i") + .unwrap_err() + .contains("needs_repair")); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM gitea_repos WHERE id='r'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + assert_eq!( + store.get("gitea/access-token/i/v1").unwrap().as_deref(), + Some(b"synthetic-token".as_slice()) + ); + } + + #[test] + fn deleting_instance_restores_token_when_later_webhook_delete_fails() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::failing_delete_for( + "gitea/webhook-secret/p/v1", + ); + store + .put("gitea/access-token/i/v1", b"synthetic-token") + .unwrap(); + store + .put("gitea/webhook-secret/p/v1", b"synthetic-hook") + .unwrap(); + conn.execute("INSERT INTO gitea_instances (id, name, url, access_token, access_token_ref) VALUES ('i', 'test', 'https://example.invalid', '', 'gitea/access-token/i/v1')", []).unwrap(); + conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret_ref) VALUES ('r', 'i', 'p', 'owner', 'repo', 'gitea/webhook-secret/p/v1')", []).unwrap(); + + assert!(delete_gitea_instance_with_store(&conn, &store, "i") + .unwrap_err() + .contains("needs_repair")); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM gitea_instances WHERE id='i'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!( + store.get("gitea/access-token/i/v1").unwrap().as_deref(), + Some(b"synthetic-token".as_slice()) + ); + assert_eq!( + store.get("gitea/webhook-secret/p/v1").unwrap().as_deref(), + Some(b"synthetic-hook".as_slice()) + ); + } + #[test] fn gitea_auth_migrates_legacy_token_and_update_preserves_old_on_store_failure() { let conn = crate::db::conn_with_schema(); @@ -2531,7 +2606,7 @@ mod tests { 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.state, "pending_migration"); assert_eq!(legacy.gitea_pr.source, "legacy_pending_migration"); let unavailable = project_resource_status_for_conn( &conn, diff --git a/src-tauri/src/credential_store.rs b/src-tauri/src/credential_store.rs index 63a6855..a3963e4 100644 --- a/src-tauri/src/credential_store.rs +++ b/src-tauri/src/credential_store.rs @@ -95,6 +95,42 @@ pub fn put_verified( } } +/// 在数据库删除事务提交前移除凭证;若中途失败,用快照恢复已删除的引用。 +pub fn delete_references( + store: &impl CredentialStore, + references: &[String], +) -> Result>)>, String> { + let mut snapshots = Vec::new(); + for reference in references { + if !snapshots.iter().any(|(saved, _)| saved == reference) { + let value = store + .get(reference) + .map_err(|_| "凭证状态 needs_repair:删除前无法读取受保护存储".to_string())?; + snapshots.push((reference.clone(), value)); + } + } + for index in 0..snapshots.len() { + if store.delete(&snapshots[index].0).is_err() { + restore_references(store, &snapshots[..=index])?; + return Err("凭证状态 needs_repair:受保护存储删除失败,数据库记录已保留".into()); + } + } + Ok(snapshots) +} + +pub fn restore_references( + store: &impl CredentialStore, + snapshots: &[(String, Option>)], +) -> Result<(), String> { + for (reference, value) in snapshots { + if let Some(value) = value { + put_verified(store, reference, value) + .map_err(|_| "凭证状态 needs_repair:受保护存储凭证恢复失败".to_string())?; + } + } + Ok(()) +} + impl LegacyCredentialKind { fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) { match self { @@ -362,6 +398,8 @@ pub(crate) struct MemoryCredentialStore { values: Mutex>>, fail_writes: bool, fail_reads: bool, + fail_deletes: bool, + fail_delete_reference: Option, } #[cfg(test)] @@ -371,6 +409,8 @@ impl MemoryCredentialStore { values: Mutex::new(HashMap::new()), fail_writes: false, fail_reads: false, + fail_deletes: false, + fail_delete_reference: None, } } pub(crate) fn failing() -> Self { @@ -378,6 +418,26 @@ impl MemoryCredentialStore { values: Mutex::new(HashMap::new()), fail_writes: true, fail_reads: true, + fail_deletes: true, + fail_delete_reference: None, + } + } + pub(crate) fn failing_deletes() -> Self { + Self { + values: Mutex::new(HashMap::new()), + fail_writes: false, + fail_reads: false, + fail_deletes: true, + fail_delete_reference: None, + } + } + pub(crate) fn failing_delete_for(reference: &str) -> Self { + Self { + values: Mutex::new(HashMap::new()), + fail_writes: false, + fail_reads: false, + fail_deletes: false, + fail_delete_reference: Some(reference.into()), } } pub(crate) fn contains_value(&self, value: &[u8]) -> bool { @@ -412,6 +472,9 @@ impl CredentialStore for MemoryCredentialStore { .cloned()) } fn delete(&self, reference: &str) -> Result<(), String> { + if self.fail_deletes || self.fail_delete_reference.as_deref() == Some(reference) { + return Err("模拟凭证存储删除失败".into()); + } self.values .lock() .map_err(|_| "凭证存储锁不可用".to_string())? diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 407284f..9aeb4ba 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1803,7 +1803,7 @@ export const giteaTestConnection = (instanceId: string) => invoke("gitea_test_connection", { instanceId }); export interface ResourceReadiness { - state: "configured" | "not_configured" | "unavailable" | "needs_repair"; + state: "configured" | "pending_migration" | "not_configured" | "unavailable" | "needs_repair"; source: "protected_store" | "legacy_pending_migration" | "gitea_binding"; }