diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index a656e17..b39a802 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -304,3 +304,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - DesktopPackages 对话框允许输入根包配置,调用 Host 完整预览,展示依赖拓扑顺序、请求权限和检查后的配置。当前 Vault、请求代次和选中根包共同约束返回结果,切换 Vault 关闭预览并丢弃迟到响应。 - 5 项前端针对性测试、两套 TypeScript 项目检查和 Rust 全目标 Clippy -D warnings 通过。新增组件测试验证使用 Host 列表、精确 Vault/配置参数、只发预览命令,以及切库后不显示旧权限结果。 - 页面明确安装执行尚未开放;依赖包配置编辑、自动取得缺失依赖、正式确认执行、沙箱与真实端到端验收仍未完成。整体生产化目标继续进行。 + + +## 修复:多公钥来源的原子确认 + +- 新增 extension_trust_confirm_group,前端一次提交整组 Host 复核凭据。Host 先核对数量、重复凭据、过期与摘要,再由安装库 savepoint 一次确认同来源全部键;不同来源/来源身份或重复键拒绝。 +- 任一后续键发生 revision 冲突时,前面已执行的更新全部回滚;确认凭据只在整组成功后一起消耗。此变更替代此前文档中逐键确认可能部分成功的流程。 +- 34 项扩展回归、2 项 Host 确认测试、3 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过。日志 `.build/extension-trust-group-tests.log`。验证后续键冲突后重开仍无部分更新,以及失败保留有效凭据、成功全部消耗。 +- 真实桌面确认链路端到端、安装执行、沙箱及总体生产化验收仍未完成。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 88a2ed9..6662aa6 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -18,6 +18,7 @@ fn main() { "host_capabilities", "extension_trust_review", "extension_trust_confirm", + "extension_trust_confirm_group", "extension_install_preview", "extension_stage", "extension_stage_prepare", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 21514bd..0e2a767 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -57,6 +57,7 @@ "allow-extension-stage-prepare", "allow-extension-stage-cancel", "allow-extension-stage-status", - "allow-extension-staged" + "allow-extension-staged", + "allow-extension-trust-confirm-group" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/extension_trust_confirm_group.toml b/frontend/src-tauri/permissions/autogenerated/extension_trust_confirm_group.toml new file mode 100644 index 0000000..5ac5bcf --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/extension_trust_confirm_group.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-trust-confirm-group" +description = "Enables the extension_trust_confirm_group command without any pre-configured scope." +commands.allow = ["extension_trust_confirm_group"] + +[[permission]] +identifier = "deny-extension-trust-confirm-group" +description = "Denies the extension_trust_confirm_group command without any pre-configured scope." +commands.deny = ["extension_trust_confirm_group"] diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index e8a0412..4b83bd7 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -247,10 +247,88 @@ pub async fn extension_staged( .map_err(|_| "EXTENSION_LIST_FAILED".to_string())? } +#[tauri::command] +pub fn extension_trust_confirm_group( + window: WebviewWindow, + host: State<'_, Host>, + requests: Vec, +) -> Result, String> { + main_window(&window)?; + trust_confirm_group(&host, requests) +} +fn trust_confirm_group(host: &Host, requests: Vec) -> Result, String> { + if requests.is_empty() || requests.len() > 64 { + return Err("EXTENSION_TRUST_INVALID".into()); + } + let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?; + let mut ids = std::collections::BTreeSet::new(); + let mut proposals = Vec::new(); + for request in &requests { + if !ids.insert(&request.review_id) { + return Err("EXTENSION_TRUST_INVALID".into()); + } + let review = reviews + .get(&request.review_id) + .ok_or("EXTENSION_REVIEW_EXPIRED")?; + if review.expires <= Instant::now() { + return Err("EXTENSION_REVIEW_EXPIRED".into()); + } + if review.fingerprint != request.fingerprint { + return Err("EXTENSION_TRUST_CONFIRMATION".into()); + } + proposals.push(( + review.setting.clone(), + review.expected.clone(), + review.fingerprint.clone(), + )); + } + let revisions = store(host, |s| s.confirm_trust_group(&proposals))?; + for request in requests { + reviews.remove(&request.review_id); + } + Ok(revisions) +} + #[cfg(test)] mod tests { use super::*; #[test] + fn failed_group_does_not_consume_valid_review_then_success_consumes_all() { + let temp = tempfile::tempdir().unwrap(); + let host = Host::default(); + *host.extensions.lock().unwrap() = Some(ExtensionStore::open(temp.path()).unwrap()); + let mut setting = TrustSetting { + source: "https://catalog.example/".into(), + source_id: "catalog".into(), + namespace: "examples".into(), + key_id: "one".into(), + public_key: ed25519_dalek::SigningKey::from_bytes(&[7; 32]) + .verifying_key() + .to_bytes(), + enabled: true, + }; + let first = trust_review(&host, setting.clone()).unwrap(); + setting.key_id = "two".into(); + let second = trust_review(&host, setting).unwrap(); + let input = |value: &Value| Confirmation { + review_id: value["review_id"].as_str().unwrap().into(), + fingerprint: value["fingerprint"].as_str().unwrap().into(), + }; + let mut wrong = input(&second); + wrong.fingerprint = "wrong".into(); + assert!(trust_confirm_group(&host, vec![input(&first), wrong]).is_err()); + assert_eq!(host.extension_reviews.0.lock().unwrap().len(), 2); + assert!(store(&host, |s| s.trust_setting( + "https://catalog.example/", + "examples", + "one" + )) + .unwrap() + .is_none()); + trust_confirm_group(&host, vec![input(&first), input(&second)]).unwrap(); + assert!(host.extension_reviews.0.lock().unwrap().is_empty()); + } + #[test] fn review_nonce_expiry_consumption_and_stale_setting_are_enforced() { let temp = tempfile::tempdir().unwrap(); let host = Host::default(); diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index f6356e1..8dae953 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -374,6 +374,39 @@ impl ExtensionStore { params![setting.source,setting.namespace,setting.key_id,serde_json::to_string(setting).unwrap(),revision])?; Ok(revision) } + pub fn confirm_trust_group( + &mut self, + proposals: &[(TrustSetting, Option, String)], + ) -> Result> { + if proposals.is_empty() || proposals.len() > 64 { + return Err(HostError::new("EXTENSION_TRUST_INVALID")); + } + let mut identities = std::collections::BTreeSet::new(); + for (setting, _, _) in proposals { + if setting.source != proposals[0].0.source + || setting.source_id != proposals[0].0.source_id + || !identities.insert((&setting.namespace, &setting.key_id)) + { + return Err(HostError::new("EXTENSION_TRUST_INVALID")); + } + } + self.db + .execute_batch("SAVEPOINT trust_confirmation_group")?; + let result = (|| { + let mut revisions = Vec::new(); + for (setting, expected, confirmed) in proposals { + revisions.push(self.confirm_trust(setting, expected.as_deref(), confirmed)?); + } + self.db.execute_batch("RELEASE trust_confirmation_group")?; + Ok(revisions) + })(); + if result.is_err() { + self.db.execute_batch( + "ROLLBACK TO trust_confirmation_group; RELEASE trust_confirmation_group", + )?; + } + result + } fn block_identity( source_url: &str, release: &Release, @@ -1112,6 +1145,58 @@ mod tests { } } #[test] + fn grouped_trust_confirmation_rolls_back_earlier_keys_on_later_conflict() { + let temp = tempfile::tempdir().unwrap(); + let (_, _, key) = fixture(); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let first = TrustSetting { + source: "https://catalog.example/".into(), + source_id: "catalog".into(), + namespace: "examples".into(), + key_id: "first".into(), + public_key: key, + enabled: true, + }; + let mut second = first.clone(); + second.key_id = "second".into(); + store + .confirm_trust(&second, None, &second.fingerprint().unwrap()) + .unwrap(); + second.enabled = false; + let proposals = vec![ + (first.clone(), None, first.fingerprint().unwrap()), + (second.clone(), None, second.fingerprint().unwrap()), + ]; + assert!(store.confirm_trust_group(&proposals).is_err()); + drop(store); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + assert!(store + .trust_setting(&first.source, &first.namespace, &first.key_id) + .unwrap() + .is_none()); + let old = store + .trust_setting(&second.source, &second.namespace, &second.key_id) + .unwrap() + .unwrap(); + assert!(old.enabled); + let mut proposals = proposals; + proposals[1].1 = Some(old.fingerprint().unwrap()); + store.confirm_trust_group(&proposals).unwrap(); + assert!(store + .trust_setting(&first.source, &first.namespace, &first.key_id) + .unwrap() + .is_some()); + assert!( + !store + .trust_setting(&second.source, &second.namespace, &second.key_id) + .unwrap() + .unwrap() + .enabled + ); + let duplicate = vec![proposals[0].clone(), proposals[0].clone()]; + assert!(store.confirm_trust_group(&duplicate).is_err()); + } + #[test] fn cancelled_stage_reports_durable_commit_race_truthfully() { let (release, archive, key) = fixture(); for boundary in ["object_stored", "receipt_recorded", "committed"] { diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 0a650f8..cde2cb3 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -850,6 +850,7 @@ fn main() { host_capabilities, extension_trust_review, extension_trust_confirm, + extension_trust_confirm_group, extension_install_preview, extension_stage, extension_stage_prepare, diff --git a/frontend/src/services/extensionTrustService.spec.ts b/frontend/src/services/extensionTrustService.spec.ts index 7531e6a..d22688a 100644 --- a/frontend/src/services/extensionTrustService.spec.ts +++ b/frontend/src/services/extensionTrustService.spec.ts @@ -10,7 +10,7 @@ it('reviews normalized source and confirms only Host-issued id and fingerprint', expect(native.invoke).toHaveBeenCalledWith('extension_trust_review', { setting: { source: 'https://catalog.example/', source_id: 'catalog', key_id: 'key', namespace: 'examples', public_key: Array(32).fill(97), enabled: true } }) expect(native.invoke).toHaveBeenCalledTimes(1) await confirmTrust(reviews) - expect(native.invoke).toHaveBeenLastCalledWith('extension_trust_confirm', { request: { review_id: 'review', fingerprint: 'digest' } }) + expect(native.invoke).toHaveBeenLastCalledWith('extension_trust_confirm_group', { requests: [{ review_id: 'review', fingerprint: 'digest' }] }) }) it('rejects invalid and revoked input before invoking Host', async () => { await expect(reviewTrust('http://example.com', 'catalog', [key], true)).rejects.toThrow() diff --git a/frontend/src/services/extensionTrustService.ts b/frontend/src/services/extensionTrustService.ts index 86eddd2..46d172f 100644 --- a/frontend/src/services/extensionTrustService.ts +++ b/frontend/src/services/extensionTrustService.ts @@ -24,7 +24,5 @@ export async function reviewTrust(url: string, sourceId: string, keys: Community } export async function confirmTrust(reviews: TrustReview[]): Promise { if (!reviews.length || reviews.length > 64) throw new Error('请重新检查来源后确认') - for (const review of reviews) { - await invoke('extension_trust_confirm', { request: { review_id: review.review_id, fingerprint: review.fingerprint } }) - } + await invoke('extension_trust_confirm_group', { requests: reviews.map(review => ({ review_id: review.review_id, fingerprint: review.fingerprint })) }) }