feat(extensions): 安装前解析有界依赖锁

This commit is contained in:
2026-09-08 20:58:48 +08:00
parent 0361ad8660
commit 915c7cdb32
7 changed files with 598 additions and 0 deletions
+9
View File
@@ -49,3 +49,12 @@ ZIP 在解压前独立校验中央目录计数、重复原始名称、本地头
清单解析最多 1 MiB、32 层、10000 个节点、一个文档,限制别名及记录的锚点大小;重复键、包含指令、循环别名、非 UTF-8 和未知 schema_version 拒绝。关闭文件包含与环境插值特性。JSON 类别先验证 JSON 语法再进行重复键检查,不接受伪装为 JSON 的 YAML。
Theme 必须声明 theme_idSkill/Plugin 支持 Runtime 现有的 id 或专用 skill_id/plugin_id,两者同时出现须相等。YAML 类型版本及全部类型权限集合须与发行一致,重复权限拒绝。人设、模板、MCP 和模型方案检查各自基本结构及递归秘密字段;声明式包通过检查不代表已具备运行权限。项目自带 markdown-workbench/note-reviewer 清单已在 Rust 和 Community 校验中验证。
## 本地依赖锁预览
Rust 暂存库可为指定根发行生成只读依赖预览。依赖键为同命名空间的包 ID,或同一来源下的 `namespace/package_id`;不跨来源借用同名包。完整裸版本(如 `1.2.3`)表示精确版本,其余采用 Rust SemVer 的 `=`, `^`, `~`, 比较符、通配符和逗号交集语法;不支持的范围明确拒绝,不能当作任意版本。
预览固定根发行,优先选择符合全部约束的较新稳定版本,发生依赖冲突时回溯;验证应用 min/max 版本、平台和架构,按依赖先于使用者输出。缺包、版本冲突和环均不生成部分结果。预算为同来源最多 4096 个候选、64 MiB 元数据、200 个解析包、10000 次搜索展开和 4 MiB 预览编码。
锁定结果包含来源、命名空间、包类型/版本、归档摘要、签名者摘要、完整发行摘要和权限集;整体摘要另绑定应用版本与目标平台/架构。所选包会重新读取归档并复核签名与清单。该本地预览不下载缺包、不启用实例、不授予许可;正式安装仍须在线复核撤回状态并确认权限,随后按锁定结果执行事务。
@@ -172,3 +172,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
## 远端测试服务就绪复查
`http://yui.kronecker.cc:18080/health` 返回 200 / status=ok`/ready` 返回 503 / DEPENDENCY_UNAVAILABLE。SSH 域名地址与公开 AAAA 地址均在认证前关闭连接、未返回 banner,尚无法读取 Docker/数据库/对象存储诊断。未执行任何远端修改,也未判定为密码错误。已询问 SSH 入口是否改变;本地工程继续推进,不因这一单项暂停整体目标。证据 `.build/acceptance/sync-readiness-current.json`
## 增量:确定性依赖锁与兼容性预览
- 暂存库新增只读依赖预览,固定根发行,只使用同来源候选;无命名空间的键归属当前命名空间,显式 namespace/package 也必须来自同来源。较新版本优先,遇到菱形冲突会回溯选择满足全部约束的版本,结果按依赖先于使用者输出。
- 裸完整版本表示精确约束;范围采用明确的 Rust SemVer 语法,未知范围拒绝。应用 min/max、平台和架构不兼容拒绝;缺包、版本冲突、依赖环或预算超限不返回部分计划。
- 锁定结果绑定包身份、完整签名发行、ZIP、签名者、权限及目标应用/平台/架构。选中包重新从暂存对象读取并校验签名和清单;重开后摘要稳定,归档损坏会拒绝。尚无安装/停止实例的副作用。
- 预算为同源 4096 候选 / 64 MiB 元数据、200 解析包、10000 搜索展开与 4 MiB 预览编码。测试覆盖输入乱序、菱形回溯、环、缺包、精确版本、平台不兼容、来源/命名空间隔离、深图和过大预览。
- Rust desktop 全目标 69 项通过(两个既有特殊入口 ignored);其后补充预览编码上限,4 项依赖测试和最终 Clippy `-D warnings` 通过。日志 `.build/extension-dependencies-rust-tests.log`。依赖使用已锁定的 semver 1.0.28。
- 正式安装的在线撤回复核、权限确认、解包与包/配置原子切换、健康检查和回滚仍未完成,不能据此通过完整 D-03/D-04。
+1
View File
@@ -3042,6 +3042,7 @@ dependencies = [
"reqwest 0.12.28",
"rfd",
"rusqlite",
"semver",
"serde",
"serde-saphyr",
"serde_json",
+1
View File
@@ -42,6 +42,7 @@ zip = { version = "8.6", default-features = false, features = ["deflate"] }
unicode-normalization = "0.1"
unicode-casefold = "0.2"
serde-saphyr = { version = "1.2", default-features = false, features = ["deserialize"] }
semver = { version = "1", features = ["serde"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
@@ -0,0 +1,438 @@
//! Deterministic, bounded dependency planning; no mutation, activation or automatic downloads.
use crate::{
extension_package::Release,
workspace::{hash, HostError, Result},
};
use semver::{Version, VersionReq};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
pub struct Candidate {
pub package_key: String,
pub source: String,
pub release: Release,
pub signer_sha256: String,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct LockedPackage {
pub package_key: String,
pub source: String,
pub namespace: String,
pub package_id: String,
pub kind: String,
pub version: String,
pub archive_sha256: String,
pub signer_sha256: String,
pub release_sha256: String,
pub permissions: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct Plan {
pub fingerprint: String,
pub packages: Vec<LockedPackage>,
}
fn error(code: &str) -> HostError {
HostError::new(code)
}
fn name(release: &Release) -> String {
format!("{}/{}", release.namespace, release.package_id)
}
fn dependency(owner: &Release, id: &str) -> Result<String> {
let resolved = if id.contains('/') {
id.to_owned()
} else {
format!("{}/{id}", owner.namespace)
};
let parts: Vec<_> = resolved.split('/').collect();
if parts.len() != 2
|| parts.iter().any(|s| {
!(2..=64).contains(&s.len())
|| !s.as_bytes()[0].is_ascii_alphanumeric()
|| !s
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
{
return Err(error("EXTENSION_DEPENDENCY_INVALID"));
}
Ok(resolved)
}
fn requirement(value: &str) -> Result<VersionReq> {
let value = if Version::parse(value).is_ok() {
format!("={value}")
} else {
value.to_owned()
};
VersionReq::parse(&value).map_err(|_| error("EXTENSION_DEPENDENCY_REQUIREMENT"))
}
type Chosen = BTreeMap<String, usize>;
struct Solver<'a> {
candidates: &'a [Candidate],
groups: BTreeMap<String, Vec<usize>>,
root: usize,
remaining: usize,
failure: &'static str,
}
impl Solver<'_> {
fn solve(
&mut self,
chosen: Chosen,
requirements: BTreeMap<String, Vec<VersionReq>>,
) -> Result<Option<Chosen>> {
if self.remaining == 0 {
return Err(error("EXTENSION_DEPENDENCY_COMPLEXITY"));
}
self.remaining -= 1;
if requirements.len() > 200 {
return Err(error("EXTENSION_DEPENDENCY_COMPLEXITY"));
}
for (key, index) in &chosen {
let version = Version::parse(&self.candidates[*index].release.version)
.map_err(|_| error("EXTENSION_DEPENDENCY_VERSION"))?;
if requirements
.get(key)
.is_some_and(|all| all.iter().any(|r| !r.matches(&version)))
{
return Ok(None);
}
}
let Some((next, constraints)) = requirements
.iter()
.find(|(key, _)| !chosen.contains_key(*key))
else {
if order(self.candidates, &chosen, self.root).is_err() {
self.failure = "EXTENSION_DEPENDENCY_CYCLE";
return Ok(None);
}
return Ok(Some(chosen));
};
let Some(indices) = self.groups.get(next).cloned() else {
self.failure = "EXTENSION_DEPENDENCY_MISSING";
return Ok(None);
};
for index in indices {
if next == &name(&self.candidates[self.root].release) && index != self.root {
continue;
}
let candidate = &self.candidates[index];
let version = Version::parse(&candidate.release.version)
.map_err(|_| error("EXTENSION_DEPENDENCY_VERSION"))?;
if constraints.iter().any(|r| !r.matches(&version)) {
continue;
}
let mut selected = chosen.clone();
selected.insert(next.clone(), index);
let mut required = requirements.clone();
for (id, range) in &candidate.release.dependencies {
required
.entry(dependency(&candidate.release, id)?)
.or_default()
.push(requirement(range)?);
}
if let Some(solution) = self.solve(selected, required)? {
return Ok(Some(solution));
}
}
Ok(None)
}
}
fn order(candidates: &[Candidate], chosen: &Chosen, root: usize) -> Result<Vec<usize>> {
fn visit(
index: usize,
candidates: &[Candidate],
chosen: &Chosen,
active: &mut BTreeSet<usize>,
done: &mut BTreeSet<usize>,
output: &mut Vec<usize>,
) -> Result<()> {
if done.contains(&index) {
return Ok(());
}
if !active.insert(index) {
return Err(error("EXTENSION_DEPENDENCY_CYCLE"));
}
for id in candidates[index].release.dependencies.keys() {
let key = dependency(&candidates[index].release, id)?;
visit(
*chosen
.get(&key)
.ok_or_else(|| error("EXTENSION_DEPENDENCY_MISSING"))?,
candidates,
chosen,
active,
done,
output,
)?;
}
active.remove(&index);
done.insert(index);
output.push(index);
Ok(())
}
let mut output = Vec::new();
visit(
root,
candidates,
chosen,
&mut BTreeSet::new(),
&mut BTreeSet::new(),
&mut output,
)?;
Ok(output)
}
pub fn plan(
candidates: &[Candidate],
root_key: &str,
app_version: &str,
platform: &str,
architecture: &str,
) -> Result<Plan> {
if candidates.len() > 4096 {
return Err(error("EXTENSION_DEPENDENCY_COMPLEXITY"));
}
let app = Version::parse(app_version).map_err(|_| error("EXTENSION_APP_VERSION"))?;
let root = candidates
.iter()
.position(|v| v.package_key == root_key)
.ok_or_else(|| error("EXTENSION_DEPENDENCY_MISSING"))?;
let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (index, candidate) in candidates.iter().enumerate() {
if candidate.source != candidates[root].source {
continue;
}
let r = &candidate.release;
r.validate()?;
let minimum =
Version::parse(&r.min_app_version).map_err(|_| error("EXTENSION_APP_VERSION"))?;
let maximum = r
.max_app_version
.as_ref()
.map(|v| Version::parse(v).map_err(|_| error("EXTENSION_APP_VERSION")))
.transpose()?;
if app < minimum
|| maximum.is_some_and(|max| app > max)
|| (!r.platforms.is_empty() && !r.platforms.iter().any(|v| v == platform))
|| (!r.architectures.is_empty() && !r.architectures.iter().any(|v| v == architecture))
{
if index == root {
return Err(error("EXTENSION_PLATFORM_INCOMPATIBLE"));
}
continue;
}
Version::parse(&r.version).map_err(|_| error("EXTENSION_DEPENDENCY_VERSION"))?;
groups.entry(name(r)).or_default().push(index);
}
for indices in groups.values_mut() {
indices.sort_by(|a, b| {
Version::parse(&candidates[*b].release.version)
.unwrap()
.cmp(&Version::parse(&candidates[*a].release.version).unwrap())
.then(candidates[*a].package_key.cmp(&candidates[*b].package_key))
});
}
let mut solver = Solver {
candidates,
groups,
root,
remaining: 10000,
failure: "EXTENSION_DEPENDENCY_CONFLICT",
};
let requirements = BTreeMap::from([(
name(&candidates[root].release),
vec![requirement(&candidates[root].release.version)?],
)]);
let chosen = solver
.solve(BTreeMap::new(), requirements)?
.ok_or_else(|| error(solver.failure))?;
let packages: Vec<_> = order(candidates, &chosen, root)?
.into_iter()
.map(|i| {
let c = &candidates[i];
let r = &c.release;
LockedPackage {
package_key: c.package_key.clone(),
source: c.source.clone(),
namespace: r.namespace.clone(),
package_id: r.package_id.clone(),
kind: r.kind.clone(),
version: r.version.clone(),
archive_sha256: r.sha256.clone(),
signer_sha256: c.signer_sha256.clone(),
release_sha256: hash(&serde_json::to_vec(r).unwrap()),
permissions: r.permissions.clone(),
}
})
.collect();
let encoded = serde_json::to_vec(&(&packages, app_version, platform, architecture)).unwrap();
if encoded.len() > 4 * 1024 * 1024 {
return Err(error("EXTENSION_DEPENDENCY_COMPLEXITY"));
}
let fingerprint = hash(&encoded);
Ok(Plan {
fingerprint,
packages,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn candidate(id: &str, version: &str, deps: &[(&str, &str)]) -> Candidate {
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();
release.package_id = id.into();
release.version = version.into();
release.dependencies = deps
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Candidate {
package_key: format!("{id}-{version}"),
source: "https://catalog.example/".into(),
release,
signer_sha256: "trusted-test-key".into(),
}
}
fn resolve(candidates: &[Candidate]) -> Result<Plan> {
plan(candidates, "root-1.0.0", "0.3.0", "windows", "x86_64")
}
#[test]
fn diamond_backtracks_and_orders_dependencies_before_users_deterministically() {
let mut candidates = vec![
candidate("root", "1.0.0", &[("aa", "*"), ("bb", "1.0.0")]),
candidate("aa", "2.0.0", &[("cc", "^2.0.0")]),
candidate("aa", "1.0.0", &[("cc", "^1.0.0")]),
candidate("bb", "1.0.0", &[("cc", "^1.0.0")]),
candidate("cc", "2.0.0", &[]),
candidate("cc", "1.0.0", &[]),
];
let first = resolve(&candidates).unwrap();
assert_eq!(
first
.packages
.iter()
.map(|p| p.package_key.as_str())
.collect::<Vec<_>>(),
vec!["cc-1.0.0", "aa-1.0.0", "bb-1.0.0", "root-1.0.0"]
);
candidates.reverse();
let second = resolve(&candidates).unwrap();
assert_eq!(first.fingerprint, second.fingerprint);
candidates
.iter_mut()
.find(|c| c.release.package_id == "root")
.unwrap()
.release
.description = "changed signed metadata".into();
assert_ne!(first.fingerprint, resolve(&candidates).unwrap().fingerprint);
}
#[test]
fn cycles_missing_versions_and_incompatible_platforms_fail() {
let cycle = [
candidate("root", "1.0.0", &[("aa", "*")]),
candidate("aa", "1.0.0", &[("root", "*")]),
];
assert_eq!(
resolve(&cycle).unwrap_err().code,
"EXTENSION_DEPENDENCY_CYCLE"
);
assert_eq!(
resolve(&[candidate("root", "1.0.0", &[("aa", "*")])])
.unwrap_err()
.code,
"EXTENSION_DEPENDENCY_MISSING"
);
assert_eq!(
resolve(&[
candidate("root", "1.0.0", &[("aa", "1.0.0")]),
candidate("aa", "1.1.0", &[])
])
.unwrap_err()
.code,
"EXTENSION_DEPENDENCY_CONFLICT"
);
let mut root = candidate("root", "1.0.0", &[]);
root.release.min_app_version = "9.0.0".into();
assert_eq!(
resolve(&[root]).unwrap_err().code,
"EXTENSION_PLATFORM_INCOMPATIBLE"
);
let root = candidate("root", "1.0.0", &[]);
assert!(plan(&[root], "root-1.0.0", "0.3.0", "linux", "x86_64").is_err());
}
#[test]
fn sources_and_namespaces_are_not_dependency_substitutes() {
let root = candidate("root", "1.0.0", &[("aa", "*")]);
let mut other = candidate("aa", "1.0.0", &[]);
other.source = "https://other.example/".into();
assert_eq!(
resolve(&[root, other]).unwrap_err().code,
"EXTENSION_DEPENDENCY_MISSING"
);
let root = candidate("root", "1.0.0", &[("trusted/aa", "*")]);
let mut other = candidate("aa", "1.0.0", &[]);
other.release.namespace = "untrusted".into();
assert_eq!(
resolve(&[root, other]).unwrap_err().code,
"EXTENSION_DEPENDENCY_MISSING"
);
let root = candidate("root", "1.0.0", &[("trusted/aa", "*")]);
let mut trusted = candidate("aa", "1.0.0", &[]);
trusted.release.namespace = "trusted".into();
let accepted = resolve(&[root, trusted]).unwrap();
assert_eq!(accepted.packages[0].namespace, "trusted");
let root = candidate("root", "1.0.0", &[("../aa", "*")]);
assert!(resolve(&[root]).is_err());
let root = candidate("root", "1.0.0", &[("aa", "shell text")]);
assert_eq!(
resolve(&[root]).unwrap_err().code,
"EXTENSION_DEPENDENCY_REQUIREMENT"
);
}
#[test]
fn deep_graph_is_bounded_without_leaving_a_partial_plan() {
let mut candidates = vec![candidate("root", "1.0.0", &[("node-000", "*")])];
for i in 0..210 {
candidates.push(candidate(
&format!("node-{i:03}"),
"1.0.0",
&[(&format!("node-{:03}", i + 1), "*")],
));
}
assert_eq!(
resolve(&candidates).unwrap_err().code,
"EXTENSION_DEPENDENCY_COMPLEXITY"
);
// A valid-size graph with oversized permission metadata must not create an unbounded IPC result.
let mut wide = vec![candidate("root", "1.0.0", &[])];
for i in 0..100 {
let id = format!("leaf-{i:03}");
wide[0].release.dependencies.insert(id.clone(), "*".into());
let mut leaf = candidate(&id, "1.0.0", &[]);
leaf.release.permissions = (0..64)
.map(|n| format!("{n:02}{}", "".repeat(254)))
.collect();
wide.push(leaf);
}
// Split the direct dependencies to respect the per-release 64-entry limit.
let second = wide[0].release.dependencies.split_off("leaf-050");
wide[0]
.release
.dependencies
.insert("branch".into(), "*".into());
let mut branch = candidate("branch", "1.0.0", &[]);
branch.release.dependencies = second;
wide.push(branch);
assert_eq!(
resolve(&wide).unwrap_err().code,
"EXTENSION_DEPENDENCY_COMPLEXITY"
);
}
}
+136
View File
@@ -106,6 +106,77 @@ fn source(value: &str) -> Result<String> {
Ok(url.to_string())
}
impl ExtensionStore {
/// Creates a lock preview from local staged packages. This does not replace online revocation checks.
pub fn dependency_plan(
&self,
root_key: &str,
app_version: &str,
platform: &str,
architecture: &str,
) -> Result<crate::extension_dependencies::Plan> {
use crate::extension_dependencies::{plan, Candidate};
let source: String = self.db.query_row(
"SELECT source FROM versions WHERE package_key=?1 AND state='staged'",
[root_key],
|r| r.get(0),
)?;
let mut statement=self.db.prepare("SELECT package_key,release,signer FROM versions WHERE source=?1 AND state='staged' ORDER BY package_key LIMIT 4097")?;
let mut rows = statement.query([&source])?;
let mut candidates = Vec::new();
let mut keys = std::collections::BTreeMap::new();
let mut total = 0usize;
while let Some(row) = rows.next()? {
let key: String = row.get(0)?;
let raw: String = row.get(1)?;
let signer: Vec<u8> = row.get(2)?;
total = total.saturating_add(raw.len());
if raw.len() > 256 * 1024 || total > 64 * 1024 * 1024 || candidates.len() >= 4096 {
return Err(HostError::new("EXTENSION_DEPENDENCY_COMPLEXITY"));
}
let public: [u8; 32] = signer
.try_into()
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let release: Release = serde_json::from_str(&raw)
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if key
!= hash(
&serde_json::to_vec(&(
&source,
&release.namespace,
&release.package_id,
&release.version,
))
.unwrap(),
)
{
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
}
keys.insert(key.clone(), public);
candidates.push(Candidate {
package_key: key,
source: source.clone(),
release,
signer_sha256: hash(&public),
});
}
let result = plan(&candidates, root_key, app_version, platform, architecture)?;
for locked in &result.packages {
let candidate = candidates
.iter()
.find(|c| c.package_key == locked.package_key)
.ok_or_else(|| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let release = &candidate.release;
release.verify_package(
&keys[&locked.package_key],
&release.key_id,
&release.namespace,
false,
false,
&self.archive(&locked.package_key)?,
)?;
}
Ok(result)
}
pub fn staged(&self, offset: u32, limit: u32) -> Result<Vec<StagedPackage>> {
if limit == 0 || limit > 200 {
return Err(HostError::new("EXTENSION_PAGE_INVALID"));
@@ -360,6 +431,71 @@ mod tests {
}
}
#[test]
fn staged_dependency_preview_survives_reopen_and_rechecks_archive_integrity() {
let (mut release, archive, key) = fixture();
let root = tempfile::tempdir().unwrap();
let mut store = ExtensionStore::open(root.path()).unwrap();
for version in ["1.0.0", "2.0.0"] {
release.package_id = "dependency".into();
release.version = version.into();
release.signature = STANDARD.encode(
SigningKey::from_bytes(&[7; 32])
.sign(&release.signed_payload().unwrap())
.to_bytes(),
);
store
.stage(request(
&Uuid::new_v4().to_string(),
&release,
&archive,
&key,
))
.unwrap();
}
release.package_id = "test-package".into();
release.version = "1.0.0".into();
release
.dependencies
.insert("dependency".into(), "^1.0.0".into());
release.signature = STANDARD.encode(
SigningKey::from_bytes(&[7; 32])
.sign(&release.signed_payload().unwrap())
.to_bytes(),
);
let receipt = store
.stage(request(
&Uuid::new_v4().to_string(),
&release,
&archive,
&key,
))
.unwrap();
let plan = store
.dependency_plan(&receipt.package_key, "0.3.0", "windows", "x86_64")
.unwrap();
assert_eq!(plan.packages.len(), 2);
assert_eq!(plan.packages[0].package_id, "dependency");
assert_eq!(plan.packages[0].version, "1.0.0");
drop(store);
let store = ExtensionStore::open(root.path()).unwrap();
assert_eq!(
plan.fingerprint,
store
.dependency_plan(&receipt.package_key, "0.3.0", "windows", "x86_64")
.unwrap()
.fingerprint
);
assert!(store
.staged(0, 100)
.unwrap()
.iter()
.all(|v| v.state == "staged"));
fs::write(store.object_path(&release.sha256).unwrap(), b"changed").unwrap();
assert!(store
.dependency_plan(&receipt.package_key, "0.3.0", "windows", "x86_64")
.is_err());
}
#[test]
fn staging_recovers_each_durable_boundary_twenty_times() {
let (release, archive, key) = fixture();
for boundary in [
+3
View File
@@ -33,3 +33,6 @@ pub mod extension_manifest;
#[cfg(feature = "desktop")]
pub mod extension_store;
#[cfg(feature = "desktop")]
pub mod extension_dependencies;