feat(extensions): 根据签名发行包验证有界清单
This commit is contained in:
@@ -89,7 +89,9 @@ def inspect(release: Release, blob: bytes):
|
||||
import yaml
|
||||
value = yaml.safe_load(files[matches[0]])
|
||||
identity = {"theme": "theme_id", "skill": "skill_id", "plugin": "plugin_id"}[release.type]
|
||||
if not isinstance(value, dict) or value.get(identity) != release.package_id or value.get("version") != release.version:
|
||||
if (not isinstance(value, dict) or value.get(identity, value.get("id") if release.type in {"plugin", "skill"} else None) != release.package_id
|
||||
or (identity in value and "id" in value and value[identity] != value["id"])
|
||||
or value.get("version") != release.version):
|
||||
raise ValueError("发行身份与类型清单不一致")
|
||||
if set(value.get("permissions", [])) != set(release.permissions):
|
||||
raise ValueError("发行权限与类型清单不一致")
|
||||
|
||||
@@ -87,3 +87,24 @@ def test_cross_namespace_and_missing_auth(env):
|
||||
assert client.post("/catalog/v1/publish/submissions", json=payload).status_code == 401
|
||||
payload["release"]["namespace"] = "other"
|
||||
assert client.post("/catalog/v1/publish/submissions", headers=author, json=payload).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,identifier", [("plugin", "markdown-workbench"), ("skill", "note-reviewer")])
|
||||
def test_repository_manifest_identity_aliases_and_conflicts(kind, identifier):
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from community.package import inspect
|
||||
source = Path(__file__).resolve().parents[2] / "backend" / "extensions" / "community" / (kind + "s") / identifier / (kind + ".yaml")
|
||||
body = source.read_text(encoding="utf-8")
|
||||
manifest = yaml.safe_load(body)
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = package(private, kind, files={kind + ".yaml": body})
|
||||
release = Release(**payload["release"])
|
||||
release.package_id = identifier
|
||||
release.permissions = manifest["permissions"]
|
||||
inspect(release, base64.b64decode(payload["archive_base64"]))
|
||||
bad = package(private, kind, files={kind + ".yaml": body + "\n" + kind + "_id: other-id\n"})
|
||||
release.sha256 = bad["release"]["sha256"]
|
||||
release.size = bad["release"]["size"]
|
||||
with pytest.raises(ValueError):
|
||||
inspect(release, base64.b64decode(bad["archive_base64"]))
|
||||
|
||||
@@ -43,4 +43,9 @@ Host 的 `extension_package` 已实现 Release v1 字段校验、递归排序 ca
|
||||
|
||||
ZIP 在解压前独立校验中央目录计数、重复原始名称、本地头一致性和数据区不重叠,避免解压库的名称映射隐藏重复项。逐条检查 NFC 路径、Windows 保留名、完整大小写折叠冲突、文件/目录前缀冲突、链接/特殊文件、加密与压缩方法;按类别检查压缩/展开/条目限额,并用有界缓冲验证全部条目及 CRC。当前只返回文件摘要清单,不写出包文件;不支持多卷或需 ZIP64 中央目录的包。
|
||||
|
||||
类型清单身份/权限/schema 验证、在线来源复核、持久安装库、迁移与事务运行生命周期仍待接入。因此当前不能据此开启 extensions capability 或标记完整 D-01 通过。
|
||||
类型清单的身份、版本和权限校验已接入 `verify_package`;完整 Runtime 配置 schema、在线来源复核、持久安装库、迁移与事务运行生命周期仍待接入。因此当前不能据此开启 extensions capability 或标记完整 D-01 通过。
|
||||
|
||||
|
||||
清单解析最多 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 校验中验证。
|
||||
|
||||
@@ -148,3 +148,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- 新增锁定依赖 ed25519-dalek 2.2.0 与 unicode-casefold 0.2.0;复用已存在的 zip 8.6.0、Unicode normalization。声明 Rust 下限调整到 ZIP 库要求的 1.88,实际验证仍使用本机 GNU 1.98.1,尚无最低工具链或 MSVC 发布证据。
|
||||
- Rust desktop 全目标 58 项通过(另两个既有显式/辅助入口 ignored),随后补充较新 Unicode 大小写映射,4 项包验证测试复跑通过,最终 Clippy `-D warnings` 通过。日志 `.build/extension-verification-rust-tests.log`。期间一次链接被仍在运行的测试 EXE 占用;确认原进程完成后,针对性测试重跑通过。
|
||||
- 此批只验证包,不提取、不安装、不执行。清单身份/权限和 schema、在线撤回复核、单一安装库、迁移及事务尚未完成,extensions capability 维持 false,完整 D-01–04 仍未通过。
|
||||
|
||||
|
||||
## 增量:有界类型清单与发行一致性校验
|
||||
|
||||
- `verify_package` 串联签名、ZIP 全字节检查和清单解析,返回经过检查的摘要清单及声明内容。清单最多 1 MiB / 32 层 / 10000 节点 / 单文档;重复键、循环别名、文件包含、未知 schema_version 和非法 JSON 拒绝。YAML 解析关闭环境插值与文件包含特性,错误不回显正文。
|
||||
- Theme 保持专用 theme_id;Skill/Plugin 兼容 Runtime 实际包使用的 id 与专用类型 ID,双声明冲突拒绝。发现并修复 Community 服务此前会拒绝项目自带 id 清单的兼容问题;两端均使用 markdown-workbench/note-reviewer 实际文件验证。
|
||||
- 版本与权限集合须匹配发行;人设/模板/MCP/模型基本结构和递归秘密字段校验已加入。此处尚不替代各 Runtime 完整配置 schema、运行许可与 OS 沙箱。
|
||||
- 锁定 serde-saphyr 1.2.0 的 deserialize 特性与传递依赖,按其要求将声明 Rust 下限调整至 1.89;实际仍为 GNU 1.98.1,不作为最低工具链测试证据。
|
||||
- Community 全量 15 项通过(两个既有弃用警告);Rust desktop 全目标 61 项通过(两个既有特殊入口 ignored)。随后核对 Theme 专用 ID,7 项扩展验证复跑与最终 Clippy `-D warnings` 通过。日志 `.build/extension-manifest-rust-tests.log`。
|
||||
- 安全解包写盘、在线来源/撤回复核、安装库迁移、依赖与配置升级事务、授权和运行生命周期仍须实现,完整 D-01–04 和整体生产化继续进行。
|
||||
|
||||
Generated
+124
@@ -100,6 +100,23 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotate-snippets"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -118,6 +135,12 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -805,6 +828,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1365,6 +1394,30 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs_io"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1933,6 +1986,16 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granit-parser"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65ec0d45986cd51c847c75c5b69a00852c4fc84d0e5e79f041173f73437d0cdf"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -2872,6 +2935,34 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
|
||||
dependencies = [
|
||||
"multiversion-macros",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion-macros"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2952,6 +3043,7 @@ dependencies = [
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde-saphyr",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
@@ -4226,6 +4318,20 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-saphyr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3afb591f9cdb6223c88ba39269aff895620c7f0716dc42b705b5733d5c7c0823"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"encoding_rs_io",
|
||||
"granit-parser",
|
||||
"num-traits",
|
||||
"serde_core",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-untagged"
|
||||
version = "0.1.9"
|
||||
@@ -4430,6 +4536,12 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -4755,6 +4867,12 @@ dependencies = [
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-features"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -5470,6 +5588,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "notesagent-desktop"
|
||||
version = "0.3.0-alpha.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "notesagent_host"
|
||||
@@ -41,6 +41,7 @@ ed25519-dalek = { version = "2.2", default-features = false, features = ["std"]
|
||||
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"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Bounded declarative manifest inspection. No includes, environment interpolation or code execution.
|
||||
use crate::{
|
||||
extension_package::{Inventory, Release},
|
||||
workspace::{HostError, Result},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io::{Cursor, Read},
|
||||
};
|
||||
|
||||
fn invalid() -> HostError {
|
||||
HostError::new("EXTENSION_MANIFEST_INVALID")
|
||||
}
|
||||
pub fn read(release: &Release, archive: &[u8], inventory: &Inventory) -> Result<Value> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|_| invalid())?;
|
||||
let file = zip.by_name(&inventory.manifest).map_err(|_| invalid())?;
|
||||
if file.size() > 1024 * 1024 {
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_LIMIT"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| invalid())?;
|
||||
if bytes.len() > 1024 * 1024 {
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_LIMIT"));
|
||||
}
|
||||
validate(release, &bytes)
|
||||
}
|
||||
pub fn validate(release: &Release, bytes: &[u8]) -> Result<Value> {
|
||||
if bytes.len() > 1024 * 1024 {
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_LIMIT"));
|
||||
}
|
||||
let input = std::str::from_utf8(bytes).map_err(|_| invalid())?;
|
||||
let yaml = matches!(release.kind.as_str(), "theme" | "plugin" | "skill");
|
||||
if !yaml {
|
||||
serde_json::from_str::<Value>(input).map_err(|_| invalid())?;
|
||||
}
|
||||
let options = serde_saphyr::options! {
|
||||
duplicate_keys: serde_saphyr::options::DuplicateKeyPolicy::Error,
|
||||
with_snippet: false,
|
||||
budget: serde_saphyr::budget! { max_documents:1, max_depth:32, max_nodes:10000, max_events:20000, max_total_scalar_bytes:1024*1024, max_aliases:128, max_anchors:128, max_recorded_anchor_events:10000, max_recorded_anchor_bytes:1024*1024 },
|
||||
};
|
||||
let value: Value =
|
||||
serde_saphyr::from_str_with_options(input, options).map_err(|_| invalid())?;
|
||||
let map = value.as_object().ok_or_else(invalid)?;
|
||||
if map
|
||||
.get("schema_version")
|
||||
.is_some_and(|v| v.as_u64() != Some(1))
|
||||
{
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_SCHEMA"));
|
||||
}
|
||||
if yaml {
|
||||
let identity = match release.kind.as_str() {
|
||||
"theme" => "theme_id",
|
||||
"plugin" => "plugin_id",
|
||||
_ => "skill_id",
|
||||
};
|
||||
let typed = map.get(identity);
|
||||
let common = map.get("id");
|
||||
if (release.kind == "theme" && typed.is_none())
|
||||
|| typed.or(common).and_then(Value::as_str) != Some(release.package_id.as_str())
|
||||
|| typed.zip(common).is_some_and(|(a, b)| a != b)
|
||||
|| map.get("version").and_then(Value::as_str) != Some(release.version.as_str())
|
||||
{
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_IDENTITY"));
|
||||
}
|
||||
}
|
||||
let permissions: Vec<String> = map
|
||||
.get("permissions")
|
||||
.map(|v| serde_json::from_value(v.clone()).map_err(|_| invalid()))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let actual: BTreeSet<_> = permissions.iter().collect();
|
||||
let expected: BTreeSet<_> = release.permissions.iter().collect();
|
||||
if actual != expected || actual.len() != permissions.len() {
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_PERMISSIONS"));
|
||||
}
|
||||
if !yaml {
|
||||
fn safe(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Object(map) => map.iter().all(|(k, v)| {
|
||||
!matches!(
|
||||
k.to_lowercase().as_str(),
|
||||
"api_key" | "password" | "token" | "secret" | "chat_history" | "messages"
|
||||
) && safe(v)
|
||||
}),
|
||||
Value::Array(items) => items.iter().all(safe),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
if !safe(&value) {
|
||||
return Err(HostError::new("EXTENSION_MANIFEST_SECRET"));
|
||||
}
|
||||
let valid = match release.kind.as_str() {
|
||||
"persona" => value["system_prompt"].is_string(),
|
||||
"template" => {
|
||||
value["markdown"].is_string()
|
||||
&& value
|
||||
.get("executable")
|
||||
.is_none_or(|v| v == false || v.is_null())
|
||||
}
|
||||
"model" => {
|
||||
["source", "revision", "license"]
|
||||
.iter()
|
||||
.all(|k| value[*k].as_str().is_some_and(|s| !s.is_empty()))
|
||||
&& value["resources"]
|
||||
.as_object()
|
||||
.is_some_and(|v| !v.is_empty())
|
||||
&& value["verified_platforms"]
|
||||
.as_array()
|
||||
.is_some_and(|v| !v.is_empty() && v.iter().all(Value::is_string))
|
||||
}
|
||||
"mcp" => match value["transport"].as_str() {
|
||||
Some("stdio") => {
|
||||
value["args"]
|
||||
.as_array()
|
||||
.is_some_and(|v| v.iter().all(Value::is_string))
|
||||
&& value
|
||||
.get("command")
|
||||
.is_none_or(|v| v.as_str().is_some_and(|s| !s.is_empty()))
|
||||
}
|
||||
Some("streamable_http" | "sse") => true,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn release(kind: &str) -> Release {
|
||||
let mut v: Value = serde_json::from_str(include_str!(
|
||||
"../../src/services/fixtures/community-python-vector.json"
|
||||
))
|
||||
.unwrap();
|
||||
let value = v["release"].as_object_mut().unwrap();
|
||||
for key in ["release_id", "withdrawn", "download_path"] {
|
||||
value.remove(key);
|
||||
}
|
||||
value.insert("type".into(), Value::String(kind.into()));
|
||||
serde_json::from_value(v["release"].clone()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn real_repository_manifests_match_identity_version_and_signed_permissions() {
|
||||
for (kind, id, bytes, permissions) in [
|
||||
(
|
||||
"plugin",
|
||||
"markdown-workbench",
|
||||
include_bytes!(
|
||||
"../../../backend/extensions/community/plugins/markdown-workbench/plugin.yaml"
|
||||
)
|
||||
.as_slice(),
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"skill",
|
||||
"note-reviewer",
|
||||
include_bytes!(
|
||||
"../../../backend/extensions/community/skills/note-reviewer/skill.yaml"
|
||||
)
|
||||
.as_slice(),
|
||||
vec!["notes.search".into(), "notes.read".into()],
|
||||
),
|
||||
] {
|
||||
let mut r = release(kind);
|
||||
r.package_id = id.into();
|
||||
r.permissions = permissions;
|
||||
validate(&r, bytes).unwrap();
|
||||
r.version = "9.0.0".into();
|
||||
assert!(validate(&r, bytes).is_err());
|
||||
}
|
||||
assert!(validate(&release("theme"), b"id: test-package\nversion: 1.0.0").is_err());
|
||||
validate(&release("theme"), b"theme_id: test-package\nversion: 1.0.0").unwrap();
|
||||
let r = release("plugin");
|
||||
for bytes in [
|
||||
b"id: test-package\nplugin_id: other\nversion: 1.0.0".as_slice(),
|
||||
b"id: test-package\nversion: 1.0.0\npermissions: [notes.read]",
|
||||
b"id: test-package\nversion: 1.0.0\npermissions: false",
|
||||
] {
|
||||
assert!(validate(&r, bytes).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_keys_documents_depth_and_includes_are_rejected() {
|
||||
let r = release("plugin");
|
||||
for bytes in [
|
||||
b"id: test-package\nversion: 1.0.0\nversion: 2.0.0".as_slice(),
|
||||
b"id: test-package\nversion: 1.0.0\n---\nid: other",
|
||||
b"id: !include /etc/passwd\nversion: 1.0.0",
|
||||
b"id: test-package\nversion: 1.0.0\nx: &a [*a]",
|
||||
] {
|
||||
assert!(validate(&r, bytes).is_err());
|
||||
}
|
||||
let deep = format!(
|
||||
"id: test-package\nversion: 1.0.0\nx: {}0{}",
|
||||
"[".repeat(40),
|
||||
"]".repeat(40)
|
||||
);
|
||||
assert!(validate(&r, deep.as_bytes()).is_err());
|
||||
assert_eq!(
|
||||
validate(&r, &vec![b' '; 1024 * 1024 + 1]).unwrap_err().code,
|
||||
"EXTENSION_MANIFEST_LIMIT"
|
||||
);
|
||||
assert_eq!(
|
||||
validate(&r, b"id: test-package\nversion: 1.0.0\nschema_version: 2")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"EXTENSION_MANIFEST_SCHEMA"
|
||||
);
|
||||
let persona = release("persona");
|
||||
assert!(validate(
|
||||
&persona,
|
||||
br#"{"system_prompt":"one","system_prompt":"two"}"#
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate(&persona, b"system_prompt: YAML is not JSON").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn declarative_types_enforce_contract_and_nested_secret_exclusion() {
|
||||
for (kind,bytes) in [("persona",br#"{"system_prompt":"hello"}"#.as_slice()),("template",br##"{"markdown":"# title","executable":false}"##),("mcp",br#"{"transport":"stdio","command":"python","args":["server.py"]}"#),("model",br#"{"source":"repository","revision":"fixed","license":"MIT","resources":{"ram_gb":8},"verified_platforms":["windows"]}"#)] {
|
||||
let r=release(kind); let valid=validate(&r,bytes).unwrap();
|
||||
for field in ["api_key","token","password","secret","messages","chat_history"] {
|
||||
let mut bad=valid.clone(); bad["nested"]=serde_json::json!([{field:"planted"}]);
|
||||
assert_eq!(validate(&r,&serde_json::to_vec(&bad).unwrap()).unwrap_err().code,"EXTENSION_MANIFEST_SECRET");
|
||||
}
|
||||
}
|
||||
assert!(validate(
|
||||
&release("template"),
|
||||
br#"{"markdown":"x","executable":true}"#
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate(&release("mcp"), br#"{"transport":"stdio","args":[42]}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,21 @@ fn version(s: &str) -> bool {
|
||||
})
|
||||
}
|
||||
impl Release {
|
||||
/// Full offline package check. Online revocation freshness and runtime permissions remain Host responsibilities.
|
||||
pub fn verify_package(
|
||||
&self,
|
||||
pinned: &[u8; 32],
|
||||
key_id: &str,
|
||||
namespace: &str,
|
||||
revoked: bool,
|
||||
withdrawn: bool,
|
||||
archive: &[u8],
|
||||
) -> Result<(Inventory, Value)> {
|
||||
self.verify(pinned, key_id, namespace, revoked, withdrawn, archive)?;
|
||||
let inventory = inspect(self, archive)?;
|
||||
let manifest = crate::extension_manifest::read(self, archive, &inventory)?;
|
||||
Ok((inventory, manifest))
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = self.schema_version == 1
|
||||
&& identity(&self.namespace)
|
||||
@@ -470,6 +485,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let inventory = inspect(&release, &bytes).unwrap();
|
||||
assert_eq!(inventory.manifest, "persona.json");
|
||||
let (_, manifest) = release
|
||||
.verify_package(&key, "test-key", "examples", false, false, &bytes)
|
||||
.unwrap();
|
||||
assert!(manifest["system_prompt"].is_string());
|
||||
let value = serde_json::to_value(&release).unwrap();
|
||||
for field in value
|
||||
.as_object()
|
||||
|
||||
@@ -27,3 +27,6 @@ pub mod sync_retry;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_package;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_manifest;
|
||||
|
||||
Reference in New Issue
Block a user