feat(extensions): 根据签名发行包验证有界清单

This commit is contained in:
2026-09-08 20:31:28 +08:00
parent 47c96547de
commit 8bcdbef016
9 changed files with 429 additions and 3 deletions
+124
View File
@@ -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 -1
View File
@@ -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()
+3
View File
@@ -27,3 +27,6 @@ pub mod sync_retry;
#[cfg(feature = "desktop")]
pub mod extension_package;
#[cfg(feature = "desktop")]
pub mod extension_manifest;