feat: add bundle provenance store

This commit is contained in:
2026-08-24 11:15:12 -06:00
parent b21699b749
commit bfcc762ec9
4 changed files with 914 additions and 7 deletions
+903
View File
@@ -0,0 +1,903 @@
use super::install_remote::{
canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name,
};
use super::paths;
use crate::function::write_file_atomic;
use anyhow::{Context, Result, bail};
use chrono::{SecondsFormat, Utc};
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,
pub(crate) renamed_to: 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>,
pub(crate) installed_at: 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>,
}
#[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>,
/// The record key this source was previously tracked under, when it changed.
pub(crate) migrated_from: Option<String>,
/// Source URL of the different-source bundle that already holds the unqualified name.
pub(crate) same_name_other_source: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct StoreContents {
#[serde(default)]
bundles: BTreeMap<String, BundleRecord>,
}
#[derive(Serialize)]
struct StoreContentsRef<'a> {
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. \
Fix or remove the file to continue",
path.display()
)
})?;
Ok(Self {
path,
bundles: contents.bundles,
})
}
pub(crate) fn save(&self) -> Result<()> {
let content = serde_yaml::to_string(&StoreContentsRef {
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()
}
/// Look up the record installed from `url`, comparing canonical source URLs
/// so https/scp/`.git` spellings of the same remote all match.
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))
}
/// Decide the record key for an install from `url`, matching by canonical
/// source URL first and name second. If the URL is already tracked under a
/// different key (manifest name added, renamed, or removed since install),
/// the existing record is migrated to the new key — the same URL never gets
/// a second record. A name held by a different-source bundle is
/// owner-qualified instead. Both cases print a notice.
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,
migrated_from: None,
same_name_other_source: None,
};
if let Some(other_source) = self.source_of_other_bundle(&base, &canonical) {
if base.contains('/') {
bail!(
"bundle name '{base}' is already used by an install from \
'{other_source}' 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}' is already used by an install from \
'{other_source}', 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 names '{base}' and '{qualified}' are both used by installs \
from other sources ('{other_source}', '{source}'); \
uninstall one or pick a different manifest name"
);
}
resolved.name = qualified;
resolved.qualified_from = Some(base);
resolved.same_name_other_source = Some(other_source);
}
let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str());
if let Some(old_key) = existing_key
&& !already_recorded
{
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
);
resolved.migrated_from = Some(old_key);
self.save()?;
}
if let (Some(from), false) = (&resolved.qualified_from, already_recorded) {
let other = resolved
.same_name_other_source
.as_deref()
.unwrap_or_default();
println!(
"Bundle name '{from}' is already used by an install from '{other}'; \
tracking this install as '{}'.",
resolved.name
);
}
Ok(resolved)
}
/// Create or update the record's metadata and persist it. Repeated installs
/// of the same bundle (e.g. with different filters) merge into one record:
/// metadata is refreshed, files accumulate, and the original install time
/// is kept.
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;
}
None => {
self.bundles.insert(
name.to_string(),
BundleRecord {
source: metadata.source,
git_ref: metadata.git_ref,
commit: metadata.commit,
version: metadata.version,
installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
files: Vec::new(),
mcp_servers: Vec::new(),
},
);
}
}
self.save()
}
/// Record one written file and persist immediately, 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| owned.path != file.path);
}
}
let record = self
.bundles
.get_mut(bundle)
.expect("bundle existence checked above");
record.files.retain(|owned| owned.path != file.path);
record.files.push(file);
self.save()
}
/// Record the mcp.json entries an install wrote, in one persisted flush.
/// An entry whose key another bundle owns transfers to `bundle`: the old
/// owner drops it, and a `replaced` action is upgraded to `transferred`
/// (removable at uninstall — plain `replaced` marks a pre-existing user
/// entry that uninstall must never delete).
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;
for (name, record) in self.bundles.iter_mut() {
if name == bundle {
continue;
}
let before = record.mcp_servers.len();
record
.mcp_servers
.retain(|owned| owned.effective_key() != key);
previously_owned |= record.mcp_servers.len() != before;
}
if previously_owned && entry.action == McpAction::Replaced {
entry.action = McpAction::Transferred;
}
let record = self
.bundles
.get_mut(bundle)
.expect("bundle existence checked above");
record
.mcp_servers
.retain(|owned| owned.effective_key() != key);
record.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))
}
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,
}
}
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),
}
}
#[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()),
},
)
.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.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_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.migrated_from, None);
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");
assert_eq!(resolved.migrated_from.as_deref(), Some("omc"));
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.migrated_from, None);
}
#[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_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"));
}
#[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();
}
}
-3
View File
@@ -374,14 +374,12 @@ fn strip_git_suffix(segment: &str) -> &str {
}
}
#[allow(dead_code)]
pub(crate) fn repo_name_slug(url: &str) -> String {
let (_, path) = split_host_and_path(url);
let last = path.rsplit('/').next().unwrap_or("");
strip_git_suffix(last).to_string()
}
#[allow(dead_code)]
pub(crate) fn owner_qualifier(url: &str) -> Option<String> {
let (host, path) = split_host_and_path(url);
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
@@ -407,7 +405,6 @@ fn sanitize_host(host: &str) -> String {
.to_string()
}
#[allow(dead_code)]
pub(crate) fn canonical_source_url(url: &str) -> String {
let (host, path) = split_host_and_path(url);
let mut path = path.to_ascii_lowercase();
+3
View File
@@ -1,6 +1,8 @@
mod agent;
mod app_config;
mod app_state;
#[allow(dead_code)]
mod bundles;
mod input;
mod install_remote;
pub(crate) mod instructions;
@@ -155,6 +157,7 @@ const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml";
const INSTALLED_BUNDLES_FILE_NAME: &str = "installed-bundles.yaml";
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
const GIT_DIR_NAME: &str = ".git";
const GITIGNORE_FILE_NAME: &str = ".gitignore";
+8 -4
View File
@@ -2,10 +2,10 @@ use super::role::Role;
use super::{
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME,
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
@@ -169,6 +169,10 @@ pub fn config_file() -> PathBuf {
}
}
pub fn installed_bundles_file() -> PathBuf {
local_dir(INSTALLED_BUNDLES_FILE_NAME)
}
pub fn roles_dir() -> PathBuf {
match env::var(get_env_name("roles_dir")) {
Ok(value) => PathBuf::from(value),