feat: 接管旧版扩展安装记录

This commit is contained in:
2026-09-10 07:15:23 +08:00
parent 2f1c0f3420
commit 2c87377740
8 changed files with 566 additions and 4 deletions
+16
View File
@@ -46,6 +46,15 @@ class InstalledRuntime:
with self._db() as db: with self._db() as db:
db.execute('CREATE TABLE IF NOT EXISTS installations (kind TEXT, id TEXT, data TEXT, PRIMARY KEY(kind,id))') 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 @contextmanager
def _db(self): def _db(self):
db = sqlite3.connect(self.path) db = sqlite3.connect(self.path)
@@ -82,6 +91,7 @@ class InstalledRuntime:
def install(self, package_path, *, managed_root=None): def install(self, package_path, *, managed_root=None):
with self.lock: with self.lock:
self._require_python_owner()
root = Path(package_path).resolve() root = Path(package_path).resolve()
package_digest(root) # 更改运行时状态之前检查。 package_digest(root) # 更改运行时状态之前检查。
if managed_root is not None: if managed_root is not None:
@@ -100,6 +110,7 @@ class InstalledRuntime:
def enable(self, identifier): def enable(self, identifier):
with self.lock: with self.lock:
self._require_python_owner()
# 必须重新安装更改的软件包以重新解析其声明。 # 必须重新安装更改的软件包以重新解析其声明。
saved = self._read(identifier) saved = self._read(identifier)
root = self.runtime._record(identifier).package_path root = self.runtime._record(identifier).package_path
@@ -111,18 +122,21 @@ class InstalledRuntime:
def disable(self, identifier): def disable(self, identifier):
with self.lock: with self.lock:
self._require_python_owner()
item = self.runtime.disable(identifier) item = self.runtime.disable(identifier)
self._save(identifier) self._save(identifier)
return item return item
def set_permissions(self, identifier, permissions): def set_permissions(self, identifier, permissions):
with self.lock: with self.lock:
self._require_python_owner()
item = self.runtime.set_permissions(identifier, permissions) item = self.runtime.set_permissions(identifier, permissions)
self._save(identifier) self._save(identifier)
return item return item
def uninstall(self, identifier, *args, **kwargs): def uninstall(self, identifier, *args, **kwargs):
with self.lock: with self.lock:
self._require_python_owner()
saved = self._read(identifier) saved = self._read(identifier)
self.runtime.uninstall(identifier, *args, **kwargs) self.runtime.uninstall(identifier, *args, **kwargs)
saved['removed'] = True saved['removed'] = True
@@ -141,6 +155,8 @@ class InstalledRuntime:
def restore(self): def restore(self):
with self.lock: with self.lock:
if (self.path.parent / 'extension-installations.rust-owned.json').is_file():
return
with self._db() as db: with self._db() as db:
rows = db.execute('SELECT id,data FROM installations WHERE kind=?', (self.kind,)).fetchall() rows = db.execute('SELECT id,data FROM installations WHERE kind=?', (self.kind,)).fetchall()
self.restoring = True self.restoring = True
@@ -66,3 +66,27 @@ def test_builtin_disabled_plugin_does_not_break_startup():
assert third.skills.get('knowledge-assistant').enabled assert third.skills.get('knowledge-assistant').enabled
for container in (first, second, third): for container in (first, second, third):
container.plugins.shutdown(); container.mcp_servers.shutdown() 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())
+377
View File
@@ -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<String>,
managed_root: Option<String>,
#[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<String>,
pub ownership: String,
pub state: String,
pub enabled: bool,
pub permissions: Vec<String>,
}
fn digest_file(path: &Path) -> Result<String> {
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<PathBuf>) -> 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<String> {
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<LegacyImport> {
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<Vec<LegacyImport>> {
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());
}
}
@@ -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_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_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 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;")?; PRAGMA user_version=6; COMMIT;")?;
crate::extension_transaction::recover(&mut db)?; crate::extension_transaction::recover(&mut db)?;
Ok(Self { Ok(Self {
@@ -316,6 +317,14 @@ impl ExtensionStore {
_lock: lock, _lock: lock,
}) })
} }
/// 只读导入旧 Python 安装记录;导入结果始终禁用、无许可且未受信任。
pub fn import_legacy_installations(
&mut self,
legacy_data_root: &Path,
) -> Result<Vec<crate::extension_legacy::LegacyImport>> {
crate::extension_legacy::import(&mut self.db, &self.root, legacy_data_root)
}
pub fn trust_setting( pub fn trust_setting(
&self, &self,
source_url: &str, source_url: &str,
+1
View File
@@ -54,6 +54,7 @@ pub mod extension_trust;
#[cfg(windows)] #[cfg(windows)]
pub mod extension_job; pub mod extension_job;
pub mod extension_legacy;
#[cfg(windows)] #[cfg(windows)]
pub mod extension_container; pub mod extension_container;
+8 -4
View File
@@ -916,13 +916,17 @@ fn main() {
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
let extension_root = app.path().app_data_dir()?.join("extensions-host"); let extension_root = app.path().app_data_dir()?.join("extensions-host");
std::fs::create_dir_all(&extension_root)?; 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::<Host>() *app.state::<Host>()
.extensions .extensions
.lock() .lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some( .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store);
notesagent_host::extension_store::ExtensionStore::open(&extension_root)
.map_err(|error| std::io::Error::other(error.code))?,
);
let credential_state = app.state::<Host>().credentials.clone(); let credential_state = app.state::<Host>().credentials.clone();
*credential_state *credential_state
.lock() .lock()
@@ -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_OWNEDRust 未签发执行许可",
},
]
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())
+14
View File
@@ -125,6 +125,20 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
"timeout_seconds": 900, "timeout_seconds": 900,
"required_metrics": (), "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": { "D-03": {
"driver": "scripts/acceptance_cases/d03_extension_transactions.py", "driver": "scripts/acceptance_cases/d03_extension_transactions.py",
"timeout_seconds": 900, "timeout_seconds": 900,