feat(extensions): 在 Rust 中验证签名发行包与有界 ZIP 清单

This commit is contained in:
2026-09-08 20:20:47 +08:00
parent 5a6fa86d10
commit 47c96547de
6 changed files with 690 additions and 1 deletions
+9
View File
@@ -35,3 +35,12 @@ namespace 归属绑定作者;作者无审核角色;审核拒绝自审。版
该原型尚不具备全量安全安装状态机:统一持久安装库迁移、事务升级/回滚、断点下载、资源托管、维护者转移、账号登录/令牌过期、举报处理 UI 和生产限流均待完成。目录读取目前在 SQLite 中筛选;大规模服务索引和负载测试待完成。客户端分页当前展示前 100 条,需缩小搜索范围。 该原型尚不具备全量安全安装状态机:统一持久安装库迁移、事务升级/回滚、断点下载、资源托管、维护者转移、账号登录/令牌过期、举报处理 UI 和生产限流均待完成。目录读取目前在 SQLite 中筛选;大规模服务索引和负载测试待完成。客户端分页当前展示前 100 条,需缩小搜索范围。
开发 CORS 只允许部署者指定的来源执行 GET,不共享作者凭据。所有返回文案作为文本渲染,包内代码不在目录服务运行。 开发 CORS 只允许部署者指定的来源执行 GET,不共享作者凭据。所有返回文案作为文本渲染,包内代码不在目录服务运行。
## Rust 包验证基础
Host 的 `extension_package` 已实现 Release v1 字段校验、递归排序 canonical JSON 和 Ed25519 严格验签,复用已有 Python 签名向量。校验入口要求提供 Host 固定信任根及已核实的撤回/键撤销状态;该离线函数自身不获取在线状态,也不授权安装。验签采用 [ed25519-dalek verify_strict](https://docs.rs/ed25519-dalek/2.2.0/ed25519_dalek/struct.VerifyingKey.html#method.verify_strict),拒绝无效签名及弱键。
ZIP 在解压前独立校验中央目录计数、重复原始名称、本地头一致性和数据区不重叠,避免解压库的名称映射隐藏重复项。逐条检查 NFC 路径、Windows 保留名、完整大小写折叠冲突、文件/目录前缀冲突、链接/特殊文件、加密与压缩方法;按类别检查压缩/展开/条目限额,并用有界缓冲验证全部条目及 CRC。当前只返回文件摘要清单,不写出包文件;不支持多卷或需 ZIP64 中央目录的包。
类型清单身份/权限/schema 验证、在线来源复核、持久安装库、迁移与事务运行生命周期仍待接入。因此当前不能据此开启 extensions capability 或标记完整 D-01 通过。
@@ -139,3 +139,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 20 次重开验证尝试累加且冻结的提交载荷不变;取消测试确认无需重启即显示 interrupted。真实 100 MiB 十次强杀后,重开查询准确显示 10 次尝试与 interrupted,最终继续上传、下载与摘要校验通过。测试过程使用独立临时 Vault 和服务账号。 - 20 次重开验证尝试累加且冻结的提交载荷不变;取消测试确认无需重启即显示 interrupted。真实 100 MiB 十次强杀后,重开查询准确显示 10 次尝试与 interrupted,最终继续上传、下载与摘要校验通过。测试过程使用独立临时 Vault 和服务账号。
- Rust desktop 全目标 53 项通过,之后新增取消测试单独通过;更新后的真实 HTTP 集成再跑通过。两个 ignored 入口分别是已在前轮显式运行的打包测试和父测试实际驱动的强杀辅助进程。前端 SyncSettings 5 项、两项目 TypeScript 检查及最终 Clippy `-D warnings` 通过。日志 `.build/job-recovery-rust-tests.log``.build/job-recovery-http-tests.log`;本增量无 Python 变更。 - Rust desktop 全目标 53 项通过,之后新增取消测试单独通过;更新后的真实 HTTP 集成再跑通过。两个 ignored 入口分别是已在前轮显式运行的打包测试和父测试实际驱动的强杀辅助进程。前端 SyncSettings 5 项、两项目 TypeScript 检查及最终 Clippy `-D warnings` 通过。日志 `.build/job-recovery-rust-tests.log``.build/job-recovery-http-tests.log`;本增量无 Python 变更。
- 此增量不等于完整 S-02/S-03 故障矩阵,pull 各边界 20 次强杀、同目标 rename 与历史恢复等完整验收仍需推进。 - 此增量不等于完整 S-02/S-03 故障矩阵,pull 各边界 20 次强杀、同目标 rename 与历史恢复等完整验收仍需推进。
## 增量:Rust 扩展签名与 ZIP 验证基础
- 新增 Rust Release v1 验签及有界 ZIP 检查,真实 Python canonical JSON/Ed25519 向量验证通过;逐个签名字段篡改、错误公钥/签名者、撤回和键撤销均拒绝,ZIP 摘要另行核对。
- ZIP 原始中央目录预检阻止映射覆盖隐藏重复项、本地头不一致和重叠数据。测试覆盖穿越、Windows 特殊路径、Unicode 大小写重名、文件/目录前缀、链接、加密和 CRC 损坏。Theme 的 5/10 MiB 与 100 条、其他包的 10/50 MiB 与 2048 条边界和超界通过。
- 新增锁定依赖 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-0104 仍未通过。
+25
View File
@@ -1294,9 +1294,24 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [ dependencies = [
"pkcs8",
"signature", "signature",
] ]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "ed25519-zebra" name = "ed25519-zebra"
version = "4.2.0" version = "4.2.0"
@@ -2927,6 +2942,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chacha20poly1305", "chacha20poly1305",
"command-group", "command-group",
"ed25519-dalek",
"fernet", "fernet",
"fs2", "fs2",
"hmac", "hmac",
@@ -2942,9 +2958,12 @@ dependencies = [
"tauri-build", "tauri-build",
"tempfile", "tempfile",
"tokio", "tokio",
"unicode-casefold",
"unicode-normalization",
"uuid", "uuid",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"zeroize", "zeroize",
"zip",
] ]
[[package]] [[package]]
@@ -5424,6 +5443,12 @@ dependencies = [
"unic-common", "unic-common",
] ]
[[package]]
name = "unicode-casefold"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
+5 -1
View File
@@ -2,7 +2,7 @@
name = "notesagent-desktop" name = "notesagent-desktop"
version = "0.3.0-alpha.1" version = "0.3.0-alpha.1"
edition = "2021" edition = "2021"
rust-version = "1.85" rust-version = "1.88"
[lib] [lib]
name = "notesagent_host" name = "notesagent_host"
@@ -37,6 +37,10 @@ iota_stronghold = "2.1"
argon2 = "0.5" argon2 = "0.5"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] } fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] }
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"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
+639
View File
@@ -0,0 +1,639 @@
//! Offline verification primitives. Passing these checks does not authorize installation or execution.
use crate::workspace::{hash, HostError, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
io::{Cursor, Read},
};
use unicode_casefold::UnicodeCaseFold;
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Release {
pub schema_version: u32,
pub namespace: String,
pub package_id: String,
#[serde(rename = "type")]
pub kind: String,
pub version: String,
pub name: String,
pub author_id: String,
pub license: String,
pub description: String,
pub sha256: String,
pub size: u64,
pub platforms: Vec<String>,
pub architectures: Vec<String>,
pub min_app_version: String,
pub max_app_version: Option<String>,
pub dependencies: BTreeMap<String, String>,
pub permissions: Vec<String>,
pub changelog: String,
pub published_at: String,
pub key_id: String,
pub signature: String,
}
fn bounded(s: &str, min: usize, max: usize) -> bool {
(min..=max).contains(&s.chars().count())
}
fn identity(s: &str) -> bool {
(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'-')
}
fn version(s: &str) -> bool {
if s.len() > 80 {
return false;
}
let (core, pre) = s
.split_once('-')
.map(|(a, b)| (a, Some(b)))
.unwrap_or((s, None));
let parts: Vec<_> = core.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
&& pre.is_none_or(|v| {
!v.is_empty()
&& v.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b))
})
}
impl Release {
pub fn validate(&self) -> Result<()> {
let valid = self.schema_version == 1
&& identity(&self.namespace)
&& identity(&self.package_id)
&& matches!(
self.kind.as_str(),
"theme" | "skill" | "plugin" | "mcp" | "persona" | "template" | "model"
)
&& version(&self.version)
&& bounded(&self.name, 1, 120)
&& bounded(&self.author_id, 1, 80)
&& bounded(&self.license, 1, 80)
&& !matches!(
self.license.to_lowercase().as_str(),
"unknown" | "none" | "unlicensed" | "tbd"
)
&& bounded(&self.description, 0, 10000)
&& bounded(&self.changelog, 0, 10000)
&& self.sha256.len() == 64
&& self
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& (1..=10 * 1024 * 1024).contains(&self.size)
&& self.platforms.len() <= 12
&& self.architectures.len() <= 12
&& self
.platforms
.iter()
.chain(&self.architectures)
.all(|s| bounded(s, 1, 80))
&& version(&self.min_app_version)
&& self.max_app_version.as_ref().is_none_or(|s| version(s))
&& self.dependencies.len() <= 64
&& self
.dependencies
.iter()
.all(|(k, v)| bounded(k, 1, 160) && bounded(v, 1, 256))
&& self.permissions.len() <= 64
&& self.permissions.iter().all(|v| bounded(v, 1, 256))
&& bounded(&self.published_at, 0, 40)
&& bounded(&self.key_id, 1, 80)
&& self
.key_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
&& self.signature.len() <= 128;
if valid {
Ok(())
} else {
Err(HostError::new("EXTENSION_RELEASE_INVALID"))
}
}
pub fn signed_payload(&self) -> Result<Vec<u8>> {
self.validate()?;
let mut value =
serde_json::to_value(self).map_err(|_| HostError::new("EXTENSION_RELEASE_INVALID"))?;
value.as_object_mut().unwrap().remove("signature");
fn canonical(v: &Value) -> String {
match v {
Value::Object(map) => {
let sorted: BTreeMap<_, _> = map.iter().collect();
format!(
"{{{}}}",
sorted
.iter()
.map(|(k, v)| format!(
"{}:{}",
serde_json::to_string(k).unwrap(),
canonical(v)
))
.collect::<Vec<_>>()
.join(",")
)
}
Value::Array(items) => format!(
"[{}]",
items.iter().map(canonical).collect::<Vec<_>>().join(",")
),
_ => v.to_string(),
}
}
Ok(canonical(&value).into_bytes())
}
/// `pinned` must come from the Host trust store, never from the archive or a WebView assertion.
pub fn verify(
&self,
pinned: &[u8; 32],
key_id: &str,
namespace: &str,
revoked: bool,
withdrawn: bool,
archive: &[u8],
) -> Result<()> {
self.validate()?;
if revoked || withdrawn {
return Err(HostError::new("EXTENSION_REVOKED"));
}
if key_id != self.key_id || namespace != self.namespace {
return Err(HostError::new("EXTENSION_SIGNER_MISMATCH"));
}
let bytes = STANDARD
.decode(&self.signature)
.map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?;
let signature = Signature::from_slice(&bytes)
.map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?;
let key = VerifyingKey::from_bytes(pinned)
.map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?;
key.verify_strict(&self.signed_payload()?, &signature)
.map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?;
if archive.len() as u64 != self.size || hash(archive) != self.sha256 {
return Err(HostError::new("EXTENSION_ARCHIVE_MISMATCH"));
}
Ok(())
}
}
#[derive(Debug)]
pub struct Inventory {
pub files: BTreeMap<String, String>,
pub expanded_size: u64,
pub manifest: String,
}
// ZipArchive stores names in a map and can hide duplicate central entries. Inspect the
// bounded central directory before handing the archive to its decompressor.
fn directory(bytes: &[u8], max_entries: usize) -> Result<()> {
let bad = || HostError::new("EXTENSION_ZIP_INVALID");
let u16at = |p: usize| -> Result<usize> {
Ok(u16::from_le_bytes(bytes.get(p..p + 2).ok_or_else(bad)?.try_into().unwrap()) as usize)
};
let u32at = |p: usize| -> Result<usize> {
Ok(u32::from_le_bytes(bytes.get(p..p + 4).ok_or_else(bad)?.try_into().unwrap()) as usize)
};
let end = (bytes.len().saturating_sub(65557)..bytes.len().saturating_sub(21))
.rev()
.find(|p| {
bytes.get(*p..*p + 4) == Some(b"PK\x05\x06")
&& u16at(*p + 20).is_ok_and(|n| *p + 22 + n == bytes.len())
})
.ok_or_else(bad)?;
let count = u16at(end + 10)?;
if u16at(end + 4)? != 0
|| u16at(end + 6)? != 0
|| u16at(end + 8)? != count
|| count == 0
|| count > max_entries
{
return Err(HostError::new("EXTENSION_ZIP_LIMIT"));
}
let mut pos = u32at(end + 16)?;
let size = u32at(end + 12)?;
if pos.checked_add(size) != Some(end) {
return Err(bad());
}
let start = pos;
let mut raw_names = BTreeSet::new();
let mut ranges = Vec::new();
for _ in 0..count {
if bytes.get(pos..pos + 4) != Some(b"PK\x01\x02") {
return Err(bad());
}
let length = u16at(pos + 28)?;
let extra = u16at(pos + 30)?;
let comment = u16at(pos + 32)?;
let next = pos
.checked_add(46 + length + extra + comment)
.filter(|p| *p <= end)
.ok_or_else(bad)?;
let name = bytes.get(pos + 46..pos + 46 + length).ok_or_else(bad)?;
if !raw_names.insert(name) {
return Err(HostError::new("EXTENSION_ZIP_DUPLICATE"));
}
let local = u32at(pos + 42)?;
if local >= start
|| bytes.get(local..local + 4) != Some(b"PK\x03\x04")
|| u16at(pos + 34)? != 0
|| u16at(local + 6)? != u16at(pos + 8)?
|| u16at(local + 8)? != u16at(pos + 10)?
{
return Err(bad());
}
let local_name = u16at(local + 26)?;
let local_extra = u16at(local + 28)?;
if bytes.get(local + 30..local + 30 + local_name) != Some(name) {
return Err(bad());
}
let data_end = local
.checked_add(30 + local_name + local_extra)
.and_then(|p| p.checked_add(u32at(pos + 20).ok()?))
.filter(|p| *p <= start)
.ok_or_else(bad)?;
ranges.push((local, data_end));
pos = next;
}
if pos != end {
return Err(bad());
}
ranges.sort_unstable();
if ranges.windows(2).any(|pair| pair[0].1 > pair[1].0) {
return Err(bad());
}
Ok(())
}
fn path(name: &str) -> Result<String> {
if name.is_empty()
|| name.len() > 1024
|| name.nfc().collect::<String>() != name
|| name
.chars()
.any(|v| v.is_control() || "\\:<>|?*\"".contains(v))
{
return Err(HostError::new("EXTENSION_ZIP_PATH"));
}
let name = name.strip_suffix('/').unwrap_or(name);
for part in name.split('/') {
let stem = part.split('.').next().unwrap_or("").to_uppercase();
if part.is_empty()
|| matches!(part, "." | "..")
|| part.ends_with(['.', ' '])
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ["COM", "LPT"].iter().any(|p| {
stem.strip_prefix(p)
.is_some_and(|s| s.len() == 1 && b"123456789".contains(&s.as_bytes()[0]))
})
{
return Err(HostError::new("EXTENSION_ZIP_PATH"));
}
}
Ok(name.to_owned())
}
/// Checks all bytes (including CRC), without creating any package files.
/// Type-specific manifest schema/identity validation must follow before staging.
pub fn inspect(release: &Release, bytes: &[u8]) -> Result<Inventory> {
release.validate()?;
if bytes.len() as u64 != release.size || hash(bytes) != release.sha256 {
return Err(HostError::new("EXTENSION_ARCHIVE_MISMATCH"));
}
let (compressed, expanded, entries) = if release.kind == "theme" {
(5 * 1024 * 1024, 10 * 1024 * 1024, 100)
} else {
(10 * 1024 * 1024, 50 * 1024 * 1024, 2048)
};
if bytes.len() > compressed {
return Err(HostError::new("EXTENSION_ZIP_LIMIT"));
}
directory(bytes, entries)?;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?;
if archive.len() > entries {
return Err(HostError::new("EXTENSION_ZIP_LIMIT"));
}
let mut names = BTreeMap::new();
let mut total = 0u64;
let mut files = BTreeMap::new();
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?;
let name = path(file.name())?;
// Include current Rust Unicode case mappings as well as full multi-character folds.
let folded: String = name
.case_fold()
.flat_map(char::to_uppercase)
.flat_map(char::to_lowercase)
.nfc()
.collect();
if names.insert(folded, file.is_dir()).is_some() {
return Err(HostError::new("EXTENSION_ZIP_DUPLICATE"));
}
let mode = file.unix_mode().unwrap_or(0) & 0o170000;
if file.encrypted()
|| !matches!(
file.compression(),
zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated
)
|| !matches!(mode, 0 | 0o100000 | 0o040000)
|| (mode == 0o040000 && !file.is_dir())
|| (mode == 0o100000 && file.is_dir())
{
return Err(HostError::new("EXTENSION_ZIP_UNSAFE"));
}
total = total
.checked_add(file.size())
.ok_or_else(|| HostError::new("EXTENSION_ZIP_LIMIT"))?;
if total > expanded {
return Err(HostError::new("EXTENSION_ZIP_LIMIT"));
}
if file.is_dir() {
if file.size() != 0 {
return Err(HostError::new("EXTENSION_ZIP_UNSAFE"));
}
continue;
}
use sha2::{Digest, Sha256};
let expected = file.size();
let mut count = 0u64;
let mut digest = Sha256::new();
let mut buffer = [0u8; 65536];
loop {
let read = file
.read(&mut buffer)
.map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?;
if read == 0 {
break;
}
count += read as u64;
if count > expected {
return Err(HostError::new("EXTENSION_ZIP_LIMIT"));
}
digest.update(&buffer[..read]);
}
if count != expected {
return Err(HostError::new("EXTENSION_ZIP_INVALID"));
}
files.insert(name, format!("{:x}", digest.finalize()));
}
for (name, is_dir) in &names {
if !is_dir
&& names
.range(format!("{name}/")..)
.next()
.is_some_and(|(next, _)| next.starts_with(&format!("{name}/")))
{
return Err(HostError::new("EXTENSION_ZIP_PREFIX"));
}
}
let required = match release.kind.as_str() {
"theme" => "theme.yaml",
"skill" => "skill.yaml",
"plugin" => "plugin.yaml",
"mcp" => "mcp.json",
"persona" => "persona.json",
"template" => "template.json",
_ => "model.json",
};
let candidates: Vec<_> = files
.keys()
.filter(|name| name.as_str() == required || name.ends_with(&format!("/{required}")))
.collect();
if candidates.len() != 1 {
return Err(HostError::new("EXTENSION_MANIFEST_INVALID"));
}
Ok(Inventory {
manifest: candidates[0].clone(),
files,
expanded_size: total,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn fixture() -> (Release, Vec<u8>, [u8; 32]) {
let mut data: Value = serde_json::from_str(include_str!(
"../../src/services/fixtures/community-python-vector.json"
))
.unwrap();
for field in ["release_id", "withdrawn", "download_path"] {
data["release"].as_object_mut().unwrap().remove(field);
}
(
serde_json::from_value(data["release"].clone()).unwrap(),
STANDARD
.decode(data["archive_base64"].as_str().unwrap())
.unwrap(),
STANDARD
.decode(data["key"]["public_key"].as_str().unwrap())
.unwrap()
.try_into()
.unwrap(),
)
}
fn archive(items: &[(&str, &[u8])], method: zip::CompressionMethod) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in items {
writer
.start_file(
*name,
zip::write::SimpleFileOptions::default()
.compression_method(method)
.unix_permissions(0o644),
)
.unwrap();
writer.write_all(body).unwrap();
}
writer.finish().unwrap().into_inner()
}
fn check(bytes: &[u8], kind: &str) -> Result<Inventory> {
let mut r = fixture().0;
r.kind = kind.into();
r.size = bytes.len() as u64;
r.sha256 = hash(bytes);
inspect(&r, bytes)
}
#[test]
fn python_signature_binds_all_metadata_archive_and_signer() {
let (release, bytes, key) = fixture();
release
.verify(&key, "test-key", "examples", false, false, &bytes)
.unwrap();
let inventory = inspect(&release, &bytes).unwrap();
assert_eq!(inventory.manifest, "persona.json");
let value = serde_json::to_value(&release).unwrap();
for field in value
.as_object()
.unwrap()
.keys()
.filter(|k| k.as_str() != "signature")
{
let mut changed = value.clone();
match &mut changed[field] {
Value::String(v) => v.push('x'),
Value::Number(v) => *v = serde_json::Number::from(v.as_u64().unwrap() + 1),
Value::Array(v) => v.push(Value::String("tampered".into())),
Value::Object(v) => {
v.insert("tampered".into(), Value::String("1.0.0".into()));
}
Value::Null => changed[field] = Value::String("9.0.0".into()),
_ => panic!(),
}
let changed: Release = serde_json::from_value(changed).unwrap();
assert!(
changed
.verify(&key, "test-key", "examples", false, false, &bytes)
.is_err(),
"unsigned field {field}"
);
}
for (revoked, withdrawn) in [(true, false), (false, true)] {
assert_eq!(
release
.verify(&key, "test-key", "examples", revoked, withdrawn, &bytes)
.unwrap_err()
.code,
"EXTENSION_REVOKED"
);
}
assert!(release
.verify(&[0; 32], "test-key", "examples", false, false, &bytes)
.is_err());
assert!(release
.verify(&key, "other", "examples", false, false, &bytes)
.is_err());
let mut changed = bytes.clone();
changed[0] ^= 1;
assert!(release
.verify(&key, "test-key", "examples", false, false, &changed)
.is_err());
}
#[test]
fn zip_rejects_traversal_aliases_links_duplicates_and_corrupt_local_headers() {
for name in [
"../outside",
"/absolute",
"a/../outside",
"a\\b",
"C:ads",
"CON.txt",
"a./b",
"a /b",
"a//b",
"a\u{0001}b",
"a\u{0301}.txt",
] {
let bytes = archive(
&[("persona.json", b"{}"), (name, b"outside")],
zip::CompressionMethod::Stored,
);
assert!(check(&bytes, "persona").is_err(), "unsafe path {name:?}");
}
for (one, two) in [
("A.txt", "a.txt"),
("Straße", "STRASSE"),
("", ""),
("a", "a/b"),
] {
let bytes = archive(
&[("persona.json", b"{}"), (one, b"1"), (two, b"2")],
zip::CompressionMethod::Stored,
);
assert!(check(&bytes, "persona").is_err());
}
let mut duplicate = archive(
&[("persona.json", b"{}"), ("A.txt", b"1"), ("B.txt", b"2")],
zip::CompressionMethod::Stored,
);
for p in 0..duplicate.len() - 5 {
if &duplicate[p..p + 5] == b"B.txt" {
duplicate[p] = b'A';
}
}
assert_eq!(
check(&duplicate, "persona").unwrap_err().code,
"EXTENSION_ZIP_DUPLICATE"
);
let original = archive(&[("persona.json", b"{}")], zip::CompressionMethod::Stored);
let central = original
.windows(4)
.position(|b| b == b"PK\x01\x02")
.unwrap();
let mut link = original.clone();
link[central + 38..central + 42].copy_from_slice(&(0o120777u32 << 16).to_le_bytes());
assert!(check(&link, "persona").is_err());
let mut encrypted = original.clone();
encrypted[6] |= 1;
encrypted[central + 8] |= 1;
assert!(check(&encrypted, "persona").is_err());
let mut mismatch = original.clone();
mismatch[30] = b'X';
assert!(check(&mismatch, "persona").is_err());
let mut corrupt = original;
let body = 30 + "persona.json".len();
corrupt[body] ^= 1;
assert!(check(&corrupt, "persona").is_err());
}
#[test]
fn compressed_limits_are_inclusive_for_both_categories() {
for (kind, manifest, limit) in [
("theme", "theme.yaml", 5 * 1024 * 1024),
("plugin", "plugin.yaml", 10 * 1024 * 1024),
] {
let overhead = archive(
&[(manifest, b"{}"), ("payload", b"")],
zip::CompressionMethod::Stored,
)
.len();
for extra in [0, 1] {
let payload = vec![0; limit - overhead + extra];
let bytes = archive(
&[(manifest, b"{}"), ("payload", &payload)],
zip::CompressionMethod::Stored,
);
assert_eq!(bytes.len(), limit + extra);
assert_eq!(check(&bytes, kind).is_ok(), extra == 0);
}
}
}
#[test]
fn category_entry_and_expanded_limits_accept_boundary_and_reject_next_byte() {
for (kind, manifest, limit, count) in [
("theme", "theme.yaml", 10 * 1024 * 1024, 100),
("plugin", "plugin.yaml", 50 * 1024 * 1024, 2048),
] {
let names: Vec<_> = (1..count + 1).map(|i| format!("entry-{i}")).collect();
let mut items = vec![(manifest, b"{}".as_slice())];
items.extend(
names
.iter()
.take(count - 1)
.map(|s| (s.as_str(), b"".as_slice())),
);
assert!(check(&archive(&items, zip::CompressionMethod::Stored), kind).is_ok());
items.push((names.last().unwrap(), b""));
assert!(check(&archive(&items, zip::CompressionMethod::Stored), kind).is_err());
let payload = vec![0; limit - 2];
let bytes = archive(
&[(manifest, b"{}"), ("payload", &payload)],
zip::CompressionMethod::Deflated,
);
assert_eq!(check(&bytes, kind).unwrap().expanded_size, limit as u64);
let payload = vec![0; limit - 1];
let bytes = archive(
&[(manifest, b"{}"), ("payload", &payload)],
zip::CompressionMethod::Deflated,
);
assert_eq!(check(&bytes, kind).unwrap_err().code, "EXTENSION_ZIP_LIMIT");
}
}
}
+3
View File
@@ -24,3 +24,6 @@ pub mod workspace;
pub mod workspace_broker; pub mod workspace_broker;
pub mod sync_retry; pub mod sync_retry;
#[cfg(feature = "desktop")]
pub mod extension_package;