fix(security): bound style-pack ZIP imports (#819)

* fix(security): bound style pack archive imports

* fix(security): validate physical ZIP directory entries

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-07-14 19:02:03 +08:00
1 parent 145a0ea17d
commit 6aaae670b0
5 files changed
+1541 -149

No files matched your search

@@ -1,4 +1,5 @@
use super::*;
use std::io::Write;
// ─────────────────────────── marketplace (Phase A) ───────────────────────────
//
@@ -172,7 +173,7 @@ pub async fn marketplace_install(
.map(|s| s.to_string());
let download_url = format!("{base}/packs/{pack_id}/download");
let bytes = net::send_with_retry(|| {
let response = net::send_with_retry(|| {
net::http()
.get(&download_url)
.timeout(std::time::Duration::from_secs(30))
@@ -180,20 +181,15 @@ pub async fn marketplace_install(
.await
.map_err(|e| format!("marketplace download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace HTTP error: {e}"))?
.bytes()
.await
.map_err(|e| format!("read body failed: {e}"))?;
.map_err(|e| format!("marketplace HTTP error: {e}"))?;
let bytes = read_marketplace_archive_response(response).await?;
// pack_id 已经过 UUID 白名单,拼临时文件路径安全。
let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip"));
std::fs::write(&tmp, &bytes).map_err(|e| format!("write tmp zip: {e}"))?;
let imported_result = coord
// 每次安装使用 create_new 的唯一临时路径;Drop 覆盖导入成功和所有错误分支。
let tmp = MarketplaceTempArchive::create(&pack_id, &bytes)?;
let imported = coord
.style_packs()
.import_from_zip(&tmp)
.map_err(|e| e.to_string());
let _ = std::fs::remove_file(&tmp);
let imported = imported_result?;
.import_from_zip(tmp.path())
.map_err(|e| e.to_string())?;
// 绑定 origin —— 后续编辑+发布走 derivative / supersede 分支。
coord
@@ -202,6 +198,87 @@ pub async fn marketplace_install(
.map_err(|e| format!("set origin failed: {e}"))
}
fn validate_marketplace_archive_content_length(content_length: Option<u64>) -> Result<(), String> {
if content_length.is_some_and(|length| {
length > crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES as u64
}) {
return Err(format!(
"marketplace archive compressed size exceeds {} bytes",
crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES
));
}
Ok(())
}
fn append_marketplace_archive_chunk(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(), String> {
if body.len().saturating_add(chunk.len())
> crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES
{
return Err(format!(
"marketplace archive streamed compressed size exceeds {} bytes",
crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES
));
}
body.extend_from_slice(chunk);
Ok(())
}
async fn read_marketplace_archive_response(response: reqwest::Response) -> Result<Vec<u8>, String> {
use futures_util::StreamExt;
validate_marketplace_archive_content_length(response.content_length())?;
let initial_capacity = response
.content_length()
.and_then(|length| usize::try_from(length).ok())
.unwrap_or(0)
.min(crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES);
let mut body = Vec::with_capacity(initial_capacity);
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("read marketplace archive body failed: {e}"))?;
append_marketplace_archive_chunk(&mut body, &chunk)?;
}
Ok(body)
}
struct MarketplaceTempArchive {
path: std::path::PathBuf,
}
impl MarketplaceTempArchive {
fn create(pack_id: &str, bytes: &[u8]) -> Result<Self, String> {
let path = std::env::temp_dir().join(format!(
"openless-marketplace-{pack_id}-{}.zip",
uuid::Uuid::new_v4().simple()
));
let write_result = (|| -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
file.write_all(bytes)?;
file.sync_all()
})();
if let Err(error) = write_result {
let _ = std::fs::remove_file(&path);
return Err(format!(
"write marketplace temporary archive failed: {error}"
));
}
Ok(Self { path })
}
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for MarketplaceTempArchive {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[tauri::command]
pub async fn marketplace_upload(
coord: CoordinatorState<'_>,
@@ -316,6 +393,40 @@ pub async fn marketplace_like(
.map_err(|e| format!("parse failed: {e}"))
}
#[cfg(test)]
mod archive_download_tests {
use super::{append_marketplace_archive_chunk, validate_marketplace_archive_content_length};
use crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES;
#[test]
fn marketplace_archive_rejects_oversized_declared_content_length() {
let error = validate_marketplace_archive_content_length(Some(
STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES as u64 + 1,
))
.expect_err("oversized content length must fail");
assert!(error.contains("compressed size"));
}
#[test]
fn marketplace_archive_rejects_streamed_chunk_crossing_limit() {
let mut body = vec![0; STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES];
let error = append_marketplace_archive_chunk(&mut body, b"x")
.expect_err("streamed overflow must fail");
assert!(error.contains("compressed size"));
assert_eq!(body.len(), STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES);
}
#[test]
fn marketplace_archive_accepts_exact_streamed_limit() {
let mut body = vec![0; STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES - 1];
append_marketplace_archive_chunk(&mut body, b"x").expect("exact limit is valid");
assert_eq!(body.len(), STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES);
}
}
/// 撤回自己发布的 pack(后端软删 state='withdrawn',前端列表不再可见)。
/// pack_id 来自远端,必须是 UUID-v4。
#[tauri::command]
@@ -32,6 +32,7 @@ mod history;
mod paths;
mod preferences;
mod style_pack;
mod style_pack_archive;
pub use activity::*;
pub use correction::*;
@@ -41,6 +42,7 @@ pub use history::*;
pub use paths::*;
pub use preferences::*;
pub use style_pack::*;
pub(crate) use style_pack_archive::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES;
const HISTORY_CAP: usize = 200;
const PREFERENCES_FILE: &str = "preferences.json";
@@ -4,15 +4,18 @@
//! enabled packs.
use std::fs;
use std::io::{Read, Write};
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::style_pack_archive::{
cleanup_style_pack_asset_dir, persist_style_pack_icon, read_style_pack_archive,
StylePackArchiveManifest,
};
use super::{atomic_write, data_dir, ensure_dir, read_or_default, PreferencesStore};
use crate::types::{
builtin_style_pack_for_mode, builtin_style_pack_id, builtin_style_packs,
@@ -23,40 +26,6 @@ use crate::types::{
const STYLE_PACKS_FILE: &str = "style-packs.json";
const STYLE_PACK_ASSETS_DIR: &str = "style-pack-assets";
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StylePackArchiveManifest {
schema_version: u32,
id: String,
name: String,
description: String,
author: Option<String>,
version: String,
base_mode: PolishMode,
tags: Vec<String>,
prompt_file: String,
examples_file: String,
icon_file: Option<String>,
recommended_model: Option<String>,
compatible_app_version: Option<String>,
/// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None;
/// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。
#[serde(
default,
alias = "orionPackId",
alias = "orion_pack_id",
alias = "origin_pack_id"
)]
origin_pack_id: Option<String>,
#[serde(
default,
alias = "orionAuthorLogin",
alias = "orion_author_login",
alias = "origin_author_login"
)]
origin_author_login: Option<String>,
}
pub struct StylePackStore {
path: PathBuf,
asset_root: PathBuf,
@@ -315,20 +284,15 @@ impl StylePackStore {
}
pub fn import_from_zip(&self, zip_path: &Path) -> Result<StylePack> {
let file = fs::File::open(zip_path)
.with_context(|| format!("open style pack zip failed: {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file).context("open style pack zip archive")?;
let manifest: StylePackArchiveManifest =
read_zip_json_entry(&mut archive, "manifest.json")?;
let prompt = read_zip_string_entry(&mut archive, &manifest.prompt_file)?;
let examples =
read_zip_json_entry::<Vec<StylePackExample>>(&mut archive, &manifest.examples_file)?;
let parsed = read_style_pack_archive(zip_path)?;
let manifest = parsed.manifest;
let manifest_id = manifest.id.clone();
let mut packs = self.state.lock();
let now = Utc::now().to_rfc3339();
let pack_id = unique_imported_style_pack_id(&packs, &manifest.id);
let icon_path = if let Some(icon_file) = manifest.icon_file.as_deref() {
extract_style_pack_icon(&mut archive, &self.asset_root, &pack_id, icon_file)?
let icon_path = if let Some(icon) = parsed.icon {
Some(persist_style_pack_icon(&self.asset_root, &pack_id, icon)?)
} else {
None
};
@@ -342,8 +306,8 @@ impl StylePackStore {
version: normalize_version(&manifest.version),
kind: StylePackKind::Imported,
base_mode: manifest.base_mode,
prompt,
examples,
prompt: parsed.prompt,
examples: parsed.examples,
tags: normalize_tags(&manifest.tags),
icon_path,
created_at: Some(now.clone()),
@@ -359,13 +323,20 @@ impl StylePackStore {
origin_pack_id: normalize_optional_text(manifest.origin_pack_id),
origin_author_login: normalize_optional_text(manifest.origin_author_login),
};
packs.insert(0, pack.clone());
write_style_packs_file(&self.path, &packs)?;
let mut next_packs = packs.clone();
next_packs.insert(0, pack.clone());
if let Err(error) = write_style_packs_file(&self.path, &next_packs) {
if pack.icon_path.is_some() {
cleanup_style_pack_asset_dir(&self.asset_root, &pack.id);
}
return Err(error);
}
*packs = next_packs;
log::info!(
"[style-pack] imported source={} installed_id={} manifest_id={} base_mode={:?} prompt_chars={} examples={} tags={} icon={}",
zip_path.display(),
pack.id,
manifest.id,
manifest_id,
pack.base_mode,
pack.prompt.chars().count(),
pack.examples.len(),
@@ -809,52 +780,6 @@ fn sanitize_style_pack_id(requested_id: &str) -> String {
}
}
fn read_zip_json_entry<T: for<'de> Deserialize<'de>>(
archive: &mut zip::ZipArchive<fs::File>,
entry_name: &str,
) -> Result<T> {
let text = read_zip_string_entry(archive, entry_name)?;
serde_json::from_str(&text)
.with_context(|| format!("decode style pack zip entry failed: {entry_name}"))
}
fn read_zip_string_entry(
archive: &mut zip::ZipArchive<fs::File>,
entry_name: &str,
) -> Result<String> {
let mut file = archive
.by_name(entry_name)
.with_context(|| format!("missing style pack zip entry: {entry_name}"))?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)
.with_context(|| format!("read style pack zip entry failed: {entry_name}"))?;
Ok(buffer)
}
fn extract_style_pack_icon(
archive: &mut zip::ZipArchive<fs::File>,
asset_root: &Path,
pack_id: &str,
entry_name: &str,
) -> Result<Option<String>> {
let mut file = archive
.by_name(entry_name)
.with_context(|| format!("missing style pack icon entry: {entry_name}"))?;
let file_name = Path::new(entry_name)
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("invalid style pack icon file name"))?;
let target_dir = asset_root.join(pack_id);
ensure_dir(&target_dir)?;
let target_path = target_dir.join(file_name);
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.with_context(|| format!("read style pack icon failed: {entry_name}"))?;
fs::write(&target_path, &bytes)
.with_context(|| format!("write style pack icon failed: {}", target_path.display()))?;
Ok(Some(target_path.to_string_lossy().to_string()))
}
fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) {
if let Some(icon_path) = pack.icon_path.as_deref() {
let path = Path::new(icon_path);
@@ -869,42 +794,5 @@ fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) {
}
#[cfg(test)]
mod tests {
use super::sync_style_pack_preferences;
use crate::types::{builtin_style_packs, CustomStylePrompts};
#[test]
fn sync_style_pack_preferences_uses_builtin_store_prompts_as_source_of_truth() {
let mut prefs = crate::types::UserPreferences {
style_system_prompts: crate::types::StyleSystemPrompts {
raw: "stale raw".into(),
light: "stale light".into(),
structured: "stale structured".into(),
formal: "stale formal".into(),
},
custom_style_prompts: CustomStylePrompts {
raw: String::new(),
light: "legacy extra instruction".into(),
structured: String::new(),
formal: String::new(),
},
..Default::default()
};
let mut packs = builtin_style_packs();
let light = packs
.iter_mut()
.find(|pack| pack.id == "builtin.light")
.expect("builtin light pack");
light.prompt = "fresh light prompt from store".into();
assert!(sync_style_pack_preferences(&mut prefs, &packs));
assert_eq!(prefs.style_system_prompts.raw, packs[0].prompt);
assert_eq!(
prefs.style_system_prompts.light,
"fresh light prompt from store"
);
assert_eq!(prefs.style_system_prompts.structured, packs[2].prompt);
assert_eq!(prefs.style_system_prompts.formal, packs[3].prompt);
assert_eq!(prefs.custom_style_prompts, CustomStylePrompts::default());
}
}
#[path = "style_pack_tests.rs"]
mod tests;
@@ -0,0 +1,862 @@
//! Resource-bounded parser for the untrusted style-pack ZIP format.
use std::collections::HashSet;
use std::fs;
use std::io::{Cursor, Read, Seek};
use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use super::{atomic_write, ensure_dir};
use crate::types::{PolishMode, StylePackExample};
pub(crate) const STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES: usize = 512 * 1024;
const MAX_ARCHIVE_ENTRIES: usize = 16;
pub(super) const MAX_ENTRY_UNCOMPRESSED_BYTES: usize = 128 * 1024;
pub(super) const MAX_MANIFEST_BYTES: usize = 32 * 1024;
pub(super) const MAX_PROMPT_BYTES: usize = 64 * 1024;
pub(super) const MAX_EXAMPLES_BYTES: usize = 64 * 1024;
pub(super) const MAX_ICON_BYTES: usize = 64 * 1024;
const MAX_ENTRY_NAME_BYTES: usize = 255;
const MAX_ICON_DIMENSION: u32 = 1024;
const MAX_ICON_PIXELS: u64 = 1024 * 1024;
pub(super) const MAX_TOTAL_UNCOMPRESSED_BYTES: usize = 256 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct StylePackArchiveManifest {
pub(super) schema_version: u32,
pub(super) id: String,
pub(super) name: String,
pub(super) description: String,
pub(super) author: Option<String>,
pub(super) version: String,
pub(super) base_mode: PolishMode,
pub(super) tags: Vec<String>,
pub(super) prompt_file: String,
pub(super) examples_file: String,
pub(super) icon_file: Option<String>,
pub(super) recommended_model: Option<String>,
pub(super) compatible_app_version: Option<String>,
/// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None;
/// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。
#[serde(
default,
alias = "orionPackId",
alias = "orion_pack_id",
alias = "origin_pack_id"
)]
pub(super) origin_pack_id: Option<String>,
#[serde(
default,
alias = "orionAuthorLogin",
alias = "orion_author_login",
alias = "origin_author_login"
)]
pub(super) origin_author_login: Option<String>,
}
pub(super) struct ParsedStylePackArchive {
pub(super) manifest: StylePackArchiveManifest,
pub(super) prompt: String,
pub(super) examples: Vec<StylePackExample>,
pub(super) icon: Option<StylePackIcon>,
}
pub(super) struct StylePackIcon {
extension: String,
bytes: Vec<u8>,
}
#[derive(Default)]
pub(super) struct StreamBudget {
pub(super) total: usize,
}
pub(super) fn read_style_pack_archive(zip_path: &Path) -> Result<ParsedStylePackArchive> {
let compressed = read_compressed_archive_bounded(zip_path)?;
let mut archive = zip::ZipArchive::new(Cursor::new(compressed.as_slice()))
.context("open style pack zip archive")?;
preflight_physical_central_directory(&compressed, &archive)?;
let entry_names = preflight_archive_metadata(&mut archive)?;
let mut stream_budget = StreamBudget::default();
let manifest_bytes = read_archive_entry(
&mut archive,
"manifest.json",
MAX_MANIFEST_BYTES,
&mut stream_budget,
)?;
let manifest: StylePackArchiveManifest = serde_json::from_slice(&manifest_bytes)
.context("decode style pack zip entry failed: manifest.json")?;
if manifest.schema_version != 1 {
bail!(
"unsupported style pack manifest schema version: {}",
manifest.schema_version
);
}
validate_manifest_reference(&manifest.prompt_file, &entry_names, ReferenceKind::Prompt)?;
validate_manifest_reference(
&manifest.examples_file,
&entry_names,
ReferenceKind::Examples,
)?;
if manifest.prompt_file == manifest.examples_file {
bail!("style pack prompt and examples entries must be distinct");
}
let prompt_bytes = read_archive_entry(
&mut archive,
&manifest.prompt_file,
MAX_PROMPT_BYTES,
&mut stream_budget,
)?;
let prompt = String::from_utf8(prompt_bytes).with_context(|| {
format!(
"style pack prompt is not valid UTF-8: {}",
manifest.prompt_file
)
})?;
let examples_bytes = read_archive_entry(
&mut archive,
&manifest.examples_file,
MAX_EXAMPLES_BYTES,
&mut stream_budget,
)?;
let examples =
serde_json::from_slice::<Vec<StylePackExample>>(&examples_bytes).with_context(|| {
format!(
"decode style pack zip entry failed: {}",
manifest.examples_file
)
})?;
let icon = if let Some(icon_file) = manifest.icon_file.as_deref() {
let extension = validate_manifest_reference(icon_file, &entry_names, ReferenceKind::Icon)?;
let bytes =
read_archive_entry(&mut archive, icon_file, MAX_ICON_BYTES, &mut stream_budget)?;
validate_icon_content(&extension, &bytes)?;
Some(StylePackIcon { extension, bytes })
} else {
None
};
Ok(ParsedStylePackArchive {
manifest,
prompt,
examples,
icon,
})
}
const CENTRAL_DIRECTORY_HEADER_SIGNATURE: &[u8; 4] = b"PK\x01\x02";
const ZIP64_END_SIGNATURE: &[u8; 4] = b"PK\x06\x06";
const ZIP64_LOCATOR_SIGNATURE: &[u8; 4] = b"PK\x06\x07";
const END_OF_CENTRAL_DIRECTORY_SIGNATURE: &[u8; 4] = b"PK\x05\x06";
const CENTRAL_DIRECTORY_HEADER_LEN: usize = 46;
const END_OF_CENTRAL_DIRECTORY_LEN: usize = 22;
const ZIP64_END_MIN_LEN: usize = 56;
const ZIP64_LOCATOR_LEN: usize = 20;
fn preflight_physical_central_directory(
bytes: &[u8],
archive: &zip::ZipArchive<Cursor<&[u8]>>,
) -> Result<()> {
let comment_len = archive.comment().len();
let eocd_len = END_OF_CENTRAL_DIRECTORY_LEN
.checked_add(comment_len)
.ok_or_else(|| anyhow!("style pack zip EOCD length overflow"))?;
let eocd_offset = bytes
.len()
.checked_sub(eocd_len)
.ok_or_else(|| anyhow!("style pack zip EOCD is truncated"))?;
if bytes.get(eocd_offset..eocd_offset + 4)
!= Some(END_OF_CENTRAL_DIRECTORY_SIGNATURE.as_slice())
{
bail!("style pack zip EOCD is not anchored at the end of the archive");
}
let declared_comment_len = read_u16(bytes, eocd_offset + 20)? as usize;
if declared_comment_len != comment_len {
bail!("style pack zip EOCD comment length is inconsistent");
}
let disk_number = read_u16(bytes, eocd_offset + 4)?;
let central_disk = read_u16(bytes, eocd_offset + 6)?;
if disk_number != 0 || central_disk != 0 {
bail!("multi-disk style pack zip archives are not supported");
}
let entries_on_disk = read_u16(bytes, eocd_offset + 8)?;
let total_entries = read_u16(bytes, eocd_offset + 10)?;
if entries_on_disk != total_entries {
bail!("multi-disk style pack zip archives are not supported");
}
let central_start = usize::try_from(archive.central_directory_start())
.map_err(|_| anyhow!("style pack zip central directory offset is unsupported"))?;
let archive_offset = usize::try_from(archive.offset())
.map_err(|_| anyhow!("style pack zip archive offset is unsupported"))?;
if central_start > eocd_offset {
bail!("style pack zip central directory starts beyond its EOCD");
}
let is_zip64 = entries_on_disk == u16::MAX
|| total_entries == u16::MAX
|| read_u32(bytes, eocd_offset + 12)? == u32::MAX
|| read_u32(bytes, eocd_offset + 16)? == u32::MAX;
let (central_end, declared_entries, declared_size, declared_relative_offset) = if is_zip64 {
parse_zip64_footer(bytes, eocd_offset, archive_offset)?
} else {
(
eocd_offset,
total_entries as u64,
read_u32(bytes, eocd_offset + 12)? as u64,
read_u32(bytes, eocd_offset + 16)? as u64,
)
};
let expected_central_start = (archive_offset as u64)
.checked_add(declared_relative_offset)
.ok_or_else(|| anyhow!("style pack zip central directory offset overflow"))?;
if expected_central_start != central_start as u64 {
bail!("style pack zip central directory offset is inconsistent");
}
let actual_central_size = central_end
.checked_sub(central_start)
.ok_or_else(|| anyhow!("style pack zip central directory bounds are inconsistent"))?;
if declared_size != actual_central_size as u64 {
bail!("style pack zip central directory size is inconsistent");
}
let mut cursor = central_start;
let mut physical_count = 0usize;
while cursor < central_end {
if bytes.get(cursor..cursor + 4) != Some(CENTRAL_DIRECTORY_HEADER_SIGNATURE.as_slice()) {
bail!("style pack zip central directory contains a malformed physical entry");
}
physical_count = physical_count
.checked_add(1)
.ok_or_else(|| anyhow!("style pack zip physical entry count overflow"))?;
if physical_count > MAX_ARCHIVE_ENTRIES {
bail!("style pack archive contains more than {MAX_ARCHIVE_ENTRIES} physical entries");
}
let name_len = read_u16(bytes, cursor + 28)? as usize;
let extra_len = read_u16(bytes, cursor + 30)? as usize;
let entry_comment_len = read_u16(bytes, cursor + 32)? as usize;
let disk_start = read_u16(bytes, cursor + 34)?;
if disk_start != 0 && disk_start != u16::MAX {
bail!("multi-disk style pack zip entries are not supported");
}
let name_start = cursor
.checked_add(CENTRAL_DIRECTORY_HEADER_LEN)
.ok_or_else(|| anyhow!("style pack zip central entry offset overflow"))?;
let record_len = CENTRAL_DIRECTORY_HEADER_LEN
.checked_add(name_len)
.and_then(|value| value.checked_add(extra_len))
.and_then(|value| value.checked_add(entry_comment_len))
.ok_or_else(|| anyhow!("style pack zip central entry length overflow"))?;
let record_end = cursor
.checked_add(record_len)
.filter(|end| *end <= central_end)
.ok_or_else(|| anyhow!("style pack zip central entry is truncated"))?;
let name_end = name_start
.checked_add(name_len)
.filter(|end| *end <= record_end)
.ok_or_else(|| anyhow!("style pack zip central entry name is truncated"))?;
bytes
.get(name_start..name_end)
.ok_or_else(|| anyhow!("style pack zip central entry name is truncated"))?;
cursor = record_end;
}
if cursor != central_end || physical_count as u64 != declared_entries {
bail!("style pack zip physical entry count is inconsistent");
}
if physical_count != archive.len() {
bail!("duplicate physical style pack zip entries were collapsed while decoding");
}
Ok(())
}
fn parse_zip64_footer(
bytes: &[u8],
eocd_offset: usize,
archive_offset: usize,
) -> Result<(usize, u64, u64, u64)> {
let locator_offset = eocd_offset
.checked_sub(ZIP64_LOCATOR_LEN)
.ok_or_else(|| anyhow!("style pack ZIP64 locator is truncated"))?;
if bytes.get(locator_offset..locator_offset + 4) != Some(ZIP64_LOCATOR_SIGNATURE.as_slice()) {
bail!("style pack ZIP64 locator is missing");
}
if read_u32(bytes, locator_offset + 4)? != 0 || read_u32(bytes, locator_offset + 16)? != 1 {
bail!("multi-disk style pack ZIP64 archives are not supported");
}
let zip64_relative_offset = usize::try_from(read_u64(bytes, locator_offset + 8)?)
.map_err(|_| anyhow!("style pack ZIP64 EOCD offset is unsupported"))?;
let zip64_offset = archive_offset
.checked_add(zip64_relative_offset)
.ok_or_else(|| anyhow!("style pack ZIP64 EOCD offset overflow"))?;
let zip64_signature_end = zip64_offset
.checked_add(4)
.ok_or_else(|| anyhow!("style pack ZIP64 EOCD offset overflow"))?;
if bytes.get(zip64_offset..zip64_signature_end) != Some(ZIP64_END_SIGNATURE.as_slice()) {
bail!("style pack ZIP64 EOCD is missing");
}
let zip64_payload_len = usize::try_from(read_u64(bytes, zip64_offset + 4)?)
.map_err(|_| anyhow!("style pack ZIP64 EOCD length is unsupported"))?;
let zip64_total_len = 12usize
.checked_add(zip64_payload_len)
.ok_or_else(|| anyhow!("style pack ZIP64 EOCD length overflow"))?;
if zip64_total_len < ZIP64_END_MIN_LEN
|| zip64_offset.checked_add(zip64_total_len) != Some(locator_offset)
{
bail!("style pack ZIP64 EOCD bounds are inconsistent");
}
if read_u32(bytes, zip64_offset + 16)? != 0 || read_u32(bytes, zip64_offset + 20)? != 0 {
bail!("multi-disk style pack ZIP64 archives are not supported");
}
let entries_on_disk = read_u64(bytes, zip64_offset + 24)?;
let total_entries = read_u64(bytes, zip64_offset + 32)?;
if entries_on_disk != total_entries {
bail!("multi-disk style pack ZIP64 archives are not supported");
}
Ok((
zip64_offset,
total_entries,
read_u64(bytes, zip64_offset + 40)?,
read_u64(bytes, zip64_offset + 48)?,
))
}
fn read_u16(bytes: &[u8], offset: usize) -> Result<u16> {
let end = offset
.checked_add(2)
.ok_or_else(|| anyhow!("style pack zip integer offset overflow"))?;
let value = bytes
.get(offset..end)
.ok_or_else(|| anyhow!("style pack zip metadata is truncated"))?;
Ok(u16::from_le_bytes([value[0], value[1]]))
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
let end = offset
.checked_add(4)
.ok_or_else(|| anyhow!("style pack zip integer offset overflow"))?;
let value = bytes
.get(offset..end)
.ok_or_else(|| anyhow!("style pack zip metadata is truncated"))?;
let value: [u8; 4] = value
.try_into()
.map_err(|_| anyhow!("style pack zip 32-bit field has an invalid length"))?;
Ok(u32::from_le_bytes(value))
}
fn read_u64(bytes: &[u8], offset: usize) -> Result<u64> {
let end = offset
.checked_add(8)
.ok_or_else(|| anyhow!("style pack zip integer offset overflow"))?;
let value = bytes
.get(offset..end)
.ok_or_else(|| anyhow!("style pack zip metadata is truncated"))?;
let value: [u8; 8] = value
.try_into()
.map_err(|_| anyhow!("style pack zip 64-bit field has an invalid length"))?;
Ok(u64::from_le_bytes(value))
}
fn read_compressed_archive_bounded(zip_path: &Path) -> Result<Vec<u8>> {
let file = fs::File::open(zip_path)
.with_context(|| format!("open style pack zip failed: {}", zip_path.display()))?;
let declared_size = file
.metadata()
.with_context(|| format!("read style pack zip metadata: {}", zip_path.display()))?
.len();
if declared_size > STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES as u64 {
bail!(
"style pack archive compressed size {declared_size} exceeds {} bytes",
STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES
);
}
let mut bytes = Vec::with_capacity(declared_size as usize);
file.take(STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("read style pack zip failed: {}", zip_path.display()))?;
if bytes.len() > STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES {
bail!(
"style pack archive streamed compressed size {} exceeds {} bytes",
bytes.len(),
STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES
);
}
Ok(bytes)
}
fn preflight_archive_metadata<R: Read + Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<HashSet<String>> {
if archive.len() > MAX_ARCHIVE_ENTRIES {
bail!(
"style pack archive contains {} entries; maximum is {MAX_ARCHIVE_ENTRIES}",
archive.len()
);
}
let mut entry_names = HashSet::with_capacity(archive.len());
let mut total_declared = 0usize;
for index in 0..archive.len() {
let file = archive
.by_index(index)
.with_context(|| format!("read style pack zip entry metadata at index {index}"))?;
let name = file.name().to_string();
validate_entry_name(&name, file.is_dir())?;
if file.enclosed_name().is_none() {
bail!("unsafe style pack zip entry name: {name}");
}
if file.is_symlink() {
bail!("unsafe style pack zip symlink entry: {name}");
}
if file.encrypted() {
bail!("encrypted style pack zip entries are not supported: {name}");
}
if !entry_names.insert(name.clone()) {
bail!("duplicate style pack zip entry name: {name}");
}
let declared = usize::try_from(file.size())
.map_err(|_| anyhow!("style pack zip entry {name} declared size is unsupported"))?;
if file.is_dir() && declared != 0 {
bail!("style pack zip directory entry {name} has non-zero size");
}
if declared > MAX_ENTRY_UNCOMPRESSED_BYTES {
bail!(
"style pack zip entry {name} declared size {declared} exceeds {MAX_ENTRY_UNCOMPRESSED_BYTES} bytes"
);
}
total_declared = total_declared
.checked_add(declared)
.ok_or_else(|| anyhow!("style pack archive declared uncompressed size overflow"))?;
if total_declared > MAX_TOTAL_UNCOMPRESSED_BYTES {
bail!(
"style pack archive total declared uncompressed size {total_declared} exceeds {MAX_TOTAL_UNCOMPRESSED_BYTES} bytes"
);
}
}
if !entry_names.contains("manifest.json") {
bail!("missing style pack zip entry: manifest.json");
}
Ok(entry_names)
}
fn validate_entry_name(name: &str, is_dir: bool) -> Result<()> {
if name.is_empty()
|| name.len() > MAX_ENTRY_NAME_BYTES
|| name.starts_with('/')
|| name.starts_with('\\')
|| name.contains('\\')
|| name.contains('\0')
|| name.chars().any(char::is_control)
{
bail!("unsafe style pack zip entry name: {name:?}");
}
let normalized = if is_dir {
name.strip_suffix('/').unwrap_or(name)
} else {
if name.ends_with('/') {
bail!("unsafe style pack zip entry name: {name:?}");
}
name
};
if normalized.is_empty()
|| normalized.split('/').any(|segment| {
segment.is_empty() || segment == "." || segment == ".." || segment.contains(':')
})
{
bail!("unsafe style pack zip entry name: {name:?}");
}
Ok(())
}
enum ReferenceKind {
Prompt,
Examples,
Icon,
}
fn validate_manifest_reference(
name: &str,
entry_names: &HashSet<String>,
kind: ReferenceKind,
) -> Result<String> {
validate_entry_name(name, false)?;
if name == "manifest.json" {
bail!("style pack manifest cannot select itself as a content entry");
}
if !entry_names.contains(name) {
bail!("missing style pack zip entry: {name}");
}
let extension = Path::new(name)
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
.ok_or_else(|| anyhow!("style pack zip entry has no extension: {name}"))?;
match kind {
ReferenceKind::Prompt if !matches!(extension.as_str(), "md" | "txt") => {
bail!("style pack prompt entry must be .md or .txt: {name}")
}
ReferenceKind::Examples if extension != "json" => {
bail!("style pack examples entry must be .json: {name}")
}
ReferenceKind::Icon => {
let segments = name.split('/').collect::<Vec<_>>();
if segments.len() != 2 || segments[0] != "assets" {
bail!("style pack icon must be directly inside assets/: {name}");
}
if !matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "webp") {
bail!(
"unsupported style pack icon extension .{extension}; allowed: png, jpg, jpeg, webp"
);
}
}
_ => {}
}
Ok(extension)
}
fn read_archive_entry<R: Read + Seek>(
archive: &mut zip::ZipArchive<R>,
entry_name: &str,
entry_limit: usize,
stream_budget: &mut StreamBudget,
) -> Result<Vec<u8>> {
let mut file = archive
.by_name(entry_name)
.with_context(|| format!("missing style pack zip entry: {entry_name}"))?;
let declared_size = usize::try_from(file.size())
.map_err(|_| anyhow!("style pack zip entry {entry_name} declared size is unsupported"))?;
read_stream_bounded(
&mut file,
entry_name,
declared_size,
entry_limit,
stream_budget,
)
}
pub(super) fn read_stream_bounded<R: Read>(
reader: &mut R,
entry_name: &str,
declared_size: usize,
entry_limit: usize,
stream_budget: &mut StreamBudget,
) -> Result<Vec<u8>> {
if declared_size > entry_limit {
bail!(
"style pack zip entry {entry_name} declared size {declared_size} exceeds {entry_limit} bytes"
);
}
let remaining_total = MAX_TOTAL_UNCOMPRESSED_BYTES.saturating_sub(stream_budget.total);
let read_ceiling = entry_limit.min(remaining_total);
let mut bytes = Vec::with_capacity(declared_size.min(read_ceiling));
reader
.take(read_ceiling as u64 + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("read style pack zip entry failed: {entry_name}"))?;
if bytes.len() > entry_limit {
bail!(
"style pack zip entry {entry_name} streamed size {} exceeds {entry_limit} bytes",
bytes.len()
);
}
if bytes.len() > remaining_total {
bail!(
"style pack archive total streamed uncompressed size exceeds {MAX_TOTAL_UNCOMPRESSED_BYTES} bytes while reading {entry_name}"
);
}
if bytes.len() != declared_size {
bail!(
"style pack zip entry {entry_name} streamed size {} does not match declared size {declared_size}",
bytes.len()
);
}
stream_budget.total += bytes.len();
Ok(bytes)
}
fn validate_icon_content(extension: &str, bytes: &[u8]) -> Result<()> {
let (width, height) = match extension {
"png" => validate_png(bytes)?,
"jpg" | "jpeg" => validate_jpeg(bytes)?,
"webp" => validate_webp(bytes)?,
_ => bail!("unsupported style pack icon extension: {extension}"),
};
if width == 0
|| height == 0
|| width > MAX_ICON_DIMENSION
|| height > MAX_ICON_DIMENSION
|| u64::from(width) * u64::from(height) > MAX_ICON_PIXELS
{
bail!(
"style pack icon dimensions {width}x{height} exceed {MAX_ICON_DIMENSION}x{MAX_ICON_DIMENSION}"
);
}
Ok(())
}
fn validate_png(bytes: &[u8]) -> Result<(u32, u32)> {
const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if !bytes.starts_with(SIGNATURE) {
bail!("style pack icon content is not a PNG image");
}
let mut offset = SIGNATURE.len();
let mut dimensions = None;
let mut saw_iend = false;
while offset.checked_add(12).is_some_and(|end| end <= bytes.len()) {
let length = u32::from_be_bytes(
bytes[offset..offset + 4]
.try_into()
.expect("four-byte PNG length"),
) as usize;
let end = offset
.checked_add(12)
.and_then(|base| base.checked_add(length))
.ok_or_else(|| anyhow!("style pack PNG chunk length overflow"))?;
if end > bytes.len() {
bail!("style pack icon contains a truncated PNG chunk");
}
let chunk_type = &bytes[offset + 4..offset + 8];
let chunk_data = &bytes[offset + 8..offset + 8 + length];
if offset == SIGNATURE.len() && (chunk_type != b"IHDR" || length != 13) {
bail!("style pack icon PNG is missing a valid IHDR chunk");
}
if chunk_type == b"IHDR" {
if length != 13 || dimensions.is_some() {
bail!("style pack icon PNG contains a duplicate or invalid IHDR chunk");
}
dimensions = Some((
u32::from_be_bytes(chunk_data[0..4].try_into().expect("PNG width")),
u32::from_be_bytes(chunk_data[4..8].try_into().expect("PNG height")),
));
} else if chunk_type == b"acTL" {
bail!("animated PNG style pack icons are not supported");
} else if chunk_type == b"IEND" {
if length != 0 || end != bytes.len() {
bail!("style pack icon PNG has an invalid IEND chunk");
}
saw_iend = true;
break;
}
offset = end;
}
if !saw_iend {
bail!("style pack icon PNG is missing IEND");
}
dimensions.ok_or_else(|| anyhow!("style pack icon PNG is missing dimensions"))
}
fn validate_jpeg(bytes: &[u8]) -> Result<(u32, u32)> {
if bytes.len() < 4 || !bytes.starts_with(&[0xff, 0xd8]) || !bytes.ends_with(&[0xff, 0xd9]) {
bail!("style pack icon content is not a complete JPEG image");
}
let mut offset = 2usize;
while offset + 1 < bytes.len() {
if bytes[offset] != 0xff {
offset += 1;
continue;
}
while offset < bytes.len() && bytes[offset] == 0xff {
offset += 1;
}
if offset >= bytes.len() {
break;
}
let marker = bytes[offset];
offset += 1;
if marker == 0xd9 {
break;
}
if marker == 0x00 || marker == 0x01 || (0xd0..=0xd8).contains(&marker) {
continue;
}
if offset + 2 > bytes.len() {
bail!("style pack icon contains a truncated JPEG segment");
}
let segment_length = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) as usize;
if segment_length < 2 || offset + segment_length > bytes.len() {
bail!("style pack icon contains an invalid JPEG segment length");
}
if matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
) {
if segment_length < 7 {
bail!("style pack icon contains a truncated JPEG frame header");
}
let height = u16::from_be_bytes([bytes[offset + 3], bytes[offset + 4]]) as u32;
let width = u16::from_be_bytes([bytes[offset + 5], bytes[offset + 6]]) as u32;
return Ok((width, height));
}
if marker == 0xda {
break;
}
offset += segment_length;
}
bail!("style pack icon JPEG is missing a supported frame header")
}
fn validate_webp(bytes: &[u8]) -> Result<(u32, u32)> {
if bytes.len() < 20 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
bail!("style pack icon content is not a WebP image");
}
let riff_size = u32::from_le_bytes(bytes[4..8].try_into().expect("WebP RIFF size")) as usize;
if riff_size.checked_add(8) != Some(bytes.len()) {
bail!("style pack icon WebP has an invalid RIFF size");
}
let chunk = &bytes[12..16];
let chunk_size =
u32::from_le_bytes(bytes[16..20].try_into().expect("WebP chunk size")) as usize;
if chunk_size > bytes.len().saturating_sub(20) {
bail!("style pack icon WebP contains a truncated image chunk");
}
match chunk {
b"VP8X" => {
if chunk_size < 10 || bytes.len() < 30 {
bail!("style pack icon WebP has a truncated VP8X header");
}
if bytes[20] & 0x02 != 0 {
bail!("animated WebP style pack icons are not supported");
}
let width = 1 + u32::from_le_bytes([bytes[24], bytes[25], bytes[26], 0]);
let height = 1 + u32::from_le_bytes([bytes[27], bytes[28], bytes[29], 0]);
Ok((width, height))
}
b"VP8 " => {
if chunk_size < 10 || bytes.len() < 30 || &bytes[23..26] != b"\x9d\x01\x2a" {
bail!("style pack icon WebP has an invalid VP8 header");
}
let width = u16::from_le_bytes([bytes[26], bytes[27]]) & 0x3fff;
let height = u16::from_le_bytes([bytes[28], bytes[29]]) & 0x3fff;
Ok((u32::from(width), u32::from(height)))
}
b"VP8L" => {
if chunk_size < 5 || bytes.len() < 25 || bytes[20] != 0x2f {
bail!("style pack icon WebP has an invalid VP8L header");
}
let width = 1 + u32::from(bytes[21]) + ((u32::from(bytes[22]) & 0x3f) << 8);
let height = 1
+ (u32::from(bytes[22]) >> 6)
+ (u32::from(bytes[23]) << 2)
+ ((u32::from(bytes[24]) & 0x0f) << 10);
Ok((width, height))
}
_ => bail!("style pack icon WebP has an unsupported first chunk"),
}
}
pub(super) fn persist_style_pack_icon(
asset_root: &Path,
pack_id: &str,
icon: StylePackIcon,
) -> Result<String> {
let target_dir = asset_root.join(pack_id);
ensure_dir(&target_dir)?;
let target_path = target_dir.join(format!("icon.{}", icon.extension));
if let Err(error) = atomic_write(&target_path, &icon.bytes) {
let _ = fs::remove_dir_all(&target_dir);
return Err(error)
.with_context(|| format!("write style pack icon failed: {}", target_path.display()));
}
Ok(target_path.to_string_lossy().into_owned())
}
pub(super) fn cleanup_style_pack_asset_dir(asset_root: &Path, pack_id: &str) {
let _ = fs::remove_dir_all(asset_root.join(pack_id));
}
#[cfg(test)]
mod tests {
use std::io::{Cursor, Read};
use super::{read_stream_bounded, validate_png, StreamBudget, MAX_TOTAL_UNCOMPRESSED_BYTES};
struct PanicReader;
impl Read for PanicReader {
fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
panic!("declared-size overflow must be rejected before reading")
}
}
#[test]
fn bounded_reader_rejects_declared_overflow_without_reading() {
let mut budget = StreamBudget::default();
let error = read_stream_bounded(&mut PanicReader, "prompt.md", 11, 10, &mut budget)
.expect_err("declared overflow must fail");
assert!(format!("{error:#}").contains("prompt.md declared size"));
assert_eq!(budget.total, 0);
}
#[test]
fn bounded_reader_rejects_streamed_overflow_when_declared_size_is_forged_small() {
let mut budget = StreamBudget::default();
let mut reader = Cursor::new(vec![b'x'; 11]);
let error = read_stream_bounded(&mut reader, "prompt.md", 1, 10, &mut budget)
.expect_err("streamed overflow must fail");
assert!(format!("{error:#}").contains("prompt.md streamed size"));
assert_eq!(budget.total, 0);
}
#[test]
fn bounded_reader_counts_actual_bytes_across_entries() {
let mut budget = StreamBudget {
total: MAX_TOTAL_UNCOMPRESSED_BYTES - 4,
};
let mut reader = Cursor::new(vec![b'x'; 5]);
let error = read_stream_bounded(&mut reader, "examples.json", 5, 10, &mut budget)
.expect_err("actual total overflow must fail");
assert!(format!("{error:#}").contains("total streamed uncompressed size"));
assert_eq!(budget.total, MAX_TOTAL_UNCOMPRESSED_BYTES - 4);
}
#[test]
fn png_validator_rejects_a_second_malformed_ihdr_without_panicking() {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
append_png_chunk(&mut png, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]);
append_png_chunk(&mut png, b"IHDR", &[]);
append_png_chunk(&mut png, b"IEND", &[]);
let error = validate_png(&png).expect_err("a duplicate malformed IHDR must fail");
assert!(format!("{error:#}").contains("IHDR"));
}
fn append_png_chunk(png: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
png.extend_from_slice(&(data.len() as u32).to_be_bytes());
png.extend_from_slice(chunk_type);
png.extend_from_slice(data);
png.extend_from_slice(&[0; 4]);
}
}
@@ -0,0 +1,529 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use parking_lot::Mutex;
use uuid::Uuid;
use zip::write::SimpleFileOptions;
use super::super::style_pack_archive::{
MAX_ENTRY_UNCOMPRESSED_BYTES, MAX_EXAMPLES_BYTES, MAX_ICON_BYTES, MAX_MANIFEST_BYTES,
MAX_PROMPT_BYTES, MAX_TOTAL_UNCOMPRESSED_BYTES, STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES,
};
use super::{sync_style_pack_preferences, StylePackStore};
use crate::types::{builtin_style_packs, CustomStylePrompts, StylePack, StylePackExample};
const VALID_PNG_1X1: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
struct TestDir(PathBuf);
impl TestDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"openless-style-pack-{label}-{}",
Uuid::new_v4().simple()
));
fs::create_dir_all(&path).expect("create test dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn test_store(root: &Path, packs: Vec<StylePack>) -> StylePackStore {
let asset_root = root.join("assets");
fs::create_dir_all(&asset_root).expect("create asset root");
StylePackStore {
path: root.join("style-packs.json"),
asset_root,
state: Mutex::new(packs),
}
}
fn manifest(prompt_file: &str, examples_file: &str, icon_file: Option<&str>) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"schemaVersion": 1,
"id": "test-pack",
"name": "Test Pack",
"description": "security fixture",
"author": "OpenLess",
"version": "1.0.0",
"baseMode": "light",
"tags": ["test"],
"promptFile": prompt_file,
"examplesFile": examples_file,
"iconFile": icon_file,
}))
.expect("encode manifest")
}
fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
let file = fs::File::create(path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, contents) in entries {
writer.start_file(*name, options).expect("start zip entry");
writer.write_all(contents).expect("write zip entry");
}
writer.finish().expect("finish zip");
}
fn duplicate_central_directory_entry(path: &Path, entry_name: &str, extra_copies: usize) {
const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02";
const EOCD: &[u8; 4] = b"PK\x05\x06";
const CENTRAL_HEADER_LEN: usize = 46;
let mut bytes = fs::read(path).expect("read zip fixture");
let eocd_offset = bytes
.windows(EOCD.len())
.rposition(|window| window == EOCD)
.expect("find zip EOCD");
let original_count = u16::from_le_bytes(
bytes[eocd_offset + 10..eocd_offset + 12]
.try_into()
.expect("EOCD entry count"),
);
let original_central_size = u32::from_le_bytes(
bytes[eocd_offset + 12..eocd_offset + 16]
.try_into()
.expect("EOCD central size"),
);
let mut offset = u32::from_le_bytes(
bytes[eocd_offset + 16..eocd_offset + 20]
.try_into()
.expect("EOCD central offset"),
) as usize;
let mut selected_record = None;
for _ in 0..original_count {
assert_eq!(
bytes.get(offset..offset + CENTRAL_HEADER.len()),
Some(CENTRAL_HEADER.as_slice()),
"central directory signature"
);
let name_len = u16::from_le_bytes(
bytes[offset + 28..offset + 30]
.try_into()
.expect("central name length"),
) as usize;
let extra_len = u16::from_le_bytes(
bytes[offset + 30..offset + 32]
.try_into()
.expect("central extra length"),
) as usize;
let comment_len = u16::from_le_bytes(
bytes[offset + 32..offset + 34]
.try_into()
.expect("central comment length"),
) as usize;
let end = offset + CENTRAL_HEADER_LEN + name_len + extra_len + comment_len;
if &bytes[offset + CENTRAL_HEADER_LEN..offset + CENTRAL_HEADER_LEN + name_len]
== entry_name.as_bytes()
{
selected_record = Some(bytes[offset..end].to_vec());
}
offset = end;
}
let record = selected_record.expect("selected central directory entry");
let added = record.repeat(extra_copies);
bytes.splice(eocd_offset..eocd_offset, added.iter().copied());
let new_eocd_offset = eocd_offset + added.len();
let new_count = original_count
.checked_add(u16::try_from(extra_copies).expect("fixture copy count fits u16"))
.expect("fixture entry count fits u16");
bytes[new_eocd_offset + 8..new_eocd_offset + 10].copy_from_slice(&new_count.to_le_bytes());
bytes[new_eocd_offset + 10..new_eocd_offset + 12].copy_from_slice(&new_count.to_le_bytes());
let new_central_size = original_central_size
.checked_add(u32::try_from(added.len()).expect("fixture size fits u32"))
.expect("fixture central size fits u32");
bytes[new_eocd_offset + 12..new_eocd_offset + 16]
.copy_from_slice(&new_central_size.to_le_bytes());
fs::write(path, bytes).expect("write duplicate central directory fixture");
}
fn valid_archive(path: &Path, icon: Option<&[u8]>) {
let manifest = manifest(
"prompt.md",
"examples.json",
icon.map(|_| "assets/icon.png"),
);
let examples = serde_json::to_vec(&vec![StylePackExample {
title: Some("Greeting".into()),
input: "hello".into(),
output: "Hello!".into(),
}])
.expect("encode examples");
let mut entries: Vec<(&str, &[u8])> = vec![
("manifest.json", &manifest),
("prompt.md", b"Write clearly and concisely."),
("examples.json", &examples),
];
if let Some(icon) = icon {
entries.push(("assets/icon.png", icon));
}
write_zip(path, &entries);
}
fn assert_import_error_contains(store: &StylePackStore, zip_path: &Path, expected: &str) {
let error = store
.import_from_zip(zip_path)
.expect_err("archive must be rejected");
let message = format!("{error:#}");
assert!(
message.contains(expected),
"expected error containing {expected:?}, got {message:?}"
);
assert!(
store.state.lock().is_empty(),
"rejected import changed state"
);
}
#[test]
fn import_rejects_duplicate_physical_manifest_entry() {
let root = TestDir::new("duplicate-manifest");
let zip_path = root.path().join("duplicate-manifest.zip");
valid_archive(&zip_path, None);
duplicate_central_directory_entry(&zip_path, "manifest.json", 1);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "duplicate physical");
}
#[test]
fn import_rejects_duplicate_physical_manifest_selected_prompt_entry() {
let root = TestDir::new("duplicate-prompt");
let zip_path = root.path().join("duplicate-prompt.zip");
valid_archive(&zip_path, None);
duplicate_central_directory_entry(&zip_path, "prompt.md", 1);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "duplicate physical");
}
#[test]
fn import_rejects_duplicate_physical_manifest_selected_icon_entry() {
let root = TestDir::new("duplicate-icon");
let zip_path = root.path().join("duplicate-icon.zip");
valid_archive(&zip_path, Some(VALID_PNG_1X1));
duplicate_central_directory_entry(&zip_path, "assets/icon.png", 1);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "duplicate physical");
}
#[test]
fn import_rejects_more_than_sixteen_physical_entries_even_when_names_repeat() {
let root = TestDir::new("physical-entry-count");
let zip_path = root.path().join("physical-entry-count.zip");
valid_archive(&zip_path, None);
duplicate_central_directory_entry(&zip_path, "prompt.md", 14);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "physical entries");
}
#[test]
fn import_rejects_compressed_archive_over_limit_before_zip_parsing() {
let root = TestDir::new("compressed-limit");
let zip_path = root.path().join("oversized.zip");
fs::write(
&zip_path,
vec![0u8; STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES + 1],
)
.expect("write oversized archive");
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "compressed size");
}
#[test]
fn import_rejects_oversized_manifest_independently() {
let root = TestDir::new("manifest-limit");
let zip_path = root.path().join("manifest.zip");
let oversized_manifest = vec![b' '; MAX_MANIFEST_BYTES + 1];
write_zip(&zip_path, &[("manifest.json", &oversized_manifest)]);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "manifest.json declared size");
}
#[test]
fn import_rejects_bomb_shaped_prompt_before_unbounded_allocation() {
let root = TestDir::new("prompt-bomb");
let zip_path = root.path().join("bomb.zip");
let manifest = manifest("prompt.md", "examples.json", None);
let prompt = vec![b'a'; MAX_PROMPT_BYTES + 1];
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("prompt.md", &prompt),
("examples.json", b"[]"),
],
);
assert!(
fs::metadata(&zip_path).expect("zip metadata").len()
< STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES as u64,
"fixture must be highly compressed"
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "prompt.md declared size");
}
#[test]
fn import_rejects_oversized_examples_independently() {
let root = TestDir::new("examples-limit");
let zip_path = root.path().join("examples.zip");
let manifest = manifest("prompt.md", "examples.json", None);
let examples = vec![b' '; MAX_EXAMPLES_BYTES + 1];
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("prompt.md", b"prompt"),
("examples.json", &examples),
],
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "examples.json declared size");
}
#[test]
fn import_rejects_oversized_icon_without_leaving_assets() {
let root = TestDir::new("icon-limit");
let zip_path = root.path().join("icon.zip");
let manifest = manifest("prompt.md", "examples.json", Some("assets/icon.png"));
let mut icon = VALID_PNG_1X1.to_vec();
icon.resize(MAX_ICON_BYTES + 1, 0);
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("prompt.md", b"prompt"),
("examples.json", b"[]"),
("assets/icon.png", &icon),
],
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "assets/icon.png declared size");
assert!(
!root.path().join("assets/test-pack").exists(),
"rejected icon left a partial asset directory"
);
}
#[test]
fn import_rejects_single_entry_over_generic_limit() {
let root = TestDir::new("entry-limit");
let zip_path = root.path().join("entry.zip");
let manifest = manifest("prompt.md", "examples.json", None);
let oversized_extra = vec![b'x'; MAX_ENTRY_UNCOMPRESSED_BYTES + 1];
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("prompt.md", b"prompt"),
("examples.json", b"[]"),
("extra.txt", &oversized_extra),
],
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "extra.txt declared size");
}
#[test]
fn import_rejects_total_declared_uncompressed_size_over_limit() {
let root = TestDir::new("total-limit");
let zip_path = root.path().join("total.zip");
let manifest = manifest("prompt.md", "examples.json", None);
let large = vec![b'x'; 60 * 1024];
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("prompt.md", &large),
("examples.json", &large),
("extra-1.txt", &large),
("extra-2.txt", &large),
("extra-3.txt", &large),
],
);
assert!(
5 * large.len() > MAX_TOTAL_UNCOMPRESSED_BYTES,
"fixture must cross total uncompressed limit"
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "total declared uncompressed size");
}
#[test]
fn import_rejects_unsafe_manifest_selected_path() {
let root = TestDir::new("unsafe-path");
let zip_path = root.path().join("unsafe.zip");
let manifest = manifest("../prompt.md", "examples.json", None);
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("../prompt.md", b"escaped prompt"),
("examples.json", b"[]"),
],
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "unsafe");
}
#[test]
fn import_rejects_malformed_backslash_entry_name() {
let root = TestDir::new("backslash-name");
let zip_path = root.path().join("backslash.zip");
let manifest = manifest("folder\\prompt.md", "examples.json", None);
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("folder\\prompt.md", b"prompt"),
("examples.json", b"[]"),
],
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "unsafe");
}
#[test]
fn import_rejects_icon_extension_content_mismatch() {
let root = TestDir::new("icon-mismatch");
let zip_path = root.path().join("mismatch.zip");
let manifest = manifest("prompt.md", "examples.json", Some("assets/icon.png"));
write_zip(
&zip_path,
&[
("manifest.json", &manifest),
("prompt.md", b"prompt"),
("examples.json", b"[]"),
("assets/icon.png", b"not a png"),
],
);
let store = test_store(root.path(), Vec::new());
assert_import_error_contains(&store, &zip_path, "PNG");
assert!(!root.path().join("assets/test-pack").exists());
}
#[test]
fn import_rolls_back_icon_and_state_when_store_persistence_fails() {
let root = TestDir::new("rollback");
let zip_path = root.path().join("valid.zip");
valid_archive(&zip_path, Some(VALID_PNG_1X1));
let mut store = test_store(root.path(), Vec::new());
store.path = root.path().join("store-target-is-a-directory");
fs::create_dir_all(&store.path).expect("create invalid store target");
let error = store
.import_from_zip(&zip_path)
.expect_err("persistence failure must abort import");
assert!(format!("{error:#}").contains("rename failed"));
assert!(store.state.lock().is_empty(), "failed import changed state");
assert!(
!root.path().join("assets/test-pack").exists(),
"failed import left a partial asset directory"
);
}
#[test]
fn style_pack_archive_round_trip_preserves_valid_pack_and_png_icon() {
let root = TestDir::new("roundtrip");
let icon_path = root.path().join("source-icon.png");
fs::write(&icon_path, VALID_PNG_1X1).expect("write source icon");
let mut pack = builtin_style_packs()
.into_iter()
.find(|pack| pack.id == "builtin.light")
.expect("builtin pack");
pack.id = "roundtrip-pack".into();
pack.name = "Roundtrip Pack".into();
pack.prompt = "Roundtrip prompt".into();
pack.examples = vec![StylePackExample {
title: Some("Example".into()),
input: "input".into(),
output: "output".into(),
}];
pack.icon_path = Some(icon_path.to_string_lossy().into_owned());
let source = test_store(&root.path().join("source"), vec![pack.clone()]);
let zip_path = root.path().join("roundtrip.zip");
source
.export_to_zip(&pack.id, &zip_path)
.expect("export valid pack");
let destination = test_store(&root.path().join("destination"), Vec::new());
let imported = destination
.import_from_zip(&zip_path)
.expect("import valid pack");
assert_eq!(imported.id, pack.id);
assert_eq!(imported.name, pack.name);
assert_eq!(imported.prompt, pack.prompt);
assert_eq!(imported.examples, pack.examples);
let imported_icon = imported.icon_path.expect("imported icon path");
assert_eq!(
fs::read(imported_icon).expect("read imported icon"),
VALID_PNG_1X1
);
}
#[test]
fn sync_style_pack_preferences_uses_builtin_store_prompts_as_source_of_truth() {
let mut prefs = crate::types::UserPreferences {
style_system_prompts: crate::types::StyleSystemPrompts {
raw: "stale raw".into(),
light: "stale light".into(),
structured: "stale structured".into(),
formal: "stale formal".into(),
},
custom_style_prompts: CustomStylePrompts {
raw: String::new(),
light: "legacy extra instruction".into(),
structured: String::new(),
formal: String::new(),
},
..Default::default()
};
let mut packs = builtin_style_packs();
let light = packs
.iter_mut()
.find(|pack| pack.id == "builtin.light")
.expect("builtin light pack");
light.prompt = "fresh light prompt from store".into();
assert!(sync_style_pack_preferences(&mut prefs, &packs));
assert_eq!(prefs.style_system_prompts.raw, packs[0].prompt);
assert_eq!(
prefs.style_system_prompts.light,
"fresh light prompt from store"
);
assert_eq!(prefs.style_system_prompts.structured, packs[2].prompt);
assert_eq!(prefs.style_system_prompts.formal, packs[3].prompt);
assert_eq!(prefs.custom_style_prompts, CustomStylePrompts::default());
}