//! A read-only view of a repository tree at one commit. //! //! The catalog parser only ever talks to [`TreeView`], so it is tested //! against [`MemTree`] with no git involved, and runs in production against //! [`GitTree`], which reads git objects straight out of the bare cache. use std::collections::BTreeMap; use sha2::{Digest, Sha256}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntryKind { File, Dir, Symlink, /// Anything else git can hold (submodule commits). Never installable. Other, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirEntry { pub name: String, pub kind: EntryKind, pub executable: bool, } pub trait TreeView { /// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir. fn list_dir(&self, path: &str) -> Result>, String>; /// Contents of the regular file at `path`. `Ok(None)` if absent or not a file. fn read_file(&self, path: &str) -> Result>, String>; /// Stable content id of the entry at `path`; `None` if absent. fn entry_id(&self, path: &str) -> Result, String>; } /// Hex-encode `bytes`. Shared by [`MemTree`]'s content id (test-only) and /// `catalog::item_fingerprint`'s plugin-entry hash (production), so there is /// one hex formatter rather than two copies of the same `format!("{:02x}")`. pub(crate) fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() } /// A tree at one commit of a bare gix repository. pub struct GitTree { repo: gix::Repository, tree_id: gix::ObjectId, } impl GitTree { pub fn open(repo_path: &std::path::Path, commit: &str) -> Result { let repo = gix::open(repo_path) .map_err(|e| format!("Could not open the marketplace cache: {}", e))?; let oid = gix::ObjectId::from_hex(commit.as_bytes()) .map_err(|e| format!("Invalid commit id {}: {}", commit, e))?; let tree_id = repo .find_commit(oid) .map_err(|e| format!("Commit {} is not in the marketplace cache: {}", commit, e))? .tree_id() .map_err(|e| format!("Commit {} has no tree: {}", commit, e))? .detach(); Ok(Self { repo, tree_id }) } fn root(&self) -> Result, String> { self.repo .find_tree(self.tree_id) .map_err(|e| format!("Could not read tree {}: {}", self.tree_id, e)) } /// `(object id, mode)` of the entry at `path`, or `None`. fn lookup( &self, path: &str, ) -> Result, String> { if path.is_empty() { return Ok(Some(( self.tree_id, gix::object::tree::EntryKind::Tree.into(), ))); } let root = self.root()?; let entry = root .lookup_entry_by_path(path) .map_err(|e| format!("Could not look up {}: {}", path, e))?; Ok(entry.map(|e| (e.object_id(), e.mode()))) } } impl TreeView for GitTree { fn list_dir(&self, path: &str) -> Result>, String> { let Some((id, mode)) = self.lookup(path)? else { return Ok(None); }; if !mode.is_tree() { return Ok(None); } let tree = self .repo .find_tree(id) .map_err(|e| format!("Could not read {}: {}", path, e))?; let mut out = Vec::new(); for entry in tree.iter() { let entry = entry.map_err(|e| format!("Could not read {}: {:?}", path, e))?; let mode = entry.mode(); let kind = if mode.is_tree() { EntryKind::Dir } else if mode.is_link() { EntryKind::Symlink } else if mode.is_blob() { EntryKind::File } else { EntryKind::Other }; out.push(DirEntry { name: entry.filename().to_string(), kind, executable: mode.is_executable(), }); } Ok(Some(out)) } fn read_file(&self, path: &str) -> Result>, String> { let Some((id, mode)) = self.lookup(path)? else { return Ok(None); }; if !mode.is_blob() { return Ok(None); } let blob = self .repo .find_blob(id) .map_err(|e| format!("Could not read {}: {}", path, e))?; Ok(Some(blob.data.clone())) } fn entry_id(&self, path: &str) -> Result, String> { Ok(self.lookup(path)?.map(|(id, _)| id.to_string())) } } #[cfg(test)] #[derive(Debug, Clone)] enum MemNode { File { data: Vec, executable: bool }, Symlink { target: String }, } /// In-memory tree for tests: path → node. Directories are implied by paths. #[cfg(test)] #[derive(Debug, Clone, Default)] pub struct MemTree { nodes: BTreeMap, } #[cfg(test)] impl MemTree { pub fn new() -> Self { Self::default() } pub fn file(mut self, path: &str, contents: &str) -> Self { self.nodes.insert( path.to_string(), MemNode::File { data: contents.as_bytes().to_vec(), executable: false, }, ); self } pub fn exec_file(mut self, path: &str, contents: &str) -> Self { self.nodes.insert( path.to_string(), MemNode::File { data: contents.as_bytes().to_vec(), executable: true, }, ); self } pub fn symlink(mut self, path: &str, target: &str) -> Self { self.nodes.insert( path.to_string(), MemNode::Symlink { target: target.to_string(), }, ); self } /// Place a file so that, inside `dir`, it is listed under the literal /// entry name `name` — including a name `file`/`exec_file`/`symlink` /// could never be asked to produce because it doesn't correspond to any /// real filesystem path a caller here would construct: `.`, `..`, empty, /// or containing `/`, `\` or a NUL byte. Exists only so a test can drive /// `catalog::collect_dir`'s hostile-entry-name rejection without relying /// on incidental behaviour of path-string splitting. pub fn raw_named_file(mut self, dir: &str, name: &str, contents: &str) -> Self { let path = if dir.is_empty() { name.to_string() } else { format!("{}/{}", dir, name) }; self.nodes.insert( path, MemNode::File { data: contents.as_bytes().to_vec(), executable: false, }, ); self } fn is_dir(&self, path: &str) -> bool { if path.is_empty() { return true; } let prefix = format!("{}/", path); self.nodes.keys().any(|k| k.starts_with(&prefix)) } } #[cfg(test)] impl TreeView for MemTree { fn list_dir(&self, path: &str) -> Result>, String> { if self.nodes.contains_key(path) || !self.is_dir(path) { return Ok(None); } let prefix = if path.is_empty() { String::new() } else { format!("{}/", path) }; let mut out: BTreeMap = BTreeMap::new(); for (key, node) in &self.nodes { let Some(rest) = key.strip_prefix(&prefix) else { continue; }; match rest.split_once('/') { Some((dir, _)) => { out.entry(dir.to_string()).or_insert(DirEntry { name: dir.to_string(), kind: EntryKind::Dir, executable: false, }); } None => { let (kind, executable) = match node { MemNode::File { executable, .. } => (EntryKind::File, *executable), MemNode::Symlink { .. } => (EntryKind::Symlink, false), }; out.insert( rest.to_string(), DirEntry { name: rest.to_string(), kind, executable, }, ); } } } Ok(Some(out.into_values().collect())) } fn read_file(&self, path: &str) -> Result>, String> { match self.nodes.get(path) { Some(MemNode::File { data, .. }) => Ok(Some(data.clone())), _ => Ok(None), } } fn entry_id(&self, path: &str) -> Result, String> { let mut hasher = Sha256::new(); let mut found = false; let prefix = format!("{}/", path); for (key, node) in &self.nodes { if key != path && !key.starts_with(&prefix) { continue; } found = true; hasher.update(key.as_bytes()); hasher.update([0]); match node { MemNode::File { data, executable } => { hasher.update([if *executable { b'x' } else { b'f' }]); hasher.update(data); } MemNode::Symlink { target } => { hasher.update(b"l"); hasher.update(target.as_bytes()); } } hasher.update([0]); } Ok(found.then(|| hex(&hasher.finalize()))) } } #[cfg(test)] mod tests { use super::*; #[test] fn mem_tree_lists_files_dirs_and_symlinks() { let t = MemTree::new() .file("agents/a.md", "x") .exec_file("hooks/h/run.sh", "#!/bin/sh") .symlink("agents/link.md", "a.md"); let root = t.list_dir("").unwrap().unwrap(); assert_eq!( root.iter() .map(|e| (e.name.as_str(), e.kind)) .collect::>(), vec![("agents", EntryKind::Dir), ("hooks", EntryKind::Dir)] ); let agents = t.list_dir("agents").unwrap().unwrap(); assert_eq!(agents[1].kind, EntryKind::Symlink); let hook = t.list_dir("hooks/h").unwrap().unwrap(); assert!(hook[0].executable); assert_eq!(t.list_dir("agents/a.md").unwrap(), None); assert_eq!(t.list_dir("missing").unwrap(), None); assert_eq!(t.read_file("agents/a.md").unwrap().unwrap(), b"x"); assert_eq!(t.read_file("agents").unwrap(), None); } #[test] fn mem_tree_entry_id_changes_only_with_content() { let a = MemTree::new() .file("skills/s/SKILL.md", "one") .file("agents/x.md", "x"); let b = MemTree::new() .file("skills/s/SKILL.md", "one") .file("agents/x.md", "changed"); let c = MemTree::new() .file("skills/s/SKILL.md", "two") .file("agents/x.md", "x"); assert_eq!( a.entry_id("skills/s").unwrap(), b.entry_id("skills/s").unwrap() ); assert_ne!( a.entry_id("skills/s").unwrap(), c.entry_id("skills/s").unwrap() ); assert_eq!(a.entry_id("nope").unwrap(), None); } }