From cf03d2e0db8fdc644344729bfde8e0aa8193741f Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 21:12:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(extensions):=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=B7=B2=E9=AA=8C=E8=AF=81=E6=8F=90=E5=8F=96=E5=87=86=E5=A4=87?= =?UTF-8?q?=E4=B8=8E=E6=81=A2=E5=A4=8D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 8 + frontend/src-tauri/src/extension_store.rs | 242 +++++++++++++++++- 2 files changed, 248 insertions(+), 2 deletions(-) diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index da102e5..a18ca3f 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -191,3 +191,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 文件逐个 sync_all,Unix 同步目录项;失败目录保留隔离状态,不标记为当前安装。未验证 Windows 断电持久性,也未将静态链接测试等同于完整并发替换攻击防护。 - 17 项扩展模块回归和全目标 Clippy -D warnings 通过,日志 `.build/extension-unpack-tests.log`。新增实际 Windows junction 越界拒绝、硬链接替换拒绝、原文件不变、篡改/缺失/额外文件及空目录拒绝测试。 - 此模块尚未接入持久安装操作、在线撤回复核、权限审批、原子切换或健康检查;扩展执行能力仍关闭。远端 Sync ready=503 和 SSH 认证前断开问题也尚未解决,不宣称整体生产化验收完成。 + + +## 增量:持久化解包准备与升级恢复 + +- ExtensionStore.prepare 接入已验证暂存包,要求调用者提供当前签名者和撤销状态;首次及重放均核对签名、归档、清单,重放还重新读取已准备目录的精确文件集合和摘要。数据库中的目录名只接受规范 UUID,不能提供任意路径。 +- schema 2 新增 prepared_packages,记录每个不可变包的准备目录和树摘要。解包完成、记录写入、事务提交三个边界分别注入错误并重开 20 次,最终只有一份有效记录,重放结果稳定。提交前遗留的隔离目录暂不回收,避免误删恢复材料;这不是安装事务或真实断电验收。 +- schema 1 升级前 VACUUM INTO 创建独立备份并同步。测试验证原暂存版本不变、备份可读且仍为 schema 1、重复打开不再备份;修复 Windows 只读文件句柄无法执行备份 sync_all 的问题。 +- 19 项扩展回归通过,日志 `.build/extension-prepare-tests.log`。当前仍只产生准备结果,未开放扩展执行或自动授予权限;在线信任刷新、依赖集合安装、配置切换、健康检查及回滚继续待完成。 diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index aac672b..f6e026e 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -45,6 +45,12 @@ pub struct StagedPackage { pub archive_sha256: String, pub state: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct PreparedPackage { + pub package_key: String, + pub directory: String, + pub tree_sha256: String, +} pub struct ExtensionStore { root: PathBuf, db: Connection, @@ -233,19 +239,126 @@ impl ExtensionStore { let 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 > 1 { + if version > 2 { return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE")); } + if version == 1 { + let backup = root.join(format!("extensions.schema1.{}.sqlite3", Uuid::new_v4())); + db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?; + OpenOptions::new().write(true).open(backup)?.sync_all()?; + } db.execute_batch("BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS versions (package_key TEXT PRIMARY KEY,source TEXT NOT NULL,namespace TEXT NOT NULL,package_id TEXT NOT NULL,version TEXT NOT NULL,fingerprint TEXT NOT NULL,release TEXT NOT NULL,manifest TEXT NOT NULL,inventory TEXT NOT NULL,archive_hash TEXT NOT NULL,size INTEGER NOT NULL,signer BLOB NOT NULL,state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS stage_operations (id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,receipt TEXT NOT NULL); - PRAGMA user_version=1; COMMIT;")?; + CREATE TABLE IF NOT EXISTS prepared_packages (package_key TEXT PRIMARY KEY REFERENCES versions(package_key),directory TEXT NOT NULL UNIQUE,tree_sha256 TEXT NOT NULL); + PRAGMA user_version=2; COMMIT;")?; Ok(Self { root, db, _lock: lock, }) } + /// Prepare a verified staged package. The caller supplies current signer/revocation + /// policy; persisted preparation does not bypass that policy on replay. + pub fn prepare( + &mut self, + package_key: &str, + signer: Signer<'_>, + withdrawn: bool, + ) -> Result { + self.prepare_inner(package_key, signer, withdrawn, |_| Ok(())) + } + fn prepare_inner( + &mut self, + package_key: &str, + signer: Signer<'_>, + withdrawn: bool, + mut checkpoint: impl FnMut(&str) -> Result<()>, + ) -> Result { + use cap_fs_ext::DirExt; + let (json, stored_key): (String, Vec) = self.db.query_row( + "SELECT release,signer FROM versions WHERE package_key=?1", + [package_key], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if stored_key.as_slice() != signer.public_key { + return Err(HostError::new("EXTENSION_SIGNER_MISMATCH")); + } + let release: Release = + serde_json::from_str(&json).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let archive = self.archive(package_key)?; + let (inventory, _) = release.verify_package( + signer.public_key, + signer.key_id, + signer.namespace, + signer.revoked, + withdrawn, + &archive, + )?; + let root = cap_std::fs::Dir::open_ambient_dir(&self.root, cap_std::ambient_authority())?; + match root.create_dir("prepared") { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + #[cfg(unix)] + root.try_clone()?.into_std_file().sync_all()?; + let staging = root.open_dir_nofollow("prepared")?; + let existing: Option<(String, String)> = self + .db + .query_row( + "SELECT directory,tree_sha256 FROM prepared_packages WHERE package_key=?1", + [package_key], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + if let Some((directory, tree_sha256)) = existing { + // Database data never supplies an arbitrary relative path. + if Uuid::parse_str(&directory) + .map(|id| id.to_string()) + .ok() + .as_ref() + != Some(&directory) + { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + let actual = crate::extension_unpack::verify_tree( + &staging.open_dir_nofollow(&directory)?, + &inventory, + )?; + if actual != tree_sha256 { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + return Ok(PreparedPackage { + package_key: package_key.into(), + directory, + tree_sha256, + }); + } + let prepared = crate::extension_unpack::prepare( + &staging, + &release, + signer.public_key, + signer.key_id, + signer.namespace, + &archive, + )?; + checkpoint("tree_prepared")?; + let receipt = PreparedPackage { + package_key: package_key.into(), + directory: prepared.directory, + tree_sha256: prepared.tree_sha256, + }; + let transaction = self.db.transaction()?; + transaction.execute( + "INSERT INTO prepared_packages VALUES (?1,?2,?3)", + params![receipt.package_key, receipt.directory, receipt.tree_sha256], + )?; + checkpoint("preparation_recorded")?; + transaction.commit()?; + checkpoint("preparation_committed")?; + Ok(receipt) + } pub fn stage(&mut self, request: Stage<'_>) -> Result { self.stage_inner(request, |_| Ok(())) } @@ -431,6 +544,131 @@ mod tests { } } #[test] + fn schema_one_upgrade_preserves_versions_and_creates_readable_backup() { + let temp = tempfile::tempdir().unwrap(); + let (release, archive, key) = fixture(); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let receipt = store + .stage(request( + &Uuid::new_v4().to_string(), + &release, + &archive, + &key, + )) + .unwrap(); + store + .db + .execute_batch("DROP TABLE prepared_packages; PRAGMA user_version=1;") + .unwrap(); + drop(store); + let store = ExtensionStore::open(temp.path()).unwrap(); + assert_eq!(store.archive(&receipt.package_key).unwrap(), archive); + let backups: Vec<_> = fs::read_dir(temp.path()) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| { + p.file_name() + .unwrap() + .to_string_lossy() + .starts_with("extensions.schema1.") + }) + .collect(); + assert_eq!(backups.len(), 1); + let backup = Connection::open(&backups[0]).unwrap(); + assert_eq!( + backup + .query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + backup + .query_row("SELECT COUNT(*) FROM versions", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + drop(backup); + drop(store); + let _store = ExtensionStore::open(temp.path()).unwrap(); + assert_eq!( + fs::read_dir(temp.path()) + .unwrap() + .filter(|e| e + .as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("extensions.schema1.")) + .count(), + 1 + ); + } + #[test] + fn preparation_replays_checks_policy_and_recovers_each_boundary() { + let (release, archive, key) = fixture(); + for boundary in [ + "tree_prepared", + "preparation_recorded", + "preparation_committed", + ] { + for _ in 0..20 { + let root = tempfile::tempdir().unwrap(); + let mut store = ExtensionStore::open(root.path()).unwrap(); + let staged = store + .stage(request( + &Uuid::new_v4().to_string(), + &release, + &archive, + &key, + )) + .unwrap(); + let signer = || Signer { + public_key: &key, + key_id: "test-key", + namespace: "examples", + revoked: false, + }; + assert!(store + .prepare_inner(&staged.package_key, signer(), false, |at| { + if at == boundary { + Err(HostError::new("INJECTED")) + } else { + Ok(()) + } + }) + .is_err()); + drop(store); + let mut store = ExtensionStore::open(root.path()).unwrap(); + let prepared = store.prepare(&staged.package_key, signer(), false).unwrap(); + assert_eq!( + prepared, + store.prepare(&staged.package_key, signer(), false).unwrap() + ); + assert_eq!( + store + .db + .query_row("SELECT COUNT(*) FROM prepared_packages", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert!(store.prepare(&staged.package_key, signer(), true).is_err()); + let mut revoked = signer(); + revoked.revoked = true; + assert!(store.prepare(&staged.package_key, revoked, false).is_err()); + fs::write( + root.path() + .join("prepared") + .join(&prepared.directory) + .join("persona.json"), + b"changed", + ) + .unwrap(); + assert!(store.prepare(&staged.package_key, signer(), false).is_err()); + } + } + } + #[test] fn staged_dependency_preview_survives_reopen_and_rechecks_archive_integrity() { let (mut release, archive, key) = fixture(); let root = tempfile::tempdir().unwrap();