diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index ee98bc2..38dc1e0 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -252,3 +252,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 在线及底层准备切换均检查持久拒绝。重启后仍拒绝,发行撤回不影响其他版本,旧键撤销不自动禁止另一个显式确认的新公钥,但新公钥也不能复活已撤回版本。 - 30 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-revocation-tests.log`。测试覆盖持久性、信任确认后仍拒绝、不同版本/来源/公钥范围和网络失败不污染拒绝记录。 - 尚未接入运行中实例和工具注册表的撤销轮询/停止,不能据此声明 D-04 的 5 秒撤销目标通过。恢复旧指针仍可保留数据,但不授予运行许可。 + + +## 增量:完整安装预览与持久确认计划 + +- installation_preview 组合确定性依赖锁、全部包权限、配置、准备目录/展开摘要、信任设置摘要、目标 Vault/应用/平台/架构及当前活动 revision。未信任、已撤销、错误配置、非计划包配置或待健康检查事务均拒绝,预览不切换活动指针或授予许可。 +- install_confirmed 首次执行重新生成预览并比较确认摘要,再进入在线复核。schema 6 保存操作 ID 对应的请求摘要、确认摘要和冻结切换组,网络或进程中断重试沿用原组;改变请求/确认并重用 ID 拒绝。重试仍经过来源/撤销/归档/配置检查和活动 revision CAS。 +- 31 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-install-preview-tests.log`。新增预览重开摘要稳定、应用版本/活动状态变化使预览失效、错误确认在联网前失败、未知配置拒绝,以及确认计划重开重放不重新选择内容的测试。 +- 真实主窗口确认 UI、Host 注入的应用/平台信息、旧实例停机、沙箱迁移和健康探测仍待接入。确认摘要 API 本身不证明用户授权;本轮没有执行第三方包或宣称整体生产化完成。 diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 63ebc84..fa759f2 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -84,6 +84,22 @@ impl TrustSetting { Ok(hash(&serde_json::to_vec(self).unwrap())) } } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallRequest { + pub root_key: String, + pub vault_id: String, + pub app_version: String, + pub platform: String, + pub architecture: String, + pub configurations: std::collections::BTreeMap, +} +#[derive(Debug, Serialize)] +pub struct InstallPreview { + pub fingerprint: String, + pub dependencies: crate::extension_dependencies::Plan, + pub changes: Vec, +} pub struct ExtensionStore { root: PathBuf, db: Connection, @@ -272,10 +288,10 @@ impl ExtensionStore { let mut db = Connection::open(database)?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version > 5 { + if version > 6 { return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE")); } - if (1..5).contains(&version) { + if (1..6).contains(&version) { let backup = root.join(format!( "extensions.schema{version}.{}.sqlite3", Uuid::new_v4() @@ -291,7 +307,8 @@ impl ExtensionStore { CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id)); CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL); - PRAGMA user_version=5; COMMIT;")?; + CREATE TABLE IF NOT EXISTS extension_confirmations(operation_id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,review_hash TEXT NOT NULL,changes TEXT NOT NULL); + PRAGMA user_version=6; COMMIT;")?; crate::extension_transaction::recover(&mut db)?; Ok(Self { root, @@ -431,6 +448,158 @@ impl ExtensionStore { )?; Ok(()) } + /// Builds the complete consent payload; staging work is allowed, but active + /// pointers, running instances and permissions are untouched. + pub fn installation_preview(&mut self, request: &InstallRequest) -> Result { + use crate::extension_transaction::{Change, Target}; + let vault = Uuid::parse_str(&request.vault_id) + .map_err(|_| HostError::new("VAULT_INVALID"))? + .to_string(); + if vault != request.vault_id || request.configurations.len() > 200 { + return Err(HostError::new("EXTENSION_TRANSACTION_INVALID")); + } + let dependencies = self.dependency_plan( + &request.root_key, + &request.app_version, + &request.platform, + &request.architecture, + )?; + for key in request.configurations.keys() { + if !dependencies.packages.iter().any(|p| &p.package_key == key) { + return Err(HostError::new("EXTENSION_CONFIG_INVALID")); + } + } + let mut changes = Vec::new(); + let mut trust_revisions = Vec::new(); + for locked in &dependencies.packages { + let (json, key): (String, Vec) = self.db.query_row( + "SELECT release,signer FROM versions WHERE package_key=?1", + [&locked.package_key], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + let release: Release = serde_json::from_str(&json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let key: [u8; 32] = key + .try_into() + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + self.check_not_revoked(&locked.source, &release, &key)?; + let trusted = self + .trust_setting(&locked.source, &release.namespace, &release.key_id)? + .ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?; + if !trusted.enabled || trusted.public_key != key { + return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED")); + } + trust_revisions.push(trusted.fingerprint()?); + let configuration = request + .configurations + .get(&locked.package_key) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let archive = self.archive(&locked.package_key)?; + let (_, manifest) = release.verify_package( + &key, + &release.key_id, + &release.namespace, + false, + false, + &archive, + )?; + crate::extension_config::validate(&manifest, &configuration)?; + let prepared = self.prepare( + &locked.package_key, + Signer { + public_key: &key, + key_id: &release.key_id, + namespace: &release.namespace, + revoked: false, + }, + false, + )?; + let slot = hash( + &serde_json::to_vec(&( + &vault, + &locked.source, + &release.namespace, + &release.package_id, + )) + .unwrap(), + ); + let current = self.active_installation(&slot)?; + if current + .as_ref() + .is_some_and(|p| p.pending_operation.is_some()) + { + return Err(HostError::new("EXTENSION_TRANSACTION_BUSY")); + } + changes.push(Change { + target: Target { + slot, + package_key: locked.package_key.clone(), + directory: prepared.directory, + tree_sha256: prepared.tree_sha256, + configuration, + }, + expected_revision: current.map(|p| p.revision), + }); + } + let bytes = + serde_json::to_vec(&(request, &dependencies, &changes, trust_revisions)).unwrap(); + if bytes.len() > 4 * 1024 * 1024 { + return Err(HostError::new("EXTENSION_TRANSACTION_INVALID")); + } + Ok(InstallPreview { + fingerprint: hash(&bytes), + dependencies, + changes, + }) + } + /// Recompute the exact reviewed payload before online checks. The main-window + /// confirmation UI must supply this digest; this method alone is not consent. + pub async fn install_confirmed( + &mut self, + operation: &str, + request: &InstallRequest, + confirmed_fingerprint: &str, + ) -> Result { + let changes = self.lock_confirmed_plan(operation, request, confirmed_fingerprint)?; + self.switch_online(operation, &request.vault_id, &changes) + .await + } + fn lock_confirmed_plan( + &mut self, + operation: &str, + request: &InstallRequest, + confirmed_fingerprint: &str, + ) -> Result> { + Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?; + let request_bytes = serde_json::to_vec(request).unwrap(); + if request_bytes.len() > 4 * 1024 * 1024 { + return Err(HostError::new("EXTENSION_TRANSACTION_INVALID")); + } + let request_hash = hash(&request_bytes); + let existing:Option<(String,String,String)>=self.db.query_row("SELECT request_hash,review_hash,changes FROM extension_confirmations WHERE operation_id=?1",[operation],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?; + if let Some((old_request, old_review, json)) = existing { + if old_request != request_hash || old_review != confirmed_fingerprint { + return Err(HostError::new("OPERATION_REUSED")); + } + return serde_json::from_str(&json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT")); + } + let preview = self.installation_preview(request)?; + if preview.fingerprint != confirmed_fingerprint { + return Err(HostError::new("EXTENSION_INSTALL_REVIEW_CHANGED")); + } + self.db.execute( + "INSERT INTO extension_confirmations VALUES (?1,?2,?3,?4)", + params![ + operation, + request_hash, + confirmed_fingerprint, + serde_json::to_string(&preview.changes).unwrap() + ], + )?; + Ok(preview.changes) + } /// Online installation gate, using confirmed Host trust settings only. pub async fn switch_online( &mut self, @@ -862,6 +1031,107 @@ mod tests { archive, } } + #[tokio::test] + async fn installation_preview_binds_target_state_and_rejects_stale_confirmation_before_network() + { + let temp = tempfile::tempdir().unwrap(); + let (release, archive, key) = fixture(); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let staged = store + .stage(request( + &Uuid::new_v4().to_string(), + &release, + &archive, + &key, + )) + .unwrap(); + let setting = TrustSetting { + source: "https://catalog.example/".into(), + source_id: "catalog".into(), + namespace: release.namespace.clone(), + key_id: release.key_id.clone(), + public_key: key, + enabled: true, + }; + store + .confirm_trust(&setting, None, &setting.fingerprint().unwrap()) + .unwrap(); + let mut req = InstallRequest { + root_key: staged.package_key, + vault_id: Uuid::new_v4().to_string(), + app_version: "1.0.0".into(), + platform: "windows".into(), + architecture: "x86_64".into(), + configurations: Default::default(), + }; + let preview = store.installation_preview(&req).unwrap(); + assert_eq!( + preview.fingerprint, + store.installation_preview(&req).unwrap().fingerprint + ); + drop(store); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + assert_eq!( + preview.fingerprint, + store.installation_preview(&req).unwrap().fingerprint + ); + assert_eq!( + store + .install_confirmed(&Uuid::new_v4().to_string(), &req, "stale") + .await + .unwrap_err() + .code, + "EXTENSION_INSTALL_REVIEW_CHANGED" + ); + req.app_version = "1.0.1".into(); + assert_ne!( + preview.fingerprint, + store.installation_preview(&req).unwrap().fingerprint + ); + req.app_version = "1.0.0".into(); + let confirmed_operation = Uuid::new_v4().to_string(); + let locked = store + .lock_confirmed_plan(&confirmed_operation, &req, &preview.fingerprint) + .unwrap(); + let id = Uuid::new_v4().to_string(); + store + .switch_prepared(&id, &req.vault_id, &preview.changes) + .unwrap(); + assert!(store.installation_preview(&req).is_err()); + store.finish_installation(&id, true).unwrap(); + assert_eq!( + locked, + store + .lock_confirmed_plan(&confirmed_operation, &req, &preview.fingerprint) + .unwrap() + ); + drop(store); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + assert_eq!( + locked, + store + .lock_confirmed_plan(&confirmed_operation, &req, &preview.fingerprint) + .unwrap() + ); + assert!(store + .lock_confirmed_plan(&confirmed_operation, &req, "different") + .is_err()); + assert_ne!( + preview.fingerprint, + store.installation_preview(&req).unwrap().fingerprint + ); + assert_eq!( + store + .install_confirmed(&Uuid::new_v4().to_string(), &req, &preview.fingerprint) + .await + .unwrap_err() + .code, + "EXTENSION_INSTALL_REVIEW_CHANGED" + ); + req.configurations + .insert("unknown-package".into(), serde_json::json!({})); + assert!(store.installation_preview(&req).is_err()); + } #[test] fn revocations_survive_restart_and_consent_without_overblocking_other_releases() { let temp = tempfile::tempdir().unwrap();