use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_shepherd")
}
struct Fixture {
_guard: tempfile::TempDir,
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let guard = tempfile::tempdir().expect("fixture");
let root = std::fs::canonicalize(guard.path()).expect("canonical fixture root");
std::fs::create_dir_all(root.join(".shepherd")).expect("native context");
Self {
_guard: guard,
root,
}
}
fn run(&self, args: &[&str], input: &[u8]) -> Output {
let mut child = Command::new(binary())
.args(args)
.current_dir(&self.root)
.env("SHEPHERD_HOME", self.root.join("home"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn shepherd");
child
.stdin
.take()
.expect("stdin")
.write_all(input)
.expect("write stdin");
child.wait_with_output().expect("wait shepherd")
}
}
#[cfg(unix)]
fn mode(path: &Path, value: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(value)).expect("mode");
}
#[cfg(not(unix))]
fn mode(_path: &Path, _value: u32) {}
#[test]
fn campaign_bundle_hash_is_canonical_and_rendered() {
let fixture = Fixture::new();
let skill = fixture.root.join("skill");
std::fs::create_dir_all(skill.join("references")).expect("references");
std::fs::write(skill.join("SKILL.md"), b"# skill\n").expect("skill");
std::fs::write(skill.join("references/guide.md"), b"guide\n").expect("guide");
mode(&skill.join("SKILL.md"), 0o644);
mode(&skill.join("references/guide.md"), 0o644);
let render = fixture.root.join("render.txt");
let output = fixture.run(
&[
"eval",
"campaign",
"bundle",
skill.to_str().unwrap(),
"--render",
render.to_str().unwrap(),
],
b"",
);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).expect("metadata");
assert_eq!(metadata["schema"], "shepherd.skill-bundle/1");
assert_eq!(metadata["files"].as_array().unwrap().len(), 2);
assert_eq!(
std::fs::read_to_string(render).unwrap(),
"=== SKILL.md (0644) ===\n# skill\n=== references/guide.md (0644) ===\nguide\n"
);
}
#[test]
fn campaign_canonicalize_and_hash_reject_non_json_and_sort_keys() {
let fixture = Fixture::new();
let source = fixture.root.join("source.json");
let canonical = fixture.root.join("canonical.json");
std::fs::write(&source, br#"{"z":1,"a":{"y":2,"x":3}}"#).expect("source");
mode(&source, 0o600);
let output = fixture.run(
&[
"eval",
"campaign",
"canonicalize",
source.to_str().unwrap(),
canonical.to_str().unwrap(),
],
b"",
);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
std::fs::read(&canonical).unwrap(),
b"{\"a\":{\"x\":3,\"y\":2},\"z\":1}"
);
let hashed = fixture.run(&["eval", "campaign", "hash", source.to_str().unwrap()], b"");
assert!(hashed.status.success());
assert_eq!(String::from_utf8_lossy(&hashed.stdout).trim().len(), 64);
let invalid = fixture.root.join("invalid.json");
std::fs::write(&invalid, b"NaN").expect("invalid");
mode(&invalid, 0o600);
let rejected = fixture.run(
&["eval", "campaign", "hash", invalid.to_str().unwrap()],
b"",
);
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("invalid JSON"));
}
#[cfg(unix)]
#[test]
fn campaign_provider_inspection_preserves_alias_and_rejects_unsafe_auth() {
let fixture = Fixture::new();
let target = fixture.root.join("versions/v1/claude");
let alias = fixture.root.join("bin/claude");
std::fs::create_dir_all(target.parent().unwrap()).expect("target parent");
std::fs::create_dir_all(alias.parent().unwrap()).expect("alias parent");
std::fs::write(&target, b"#!/bin/sh\n").expect("provider");
mode(&target, 0o755);
std::os::unix::fs::symlink(&target, &alias).expect("alias");
let provider = fixture.run(
&[
"eval",
"campaign",
"inspect-provider",
alias.to_str().unwrap(),
],
b"",
);
assert!(
provider.status.success(),
"{}",
String::from_utf8_lossy(&provider.stderr)
);
let identity: serde_json::Value = serde_json::from_slice(&provider.stdout).expect("identity");
assert_eq!(identity["alias"]["kind"], "symlink");
assert_eq!(identity["target_path"], target.to_str().unwrap());
std::fs::rename(&target, target.with_extension("old")).expect("move provider target");
std::fs::copy(target.with_extension("old"), &target).expect("replace provider target");
mode(&target, 0o755);
let replaced = fixture.run(
&[
"eval",
"campaign",
"inspect-provider",
alias.to_str().unwrap(),
],
b"",
);
assert!(replaced.status.success());
let replaced_identity: serde_json::Value =
serde_json::from_slice(&replaced.stdout).expect("replaced identity");
assert_ne!(
identity["target"]["ino"],
replaced_identity["target"]["ino"]
);
let auth = fixture.root.join("auth.json");
std::fs::write(&auth, br#"{"claudeAiOauth":{"accessToken":"fixture"}}"#).expect("auth");
mode(&auth, 0o644);
let rejected = fixture.run(
&[
"eval",
"campaign",
"inspect-auth",
auth.to_str().unwrap(),
"session",
],
b"",
);
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("0600"));
}