diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 1701f03..363fe36 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -158,3 +158,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 锁定 serde-saphyr 1.2.0 的 deserialize 特性与传递依赖,按其要求将声明 Rust 下限调整至 1.89;实际仍为 GNU 1.98.1,不作为最低工具链测试证据。 - Community 全量 15 项通过(两个既有弃用警告);Rust desktop 全目标 61 项通过(两个既有特殊入口 ignored)。随后核对 Theme 专用 ID,7 项扩展验证复跑与最终 Clippy `-D warnings` 通过。日志 `.build/extension-manifest-rust-tests.log`。 - 安全解包写盘、在线来源/撤回复核、安装库迁移、依赖与配置升级事务、授权和运行生命周期仍须实现,完整 D-01–04 和整体生产化继续进行。 + + +## 增量:Rust 持久扩展暂存库 + +- 新增独占应用目录的扩展暂存库 schema 1;只接收通过签名、ZIP、清单检查的发行。先 fsync 不可变 ZIP 对象,再在同一 SQLite 事务保存版本元数据和操作回执。来源/命名空间/包/版本组成不可变身份,签名内容改变不得原地替换。 +- 同操作重放返回原回执,仍重新检查撤销标志和 ZIP 完整性;不同请求重用操作 ID 拒绝。数据库和分页列表仅声明 staged,不声明安装/启用,也不产生运行许可。暂不清理无引用对象,避免中断后删除恢复材料。 +- 对象写入后、版本记录后、回执记录后、事务提交后,各注入错误并重开 20 轮,均恢复为一份版本及回执。另验证独占锁、不可变发行、撤销重放拒绝、对象损坏拒绝、数据库及 WAL/SHM/journal 预置硬链接拒绝且目标原文件不变。 +- Rust desktop 全目标 64 项与 Clippy `-D warnings` 通过;日志 `.build/extension-store-rust-tests.log`。这些是受控错误注入,不能替代完整 D-03 的真实断电/磁盘满矩阵。安全解包、活动版本和配置切换、在线信任/撤回、旧库迁移及运行生命周期尚待接入。 +- 同轮复查远端测试 Sync 的 `/ready` 返回 503 / DEPENDENCY_UNAVAILABLE,正继续定位;不视为服务器验收通过。 diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 525a399..d1f9ba6 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ unicode-casefold = "0.2" serde-saphyr = { version = "1.2", default-features = false, features = ["deserialize"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } [build-dependencies] tauri-build = { version = "2", optional = true , features = [] } diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs new file mode 100644 index 0000000..cf593fa --- /dev/null +++ b/frontend/src-tauri/src/extension_store.rs @@ -0,0 +1,485 @@ +//! Durable verified-package staging. Staging never enables a package or grants permissions. +use crate::{ + extension_package::Release, + workspace::{hash, HostError, Result}, +}; +use fs2::FileExt; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +pub struct Signer<'a> { + pub public_key: &'a [u8; 32], + pub key_id: &'a str, + pub namespace: &'a str, + pub revoked: bool, +} +pub struct Stage<'a> { + pub operation_id: &'a str, + pub source: &'a str, + pub signer: Signer<'a>, + pub release: &'a Release, + pub withdrawn: bool, + pub archive: &'a [u8], +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Receipt { + pub operation_id: String, + pub package_key: String, + pub archive_sha256: String, + pub version: String, + pub state: String, +} +#[derive(Debug, Serialize)] +pub struct StagedPackage { + pub package_key: String, + pub source: String, + pub namespace: String, + pub package_id: String, + pub version: String, + pub archive_sha256: String, + pub state: String, +} +pub struct ExtensionStore { + root: PathBuf, + db: Connection, + _lock: File, +} +fn ordinary(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + } + if metadata.is_file() { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + let file = File::open(path)?; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 + || info.nNumberOfLinks != 1 + { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + } + } + Ok(()) +} +fn source(value: &str) -> Result { + let mut url = + reqwest::Url::parse(value).map_err(|_| HostError::new("EXTENSION_SOURCE_INVALID"))?; + if value.len() > 2048 + || url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(HostError::new("EXTENSION_SOURCE_INVALID")); + } + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url.to_string()) +} +impl ExtensionStore { + pub fn staged(&self, offset: u32, limit: u32) -> Result> { + if limit == 0 || limit > 200 { + return Err(HostError::new("EXTENSION_PAGE_INVALID")); + } + let mut statement = self.db.prepare("SELECT package_key,source,namespace,package_id,version,archive_hash,state FROM versions ORDER BY package_key LIMIT ?1 OFFSET ?2")?; + let rows = statement.query_map(params![limit, offset], |r| { + Ok(StagedPackage { + package_key: r.get(0)?, + source: r.get(1)?, + namespace: r.get(2)?, + package_id: r.get(3)?, + version: r.get(4)?, + archive_sha256: r.get(5)?, + state: r.get(6)?, + }) + })?; + Ok(rows.collect::>()?) + } + /// Host supplies an existing application-owned directory, never a package-supplied path. + pub fn open(root: &Path) -> Result { + ordinary(root)?; + if !root.is_dir() { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + let root = root.canonicalize()?; + let lock_path = root.join("extensions.lock"); + if lock_path.exists() { + ordinary(&lock_path)?; + } + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(lock_path)?; + lock.try_lock_exclusive() + .map_err(|_| HostError::new("EXTENSION_STORE_BUSY"))?; + let objects = root.join("objects"); + if objects.exists() { + ordinary(&objects)?; + } else { + fs::create_dir(&objects)?; + } + let database = root.join("extensions.sqlite3"); + if database.exists() { + ordinary(&database)?; + } + for suffix in ["-wal", "-shm", "-journal"] { + let sidecar = root.join(format!("extensions.sqlite3{suffix}")); + if sidecar.exists() { + ordinary(&sidecar)?; + } + } + 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 { + return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE")); + } + 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;")?; + Ok(Self { + root, + db, + _lock: lock, + }) + } + pub fn stage(&mut self, request: Stage<'_>) -> Result { + self.stage_inner(request, |_| Ok(())) + } + fn stage_inner( + &mut self, + request: Stage<'_>, + mut checkpoint: impl FnMut(&str) -> Result<()>, + ) -> Result { + Uuid::parse_str(request.operation_id) + .map_err(|_| HostError::new("OPERATION_ID_INVALID"))?; + let source = source(request.source)?; + let release = request.release; + let (inventory, manifest) = release.verify_package( + request.signer.public_key, + request.signer.key_id, + request.signer.namespace, + request.signer.revoked, + request.withdrawn, + request.archive, + )?; + let key = hash( + serde_json::to_vec(&( + &source, + &release.namespace, + &release.package_id, + &release.version, + )) + .unwrap() + .as_slice(), + ); + let fingerprint = hash( + serde_json::to_vec(&(&source, release, request.signer.public_key.as_slice())) + .unwrap() + .as_slice(), + ); + if let Some((old, receipt)) = self + .db + .query_row( + "SELECT fingerprint,receipt FROM stage_operations WHERE id=?1", + [request.operation_id], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + ) + .optional()? + { + if old != fingerprint { + return Err(HostError::new("OPERATION_REUSED")); + } + let receipt: Receipt = serde_json::from_str(&receipt) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + self.verify_object(&receipt.archive_sha256, release.size)?; + return Ok(receipt); + } + let old: Option = self + .db + .query_row( + "SELECT fingerprint FROM versions WHERE package_key=?1", + [&key], + |r| r.get(0), + ) + .optional()?; + if old.is_some_and(|old| old != fingerprint) { + return Err(HostError::new("EXTENSION_VERSION_IMMUTABLE")); + } + let target = self.object_path(&release.sha256)?; + if target.exists() { + self.verify_object(&release.sha256, release.size)?; + } else { + let mut file = tempfile::NamedTempFile::new_in(target.parent().unwrap())?; + file.write_all(request.archive)?; + file.as_file().sync_all()?; + file.persist_noclobber(&target) + .map_err(|_| HostError::new("EXTENSION_STORE_WRITE_FAILED"))?; + #[cfg(unix)] + File::open(target.parent().unwrap())?.sync_all()?; + } + checkpoint("object_stored")?; + let receipt = Receipt { + operation_id: request.operation_id.into(), + package_key: key.clone(), + archive_sha256: release.sha256.clone(), + version: release.version.clone(), + state: "staged".into(), + }; + let tx = self.db.transaction()?; + tx.execute("INSERT OR IGNORE INTO versions VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,'staged')",params![key,source,release.namespace,release.package_id,release.version,fingerprint,serde_json::to_string(release).unwrap(),serde_json::to_string(&manifest).unwrap(),serde_json::to_string(&inventory.files).unwrap(),release.sha256,release.size,request.signer.public_key.as_slice()])?; + checkpoint("version_recorded")?; + tx.execute( + "INSERT INTO stage_operations VALUES (?1,?2,?3)", + params![ + request.operation_id, + fingerprint, + serde_json::to_string(&receipt).unwrap() + ], + )?; + checkpoint("receipt_recorded")?; + tx.commit()?; + checkpoint("committed")?; + Ok(receipt) + } + fn object_path(&self, digest: &str) -> Result { + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + ordinary(&self.root)?; + ordinary(&self.root.join("objects"))?; + Ok(self.root.join("objects").join(digest)) + } + fn verify_object(&self, digest: &str, size: u64) -> Result<()> { + let path = self.object_path(digest)?; + ordinary(&path)?; + crate::payloads::verify(&path, digest, size) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT")) + } + pub fn archive(&self, package_key: &str) -> Result> { + let (digest, size): (String, u64) = self.db.query_row( + "SELECT archive_hash,size FROM versions WHERE package_key=?1", + [package_key], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if size > 10 * 1024 * 1024 { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + self.verify_object(&digest, size)?; + let mut bytes = Vec::new(); + File::open(self.object_path(&digest)?)? + .take(size + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 != size || hash(&bytes) != digest { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer as _, SigningKey}; + use serde_json::Value; + fn fixture() -> (Release, Vec, [u8; 32]) { + let mut data: Value = serde_json::from_str(include_str!( + "../../src/services/fixtures/community-python-vector.json" + )) + .unwrap(); + for key in ["release_id", "withdrawn", "download_path"] { + data["release"].as_object_mut().unwrap().remove(key); + } + let mut release: Release = serde_json::from_value(data["release"].clone()).unwrap(); + let signer = SigningKey::from_bytes(&[7; 32]); + release.signature = + STANDARD.encode(signer.sign(&release.signed_payload().unwrap()).to_bytes()); + ( + release, + STANDARD + .decode(data["archive_base64"].as_str().unwrap()) + .unwrap(), + signer.verifying_key().to_bytes(), + ) + } + fn request<'a>( + id: &'a str, + release: &'a Release, + archive: &'a [u8], + key: &'a [u8; 32], + ) -> Stage<'a> { + Stage { + operation_id: id, + source: "https://catalog.example", + signer: Signer { + public_key: key, + key_id: "test-key", + namespace: "examples", + revoked: false, + }, + release, + withdrawn: false, + archive, + } + } + #[test] + fn staging_recovers_each_durable_boundary_twenty_times() { + let (release, archive, key) = fixture(); + for boundary in [ + "object_stored", + "version_recorded", + "receipt_recorded", + "committed", + ] { + for _ in 0..20 { + let root = tempfile::tempdir().unwrap(); + let mut store = ExtensionStore::open(root.path()).unwrap(); + let id = Uuid::new_v4().to_string(); + assert!(store + .stage_inner(request(&id, &release, &archive, &key), |point| { + if point == boundary { + Err(HostError::new("INJECTED_FAILURE")) + } else { + Ok(()) + } + }) + .is_err()); + drop(store); + let mut store = ExtensionStore::open(root.path()).unwrap(); + let receipt = store.stage(request(&id, &release, &archive, &key)).unwrap(); + assert_eq!(receipt.state, "staged"); + let items = store.staged(0, 100).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].package_key, receipt.package_key); + assert_eq!(items[0].source, "https://catalog.example/"); + assert_eq!(items[0].state, "staged"); + assert!(store.staged(1, 100).unwrap().is_empty()); + assert_eq!(store.archive(&receipt.package_key).unwrap(), archive); + assert_eq!( + store.stage(request(&id, &release, &archive, &key)).unwrap(), + receipt + ); + assert_eq!( + store + .db + .query_row("SELECT COUNT(*) FROM versions", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + store + .db + .query_row("SELECT COUNT(*) FROM stage_operations", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + } + } + } + #[test] + fn immutable_versions_and_revocation_are_not_bypassed_by_operation_replay() { + let (release, archive, key) = fixture(); + let root = tempfile::tempdir().unwrap(); + let mut store = ExtensionStore::open(root.path()).unwrap(); + let id = Uuid::new_v4().to_string(); + let receipt = store.stage(request(&id, &release, &archive, &key)).unwrap(); + let mut revoked = request(&id, &release, &archive, &key); + revoked.signer.revoked = true; + assert_eq!(store.stage(revoked).unwrap_err().code, "EXTENSION_REVOKED"); + let mut changed = release.clone(); + changed.name = "Different signed release".into(); + changed.signature = STANDARD.encode( + SigningKey::from_bytes(&[7; 32]) + .sign(&changed.signed_payload().unwrap()) + .to_bytes(), + ); + assert_eq!( + store + .stage(request(&id, &changed, &archive, &key)) + .unwrap_err() + .code, + "OPERATION_REUSED" + ); + assert_eq!( + store + .stage(request( + &Uuid::new_v4().to_string(), + &changed, + &archive, + &key + )) + .unwrap_err() + .code, + "EXTENSION_VERSION_IMMUTABLE" + ); + fs::write(store.object_path(&release.sha256).unwrap(), b"corrupt").unwrap(); + assert!(store.archive(&receipt.package_key).is_err()); + assert!(store.stage(request(&id, &release, &archive, &key)).is_err()); + } + #[test] + fn store_ownership_and_preexisting_hardlinks_are_rejected() { + let root = tempfile::tempdir().unwrap(); + let store = ExtensionStore::open(root.path()).unwrap(); + assert!(ExtensionStore::open(root.path()).is_err()); + drop(store); + ExtensionStore::open(root.path()).unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + let linked = tempfile::tempdir().unwrap(); + fs::hard_link(outside.path(), linked.path().join("extensions.sqlite3")).unwrap(); + assert!(ExtensionStore::open(linked.path()).is_err()); + assert_eq!(outside.as_file().metadata().unwrap().len(), 0); + for suffix in ["-wal", "-shm", "-journal"] { + let linked = tempfile::tempdir().unwrap(); + fs::hard_link( + outside.path(), + linked.path().join(format!("extensions.sqlite3{suffix}")), + ) + .unwrap(); + assert!(ExtensionStore::open(linked.path()).is_err()); + } + assert!(source("https://user:password@example.com").is_err()); + assert!(source("https://example.com/?token=secret").is_err()); + assert_eq!( + source("https://catalog.example").unwrap(), + "https://catalog.example/" + ); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index be6f207..e8e4d9b 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -30,3 +30,6 @@ pub mod extension_package; #[cfg(feature = "desktop")] pub mod extension_manifest; + +#[cfg(feature = "desktop")] +pub mod extension_store;