diff --git a/docs/contracts/Community-v1契约.md b/docs/contracts/Community-v1契约.md index 096fa87..6ef102f 100644 --- a/docs/contracts/Community-v1契约.md +++ b/docs/contracts/Community-v1契约.md @@ -49,3 +49,12 @@ ZIP 在解压前独立校验中央目录计数、重复原始名称、本地头 清单解析最多 1 MiB、32 层、10000 个节点、一个文档,限制别名及记录的锚点大小;重复键、包含指令、循环别名、非 UTF-8 和未知 schema_version 拒绝。关闭文件包含与环境插值特性。JSON 类别先验证 JSON 语法再进行重复键检查,不接受伪装为 JSON 的 YAML。 Theme 必须声明 theme_id;Skill/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 预览编码。 + +锁定结果包含来源、命名空间、包类型/版本、归档摘要、签名者摘要、完整发行摘要和权限集;整体摘要另绑定应用版本与目标平台/架构。所选包会重新读取归档并复核签名与清单。该本地预览不下载缺包、不启用实例、不授予许可;正式安装仍须在线复核撤回状态并确认权限,随后按锁定结果执行事务。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 40fdd74..56ebc7a 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -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。 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 69e04d7..ae99d68 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -3042,6 +3042,7 @@ dependencies = [ "reqwest 0.12.28", "rfd", "rusqlite", + "semver", "serde", "serde-saphyr", "serde_json", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index d1f9ba6..51ba5e6 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -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"] } diff --git a/frontend/src-tauri/src/extension_dependencies.rs b/frontend/src-tauri/src/extension_dependencies.rs new file mode 100644 index 0000000..e7ab473 --- /dev/null +++ b/frontend/src-tauri/src/extension_dependencies.rs @@ -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, +} +#[derive(Debug, Serialize)] +pub struct Plan { + pub fingerprint: String, + pub packages: Vec, +} +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 { + 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 { + 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; +struct Solver<'a> { + candidates: &'a [Candidate], + groups: BTreeMap>, + root: usize, + remaining: usize, + failure: &'static str, +} +impl Solver<'_> { + fn solve( + &mut self, + chosen: Chosen, + requirements: BTreeMap>, + ) -> Result> { + 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> { + fn visit( + index: usize, + candidates: &[Candidate], + chosen: &Chosen, + active: &mut BTreeSet, + done: &mut BTreeSet, + output: &mut Vec, + ) -> 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 { + 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> = 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(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!["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" + ); + } +} diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index cf593fa..aac672b 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -106,6 +106,77 @@ fn source(value: &str) -> Result { 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 { + 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 = 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> { 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 [ diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index e8e4d9b..a0c7ecb 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -33,3 +33,6 @@ pub mod extension_manifest; #[cfg(feature = "desktop")] pub mod extension_store; + +#[cfg(feature = "desktop")] +pub mod extension_dependencies;