use crate::{git, lockfile::LockedSkill, paths};
use anyhow::{bail, Result};
use std::path::{Path, PathBuf};
pub struct Ensured {
pub path: PathBuf,
pub fetched: bool,
}
pub fn ensure(locked: &LockedSkill) -> Result<Ensured> {
let repo_dir = paths::store_dir()?.join(&locked.store);
let fetched = !git::is_at_commit(&repo_dir, &locked.commit);
if fetched {
if repo_dir.exists() {
std::fs::remove_dir_all(&repo_dir)?;
}
std::fs::create_dir_all(repo_dir.parent().unwrap())?;
git::fetch_commit(&locked.git, &locked.commit, &repo_dir)?;
}
let content = match &locked.path {
Some(sub) => repo_dir.join(sub),
None => repo_dir.clone(),
};
if !content.exists() {
bail!(
"path `{}` not found in {}@{}",
locked.path.as_deref().unwrap_or("."),
locked.git,
&locked.commit[..locked.commit.len().min(8)]
);
}
let repo_canon = repo_dir.canonicalize()?;
let content_canon = content.canonicalize()?;
if !content_canon.starts_with(&repo_canon) {
bail!(
"path `{}` escapes the repository checkout for {}",
locked.path.as_deref().unwrap_or("."),
locked.git
);
}
Ok(Ensured {
path: content_canon,
fetched,
})
}
pub struct StoreStats {
pub entries: usize,
pub bytes: u64,
}
pub fn stats() -> Result<StoreStats> {
let dir = paths::store_dir()?;
if !dir.exists() {
return Ok(StoreStats {
entries: 0,
bytes: 0,
});
}
let mut entries = 0;
let mut bytes = 0;
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
entries += 1;
}
bytes += dir_size(&entry.path())?;
}
Ok(StoreStats { entries, bytes })
}
pub fn is_empty() -> Result<bool> {
let dir = paths::store_dir()?;
match std::fs::read_dir(&dir) {
Ok(mut rd) => Ok(rd.next().is_none()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(e) => Err(e.into()),
}
}
pub fn checkout_count() -> Result<usize> {
let dir = paths::store_dir()?;
let mut n = 0;
match std::fs::read_dir(&dir) {
Ok(rd) => {
for entry in rd {
if entry?.file_type()?.is_dir() {
n += 1;
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
Ok(n)
}
pub fn remove_all() -> Result<()> {
let dir = paths::store_dir()?;
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
fn dir_size(path: &Path) -> Result<u64> {
let meta = std::fs::symlink_metadata(path)?;
if meta.file_type().is_dir() {
let mut total = 0;
for entry in std::fs::read_dir(path)? {
total += dir_size(&entry?.path())?;
}
Ok(total)
} else {
Ok(meta.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::SpmHomeGuard;
use std::process::Command as StdCommand;
fn scratch(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"spm-store-test-{name}-{}-{nanos}",
std::process::id(),
))
}
fn with_spm_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
let _guard = SpmHomeGuard::set(home);
f()
}
#[test]
fn absent_store_reads_as_empty_everywhere() {
let home = scratch("absent");
std::fs::create_dir_all(&home).unwrap();
with_spm_home(&home, || {
assert!(is_empty().unwrap());
assert_eq!(checkout_count().unwrap(), 0);
let s = stats().unwrap();
assert_eq!(s.entries, 0);
assert_eq!(s.bytes, 0);
remove_all().unwrap();
});
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn blocked_store_path_surfaces_a_real_error() {
let home = scratch("blocked");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("store"), b"not a directory").unwrap();
with_spm_home(&home, || {
assert!(is_empty().is_err(), "expected a real error, not `Ok(true)`");
assert!(checkout_count().is_err());
});
std::fs::remove_dir_all(&home).unwrap();
}
fn make_skill_repo(root: &Path) -> String {
std::fs::create_dir_all(root).unwrap();
std::fs::write(root.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
let run = |args: &[&str]| {
assert!(StdCommand::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(root)
.status()
.unwrap()
.success());
};
run(&["init", "-q", "-b", "main"]);
run(&["add", "-A"]);
run(&["commit", "-qm", "initial"]);
let out = StdCommand::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn ensure_refetches_a_stale_cached_checkout() {
let home = scratch("stale-refetch");
std::fs::create_dir_all(&home).unwrap();
let src = scratch("stale-refetch-src");
let sha = make_skill_repo(&src);
let locked = LockedSkill {
git: format!("file://{}", src.display()),
reference: "branch:main".into(),
commit: sha.clone(),
path: None,
store: crate::lockfile::store_key(&format!("file://{}", src.display()), &sha),
};
with_spm_home(&home, || {
let first = ensure(&locked).unwrap();
assert!(first.fetched);
std::fs::remove_dir_all(first.path.join(".git")).unwrap();
let second = ensure(&locked).unwrap();
assert!(second.fetched, "a stale checkout must be re-fetched");
assert!(second.path.join(".git").exists());
});
std::fs::remove_dir_all(&home).unwrap();
std::fs::remove_dir_all(&src).unwrap();
}
#[test]
fn ensure_errors_when_locked_path_is_missing() {
let home = scratch("missing-path");
std::fs::create_dir_all(&home).unwrap();
let src = scratch("missing-path-src");
let sha = make_skill_repo(&src);
let git_url = format!("file://{}", src.display());
let locked = LockedSkill {
git: git_url.clone(),
reference: "branch:main".into(),
commit: sha.clone(),
path: Some("does/not/exist".into()),
store: crate::lockfile::store_key(&git_url, &sha),
};
with_spm_home(&home, || {
let Err(err) = ensure(&locked) else {
panic!("expected an error");
};
assert!(format!("{err}").contains("not found in"), "{err}");
});
std::fs::remove_dir_all(&home).unwrap();
std::fs::remove_dir_all(&src).unwrap();
}
#[cfg(unix)]
#[test]
fn ensure_rejects_symlink_escaping_the_checkout() {
let home = scratch("symlink-escape");
std::fs::create_dir_all(&home).unwrap();
let src = scratch("symlink-escape-src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
std::os::unix::fs::symlink("..", src.join("escape")).unwrap();
let run = |args: &[&str]| {
assert!(StdCommand::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(&src)
.status()
.unwrap()
.success());
};
run(&["init", "-q", "-b", "main"]);
run(&["add", "-A"]);
run(&["commit", "-qm", "initial"]);
let out = StdCommand::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&src)
.output()
.unwrap();
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
let git_url = format!("file://{}", src.display());
let locked = LockedSkill {
git: git_url.clone(),
reference: "branch:main".into(),
commit: sha.clone(),
path: Some("escape".into()),
store: crate::lockfile::store_key(&git_url, &sha),
};
with_spm_home(&home, || {
let Err(err) = ensure(&locked) else {
panic!("expected an error");
};
assert!(
format!("{err}").contains("escapes the repository checkout"),
"{err}"
);
});
std::fs::remove_dir_all(&home).unwrap();
std::fs::remove_dir_all(&src).unwrap();
}
}