Files
Kigi-CLI/crates/codegen/kigi-shell/src/bundle.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

1512 lines
50 KiB
Rust

use anyhow::{Context, Result, bail};
use prod_mc_cli_chat_proxy_types::SubagentBundle;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::{ErrorKind, Read};
use std::path::{Path, PathBuf};
const BUNDLED_DIR_NAME: &str = "bundled";
const MANIFEST_FILE_NAME: &str = "manifest.json";
const ARCHIVE_MAX_DECOMPRESSED_SIZE: usize = 50 * 1024 * 1024;
const ARCHIVE_MAX_ENTRIES: usize = 1000;
const ARCHIVE_MAX_ENTRY_SIZE: u64 = 1024 * 1024;
#[derive(Deserialize)]
struct ArchiveBundleMetadata {
version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleManifest {
pub version: String,
pub checksums: HashMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BundleFileKind {
Persona,
Role,
Agent,
Skill,
}
impl BundleFileKind {
fn dir_name(self) -> &'static str {
match self {
Self::Persona => "personas",
Self::Role => "roles",
Self::Agent => "agents",
Self::Skill => "skills",
}
}
fn extension(self) -> &'static str {
match self {
Self::Agent | Self::Skill => "md",
Self::Persona | Self::Role => "toml",
}
}
fn label(self) -> &'static str {
match self {
Self::Persona => "persona",
Self::Role => "role",
Self::Agent => "agent",
Self::Skill => "skill",
}
}
fn from_dir_name(dir_name: &str) -> Option<Self> {
match dir_name {
"personas" => Some(Self::Persona),
"roles" => Some(Self::Role),
"agents" => Some(Self::Agent),
"skills" => Some(Self::Skill),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BundleFileState {
Absent,
MatchesManaged,
ModifiedOrUnmanaged,
}
#[derive(Debug)]
struct BundleFile<'a> {
relative_path: String,
checksum: String,
content: &'a str,
}
pub fn bundled_root() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".kigi")
.join(BUNDLED_DIR_NAME)
}
pub fn read_cached_manifest(root: &Path) -> Result<Option<BundleManifest>> {
let manifest_path = manifest_path(root);
let bytes = match std::fs::read(&manifest_path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error)
.with_context(|| format!("failed to read {}", manifest_path.display()));
}
};
serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse {}", manifest_path.display()))
.map(Some)
}
pub fn write_bundle_to_cache(root: &Path, bundle: &SubagentBundle) -> Result<BundleManifest> {
let old_manifest = read_cached_manifest(root)?.map(sanitize_manifest);
ensure_bundle_dirs(root)?;
let bundle_files = bundle_files(bundle)?;
let mut next_checksums = HashMap::new();
for bundle_file in &bundle_files {
let previous_checksum = old_manifest
.as_ref()
.and_then(|manifest| manifest.checksums.get(&bundle_file.relative_path));
let absolute_path = root.join(&bundle_file.relative_path);
match bundle_file_state(&absolute_path, previous_checksum.map(String::as_str))? {
BundleFileState::Absent | BundleFileState::MatchesManaged => {
write_bundle_file(&absolute_path, bundle_file.content.as_bytes())?;
next_checksums.insert(
bundle_file.relative_path.clone(),
bundle_file.checksum.clone(),
);
}
BundleFileState::ModifiedOrUnmanaged => {
if let Some(previous_checksum) = previous_checksum {
next_checksums
.insert(bundle_file.relative_path.clone(), previous_checksum.clone());
}
}
}
}
if let Some(old_manifest) = old_manifest.as_ref() {
prune_removed_files(root, old_manifest, &mut next_checksums)?;
}
let next_manifest = BundleManifest {
version: bundle.version.clone(),
checksums: next_checksums,
};
let manifest_json =
serde_json::to_vec_pretty(&next_manifest).context("failed to serialize bundle manifest")?;
std::fs::write(manifest_path(root), manifest_json)
.with_context(|| format!("failed to write {}", manifest_path(root).display()))?;
Ok(next_manifest)
}
pub fn extract_bundle_archive(root: &Path, archive_bytes: &[u8]) -> Result<BundleManifest> {
let decoder = flate2::read::GzDecoder::new(archive_bytes);
let mut archive = tar::Archive::new(decoder);
let old_manifest = read_cached_manifest(root)?.map(sanitize_manifest);
ensure_bundle_dirs(root)?;
let mut next_checksums = HashMap::new();
let mut version = String::new();
let mut total_decompressed: usize = 0;
let mut entry_count: usize = 0;
for entry_result in archive
.entries()
.context("failed to read archive entries")?
{
let mut entry = entry_result.context("failed to read archive entry")?;
if entry.header().entry_type() != tar::EntryType::Regular {
continue;
}
entry_count += 1;
if entry_count > ARCHIVE_MAX_ENTRIES {
bail!("archive exceeds maximum entry count ({ARCHIVE_MAX_ENTRIES})");
}
let entry_size = entry.header().size().context("failed to read entry size")?;
if entry_size > ARCHIVE_MAX_ENTRY_SIZE {
bail!("archive entry exceeds maximum size ({ARCHIVE_MAX_ENTRY_SIZE} bytes)");
}
total_decompressed = total_decompressed
.checked_add(entry_size as usize)
.context("decompressed size overflow")?;
if total_decompressed > ARCHIVE_MAX_DECOMPRESSED_SIZE {
bail!(
"archive exceeds maximum decompressed size ({ARCHIVE_MAX_DECOMPRESSED_SIZE} bytes)"
);
}
let raw_path = entry
.path()
.context("failed to read entry path")?
.to_string_lossy()
.into_owned();
let path = raw_path.strip_prefix("./").unwrap_or(&raw_path);
if path == "bundle.json" {
let mut content = String::new();
entry
.read_to_string(&mut content)
.context("failed to read bundle.json")?;
let meta: ArchiveBundleMetadata =
serde_json::from_str(&content).context("failed to parse bundle.json")?;
version = meta.version;
continue;
}
let cache_relative_path = match map_archive_path_to_cache_path(path) {
Some(p) => p,
None => continue,
};
let mut content = Vec::with_capacity(entry_size as usize);
entry
.read_to_end(&mut content)
.with_context(|| format!("failed to read archive entry: {path}"))?;
let checksum = checksum_bytes(&content);
let absolute_path = root.join(&cache_relative_path);
let previous_checksum = old_manifest
.as_ref()
.and_then(|m| m.checksums.get(&cache_relative_path));
match bundle_file_state(&absolute_path, previous_checksum.map(String::as_str))? {
BundleFileState::Absent | BundleFileState::MatchesManaged => {
write_bundle_file(&absolute_path, &content)?;
next_checksums.insert(cache_relative_path, checksum);
}
BundleFileState::ModifiedOrUnmanaged => {
if let Some(prev) = previous_checksum {
next_checksums.insert(cache_relative_path, prev.clone());
}
}
}
}
if version.is_empty() {
bail!("archive missing bundle.json with version field");
}
if let Some(old_manifest) = old_manifest.as_ref() {
prune_removed_files(root, old_manifest, &mut next_checksums)?;
}
let next_manifest = BundleManifest {
version,
checksums: next_checksums,
};
let manifest_json =
serde_json::to_vec_pretty(&next_manifest).context("failed to serialize bundle manifest")?;
std::fs::write(manifest_path(root), manifest_json)
.with_context(|| format!("failed to write {}", manifest_path(root).display()))?;
Ok(next_manifest)
}
pub fn checksum_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub fn checksum_file(path: &Path) -> Result<String> {
let bytes = std::fs::read(path)
.with_context(|| format!("failed to read {} for checksum", path.display()))?;
Ok(checksum_bytes(&bytes))
}
pub fn prune_removed_files(
root: &Path,
old_manifest: &BundleManifest,
retained_checksums: &mut HashMap<String, String>,
) -> Result<()> {
for (relative_path, previous_checksum) in sanitize_manifest(old_manifest.clone()).checksums {
if retained_checksums.contains_key(&relative_path) {
continue;
}
let absolute_path = root.join(&relative_path);
match bundle_file_state(&absolute_path, Some(previous_checksum.as_str()))? {
BundleFileState::Absent => {}
BundleFileState::MatchesManaged => {
std::fs::remove_file(&absolute_path)
.with_context(|| format!("failed to remove {}", absolute_path.display()))?;
}
BundleFileState::ModifiedOrUnmanaged => {
retained_checksums.insert(relative_path, previous_checksum);
}
}
}
Ok(())
}
fn ensure_bundle_dirs(root: &Path) -> Result<()> {
std::fs::create_dir_all(root)
.with_context(|| format!("failed to create {}", root.display()))?;
for dir_name in ["personas", "roles", "agents", "skills"] {
let dir = root.join(dir_name);
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
}
Ok(())
}
fn manifest_path(root: &Path) -> PathBuf {
root.join(MANIFEST_FILE_NAME)
}
fn checksum_file_if_exists(path: &Path) -> Result<Option<String>> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(checksum_bytes(&bytes))),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => {
Err(error).with_context(|| format!("failed to read {} for checksum", path.display()))
}
}
}
fn write_bundle_file(absolute_path: &Path, content: &[u8]) -> Result<()> {
if let Some(parent) = absolute_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(absolute_path, content)
.with_context(|| format!("failed to write {}", absolute_path.display()))?;
Ok(())
}
fn bundle_file_state(path: &Path, old_checksum: Option<&str>) -> Result<BundleFileState> {
let current_checksum = match checksum_file_if_exists(path)? {
Some(checksum) => checksum,
None => return Ok(BundleFileState::Absent),
};
Ok(match old_checksum {
Some(old_checksum) if current_checksum == old_checksum => BundleFileState::MatchesManaged,
Some(_) | None => BundleFileState::ModifiedOrUnmanaged,
})
}
fn sanitize_manifest(manifest: BundleManifest) -> BundleManifest {
let checksums = manifest
.checksums
.into_iter()
.filter_map(|(relative_path, checksum)| {
sanitize_relative_path(&relative_path).map(|relative_path| (relative_path, checksum))
})
.collect();
BundleManifest {
version: manifest.version,
checksums,
}
}
fn sanitize_relative_path(relative_path: &str) -> Option<String> {
if relative_path.is_empty() || relative_path.starts_with('/') || relative_path.contains('\\') {
return None;
}
let mut parts = relative_path.split('/');
let dir_name = parts.next()?;
let second = parts.next()?;
match parts.next() {
None => {
if second.is_empty() {
return None;
}
let kind = BundleFileKind::from_dir_name(dir_name)?;
if kind == BundleFileKind::Skill {
return None;
}
let file_stem = second.strip_suffix(&format!(".{}", kind.extension()))?;
validate_bundle_name(kind, file_stem).ok()?;
Some(relative_path_for(kind, file_stem))
}
Some(third) => {
if dir_name != "skills" {
return None;
}
validate_bundle_name(BundleFileKind::Skill, second).ok()?;
// Reject components that would let extraction escape the per-skill directory.
for component in std::iter::once(third).chain(parts) {
if component.is_empty()
|| component == "."
|| component == ".."
|| component.chars().any(char::is_control)
{
return None;
}
}
Some(relative_path.to_string())
}
}
}
fn map_archive_path_to_cache_path(archive_path: &str) -> Option<String> {
if let Some(rest) = archive_path.strip_prefix("subagents/") {
return sanitize_relative_path(rest);
}
if archive_path.starts_with("skills/") {
return sanitize_relative_path(archive_path);
}
None
}
pub fn count_entries_by_prefix(manifest: &BundleManifest, prefix: &str) -> usize {
manifest
.checksums
.keys()
.filter(|k| k.starts_with(prefix))
.count()
}
fn bundle_files(bundle: &SubagentBundle) -> Result<Vec<BundleFile<'_>>> {
let mut files = Vec::new();
extend_bundle_files(&mut files, BundleFileKind::Persona, &bundle.personas)?;
extend_bundle_files(&mut files, BundleFileKind::Role, &bundle.roles)?;
extend_bundle_files(&mut files, BundleFileKind::Agent, &bundle.agents)?;
extend_bundle_files(&mut files, BundleFileKind::Skill, &bundle.skills)?;
Ok(files)
}
fn extend_bundle_files<'a>(
files: &mut Vec<BundleFile<'a>>,
kind: BundleFileKind,
entries: &'a HashMap<String, String>,
) -> Result<()> {
for (name, content) in entries {
validate_bundle_name(kind, name)?;
files.push(BundleFile {
relative_path: relative_path_for(kind, name),
checksum: checksum_bytes(content.as_bytes()),
content,
});
}
Ok(())
}
fn relative_path_for(kind: BundleFileKind, name: &str) -> String {
match kind {
BundleFileKind::Skill => format!("{}/{name}/SKILL.md", kind.dir_name()),
_ => format!("{}/{name}.{}", kind.dir_name(), kind.extension()),
}
}
fn validate_bundle_name(kind: BundleFileKind, name: &str) -> Result<()> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.chars().any(char::is_control)
{
bail!("invalid bundled {} name: {name:?}", kind.label());
}
Ok(())
}
#[cfg(test)]
pub(crate) mod test_helpers {
pub fn make_test_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
for &(path, content) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, path, content).unwrap();
}
let encoder = builder.into_inner().unwrap();
encoder.finish().unwrap()
}
pub fn bundle_json(version: &str) -> String {
format!(r#"{{"version":"{version}"}}"#)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn bundle_with_persona(version: &str, name: &str, content: &str) -> SubagentBundle {
let mut bundle = SubagentBundle::empty(version);
bundle
.personas
.insert(name.to_string(), content.to_string());
bundle
}
fn bundle_with_skill(version: &str, name: &str, content: &str) -> SubagentBundle {
let mut bundle = SubagentBundle::empty(version);
bundle.skills.insert(name.to_string(), content.to_string());
bundle
}
fn cache_root(tmp: &TempDir) -> PathBuf {
tmp.path().join("bundled")
}
#[test]
fn write_new_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let bundle = bundle_with_persona("v1", "researcher", "instructions = \"hello\"");
let manifest = write_bundle_to_cache(&root, &bundle).unwrap();
assert_eq!(manifest.version, "v1");
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"hello\""
);
assert_eq!(read_cached_manifest(&root).unwrap(), Some(manifest));
}
#[test]
fn overwrite_unchanged_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
write_bundle_to_cache(
&root,
&bundle_with_persona("v1", "researcher", "instructions = \"old\""),
)
.unwrap();
write_bundle_to_cache(
&root,
&bundle_with_persona("v2", "researcher", "instructions = \"new\""),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"new\""
);
}
#[test]
fn skip_user_modified_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let manifest_v1 = write_bundle_to_cache(
&root,
&bundle_with_persona("v1", "researcher", "instructions = \"old\""),
)
.unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"user edit\"",
)
.unwrap();
let manifest_v2 = write_bundle_to_cache(
&root,
&bundle_with_persona("v2", "researcher", "instructions = \"new\""),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"user edit\""
);
assert_eq!(
manifest_v2.checksums.get("personas/researcher.toml"),
manifest_v1.checksums.get("personas/researcher.toml")
);
}
#[test]
fn prune_removed_unmodified_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
write_bundle_to_cache(
&root,
&bundle_with_persona("v1", "researcher", "instructions = \"old\""),
)
.unwrap();
let manifest = write_bundle_to_cache(&root, &SubagentBundle::empty("v2")).unwrap();
assert!(!root.join("personas/researcher.toml").exists());
assert!(!manifest.checksums.contains_key("personas/researcher.toml"));
}
#[test]
fn keep_removed_modified_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let manifest_v1 = write_bundle_to_cache(
&root,
&bundle_with_persona("v1", "researcher", "instructions = \"old\""),
)
.unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"user edit\"",
)
.unwrap();
let manifest_v2 = write_bundle_to_cache(&root, &SubagentBundle::empty("v2")).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"user edit\""
);
assert_eq!(
manifest_v2.checksums.get("personas/researcher.toml"),
manifest_v1.checksums.get("personas/researcher.toml")
);
}
#[test]
fn write_rejects_path_traversal_names() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let outside = tmp.path().join("outside.toml");
let bundle = bundle_with_persona("v1", "../../outside", "instructions = \"evil\"");
let error = write_bundle_to_cache(&root, &bundle).unwrap_err();
assert!(error.to_string().contains("invalid bundled persona name"));
assert!(!outside.exists());
}
#[test]
fn prune_skips_unsafe_manifest_paths() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
std::fs::create_dir_all(&root).unwrap();
let outside = tmp.path().join("outside.toml");
std::fs::write(&outside, "keep me").unwrap();
let old_manifest = BundleManifest {
version: "v1".to_string(),
checksums: HashMap::from([(
"personas/../../outside.toml".to_string(),
checksum_file(&outside).unwrap(),
)]),
};
let mut retained = HashMap::new();
prune_removed_files(&root, &old_manifest, &mut retained).unwrap();
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep me");
assert!(retained.is_empty());
}
#[test]
fn user_revert_after_skipped_update_allows_future_update() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
write_bundle_to_cache(
&root,
&bundle_with_persona("v1", "researcher", "instructions = \"old\""),
)
.unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"user edit\"",
)
.unwrap();
write_bundle_to_cache(
&root,
&bundle_with_persona("v2", "researcher", "instructions = \"new\""),
)
.unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"old\"",
)
.unwrap();
write_bundle_to_cache(
&root,
&bundle_with_persona("v3", "researcher", "instructions = \"latest\""),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"latest\""
);
}
#[test]
fn user_revert_after_preserved_remove_allows_future_prune() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
write_bundle_to_cache(
&root,
&bundle_with_persona("v1", "researcher", "instructions = \"old\""),
)
.unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"user edit\"",
)
.unwrap();
write_bundle_to_cache(&root, &SubagentBundle::empty("v2")).unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"old\"",
)
.unwrap();
let manifest = write_bundle_to_cache(&root, &SubagentBundle::empty("v3")).unwrap();
assert!(!root.join("personas/researcher.toml").exists());
assert!(!manifest.checksums.contains_key("personas/researcher.toml"));
}
#[test]
fn same_version_retry_repairs_missing_managed_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let bundle = bundle_with_persona("v1", "researcher", "instructions = \"hello\"");
write_bundle_to_cache(&root, &bundle).unwrap();
std::fs::remove_file(root.join("personas/researcher.toml")).unwrap();
write_bundle_to_cache(&root, &bundle).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"hello\""
);
}
#[test]
fn write_new_skill_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let bundle = bundle_with_skill("v1", "commit", "# Commit Skill\nRun git commit.");
let manifest = write_bundle_to_cache(&root, &bundle).unwrap();
assert_eq!(manifest.version, "v1");
assert_eq!(
std::fs::read_to_string(root.join("skills/commit/SKILL.md")).unwrap(),
"# Commit Skill\nRun git commit."
);
assert!(manifest.checksums.contains_key("skills/commit/SKILL.md"));
}
#[test]
fn skip_user_modified_skill() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let manifest_v1 =
write_bundle_to_cache(&root, &bundle_with_skill("v1", "commit", "# Original")).unwrap();
std::fs::write(root.join("skills/commit/SKILL.md"), "# User custom").unwrap();
let manifest_v2 =
write_bundle_to_cache(&root, &bundle_with_skill("v2", "commit", "# Updated")).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("skills/commit/SKILL.md")).unwrap(),
"# User custom"
);
assert_eq!(
manifest_v2.checksums.get("skills/commit/SKILL.md"),
manifest_v1.checksums.get("skills/commit/SKILL.md")
);
}
#[test]
fn prune_removed_unmodified_skill() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
write_bundle_to_cache(&root, &bundle_with_skill("v1", "commit", "# Original")).unwrap();
let manifest = write_bundle_to_cache(&root, &SubagentBundle::empty("v2")).unwrap();
assert!(!root.join("skills/commit/SKILL.md").exists());
assert!(!manifest.checksums.contains_key("skills/commit/SKILL.md"));
}
#[test]
fn keep_removed_modified_skill() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let manifest_v1 =
write_bundle_to_cache(&root, &bundle_with_skill("v1", "commit", "# Original")).unwrap();
std::fs::write(root.join("skills/commit/SKILL.md"), "# User custom").unwrap();
let manifest_v2 = write_bundle_to_cache(&root, &SubagentBundle::empty("v2")).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("skills/commit/SKILL.md")).unwrap(),
"# User custom"
);
assert_eq!(
manifest_v2.checksums.get("skills/commit/SKILL.md"),
manifest_v1.checksums.get("skills/commit/SKILL.md")
);
}
#[test]
fn skill_rejects_path_traversal() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let outside = tmp.path().join("outside.md");
let bundle = bundle_with_skill("v1", "../../outside", "# evil");
let error = write_bundle_to_cache(&root, &bundle).unwrap_err();
assert!(error.to_string().contains("invalid bundled skill name"));
assert!(!outside.exists());
}
#[test]
fn sanitize_accepts_valid_skill_path() {
assert_eq!(
sanitize_relative_path("skills/commit/SKILL.md"),
Some("skills/commit/SKILL.md".to_string())
);
}
#[test]
fn sanitize_accepts_nested_skill_paths() {
assert_eq!(
sanitize_relative_path("skills/implement/scripts/memory.py"),
Some("skills/implement/scripts/memory.py".to_string())
);
assert_eq!(
sanitize_relative_path("skills/implement/tests/test_memory.py"),
Some("skills/implement/tests/test_memory.py".to_string())
);
assert_eq!(
sanitize_relative_path("skills/commit/README.md"),
Some("skills/commit/README.md".to_string())
);
assert_eq!(
sanitize_relative_path("skills/foo/a/b/c/d.txt"),
Some("skills/foo/a/b/c/d.txt".to_string())
);
}
#[test]
fn sanitize_rejects_invalid_skill_paths() {
// Wrong top-level directory.
assert_eq!(sanitize_relative_path("personas/commit/SKILL.md"), None);
// Two-component skill path (must be at least 3).
assert_eq!(sanitize_relative_path("skills/commit.md"), None);
// Empty skill name.
assert_eq!(sanitize_relative_path("skills//SKILL.md"), None);
// Path traversal in skill name.
assert_eq!(sanitize_relative_path("skills/../SKILL.md"), None);
assert_eq!(sanitize_relative_path("skills/../etc/SKILL.md"), None);
// Path traversal in nested components.
assert_eq!(sanitize_relative_path("skills/foo/../bar/SKILL.md"), None);
assert_eq!(
sanitize_relative_path("skills/foo/scripts/../../etc/passwd"),
None
);
// `.` and empty components in the nested portion are rejected.
assert_eq!(sanitize_relative_path("skills/foo/./SKILL.md"), None);
assert_eq!(sanitize_relative_path("skills/foo//SKILL.md"), None);
}
#[test]
fn sanitize_accepts_valid_two_component_paths() {
assert_eq!(
sanitize_relative_path("personas/researcher.toml"),
Some("personas/researcher.toml".to_string())
);
assert_eq!(
sanitize_relative_path("roles/reviewer.toml"),
Some("roles/reviewer.toml".to_string())
);
assert_eq!(
sanitize_relative_path("agents/coder.md"),
Some("agents/coder.md".to_string())
);
}
// --- archive extraction tests ---
use super::test_helpers::{bundle_json, make_test_archive};
#[test]
fn extract_archive_writes_personas_roles_agents_and_skills() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v = bundle_json("v1");
let archive = make_test_archive(&[
("bundle.json", v.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"hello\"",
),
("subagents/roles/reviewer.toml", b"description = \"review\""),
("subagents/agents/default.md", b"# agent"),
("skills/commit/SKILL.md", b"# Commit skill"),
]);
let manifest = extract_bundle_archive(&root, &archive).unwrap();
assert_eq!(manifest.version, "v1");
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"hello\""
);
assert_eq!(
std::fs::read_to_string(root.join("roles/reviewer.toml")).unwrap(),
"description = \"review\""
);
assert_eq!(
std::fs::read_to_string(root.join("agents/default.md")).unwrap(),
"# agent"
);
assert_eq!(
std::fs::read_to_string(root.join("skills/commit/SKILL.md")).unwrap(),
"# Commit skill"
);
assert!(manifest.checksums.contains_key("personas/researcher.toml"));
assert!(manifest.checksums.contains_key("roles/reviewer.toml"));
assert!(manifest.checksums.contains_key("agents/default.md"));
assert!(manifest.checksums.contains_key("skills/commit/SKILL.md"));
}
#[test]
fn extract_archive_writes_nested_skill_files() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v = bundle_json("v1");
let archive = make_test_archive(&[
("bundle.json", v.as_bytes()),
("skills/implement/SKILL.md", b"# Implement skill"),
("skills/implement/scripts/memory.py", b"print('memory')\n"),
(
"skills/implement/tests/test_memory.py",
b"def test_memory():\n pass\n",
),
]);
let manifest = extract_bundle_archive(&root, &archive).unwrap();
assert_eq!(manifest.version, "v1");
assert_eq!(
std::fs::read_to_string(root.join("skills/implement/SKILL.md")).unwrap(),
"# Implement skill"
);
assert_eq!(
std::fs::read_to_string(root.join("skills/implement/scripts/memory.py")).unwrap(),
"print('memory')\n"
);
assert_eq!(
std::fs::read_to_string(root.join("skills/implement/tests/test_memory.py")).unwrap(),
"def test_memory():\n pass\n"
);
assert!(manifest.checksums.contains_key("skills/implement/SKILL.md"));
assert!(
manifest
.checksums
.contains_key("skills/implement/scripts/memory.py")
);
assert!(
manifest
.checksums
.contains_key("skills/implement/tests/test_memory.py")
);
}
#[test]
fn extract_archive_prunes_removed_nested_skill_files() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
("skills/implement/SKILL.md", b"# Implement"),
("skills/implement/scripts/memory.py", b"# v1 helper\n"),
]);
extract_bundle_archive(&root, &v1_archive).unwrap();
assert!(root.join("skills/implement/scripts/memory.py").exists());
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[
("bundle.json", v2.as_bytes()),
("skills/implement/SKILL.md", b"# Implement"),
]);
let manifest = extract_bundle_archive(&root, &v2_archive).unwrap();
assert!(!root.join("skills/implement/scripts/memory.py").exists());
assert!(
!manifest
.checksums
.contains_key("skills/implement/scripts/memory.py")
);
assert!(manifest.checksums.contains_key("skills/implement/SKILL.md"));
}
#[test]
fn extract_archive_keeps_user_modified_nested_skill_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
("skills/implement/SKILL.md", b"# v1"),
("skills/implement/scripts/memory.py", b"# v1 helper\n"),
]);
let manifest_v1 = extract_bundle_archive(&root, &v1_archive).unwrap();
std::fs::write(
root.join("skills/implement/scripts/memory.py"),
b"# user edit\n",
)
.unwrap();
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[
("bundle.json", v2.as_bytes()),
("skills/implement/SKILL.md", b"# v2"),
("skills/implement/scripts/memory.py", b"# v2 helper\n"),
]);
let manifest_v2 = extract_bundle_archive(&root, &v2_archive).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("skills/implement/scripts/memory.py")).unwrap(),
"# user edit\n"
);
assert_eq!(
manifest_v2
.checksums
.get("skills/implement/scripts/memory.py"),
manifest_v1
.checksums
.get("skills/implement/scripts/memory.py")
);
}
#[test]
fn extract_archive_overwrites_unchanged_nested_skill_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
("skills/implement/SKILL.md", b"# v1"),
("skills/implement/scripts/memory.py", b"# v1 helper\n"),
]);
extract_bundle_archive(&root, &v1_archive).unwrap();
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[
("bundle.json", v2.as_bytes()),
("skills/implement/SKILL.md", b"# v2"),
("skills/implement/scripts/memory.py", b"# v2 helper\n"),
]);
extract_bundle_archive(&root, &v2_archive).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("skills/implement/scripts/memory.py")).unwrap(),
"# v2 helper\n"
);
}
#[test]
fn extract_archive_skips_user_modified_files() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"old\"",
),
]);
let manifest_v1 = extract_bundle_archive(&root, &v1_archive).unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"user edit\"",
)
.unwrap();
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[
("bundle.json", v2.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"new\"",
),
]);
let manifest_v2 = extract_bundle_archive(&root, &v2_archive).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"user edit\""
);
assert_eq!(
manifest_v2.checksums.get("personas/researcher.toml"),
manifest_v1.checksums.get("personas/researcher.toml")
);
}
#[test]
fn extract_archive_prunes_removed_files() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"hello\"",
),
]);
extract_bundle_archive(&root, &v1_archive).unwrap();
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[("bundle.json", v2.as_bytes())]);
let manifest = extract_bundle_archive(&root, &v2_archive).unwrap();
assert!(!root.join("personas/researcher.toml").exists());
assert!(!manifest.checksums.contains_key("personas/researcher.toml"));
}
#[test]
fn extract_archive_keeps_removed_modified_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"old\"",
),
]);
let manifest_v1 = extract_bundle_archive(&root, &v1_archive).unwrap();
std::fs::write(
root.join("personas/researcher.toml"),
"instructions = \"user edit\"",
)
.unwrap();
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[("bundle.json", v2.as_bytes())]);
let manifest_v2 = extract_bundle_archive(&root, &v2_archive).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"user edit\""
);
assert_eq!(
manifest_v2.checksums.get("personas/researcher.toml"),
manifest_v1.checksums.get("personas/researcher.toml")
);
}
#[test]
fn extract_archive_rejects_oversized_entry() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v = bundle_json("v1");
let big_content = vec![0u8; ARCHIVE_MAX_ENTRY_SIZE as usize + 1];
let archive = make_test_archive(&[
("bundle.json", v.as_bytes()),
("subagents/personas/big.toml", &big_content),
]);
let err = extract_bundle_archive(&root, &archive).unwrap_err();
assert!(err.to_string().contains("exceeds maximum size"));
}
#[test]
fn extract_archive_rejects_too_many_entries() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
let v = r#"{"version":"v1"}"#;
let mut header = tar::Header::new_gnu();
header.set_size(v.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "bundle.json", v.as_bytes())
.unwrap();
let small = b"x";
for i in 0..ARCHIVE_MAX_ENTRIES {
let path = format!("unknown/f{i}");
let mut h = tar::Header::new_gnu();
h.set_size(small.len() as u64);
h.set_mode(0o644);
h.set_cksum();
builder.append_data(&mut h, &path, &small[..]).unwrap();
}
let encoder = builder.into_inner().unwrap();
let archive = encoder.finish().unwrap();
let err = extract_bundle_archive(&root, &archive).unwrap_err();
assert!(err.to_string().contains("exceeds maximum entry count"));
}
#[test]
fn extract_archive_skips_directory_entries() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
let v = r#"{"version":"v1"}"#;
let mut h = tar::Header::new_gnu();
h.set_size(v.len() as u64);
h.set_mode(0o644);
h.set_cksum();
builder
.append_data(&mut h, "bundle.json", v.as_bytes())
.unwrap();
let mut dir_h = tar::Header::new_gnu();
dir_h.set_size(0);
dir_h.set_mode(0o755);
dir_h.set_entry_type(tar::EntryType::Directory);
dir_h.set_cksum();
builder
.append_data(&mut dir_h, "subagents/personas/", &[] as &[u8])
.unwrap();
let content = b"instructions = \"hello\"";
let mut fh = tar::Header::new_gnu();
fh.set_size(content.len() as u64);
fh.set_mode(0o644);
fh.set_cksum();
builder
.append_data(&mut fh, "subagents/personas/researcher.toml", &content[..])
.unwrap();
let encoder = builder.into_inner().unwrap();
let archive = encoder.finish().unwrap();
let manifest = extract_bundle_archive(&root, &archive).unwrap();
assert_eq!(manifest.checksums.len(), 1);
assert!(manifest.checksums.contains_key("personas/researcher.toml"));
}
#[test]
fn extract_archive_missing_bundle_json_fails() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let archive = make_test_archive(&[(
"subagents/personas/researcher.toml",
b"instructions = \"hello\"" as &[u8],
)]);
let err = extract_bundle_archive(&root, &archive).unwrap_err();
assert!(err.to_string().contains("bundle.json"));
}
#[test]
fn extract_archive_handles_dot_slash_prefix() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v = bundle_json("v1");
let archive = make_test_archive(&[
("./bundle.json", v.as_bytes()),
(
"./subagents/personas/researcher.toml",
b"instructions = \"hello\"",
),
("./skills/commit/SKILL.md", b"# Commit"),
]);
let manifest = extract_bundle_archive(&root, &archive).unwrap();
assert_eq!(manifest.version, "v1");
assert!(manifest.checksums.contains_key("personas/researcher.toml"));
assert!(manifest.checksums.contains_key("skills/commit/SKILL.md"));
}
#[test]
fn extract_archive_overwrites_unchanged_file() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v1 = bundle_json("v1");
let v1_archive = make_test_archive(&[
("bundle.json", v1.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"old\"",
),
]);
extract_bundle_archive(&root, &v1_archive).unwrap();
let v2 = bundle_json("v2");
let v2_archive = make_test_archive(&[
("bundle.json", v2.as_bytes()),
(
"subagents/personas/researcher.toml",
b"instructions = \"new\"",
),
]);
extract_bundle_archive(&root, &v2_archive).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("personas/researcher.toml")).unwrap(),
"instructions = \"new\""
);
}
#[test]
fn extract_archive_skips_unknown_top_level_paths() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v = bundle_json("v1");
let archive = make_test_archive(&[
("bundle.json", v.as_bytes()),
("README.md", b"# readme"),
("unknown/file.txt", b"data"),
("subagents/personas/valid.toml", b"instructions = \"ok\""),
]);
let manifest = extract_bundle_archive(&root, &archive).unwrap();
assert_eq!(manifest.checksums.len(), 1);
assert!(manifest.checksums.contains_key("personas/valid.toml"));
}
#[test]
fn extract_archive_rejects_excessive_total_decompressed_size() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
let v = r#"{"version":"v1"}"#;
let mut h = tar::Header::new_gnu();
h.set_size(v.len() as u64);
h.set_mode(0o644);
h.set_cksum();
builder
.append_data(&mut h, "bundle.json", v.as_bytes())
.unwrap();
// 51 entries of 1 MB each = 51 MB > 50 MB limit.
// Each entry is at the per-entry limit (not over), so only the aggregate check fires.
let big = vec![0u8; ARCHIVE_MAX_ENTRY_SIZE as usize];
for i in 0..51 {
let path = format!("subagents/personas/p{i}.toml");
let mut fh = tar::Header::new_gnu();
fh.set_size(big.len() as u64);
fh.set_mode(0o644);
fh.set_cksum();
builder.append_data(&mut fh, &path, big.as_slice()).unwrap();
}
let encoder = builder.into_inner().unwrap();
let archive = encoder.finish().unwrap();
let err = extract_bundle_archive(&root, &archive).unwrap_err();
assert!(
err.to_string()
.contains("exceeds maximum decompressed size")
);
}
// --- map_archive_path_to_cache_path tests ---
#[test]
fn map_archive_strips_subagents_prefix() {
assert_eq!(
map_archive_path_to_cache_path("subagents/personas/researcher.toml"),
Some("personas/researcher.toml".to_string())
);
assert_eq!(
map_archive_path_to_cache_path("subagents/roles/reviewer.toml"),
Some("roles/reviewer.toml".to_string())
);
assert_eq!(
map_archive_path_to_cache_path("subagents/agents/default.md"),
Some("agents/default.md".to_string())
);
}
#[test]
fn map_archive_preserves_skills_path() {
assert_eq!(
map_archive_path_to_cache_path("skills/commit/SKILL.md"),
Some("skills/commit/SKILL.md".to_string())
);
}
#[test]
fn map_archive_preserves_nested_skill_paths() {
assert_eq!(
map_archive_path_to_cache_path("skills/implement/scripts/memory.py"),
Some("skills/implement/scripts/memory.py".to_string())
);
assert_eq!(
map_archive_path_to_cache_path("skills/implement/tests/test_memory.py"),
Some("skills/implement/tests/test_memory.py".to_string())
);
}
#[test]
fn sanitize_accepts_shared_data_under_skills() {
// Non-skill directories under skills/ (e.g., shared/personas/) are
// valid archive entries -- they carry data that skills read at runtime.
assert_eq!(
sanitize_relative_path("skills/shared/personas/reviewer.md"),
Some("skills/shared/personas/reviewer.md".to_string())
);
assert_eq!(
sanitize_relative_path("skills/shared/personas/implementer.md"),
Some("skills/shared/personas/implementer.md".to_string())
);
}
#[test]
fn extract_archive_writes_shared_data_under_skills() {
let tmp = TempDir::new().unwrap();
let root = cache_root(&tmp);
let v = bundle_json("v1");
let archive = make_test_archive(&[
("bundle.json", v.as_bytes()),
("skills/review/SKILL.md", b"# Review skill"),
("skills/shared/personas/reviewer.md", b"You are a reviewer."),
]);
let manifest = extract_bundle_archive(&root, &archive).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("skills/shared/personas/reviewer.md")).unwrap(),
"You are a reviewer."
);
assert!(
manifest
.checksums
.contains_key("skills/shared/personas/reviewer.md")
);
}
#[test]
fn map_archive_skips_unknown_paths() {
assert_eq!(map_archive_path_to_cache_path("unknown/file.txt"), None);
assert_eq!(map_archive_path_to_cache_path("README.md"), None);
assert_eq!(map_archive_path_to_cache_path(""), None);
}
#[test]
fn map_archive_rejects_traversal_under_subagents() {
assert_eq!(
map_archive_path_to_cache_path("subagents/personas/../../etc/passwd"),
None
);
}
// --- count_entries_by_prefix tests ---
#[test]
fn count_entries_by_prefix_counts_correctly() {
let manifest = BundleManifest {
version: "v1".to_string(),
checksums: HashMap::from([
("personas/a.toml".to_string(), "abc".to_string()),
("personas/b.toml".to_string(), "def".to_string()),
("roles/r.toml".to_string(), "ghi".to_string()),
("skills/commit/SKILL.md".to_string(), "jkl".to_string()),
]),
};
assert_eq!(count_entries_by_prefix(&manifest, "personas/"), 2);
assert_eq!(count_entries_by_prefix(&manifest, "roles/"), 1);
assert_eq!(count_entries_by_prefix(&manifest, "skills/"), 1);
assert_eq!(count_entries_by_prefix(&manifest, "agents/"), 0);
}
}