Files
coyote/src/config/bundles.rs
T
Dark-Alex-17 e55120dac6 fix(bundles): harden the install pipeline for cross-platform correctness
Windows review findings on the bundle provenance code:

- clones now pin core.autocrlf=false and core.eol=lf so recorded sha256
  values reflect repository bytes, not the machine's git config (autocrlf
  on Windows previously made every text file a false conflict on update),
  plus core.longpaths=true for deep bundle trees
- is_safe_relative_path additionally rejects NTFS alternate data stream
  colons, reserved device names (con, nul, COM1..), and trailing dots or
  spaces; such names never come from a valid checkout and previously
  desynced or failed on Windows
- file ownership dedupe compares paths case-insensitively on Windows and
  macOS where case variants denote one physical file (uninstalling one
  bundle could previously delete another bundle's file)
- a failed git clone no longer leaks its partial tree in the temp dir,
  and temp cleanup failures are logged instead of swallowed
- recording a bundle file outside the config dir (asset dir override)
  now warns instead of silently producing an undeletable record
2026-08-25 11:37:41 -06:00

1557 lines
51 KiB
Rust

use super::install_remote::{
canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name,
};
use super::{paths, request_context};
use crate::config::AssetCategory;
use crate::function::write_file_atomic;
use crate::utils::IS_STDOUT_TERMINAL;
use anyhow::{Context, Result, bail};
use chrono::{SecondsFormat, Utc};
use inquire::Confirm;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum FileAction {
New,
Replaced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum McpAction {
Added,
Replaced,
Renamed,
Transferred,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct FileRecord {
/// Relative to the config dir.
pub(crate) path: String,
pub(crate) category: String,
/// Content hash at install time; a later mismatch means the user modified the file.
pub(crate) sha256: String,
pub(crate) action: FileAction,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpServerRecord {
pub(crate) name: String,
pub(crate) action: McpAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) renamed_to: Option<String>,
/// Hash of the mcp.json entry as written; a later mismatch means the user
/// modified it. Absent on records made before entry hashing existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) sha256: Option<String>,
}
impl McpServerRecord {
/// The key this entry actually occupies in mcp.json (the rename target, if any).
pub(crate) fn effective_key(&self) -> &str {
self.renamed_to.as_deref().unwrap_or(&self.name)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct BundleRecord {
pub(crate) source: String,
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
pub(crate) git_ref: Option<String>,
pub(crate) commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) homepage: Option<String>,
pub(crate) installed_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<String>,
#[serde(default)]
pub(crate) files: Vec<FileRecord>,
#[serde(default)]
pub(crate) mcp_servers: Vec<McpServerRecord>,
}
#[derive(Debug, Clone)]
pub(crate) struct InstallMetadata {
pub(crate) source: String,
pub(crate) git_ref: Option<String>,
pub(crate) commit: String,
pub(crate) version: Option<String>,
pub(crate) description: Option<String>,
pub(crate) homepage: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResolvedBundleName {
pub(crate) name: String,
/// The unqualified name this install asked for, when it had to be owner-qualified.
pub(crate) qualified_from: Option<String>,
/// Source URL of the different-source bundle that already holds the unqualified name.
pub(crate) same_name_other_source: Option<String>,
}
const STORE_VERSION: u32 = 1;
#[derive(Debug, Default, Deserialize)]
struct StoreContents {
#[serde(default)]
version: u32,
#[serde(default)]
bundles: BTreeMap<String, BundleRecord>,
}
#[derive(Serialize)]
struct StoreContentsRef<'a> {
version: u32,
bundles: &'a BTreeMap<String, BundleRecord>,
}
#[derive(Debug)]
pub(crate) struct BundleStore {
path: PathBuf,
bundles: BTreeMap<String, BundleRecord>,
}
impl BundleStore {
pub(crate) fn load() -> Result<Self> {
Self::load_from(paths::installed_bundles_file())
}
/// A corrupt store is an error, never an empty store: treating it as empty
/// would let a reinstall re-acquire ownership over files the user may have
/// modified since.
pub(crate) fn load_from(path: PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self {
path,
bundles: BTreeMap::new(),
});
}
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| {
format!(
"failed to parse {}; refusing to treat it as empty. \
Restore or repair the file to continue; removing it forfeits \
uninstall tracking for every installed bundle",
path.display()
)
})?;
if contents.version > STORE_VERSION {
bail!(
"{} has store version {}, but this coyote build supports up to \
{STORE_VERSION}; update coyote or restore a matching store",
path.display(),
contents.version
);
}
Ok(Self {
path,
bundles: contents.bundles,
})
}
pub(crate) fn save(&self) -> Result<()> {
let content = serde_yaml::to_string(&StoreContentsRef {
version: STORE_VERSION,
bundles: &self.bundles,
})
.context("failed to serialize the installed-bundles store")?;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
write_file_atomic(&self.path, &content, None)
.with_context(|| format!("failed to write {}", self.path.display()))
}
pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> {
self.bundles.get(name)
}
pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &BundleRecord)> {
self.bundles
.iter()
.map(|(name, record)| (name.as_str(), record))
}
pub(crate) fn bundle_names(&self) -> Vec<&str> {
self.bundles.keys().map(String::as_str).collect()
}
pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> {
let canonical = canonical_source_url(url);
self.bundles
.iter()
.find(|(_, record)| canonical_source_url(&record.source) == canonical)
.map(|(name, record)| (name.as_str(), record))
}
/// A URL already tracked under a different key migrates to the new key, so
/// the same URL never gets a second record. A name held by a
/// different-source bundle is owner-qualified instead.
pub(crate) fn resolve_bundle_name(
&mut self,
url: &str,
manifest_name: Option<&str>,
) -> Result<ResolvedBundleName> {
let canonical = canonical_source_url(url);
let existing_key = self
.bundles
.iter()
.find(|(_, record)| canonical_source_url(&record.source) == canonical)
.map(|(name, _)| name.clone());
let base = match manifest_name {
Some(name) => {
validate_bundle_name(name)?;
name.to_string()
}
None => {
let slug = sanitize_name_segment(&repo_name_slug(url));
if slug.is_empty() {
bail!(
"cannot derive a bundle name from '{url}'; \
add a coyote-bundle.yaml manifest with a name"
);
}
slug
}
};
let mut resolved = ResolvedBundleName {
name: base.clone(),
qualified_from: None,
same_name_other_source: None,
};
let reserved = AssetCategory::parse(&base).is_some();
let collision = self.source_of_other_bundle(&base, &canonical);
if reserved || collision.is_some() {
let reason = match &collision {
Some(other_source) => {
format!("is already used by an install from '{other_source}'")
}
None => "is reserved for an asset category".to_string(),
};
if base.contains('/') {
bail!(
"bundle name '{base}' {reason} and cannot be qualified further; \
uninstall it or pick a different manifest name"
);
}
let owner = owner_qualifier(url)
.map(|owner| sanitize_name_segment(&owner))
.filter(|owner| !owner.is_empty());
let Some(owner) = owner else {
bail!(
"bundle name '{base}' {reason}, and no owner qualifier \
can be derived from '{url}'"
);
};
let qualified = format!("{owner}/{base}");
if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) {
bail!(
"bundle name '{base}' {reason}, and '{qualified}' is already \
used by an install from '{source}'; \
uninstall one or pick a different manifest name"
);
}
if let Some(other_source) = &collision
&& manifest_name.is_some()
&& *IS_STDOUT_TERMINAL
{
let proceed = Confirm::new(&format!(
"Bundle name '{base}' is already used by an install from \
'{other_source}' (a fork or typo-squat?). Track this install \
as '{qualified}'?"
))
.with_default(false)
.prompt()
.with_context(|| "failed to read bundle name confirmation")?;
if !proceed {
bail!("install aborted: bundle name '{base}' {reason}");
}
}
resolved.name = qualified;
resolved.qualified_from = Some(base);
resolved.same_name_other_source = collision;
}
let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str());
if let Some(old_key) = existing_key
&& !already_recorded
{
if self.bundles.contains_key(&resolved.name) {
bail!(
"records '{old_key}' and '{}' both track source '{url}'; \
uninstall one or repair installed-bundles.yaml before continuing",
resolved.name
);
}
let record = self
.bundles
.remove(&old_key)
.expect("existing_key was found in the map");
self.bundles.insert(resolved.name.clone(), record);
println!(
"Bundle '{old_key}' from {url} is now tracked as '{}'.",
resolved.name
);
self.save()?;
}
if let (Some(from), false) = (&resolved.qualified_from, already_recorded) {
let detail = match resolved.same_name_other_source.as_deref() {
Some(other) => format!("is already used by an install from '{other}'"),
None => "is reserved for an asset category".to_string(),
};
println!(
"Bundle name '{from}' {detail}; tracking this install as '{}'.",
resolved.name
);
}
debug_assert!(
validate_bundle_name(&resolved.name).is_ok(),
"derived bundle names must satisfy validate_bundle_name"
);
Ok(resolved)
}
pub(crate) fn upsert_bundle(&mut self, name: &str, metadata: InstallMetadata) -> Result<()> {
match self.bundles.get_mut(name) {
Some(record) => {
record.source = metadata.source;
record.git_ref = metadata.git_ref;
record.commit = metadata.commit;
record.version = metadata.version;
record.description = metadata.description;
record.homepage = metadata.homepage;
}
None => {
self.bundles.insert(
name.to_string(),
BundleRecord {
source: metadata.source,
git_ref: metadata.git_ref,
commit: metadata.commit,
version: metadata.version,
description: metadata.description,
homepage: metadata.homepage,
installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
updated_at: None,
files: Vec::new(),
mcp_servers: Vec::new(),
},
);
}
}
self.save()
}
pub(crate) fn mark_updated(&mut self, name: &str) -> Result<()> {
self.ensure_bundle_exists(name)?;
let record = self
.bundles
.get_mut(name)
.expect("bundle existence checked above");
record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true));
self.save()
}
/// Written only after an update's files and mcp entries land, so an
/// aborted update cannot leave the record claiming a commit whose content
/// never finished applying.
pub(crate) fn set_bundle_versions(
&mut self,
name: &str,
commit: &str,
version: Option<String>,
) -> Result<()> {
self.ensure_bundle_exists(name)?;
let record = self
.bundles
.get_mut(name)
.expect("bundle existence checked above");
record.commit = commit.to_string();
record.version = version;
self.save()
}
pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> {
self.ensure_bundle_exists(bundle)?;
let record = self
.bundles
.get_mut(bundle)
.expect("bundle existence checked above");
record.files.retain(|owned| owned.path != path);
self.save()
}
pub(crate) fn remove_mcp_record(&mut self, bundle: &str, effective_key: &str) -> Result<()> {
self.ensure_bundle_exists(bundle)?;
let record = self
.bundles
.get_mut(bundle)
.expect("bundle existence checked above");
record
.mcp_servers
.retain(|owned| owned.effective_key() != effective_key);
self.save()
}
pub(crate) fn remove_bundle(&mut self, name: &str) -> Result<()> {
self.ensure_bundle_exists(name)?;
self.bundles.remove(name);
self.save()
}
/// Persists per call, so an install aborted partway through still has
/// provenance for everything already on disk.
/// A path owned by another bundle transfers to `bundle`.
pub(crate) fn record_file(&mut self, bundle: &str, file: FileRecord) -> Result<()> {
self.ensure_bundle_exists(bundle)?;
for (name, record) in self.bundles.iter_mut() {
if name != bundle {
record
.files
.retain(|owned| !same_installed_path(&owned.path, &file.path));
}
}
let record = self
.bundles
.get_mut(bundle)
.expect("bundle existence checked above");
record
.files
.retain(|owned| !same_installed_path(&owned.path, &file.path));
record.files.push(file);
self.save()
}
/// An entry whose key any bundle already owns transfers to `bundle`, and
/// its `replaced` action upgrades to `transferred` only when the prior
/// record proves bundle origin. A prior `replaced` record marks a
/// pre-existing user entry that uninstall must never delete, and that
/// marker survives re-records and cross-bundle transfers.
pub(crate) fn record_mcp_servers(
&mut self,
bundle: &str,
entries: Vec<McpServerRecord>,
) -> Result<()> {
self.ensure_bundle_exists(bundle)?;
for mut entry in entries {
let key = entry.effective_key().to_string();
let mut previously_owned = false;
let mut prior_user_origin = false;
for record in self.bundles.values_mut() {
let before = record.mcp_servers.len();
record.mcp_servers.retain(|owned| {
if owned.effective_key() != key {
return true;
}
if owned.action == McpAction::Replaced {
prior_user_origin = true;
}
false
});
previously_owned |= record.mcp_servers.len() != before;
}
if previously_owned && !prior_user_origin && entry.action == McpAction::Replaced {
entry.action = McpAction::Transferred;
}
self.bundles
.get_mut(bundle)
.expect("bundle existence checked above")
.mcp_servers
.push(entry);
}
self.save()
}
fn ensure_bundle_exists(&self, bundle: &str) -> Result<()> {
if !self.bundles.contains_key(bundle) {
bail!(
"no installed bundle named '{bundle}' (installed: {})",
if self.bundles.is_empty() {
"none".to_string()
} else {
self.bundle_names().join(", ")
}
);
}
Ok(())
}
fn source_of_other_bundle(&self, name: &str, canonical: &str) -> Option<String> {
self.bundles
.get(name)
.filter(|record| canonical_source_url(&record.source) != canonical)
.map(|record| record.source.clone())
}
}
pub(crate) fn hash_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub(crate) fn hash_file(path: &Path) -> Result<String> {
let bytes =
fs::read(path).with_context(|| format!("failed to read {} for hashing", path.display()))?;
Ok(hash_bytes(&bytes))
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct DriftSummary {
pub(crate) intact: usize,
pub(crate) modified: usize,
pub(crate) missing: usize,
}
impl DriftSummary {
pub(crate) fn display(&self) -> String {
if self.intact + self.modified + self.missing == 0 {
return "-".to_string();
}
let mut parts = Vec::new();
if self.intact > 0 {
parts.push(format!("{} intact", self.intact));
}
if self.modified > 0 {
parts.push(format!("{} modified locally", self.modified));
}
if self.missing > 0 {
parts.push(format!("{} missing", self.missing));
}
parts.join(", ")
}
}
#[derive(Debug)]
pub(crate) struct BundleListRow {
pub(crate) name: String,
pub(crate) version: String,
pub(crate) source: String,
pub(crate) git_ref: String,
pub(crate) installed_at: String,
pub(crate) file_counts: String,
pub(crate) drift: DriftSummary,
}
/// NTFS and default APFS resolve file names case-insensitively, so records
/// differing only in case denote the same physical file there. Linux keeps
/// exact matching because case variants are genuinely distinct files.
fn same_installed_path(a: &str, b: &str) -> bool {
if cfg!(any(windows, target_os = "macos")) {
a.eq_ignore_ascii_case(b)
} else {
a == b
}
}
/// An unreadable file counts as locally modified: it exists but its integrity
/// cannot be verified.
pub(crate) fn bundle_list_rows(store: &BundleStore, config_dir: &Path) -> Vec<BundleListRow> {
store
.iter()
.map(|(name, record)| {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut drift = DriftSummary::default();
for file in &record.files {
*counts.entry(file.category.as_str()).or_default() += 1;
let path = config_dir.join(&file.path);
if !path.exists() {
drift.missing += 1;
} else {
match hash_file(&path) {
Ok(hash) if hash == file.sha256 => drift.intact += 1,
_ => drift.modified += 1,
}
}
}
let file_counts = if counts.is_empty() {
"-".to_string()
} else {
counts
.iter()
.map(|(category, count)| format!("{category}: {count}"))
.collect::<Vec<_>>()
.join(", ")
};
BundleListRow {
name: name.to_string(),
version: record
.version
.clone()
.unwrap_or_else(|| record.commit.chars().take(7).collect()),
source: record.source.clone(),
git_ref: record.git_ref.clone().unwrap_or_else(|| "-".to_string()),
installed_at: record.installed_at.clone(),
file_counts,
drift,
}
})
.collect()
}
pub fn list_installed_bundles() -> Result<()> {
let store = BundleStore::load()?;
let rows = bundle_list_rows(&store, &paths::config_dir());
if rows.is_empty() {
println!(
"No bundles installed. Install one with `coyote --install <git-url|owner/repo>` \
or, from the REPL, `.install <git-url|owner/repo>` (see `.install --help`)."
);
return Ok(());
}
let mut table = request_context::asset_table(&[
"name",
"version",
"source",
"ref",
"installed",
"files",
"drift",
]);
for row in rows {
table.add_row(vec![
row.name.as_str(),
&row.version,
&row.source,
&row.git_ref,
&row.installed_at,
&row.file_counts,
&row.drift.display(),
]);
}
println!("Bundles:");
println!("{table}");
Ok(())
}
fn sanitize_name_segment(segment: &str) -> String {
segment
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::{get_env_name, temp_file};
use serial_test::serial;
use std::env;
use std::ffi::OsString;
struct TempStoreDir(PathBuf);
impl TempStoreDir {
fn new(label: &str) -> Self {
let dir = temp_file(label, "");
fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn store_path(&self) -> PathBuf {
self.0.join("installed-bundles.yaml")
}
fn store(&self) -> BundleStore {
BundleStore::load_from(self.store_path()).unwrap()
}
}
impl Drop for TempStoreDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn metadata(source: &str, commit: &str) -> InstallMetadata {
InstallMetadata {
source: source.to_string(),
git_ref: None,
commit: commit.to_string(),
version: None,
description: None,
homepage: None,
}
}
fn file_record(path: &str, contents: &str) -> FileRecord {
FileRecord {
path: path.to_string(),
category: "macros".to_string(),
sha256: hash_bytes(contents.as_bytes()),
action: FileAction::New,
}
}
fn mcp_record(name: &str, action: McpAction, renamed_to: Option<&str>) -> McpServerRecord {
McpServerRecord {
name: name.to_string(),
action,
renamed_to: renamed_to.map(str::to_string),
sha256: None,
}
}
#[test]
fn load_missing_file_yields_empty_store() {
let dir = TempStoreDir::new("bundles-empty");
let store = dir.store();
assert!(store.bundle_names().is_empty());
}
#[test]
fn corrupt_store_fails_closed() {
let dir = TempStoreDir::new("bundles-corrupt");
fs::write(dir.store_path(), "bundles:\n - this is not a map\n").unwrap();
let result = BundleStore::load_from(dir.store_path());
let message = format!("{:#}", result.unwrap_err());
assert!(
message.contains("refusing to treat it as empty"),
"{message}"
);
}
#[test]
fn save_and_reload_roundtrip() {
let dir = TempStoreDir::new("bundles-roundtrip");
let mut store = dir.store();
store
.upsert_bundle(
"omc",
InstallMetadata {
source: "https://github.com/x/omc".to_string(),
git_ref: Some("main".to_string()),
commit: "abc123".to_string(),
version: Some("1.4.0".to_string()),
description: Some("Opinionated roles and macros".to_string()),
homepage: Some("https://github.com/x/omc".to_string()),
},
)
.unwrap();
store
.record_file("omc", file_record("macros/a.yaml", "a"))
.unwrap();
store
.record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)])
.unwrap();
let raw = fs::read_to_string(dir.store_path()).unwrap();
let reloaded = dir.store();
assert!(raw.contains("ref: main"), "{raw}");
assert!(!raw.contains("git_ref"), "{raw}");
assert!(raw.contains("action: added"), "{raw}");
let record = reloaded.get("omc").unwrap();
assert_eq!(record.git_ref.as_deref(), Some("main"));
assert_eq!(record.version.as_deref(), Some("1.4.0"));
assert_eq!(
record.description.as_deref(),
Some("Opinionated roles and macros")
);
assert_eq!(record.homepage.as_deref(), Some("https://github.com/x/omc"));
assert_eq!(record.files.len(), 1);
assert_eq!(record.mcp_servers.len(), 1);
}
#[test]
fn files_recorded_before_an_abort_survive_it() {
let dir = TempStoreDir::new("bundles-abort");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_file("omc", file_record("macros/a.yaml", "a"))
.unwrap();
store
.record_file("omc", file_record("skills/b.md", "b"))
.unwrap();
drop(store);
let reloaded = dir.store();
let paths: Vec<&str> = reloaded
.get("omc")
.unwrap()
.files
.iter()
.map(|f| f.path.as_str())
.collect();
assert_eq!(paths, vec!["macros/a.yaml", "skills/b.md"]);
}
#[test]
fn recording_the_same_path_twice_updates_in_place() {
let dir = TempStoreDir::new("bundles-dedupe");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_file("omc", file_record("macros/a.yaml", "v1"))
.unwrap();
let mut updated = file_record("macros/a.yaml", "v2");
updated.action = FileAction::Replaced;
store.record_file("omc", updated).unwrap();
let record = store.get("omc").unwrap();
assert_eq!(record.files.len(), 1);
assert_eq!(record.files[0].sha256, hash_bytes(b"v2"));
assert_eq!(record.files[0].action, FileAction::Replaced);
}
#[test]
fn overwritten_file_transfers_ownership() {
let dir = TempStoreDir::new("bundles-transfer");
let mut store = dir.store();
store
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
.unwrap();
store
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
.unwrap();
store
.record_file("alpha", file_record("macros/shared.yaml", "a"))
.unwrap();
let mut taken = file_record("macros/shared.yaml", "b");
taken.action = FileAction::Replaced;
store.record_file("beta", taken).unwrap();
let reloaded = dir.store();
assert!(reloaded.get("alpha").unwrap().files.is_empty());
let beta_files = &reloaded.get("beta").unwrap().files;
assert_eq!(beta_files.len(), 1);
assert_eq!(beta_files[0].path, "macros/shared.yaml");
}
#[test]
fn overwritten_mcp_entry_transfers_ownership_as_transferred() {
let dir = TempStoreDir::new("bundles-mcp-transfer");
let mut store = dir.store();
store
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
.unwrap();
store
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
.unwrap();
store
.record_mcp_servers("alpha", vec![mcp_record("srv", McpAction::Added, None)])
.unwrap();
store
.record_mcp_servers("beta", vec![mcp_record("srv", McpAction::Replaced, None)])
.unwrap();
let reloaded = dir.store();
assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty());
let beta_servers = &reloaded.get("beta").unwrap().mcp_servers;
assert_eq!(beta_servers.len(), 1);
assert_eq!(beta_servers[0].action, McpAction::Transferred);
}
#[test]
fn mcp_replacement_of_unowned_entry_stays_replaced() {
let dir = TempStoreDir::new("bundles-mcp-replaced");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_mcp_servers(
"omc",
vec![mcp_record("user-srv", McpAction::Replaced, None)],
)
.unwrap();
assert_eq!(
store.get("omc").unwrap().mcp_servers[0].action,
McpAction::Replaced
);
}
#[test]
fn mcp_rerecord_of_own_entry_upgrades_replaced_to_transferred() {
let dir = TempStoreDir::new("bundles-mcp-self-update");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)])
.unwrap();
let mut updated = mcp_record("srv", McpAction::Replaced, None);
updated.sha256 = Some("deadbeef".to_string());
store.record_mcp_servers("omc", vec![updated]).unwrap();
let servers = &store.get("omc").unwrap().mcp_servers;
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].action, McpAction::Transferred);
assert_eq!(servers[0].sha256.as_deref(), Some("deadbeef"));
}
#[test]
fn mcp_rerecord_of_own_replaced_entry_stays_replaced() {
let dir = TempStoreDir::new("bundles-mcp-sticky-self");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_mcp_servers(
"omc",
vec![mcp_record("user-srv", McpAction::Replaced, None)],
)
.unwrap();
store
.record_mcp_servers(
"omc",
vec![mcp_record("user-srv", McpAction::Replaced, None)],
)
.unwrap();
assert_eq!(
store.get("omc").unwrap().mcp_servers[0].action,
McpAction::Replaced
);
}
#[test]
fn mcp_transfer_of_replaced_entry_keeps_user_origin_marker() {
let dir = TempStoreDir::new("bundles-mcp-sticky-transfer");
let mut store = dir.store();
store
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
.unwrap();
store
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
.unwrap();
store
.record_mcp_servers(
"alpha",
vec![mcp_record("user-srv", McpAction::Replaced, None)],
)
.unwrap();
store
.record_mcp_servers(
"beta",
vec![mcp_record("user-srv", McpAction::Replaced, None)],
)
.unwrap();
assert!(store.get("alpha").unwrap().mcp_servers.is_empty());
assert_eq!(
store.get("beta").unwrap().mcp_servers[0].action,
McpAction::Replaced
);
}
#[test]
fn mcp_transfer_matches_renamed_entries_by_effective_key() {
let dir = TempStoreDir::new("bundles-mcp-renamed");
let mut store = dir.store();
store
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
.unwrap();
store
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
.unwrap();
store
.record_mcp_servers(
"alpha",
vec![mcp_record("srv", McpAction::Renamed, Some("srv-remote"))],
)
.unwrap();
store
.record_mcp_servers(
"beta",
vec![mcp_record("srv-remote", McpAction::Replaced, None)],
)
.unwrap();
let reloaded = dir.store();
assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty());
assert_eq!(
reloaded.get("beta").unwrap().mcp_servers[0].action,
McpAction::Transferred
);
}
#[test]
fn resolve_same_url_same_name_is_an_update() {
let dir = TempStoreDir::new("bundles-resolve-update");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
let resolved = store
.resolve_bundle_name("git@github.com:x/omc.git", None)
.unwrap();
assert_eq!(resolved.name, "omc");
assert_eq!(resolved.qualified_from, None);
}
#[test]
fn resolve_migrates_record_when_identity_changes() {
let dir = TempStoreDir::new("bundles-migrate");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_file("omc", file_record("macros/a.yaml", "a"))
.unwrap();
let resolved = store
.resolve_bundle_name("git@github.com:x/omc.git", Some("oh-my-coyote"))
.unwrap();
assert_eq!(resolved.name, "oh-my-coyote");
let reloaded = dir.store();
assert!(reloaded.get("omc").is_none());
assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1);
}
#[test]
fn resolve_qualifies_colliding_name_from_https_source() {
let dir = TempStoreDir::new("bundles-qualify-https");
let mut store = dir.store();
store
.upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123"))
.unwrap();
let resolved = store
.resolve_bundle_name("https://gitlab.com/b/repo.git", None)
.unwrap();
assert_eq!(resolved.name, "b/repo");
assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
assert_eq!(
resolved.same_name_other_source.as_deref(),
Some("https://github.com/a/repo")
);
}
#[test]
fn resolve_qualifies_colliding_name_from_scp_source() {
let dir = TempStoreDir::new("bundles-qualify-scp");
let mut store = dir.store();
store
.upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123"))
.unwrap();
let resolved = store
.resolve_bundle_name("git@bitbucket.org:c/repo.git", None)
.unwrap();
assert_eq!(resolved.name, "c/repo");
assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
}
#[test]
fn resolve_of_already_qualified_record_is_stable() {
let dir = TempStoreDir::new("bundles-qualify-stable");
let mut store = dir.store();
store
.upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123"))
.unwrap();
store
.upsert_bundle("b/repo", metadata("https://gitlab.com/b/repo", "def456"))
.unwrap();
let resolved = store
.resolve_bundle_name("https://gitlab.com/b/repo.git", None)
.unwrap();
assert_eq!(resolved.name, "b/repo");
assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
}
#[test]
fn resolve_sanitizes_derived_names() {
let dir = TempStoreDir::new("bundles-sanitize");
let mut store = dir.store();
let resolved = store
.resolve_bundle_name("https://github.com/vercel/next.js.git", None)
.unwrap();
assert_eq!(resolved.name, "next-js");
}
#[test]
fn resolve_reserves_asset_category_names() {
let dir = TempStoreDir::new("bundles-reserved");
let mut store = dir.store();
let resolved = store
.resolve_bundle_name("https://github.com/x/agents", None)
.unwrap();
assert_eq!(resolved.name, "x/agents");
assert_eq!(resolved.qualified_from.as_deref(), Some("agents"));
assert!(resolved.same_name_other_source.is_none());
let resolved = store
.resolve_bundle_name("https://github.com/y/repo", Some("mcp_config"))
.unwrap();
assert_eq!(resolved.name, "y/mcp_config");
assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config"));
}
#[test]
fn resolve_rejects_invalid_manifest_names() {
let dir = TempStoreDir::new("bundles-invalid-name");
let mut store = dir.store();
let result = store.resolve_bundle_name("https://github.com/x/repo", Some("a/b/c"));
assert!(result.is_err());
}
#[test]
fn upsert_merges_metadata_and_preserves_files_and_install_time() {
let dir = TempStoreDir::new("bundles-merge");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_file("omc", file_record("macros/a.yaml", "a"))
.unwrap();
let installed_at = store.get("omc").unwrap().installed_at.clone();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc.git", "def456"))
.unwrap();
let record = store.get("omc").unwrap();
assert_eq!(record.commit, "def456");
assert_eq!(record.source, "https://github.com/x/omc.git");
assert_eq!(record.installed_at, installed_at);
assert_eq!(record.files.len(), 1);
}
#[test]
fn recording_against_unknown_bundle_fails() {
let dir = TempStoreDir::new("bundles-unknown");
let mut store = dir.store();
let result = store.record_file("ghost", file_record("macros/a.yaml", "a"));
assert!(result.unwrap_err().to_string().contains("ghost"));
}
#[test]
fn mark_updated_stamps_and_round_trips() {
let dir = TempStoreDir::new("bundles-mark-updated");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
let raw_before = fs::read_to_string(dir.store_path()).unwrap();
assert!(!raw_before.contains("updated_at"), "{raw_before}");
assert_eq!(store.get("omc").unwrap().updated_at, None);
store.mark_updated("omc").unwrap();
let raw_after = fs::read_to_string(dir.store_path()).unwrap();
assert!(raw_after.contains("updated_at"), "{raw_after}");
let reloaded = dir.store();
assert!(reloaded.get("omc").unwrap().updated_at.is_some());
}
#[test]
fn mark_updated_unknown_bundle_fails() {
let dir = TempStoreDir::new("bundles-mark-unknown");
let mut store = dir.store();
let result = store.mark_updated("ghost");
assert!(result.unwrap_err().to_string().contains("ghost"));
}
#[test]
fn remove_file_record_drops_only_the_named_path() {
let dir = TempStoreDir::new("bundles-remove-file");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_file("omc", file_record("macros/a.yaml", "a"))
.unwrap();
store
.record_file("omc", file_record("macros/b.yaml", "b"))
.unwrap();
store.remove_file_record("omc", "macros/a.yaml").unwrap();
let reloaded = dir.store();
let paths: Vec<&str> = reloaded
.get("omc")
.unwrap()
.files
.iter()
.map(|f| f.path.as_str())
.collect();
assert_eq!(paths, vec!["macros/b.yaml"]);
}
#[test]
fn remove_file_record_unknown_bundle_fails() {
let dir = TempStoreDir::new("bundles-remove-unknown");
let mut store = dir.store();
let result = store.remove_file_record("ghost", "macros/a.yaml");
assert!(result.unwrap_err().to_string().contains("ghost"));
}
#[test]
fn remove_mcp_record_drops_only_the_named_effective_key() {
let dir = TempStoreDir::new("bundles-remove-mcp");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store
.record_mcp_servers(
"omc",
vec![
mcp_record("srv", McpAction::Renamed, Some("srv-remote")),
mcp_record("other", McpAction::Added, None),
],
)
.unwrap();
store.remove_mcp_record("omc", "srv-remote").unwrap();
let reloaded = dir.store();
let keys: Vec<&str> = reloaded
.get("omc")
.unwrap()
.mcp_servers
.iter()
.map(|s| s.effective_key())
.collect();
assert_eq!(keys, vec!["other"]);
}
#[test]
fn remove_bundle_deletes_the_record_and_persists() {
let dir = TempStoreDir::new("bundles-remove-bundle");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
store.remove_bundle("omc").unwrap();
let reloaded = dir.store();
assert!(reloaded.get("omc").is_none());
assert!(reloaded.bundle_names().is_empty());
}
#[test]
fn remove_bundle_unknown_name_fails() {
let dir = TempStoreDir::new("bundles-remove-bundle-unknown");
let mut store = dir.store();
let result = store.remove_bundle("ghost");
assert!(result.unwrap_err().to_string().contains("ghost"));
}
#[cfg(unix)]
#[test]
fn failed_save_preserves_the_existing_store() {
use std::os::unix::fs::PermissionsExt;
let dir = TempStoreDir::new("bundles-atomic");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
let before = fs::read_to_string(dir.store_path()).unwrap();
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o555)).unwrap();
let probe = dir.0.join(".write-probe");
if fs::write(&probe, "x").is_ok() {
// A privileged user bypasses permission bits; the failure path
// cannot be provoked this way.
let _ = fs::remove_file(&probe);
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap();
return;
}
let result = store.record_file("omc", file_record("macros/a.yaml", "a"));
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap();
assert!(result.is_err());
assert_eq!(fs::read_to_string(dir.store_path()).unwrap(), before);
let reloaded = dir.store();
assert!(reloaded.get("omc").unwrap().files.is_empty());
}
#[test]
fn hash_helpers_are_stable() {
let dir = TempStoreDir::new("bundles-hash");
let path = dir.0.join("artifact.yaml");
fs::write(&path, "hello").unwrap();
assert_eq!(
hash_bytes(b"hello"),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
assert_eq!(hash_file(&path).unwrap(), hash_bytes(b"hello"));
assert_ne!(hash_bytes(b"hello"), hash_bytes(b"hello!"));
}
#[test]
#[serial]
fn default_store_path_follows_the_config_dir() {
let dir = TempStoreDir::new("bundles-env");
let key = get_env_name("config_dir");
let previous: Option<OsString> = env::var_os(&key);
unsafe {
env::set_var(&key, &dir.0);
}
let result = (|| -> Result<()> {
let mut store = BundleStore::load()?;
assert!(store.bundle_names().is_empty());
store.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))?;
let reloaded = BundleStore::load()?;
assert!(reloaded.get("omc").is_some());
assert!(dir.store_path().is_file());
Ok(())
})();
unsafe {
match &previous {
Some(value) => env::set_var(&key, value),
None => env::remove_var(&key),
}
}
result.unwrap();
}
#[test]
fn bundle_rows_classify_drift_per_file() {
let dir = TempStoreDir::new("bundles-list-drift");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
.unwrap();
fs::create_dir_all(dir.0.join("macros")).unwrap();
fs::write(dir.0.join("macros/intact.yaml"), "a").unwrap();
store
.record_file("omc", file_record("macros/intact.yaml", "a"))
.unwrap();
fs::create_dir_all(dir.0.join("skills")).unwrap();
fs::write(dir.0.join("skills/modified.md"), "changed").unwrap();
let mut modified = file_record("skills/modified.md", "original");
modified.category = "skills".to_string();
store.record_file("omc", modified).unwrap();
let mut missing = file_record("roles/missing.md", "gone");
missing.category = "roles".to_string();
store.record_file("omc", missing).unwrap();
let rows = bundle_list_rows(&store, &dir.0);
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.name, "omc");
assert_eq!(row.source, "https://github.com/x/omc");
assert_eq!(
row.drift,
DriftSummary {
intact: 1,
modified: 1,
missing: 1,
}
);
assert_eq!(row.file_counts, "macros: 1, roles: 1, skills: 1");
assert_eq!(
row.drift.display(),
"1 intact, 1 modified locally, 1 missing"
);
}
#[test]
fn bundle_rows_fall_back_to_the_short_commit_when_unversioned() {
let dir = TempStoreDir::new("bundles-list-fallback");
let mut store = dir.store();
store
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123def456"))
.unwrap();
let rows = bundle_list_rows(&store, &dir.0);
assert_eq!(rows[0].version, "abc123d");
assert_eq!(rows[0].git_ref, "-");
assert_eq!(rows[0].file_counts, "-");
assert_eq!(rows[0].drift, DriftSummary::default());
assert_eq!(rows[0].drift.display(), "-");
}
#[test]
fn bundle_rows_show_manifest_version_and_pinned_ref() {
let dir = TempStoreDir::new("bundles-list-versioned");
let mut store = dir.store();
store
.upsert_bundle(
"omc",
InstallMetadata {
source: "git@github.com:x/omc.git".to_string(),
git_ref: Some("v1.4.0".to_string()),
commit: "abc123def456".to_string(),
version: Some("1.4.0".to_string()),
description: None,
homepage: None,
},
)
.unwrap();
let rows = bundle_list_rows(&store, &dir.0);
assert_eq!(rows[0].version, "1.4.0");
assert_eq!(rows[0].git_ref, "v1.4.0");
assert_eq!(rows[0].source, "git@github.com:x/omc.git");
assert!(!rows[0].installed_at.is_empty());
}
#[test]
fn bundle_rows_are_empty_for_an_empty_store() {
let dir = TempStoreDir::new("bundles-list-empty");
let rows = bundle_list_rows(&dir.store(), &dir.0);
assert!(rows.is_empty());
}
#[test]
fn same_installed_path_matches_filesystem_case_semantics() {
assert!(same_installed_path("macros/a.yaml", "macros/a.yaml"));
assert!(!same_installed_path("macros/a.yaml", "macros/b.yaml"));
assert_eq!(
same_installed_path("macros/Foo.yaml", "macros/foo.yaml"),
cfg!(any(windows, target_os = "macos"))
);
}
#[test]
fn record_file_transfers_case_variant_ownership_on_case_insensitive_hosts() {
let dir = TempStoreDir::new("bundles-case-variant");
let mut store = dir.store();
store
.upsert_bundle("alpha", metadata("https://x/a", "aaa"))
.unwrap();
store
.upsert_bundle("beta", metadata("https://x/b", "bbb"))
.unwrap();
store
.record_file("alpha", file_record("macros/Shared.yaml", "one"))
.unwrap();
store
.record_file("beta", file_record("macros/shared.yaml", "two"))
.unwrap();
let alpha_still_owns = store
.get("alpha")
.unwrap()
.files
.iter()
.any(|f| f.path == "macros/Shared.yaml");
assert_eq!(
alpha_still_owns,
!cfg!(any(windows, target_os = "macos")),
"case-variant paths are one physical file on case-insensitive hosts"
);
assert!(
store
.get("beta")
.unwrap()
.files
.iter()
.any(|f| f.path == "macros/shared.yaml")
);
}
}