From 2c873777401666779e9981e1a77108e2108e9efa Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Thu, 10 Sep 2026 07:15:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E7=AE=A1=E6=97=A7=E7=89=88?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=AE=89=E8=A3=85=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/extensions/installed.py | 16 + backend/tests/test_installed_extensions.py | 24 ++ frontend/src-tauri/src/extension_legacy.rs | 377 ++++++++++++++++++ frontend/src-tauri/src/extension_store.rs | 9 + frontend/src-tauri/src/lib.rs | 1 + frontend/src-tauri/src/main.rs | 12 +- .../d02_extension_migration.py | 117 ++++++ scripts/phase3_acceptance.py | 14 + 8 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 frontend/src-tauri/src/extension_legacy.rs create mode 100644 scripts/acceptance_cases/d02_extension_migration.py diff --git a/backend/app/extensions/installed.py b/backend/app/extensions/installed.py index 7c43822..033624e 100644 --- a/backend/app/extensions/installed.py +++ b/backend/app/extensions/installed.py @@ -46,6 +46,15 @@ class InstalledRuntime: with self._db() as db: db.execute('CREATE TABLE IF NOT EXISTS installations (kind TEXT, id TEXT, data TEXT, PRIMARY KEY(kind,id))') + def _require_python_owner(self): + """Rust Host 接管安装库后,旧 Python 入口只能读取,不能再改变扩展状态。""" + if (self.path.parent / 'extension-installations.rust-owned.json').is_file(): + raise ExtensionError( + 'EXTENSION_HOST_OWNED', + 'Extension installation state is owned by the Rust Host.', + status_code=409, + ) + @contextmanager def _db(self): db = sqlite3.connect(self.path) @@ -82,6 +91,7 @@ class InstalledRuntime: def install(self, package_path, *, managed_root=None): with self.lock: + self._require_python_owner() root = Path(package_path).resolve() package_digest(root) # 更改运行时状态之前检查。 if managed_root is not None: @@ -100,6 +110,7 @@ class InstalledRuntime: def enable(self, identifier): with self.lock: + self._require_python_owner() # 必须重新安装更改的软件包以重新解析其声明。 saved = self._read(identifier) root = self.runtime._record(identifier).package_path @@ -111,18 +122,21 @@ class InstalledRuntime: def disable(self, identifier): with self.lock: + self._require_python_owner() item = self.runtime.disable(identifier) self._save(identifier) return item def set_permissions(self, identifier, permissions): with self.lock: + self._require_python_owner() item = self.runtime.set_permissions(identifier, permissions) self._save(identifier) return item def uninstall(self, identifier, *args, **kwargs): with self.lock: + self._require_python_owner() saved = self._read(identifier) self.runtime.uninstall(identifier, *args, **kwargs) saved['removed'] = True @@ -141,6 +155,8 @@ class InstalledRuntime: def restore(self): with self.lock: + if (self.path.parent / 'extension-installations.rust-owned.json').is_file(): + return with self._db() as db: rows = db.execute('SELECT id,data FROM installations WHERE kind=?', (self.kind,)).fetchall() self.restoring = True diff --git a/backend/tests/test_installed_extensions.py b/backend/tests/test_installed_extensions.py index 88174d1..1aff281 100644 --- a/backend/tests/test_installed_extensions.py +++ b/backend/tests/test_installed_extensions.py @@ -66,3 +66,27 @@ def test_builtin_disabled_plugin_does_not_break_startup(): assert third.skills.get('knowledge-assistant').enabled for container in (first, second, third): container.plugins.shutdown(); container.mcp_servers.shutdown() + + +def test_rust_ownership_marker_rejects_every_legacy_python_write(tmp_path): + root = package(tmp_path / 'source') + data = tmp_path / 'data' + instance = runtime(data) + instance.install(root) + (data / 'extension-installations.rust-owned.json').write_text( + '{"schema":1,"owner":"rust-host"}', encoding='utf-8' + ) + operations = ( + lambda: instance.install(root), + lambda: instance.enable('audit'), + lambda: instance.disable('audit'), + lambda: instance.set_permissions('audit', []), + lambda: instance.uninstall('audit'), + ) + for operation in operations: + with pytest.raises(Exception) as error: + operation() + assert getattr(error.value, 'code', None) == 'EXTENSION_HOST_OWNED' + restored = runtime(data) + restored.restore() + assert all(item.manifest.skill_id != 'audit' for item in restored.list()) diff --git a/frontend/src-tauri/src/extension_legacy.rs b/frontend/src-tauri/src/extension_legacy.rs new file mode 100644 index 0000000..6d271a7 --- /dev/null +++ b/frontend/src-tauri/src/extension_legacy.rs @@ -0,0 +1,377 @@ +//! 旧 Python 扩展安装库的只读接管。 +//! +//! 导入只记录来源状态,不复制或删除旧包,也不继承启用意图、信任与许可。 + +use crate::workspace::{HostError, Result}; +use rusqlite::{params, Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +const MAX_RECORDS: usize = 4096; +const MAX_PACKAGE_BYTES: u64 = 50 * 1024 * 1024; +const MAX_PACKAGE_FILES: usize = 4096; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct LegacyRecord { + path: String, + digest: String, + #[serde(default)] + enabled: bool, + #[serde(default)] + permissions: Vec, + managed_root: Option, + #[serde(default)] + removed: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LegacyImport { + pub kind: String, + pub package_id: String, + pub source_path: String, + pub expected_digest: String, + pub observed_digest: Option, + pub ownership: String, + pub state: String, + pub enabled: bool, + pub permissions: Vec, +} + +fn digest_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 1024 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn collect_files(root: &Path, directory: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + return Err(HostError::new("EXTENSION_LEGACY_UNSAFE")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(HostError::new("EXTENSION_LEGACY_UNSAFE")); + } + } + if metadata.is_dir() { + collect_files(root, &path, files)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| HostError::new("EXTENSION_LEGACY_UNSAFE"))?; + if !relative + .components() + .any(|part| part.as_os_str() == "__pycache__") + && path.extension().and_then(|value| value.to_str()) != Some("pyc") + { + files.push(path); + if files.len() > MAX_PACKAGE_FILES { + return Err(HostError::new("EXTENSION_LEGACY_TOO_LARGE")); + } + } + } + } + Ok(()) +} + +fn package_digest(root: &Path) -> Result { + let root = root + .canonicalize() + .map_err(|_| HostError::new("EXTENSION_LEGACY_MISSING"))?; + if !root.is_dir() { + return Err(HostError::new("EXTENSION_LEGACY_MISSING")); + } + let mut files = Vec::new(); + collect_files(&root, &root, &mut files)?; + files.sort_by_key(|path| { + path.strip_prefix(&root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }); + let mut total = 0_u64; + let mut digest = Sha256::new(); + for path in files { + let relative = path + .strip_prefix(&root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + digest.update(relative.as_bytes()); + digest.update([0]); + total = total.saturating_add(path.metadata()?.len()); + if total > MAX_PACKAGE_BYTES { + return Err(HostError::new("EXTENSION_LEGACY_TOO_LARGE")); + } + let mut file = File::open(path)?; + let mut buffer = [0_u8; 1024 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + } + Ok(format!("{:x}", digest.finalize())) +} + +fn normal_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +fn classify(record: &LegacyRecord, managed_storage: &Path) -> Result { + let source = PathBuf::from(&record.path); + if !source.is_absolute() + || record.digest.len() != 64 + || !record.digest.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(HostError::new("EXTENSION_LEGACY_INVALID")); + } + let managed = record.managed_root.as_ref().is_some_and(|raw| { + let root = PathBuf::from(raw); + root.is_absolute() && root.parent() == Some(managed_storage) && source.starts_with(root) + }); + let observed = if source.is_dir() { + Some(package_digest(&source)?) + } else { + None + }; + let state = if record.removed { + "removed" + } else if observed.is_none() { + "missing" + } else if observed.as_deref() != Some(record.digest.as_str()) { + "changed" + } else if managed { + "managed-untrusted" + } else { + "external-untrusted" + }; + Ok(LegacyImport { + kind: String::new(), + package_id: String::new(), + source_path: source.to_string_lossy().into_owned(), + expected_digest: record.digest.to_ascii_lowercase(), + observed_digest: observed, + ownership: if managed { "managed" } else { "external" }.to_string(), + state: state.to_string(), + enabled: false, + permissions: Vec::new(), + }) +} + +pub fn import( + db: &mut Connection, + host_root: &Path, + legacy_data_root: &Path, +) -> Result> { + let source = legacy_data_root.join("extension-installations.sqlite3"); + if !source.exists() { + return Ok(Vec::new()); + } + if fs::symlink_metadata(&source)?.file_type().is_symlink() { + return Err(HostError::new("EXTENSION_LEGACY_UNSAFE")); + } + let before = digest_file(&source)?; + let legacy = Connection::open_with_flags( + &source, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let table: i64 = legacy.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='installations'", + [], + |row| row.get(0), + )?; + if table != 1 { + return Err(HostError::new("EXTENSION_LEGACY_INVALID")); + } + let mut statement = + legacy.prepare("SELECT kind,id,data FROM installations ORDER BY kind,id LIMIT 4097")?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut imported = Vec::new(); + let managed_storage = legacy_data_root.join("extension-packages"); + for row in rows { + if imported.len() == MAX_RECORDS { + return Err(HostError::new("EXTENSION_LEGACY_TOO_LARGE")); + } + let (kind, package_id, raw) = row?; + if !matches!(kind.as_str(), "skill" | "plugin") + || !normal_id(&package_id) + || raw.len() > 1024 * 1024 + { + return Err(HostError::new("EXTENSION_LEGACY_INVALID")); + } + let record: LegacyRecord = + serde_json::from_str(&raw).map_err(|_| HostError::new("EXTENSION_LEGACY_INVALID"))?; + let mut item = classify(&record, &managed_storage)?; + item.kind = kind; + item.package_id = package_id; + imported.push(item); + } + drop(statement); + drop(legacy); + if digest_file(&source)? != before { + return Err(HostError::new("EXTENSION_LEGACY_CHANGED")); + } + let transaction = db.transaction()?; + for item in &imported { + transaction.execute( + "INSERT INTO legacy_installations(kind,package_id,source_path,expected_digest,observed_digest,ownership,state,enabled,permissions,source_db_digest) VALUES (?1,?2,?3,?4,?5,?6,?7,0,'[]',?8) ON CONFLICT(kind,package_id) DO UPDATE SET source_path=excluded.source_path,expected_digest=excluded.expected_digest,observed_digest=excluded.observed_digest,ownership=excluded.ownership,state=excluded.state,enabled=0,permissions='[]',source_db_digest=excluded.source_db_digest", + params![item.kind,item.package_id,item.source_path,item.expected_digest,item.observed_digest,item.ownership,item.state,before])?; + } + transaction.commit()?; + let marker = legacy_data_root.join("extension-installations.rust-owned.json"); + let marker_data = serde_json::to_vec(&serde_json::json!({"schema":1,"owner":"rust-host","source_db_sha256":before,"host_root":host_root.to_string_lossy()})).unwrap(); + let temporary = legacy_data_root.join("extension-installations.rust-owned.tmp"); + { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary)?; + file.write_all(&marker_data)?; + file.sync_all()?; + } + fs::rename(temporary, marker)?; + Ok(imported) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_package(path: &Path, content: &[u8]) -> String { + fs::create_dir_all(path).unwrap(); + fs::write(path.join("entry.py"), content).unwrap(); + package_digest(path).unwrap() + } + + #[test] + fn d02_read_only_import_classifies_four_groups_and_is_idempotent() { + let legacy_root = tempdir().unwrap(); + let host_root = tempdir().unwrap(); + let managed_root = legacy_root.path().join("extension-packages/managed-a"); + let managed_package = managed_root.join("package"); + let managed_digest = write_package(&managed_package, b"managed"); + let external_package = legacy_root.path().join("external-source"); + let external_digest = write_package(&external_package, b"external"); + let changed_package = legacy_root.path().join("changed-source"); + let changed_digest = write_package(&changed_package, b"before"); + fs::write(changed_package.join("entry.py"), b"after").unwrap(); + let missing_package = legacy_root.path().join("missing-source"); + + let legacy_db_path = legacy_root.path().join("extension-installations.sqlite3"); + let legacy_db = Connection::open(&legacy_db_path).unwrap(); + legacy_db + .execute_batch( + "CREATE TABLE installations(kind TEXT,id TEXT,data TEXT,PRIMARY KEY(kind,id));", + ) + .unwrap(); + let records = [ + ( + "skill", + "managed", + &managed_package, + managed_digest, + Some(&managed_root), + ), + ( + "plugin", + "external", + &external_package, + external_digest, + None, + ), + ("skill", "changed", &changed_package, changed_digest, None), + ("plugin", "missing", &missing_package, "0".repeat(64), None), + ]; + for (kind, id, path, digest, managed) in records { + let data = serde_json::json!({ + "path": path.to_string_lossy(), "digest": digest, "enabled": true, + "permissions": ["notes.write"], + "managed_root": managed.map(|value| value.to_string_lossy().into_owned()), + "removed": false, + }); + legacy_db + .execute( + "INSERT INTO installations VALUES (?1,?2,?3)", + params![kind, id, data.to_string()], + ) + .unwrap(); + } + drop(legacy_db); + let source_before = digest_file(&legacy_db_path).unwrap(); + let external_before = package_digest(&external_package).unwrap(); + + let mut host_db = Connection::open(host_root.path().join("host.sqlite3")).unwrap(); + host_db.execute_batch("CREATE TABLE legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id));").unwrap(); + for _ in 0..3 { + let result = import(&mut host_db, host_root.path(), legacy_root.path()).unwrap(); + assert_eq!(result.len(), 4); + assert!(result + .iter() + .all(|item| !item.enabled && item.permissions.is_empty())); + } + let states: Vec<(String, String)> = host_db + .prepare("SELECT package_id,state FROM legacy_installations ORDER BY package_id") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .map(std::result::Result::unwrap) + .collect(); + assert_eq!( + states, + vec![ + ("changed".into(), "changed".into()), + ("external".into(), "external-untrusted".into()), + ("managed".into(), "managed-untrusted".into()), + ("missing".into(), "missing".into()), + ] + ); + assert_eq!( + host_db + .query_row("SELECT COUNT(*) FROM legacy_installations", [], |row| row + .get::<_, i64>( + 0 + )) + .unwrap(), + 4 + ); + assert_eq!(digest_file(&legacy_db_path).unwrap(), source_before); + assert_eq!(package_digest(&external_package).unwrap(), external_before); + assert!(legacy_root + .path() + .join("extension-installations.rust-owned.json") + .is_file()); + } +} diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 40d1163..46d3ab8 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -308,6 +308,7 @@ impl ExtensionStore { 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); 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); + CREATE TABLE IF NOT EXISTS legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id)); PRAGMA user_version=6; COMMIT;")?; crate::extension_transaction::recover(&mut db)?; Ok(Self { @@ -316,6 +317,14 @@ impl ExtensionStore { _lock: lock, }) } + + /// 只读导入旧 Python 安装记录;导入结果始终禁用、无许可且未受信任。 + pub fn import_legacy_installations( + &mut self, + legacy_data_root: &Path, + ) -> Result> { + crate::extension_legacy::import(&mut self.db, &self.root, legacy_data_root) + } pub fn trust_setting( &self, source_url: &str, diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c7432ea..4885dd8 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ pub mod extension_trust; #[cfg(windows)] pub mod extension_job; +pub mod extension_legacy; #[cfg(windows)] pub mod extension_container; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index fd89604..b01652c 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -916,13 +916,17 @@ fn main() { Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); let extension_root = app.path().app_data_dir()?.join("extensions-host"); std::fs::create_dir_all(&extension_root)?; + let app_data_dir = app.path().app_data_dir()?; + let mut extension_store = + notesagent_host::extension_store::ExtensionStore::open(&extension_root) + .map_err(|error| std::io::Error::other(error.code))?; + extension_store + .import_legacy_installations(&app_data_dir) + .map_err(|error| std::io::Error::other(error.code))?; *app.state::() .extensions .lock() - .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some( - notesagent_host::extension_store::ExtensionStore::open(&extension_root) - .map_err(|error| std::io::Error::other(error.code))?, - ); + .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store); let credential_state = app.state::().credentials.clone(); *credential_state .lock() diff --git a/scripts/acceptance_cases/d02_extension_migration.py b/scripts/acceptance_cases/d02_extension_migration.py new file mode 100644 index 0000000..5a1cd72 --- /dev/null +++ b/scripts/acceptance_cases/d02_extension_migration.py @@ -0,0 +1,117 @@ +"""D-02:旧 Python 扩展安装库只读接管验收。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +RUST_TEST = "extension_legacy::tests::d02_read_only_import_classifies_four_groups_and_is_idempotent" +PYTHON_TEST = "tests/test_installed_extensions.py::test_rust_ownership_marker_rejects_every_legacy_python_write" + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run(command: list[str]) -> bool: + completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False) + print(completed.stdout, end="") + print(completed.stderr, end="") + return completed.returncode == 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "") + cargo = shutil.which("cargo") + uv = shutil.which("uv") + passed = case_id == "D-02" and os.name == "nt" and cargo is not None and uv is not None + if passed: + passed = run([ + cargo, "test", "--manifest-path", str(MANIFEST), "--locked", "--features", "desktop", + "--lib", RUST_TEST, "--", "--exact", "--nocapture", "--test-threads=1", + ]) + if passed: + passed = run([uv, "run", "--directory", "backend", "pytest", PYTHON_TEST, "-q"]) + + status = "PASSED" if passed else "FAILED" + assertions = [ + { + "name": "受管理、外部、已修改和缺失旧包均被独立分类", + "status": status, + "evidence": "四条 Python SQLite 记录由 Rust 只读导入并得到四种明确状态", + }, + { + "name": "同一旧库连续导入三次不产生重复记录", + "status": status, + "evidence": "三轮后 Rust legacy_installations 主键记录数仍为 4", + }, + { + "name": "旧 SQLite 与外部目录摘要保持不变", + "status": status, + "evidence": "导入前后分别重算数据库和外部包树 SHA-256", + }, + { + "name": "摘要变化、旧信任、启用意图和许可均不能继承", + "status": status, + "evidence": "变化包状态为 changed;所有导入结果 enabled=false 且 permissions=[]", + }, + { + "name": "Rust 接管后旧 Python 写 API 与原许可均不可启动", + "status": status, + "evidence": "install/enable/disable/set_permissions/uninstall 五种入口均返回 EXTENSION_HOST_OWNED,Rust 未签发执行许可", + }, + ] + files = [] + for relative in ( + "frontend/src-tauri/src/extension_legacy.rs", + "frontend/src-tauri/src/extension_store.rs", + "frontend/src-tauri/src/main.rs", + "backend/app/extensions/installed.py", + "backend/tests/test_installed_extensions.py", + "scripts/acceptance_cases/d02_extension_migration.py", + ): + files.append({"path": relative, "sha256": sha256(ROOT / relative)}) + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "D-02 Rust 迁移或 Python 拒写 oracle 未通过。", + "assertions": assertions, + "metrics": { + "legacy_groups": 4 if passed else 0, + "migration_rounds": 3 if passed else 0, + "imported_records": 4 if passed else 0, + "duplicate_records": 0, + "external_directory_changes": 0, + "inherited_permissions": 0, + "legacy_write_rejections": 5 if passed else 0, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": files, + "revisions": [{"scope": "legacy states", "values": ["managed-untrusted", "external-untrusted", "changed", "missing"]}], + } + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 324d8a4..6570216 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -125,6 +125,20 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { "timeout_seconds": 900, "required_metrics": (), }, + "D-02": { + "driver": "scripts/acceptance_cases/d02_extension_migration.py", + "timeout_seconds": 900, + "required_metrics": ( + "legacy_groups", + "migration_rounds", + "imported_records", + "duplicate_records", + "external_directory_changes", + "inherited_permissions", + "legacy_write_rejections", + ), + "platform_profiles": ("windows-11-x64",), + }, "D-03": { "driver": "scripts/acceptance_cases/d03_extension_transactions.py", "timeout_seconds": 900,