use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
root: PathBuf,
skill_repo: PathBuf,
project: PathBuf,
spm_home: PathBuf,
}
impl Sandbox {
fn new() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root =
std::env::temp_dir().join(format!("spm-test-{}-{nanos}-{n}", std::process::id()));
let sb = Sandbox {
skill_repo: root.join("skill"),
project: root.join("project"),
spm_home: root.join("home"),
root,
};
std::fs::create_dir_all(&sb.project).unwrap();
sb.init_skill_repo();
sb
}
fn init_skill_repo(&self) {
std::fs::create_dir_all(&self.skill_repo).unwrap();
std::fs::write(
self.skill_repo.join("SKILL.md"),
"---\nname: greet\ndescription: Say hello nicely.\n---\nGreet warmly.\n",
)
.unwrap();
let g = |args: &[&str]| {
let ok = Command::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgSign=false",
])
.args(args)
.current_dir(&self.skill_repo)
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed");
};
g(&["init", "-q", "-b", "main"]);
g(&["add", "-A"]);
g(&["commit", "-qm", "initial"]);
g(&["tag", "-a", "v0.1.0", "-m", "v0.1.0"]);
}
fn skill_url(&self) -> String {
format!("file://{}", self.skill_repo.display())
}
fn spm(&self, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_spm"))
.args(args)
.current_dir(&self.project)
.env("SPM_HOME", &self.spm_home)
.output()
.unwrap()
}
fn ok(&self, args: &[&str]) -> String {
let out = self.spm(args);
assert!(
out.status.success(),
"spm {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.project.join(rel)).unwrap()
}
fn claude_market_dir(&self) -> PathBuf {
self.project.join(".spm/claude")
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn skill_head(repo: &Path) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn claude_add_resolves_tag_to_commit_and_wires_marketplace() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
let lock = sb.read("ai.lock");
assert!(lock.contains("\"reference\": \"tag:v0.1.0\""), "{lock}");
assert!(
lock.contains(&skill_head(&sb.skill_repo)),
"lock should pin repo HEAD commit: {lock}"
);
let skill_md = sb.claude_market_dir().join("plugin/skills/greet/SKILL.md");
assert!(skill_md.exists(), "missing {}", skill_md.display());
let settings = sb.read(".claude/settings.local.json");
assert!(settings.contains("\"spm@spm\": true"), "{settings}");
assert!(settings.contains("extraKnownMarketplaces"), "{settings}");
let gitignore = sb.read(".gitignore");
assert!(gitignore.contains(".spm/"), "{gitignore}");
assert!(gitignore.contains("spm-managed Claude"), "{gitignore}");
assert!(!sb.spm_home.join("vendors").exists());
assert!(!sb.project.join("skills").exists());
}
#[test]
fn copilot_add_materializes_project_local_skills() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
sb.ok(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--name",
"greet",
]);
let skill_md = sb
.project
.join(".agents/skills/spm-managed-skills/greet/SKILL.md");
assert!(skill_md.exists(), "missing {}", skill_md.display());
assert!(!sb.spm_home.join("vendors/copilot").exists());
let gitignore = sb.read(".gitignore");
assert!(
gitignore.contains(".agents/skills/spm-managed-skills/"),
"{gitignore}"
);
assert!(gitignore.contains("spm-managed"), "{gitignore}");
}
#[test]
fn remove_prunes_skill() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
sb.ok(&["remove", "greet"]);
assert!(!sb.read("ai.json").contains("greet"));
let skills_dir = sb.claude_market_dir().join("plugin/skills");
assert!(std::fs::read_dir(&skills_dir).unwrap().next().is_none());
}
#[test]
fn install_is_idempotent_from_lock() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
let lock_before = sb.read("ai.lock");
sb.ok(&["install"]);
let lock_after = sb.read("ai.lock");
assert_eq!(lock_before, lock_after, "install must not change the lock");
}
#[test]
fn clean_removes_generated_config() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
sb.ok(&["clean"]);
let settings = sb.read(".claude/settings.local.json");
assert!(!settings.contains("spm@spm"), "{settings}");
assert!(
!sb.claude_market_dir().exists(),
"marketplace dir should be gone after clean"
);
let gitignore = sb.project.join(".gitignore");
if gitignore.exists() {
let gi = std::fs::read_to_string(&gitignore).unwrap();
assert!(!gi.contains(".spm/"), "{gi}");
}
}
#[test]
fn multi_target_wires_both_vendors() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude,copilot"]);
sb.ok(&["add", &sb.skill_url(), "--tag", "v0.1.0", "--name", "greet"]);
assert!(sb
.claude_market_dir()
.join("plugin/skills/greet/SKILL.md")
.exists());
assert!(sb
.project
.join(".agents/skills/spm-managed-skills/greet/SKILL.md")
.exists());
assert!(sb.read(".claude/settings.local.json").contains("spm@spm"));
}
#[test]
fn unknown_target_is_rejected() {
let sb = Sandbox::new();
let out = sb.spm(&["init", "--target", "nonsense"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown target"));
}
#[test]
fn schema_rejects_unknown_target_value() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["bogus"],"skills":{}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("does not match schema"), "{err}");
}
#[test]
fn schema_rejects_skill_without_version_selector() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"x":{"git":"u"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("oneOf"));
}
#[test]
fn rejects_path_traversal_in_skill_path() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"evil":{"git":"u","tag":"v0.1.0","path":"../../../../../../etc"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "traversal path must be rejected");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("`..`"), "{err}");
}
#[test]
fn rejects_absolute_skill_path() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"evil":{"git":"u","tag":"v0.1.0","path":"/etc"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "absolute path must be rejected");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("relative to the repo root"), "{err}");
}
#[test]
fn rejects_path_traversal_in_skill_name() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"../../evil":{"git":"u","tag":"v0.1.0"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "traversal name must be rejected");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("invalid skill name"), "{err}");
}
#[test]
fn rejects_traversal_name_on_add() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
let out = sb.spm(&[
"add",
&sb.skill_url(),
"--tag",
"v0.1.0",
"--name",
"../escape",
]);
assert!(!out.status.success(), "add must reject a traversal name");
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid skill name"));
assert!(!sb.read("ai.json").contains("escape"));
}
#[test]
fn rejects_forged_absolute_store_in_lock() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{}}"#,
)
.unwrap();
std::fs::write(
sb.project.join("ai.lock"),
r#"{"id":"spm-deadbeef","skills":{"evil":{"git":"u","reference":"branch:main","commit":"0000000000000000000000000000000000000000","store":"/home/victim/.config/autostart"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "forged store must be rejected");
assert!(String::from_utf8_lossy(&out.stderr).contains("store key"));
}
#[test]
fn rejects_forged_project_id_in_lock() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["copilot"],"skills":{}}"#,
)
.unwrap();
std::fs::write(
sb.project.join("ai.lock"),
r#"{"id":"../../evil","skills":{}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "forged id must be rejected");
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid project id"));
}
#[test]
fn schema_rejects_abbreviated_commit() {
let sb = Sandbox::new();
std::fs::write(
sb.project.join("ai.json"),
r#"{"targets":["claude"],"skills":{"x":{"git":"u","commit":"abc1234"}}}"#,
)
.unwrap();
let out = sb.spm(&["install"]);
assert!(!out.status.success(), "abbreviated commit must be rejected");
assert!(String::from_utf8_lossy(&out.stderr).contains("does not match schema"));
}
#[test]
fn copilot_clean_removes_project_local_dir_and_gitignore_entry() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "copilot"]);
sb.ok(&[
"add",
&sb.skill_url(),
"--branch",
"main",
"--name",
"greet",
]);
let managed = sb.project.join(".agents/skills/spm-managed-skills");
assert!(
managed.exists(),
"skills should be materialized before clean"
);
assert!(sb
.read(".gitignore")
.contains(".agents/skills/spm-managed-skills/"));
sb.ok(&["clean"]);
assert!(
!managed.exists(),
"clean must remove the managed skills dir"
);
assert!(
!sb.read(".gitignore")
.contains(".agents/skills/spm-managed-skills/"),
"clean must drop the gitignore entry"
);
}
#[test]
fn uppercase_commit_pin_is_not_refetched() {
let sb = Sandbox::new();
sb.ok(&["init", "--target", "claude"]);
let head = skill_head(&sb.skill_repo).to_uppercase();
sb.ok(&["add", &sb.skill_url(), "--commit", &head, "--name", "greet"]);
let second = sb.ok(&["install"]);
assert!(
second.contains("cached") && !second.contains("fetched"),
"second install should be cached, got: {second}"
);
assert!(sb.read("ai.lock").contains(&head.to_lowercase()));
}