feat: 接管旧版扩展安装记录
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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_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<Vec<crate::extension_legacy::LegacyImport>> {
|
||||
crate::extension_legacy::import(&mut self.db, &self.root, legacy_data_root)
|
||||
}
|
||||
pub fn trust_setting(
|
||||
&self,
|
||||
source_url: &str,
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod extension_trust;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod extension_job;
|
||||
pub mod extension_legacy;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod extension_container;
|
||||
|
||||
@@ -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::<Host>()
|
||||
.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::<Host>().credentials.clone();
|
||||
*credential_state
|
||||
.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_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())
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user