use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_spec-spine"))
}
fn code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
fn run_in(root: &Path, args: &[&str]) -> Output {
bin().arg("--repo").arg(root).args(args).output().unwrap()
}
fn write(path: &Path, content: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn yaml_quoted(id: &str) -> String {
let mut out = String::from("\"");
for c in id.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\0' => out.push_str("\\0"),
c => out.push(c),
}
}
out.push('"');
out
}
fn write_spec(root: &Path, dir: &str, id: &str) {
write(
&root.join(format!("specs/{dir}/spec.md")),
format!(
"---\nid: {}\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-23\"\n\
summary: \"s\"\n---\n# T\n\n## 1. Purpose\n\nWhy.\n",
yaml_quoted(id)
)
.as_bytes(),
);
}
type Tree = BTreeMap<PathBuf, Option<Vec<u8>>>;
fn snapshot(dir: &Path) -> Tree {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in fs::read_dir(&d).unwrap() {
let p = entry.unwrap().path();
let rel = p.strip_prefix(dir).unwrap().to_path_buf();
if p.is_dir() {
out.insert(rel, None);
stack.push(p);
} else {
out.insert(rel, Some(fs::read(&p).unwrap()));
}
}
}
out
}
const VICTIM_IN_REPO: &[u8] = b"{\"name\":\"victim-in-repo\"}\n";
const VICTIM_OUTSIDE: &[u8] = b"{\"victim\":\"outside the repository\"}\n";
fn fixture(built: bool) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
write_spec(&repo, "001-a", "001-a");
write(&repo.join("package.json"), VICTIM_IN_REPO);
write(&tmp.path().join("victim.json"), VICTIM_OUTSIDE);
if built {
for args in [&["compile"][..], &["index"], &["attest", "--spec", "001-a"]] {
let out = run_in(&repo, args);
assert_eq!(
code(&out),
0,
"a valid id: `{}` succeeds; stderr: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
for path in [
".derived/spec-registry/by-spec/001-a.json",
".derived/codebase-index/by-spec/001-a.json",
".derived/attestation/by-spec/001-a.json",
] {
assert!(repo.join(path).is_file(), "a valid id writes {path}");
}
}
(tmp, repo)
}
struct Hostile {
id: String,
what: &'static str,
addressable: bool,
}
fn hostile_ids(outer: &Path) -> Vec<Hostile> {
let h = |id: String, what, addressable| Hostile {
id,
what,
addressable,
};
vec![
h(
"../../../package".into(),
"relative traversal onto the repository's package.json",
true,
),
h(
"../../../../victim".into(),
"relative traversal onto a file outside the repository",
true,
),
h(
outer.join("absolute").to_string_lossy().into_owned(),
"an absolute path outside the repository",
true,
),
h(String::new(), "an empty id (a hidden `.json`)", false),
h(".hidden".into(), "a leading dot", true),
h("a\\b".into(), "a backslash separator", true),
h("C:evil".into(), "a Windows drive prefix", true),
h("a:b".into(), "a colon", true),
h("a\0b".into(), "a NUL", false),
]
}
fn assert_refused(out: &Output, id: &str, what: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(code(out), 2, "{what}: exit 2; stderr: {stderr}");
let quoted = format!("{:?}", format!("{id}.json"));
for needle in [
quoted.as_str(),
"nothing was written",
"spec-spine compile --check",
] {
assert!(
stderr.contains(needle),
"{what}: the refusal carries {needle:?}; stderr: {stderr}"
);
}
}
#[test]
fn compile_and_index_refuse_an_id_that_is_not_a_file_name() {
for verb in ["compile", "index"] {
for case in 0..hostile_ids(Path::new("/")).len() {
let (tmp, repo) = fixture(true);
let h = hostile_ids(tmp.path()).swap_remove(case);
write_spec(&repo, "002-b", &h.id);
let before = snapshot(tmp.path());
let out = run_in(&repo, &[verb]);
let what = format!("`{verb}` with id {:?} ({})", h.id, h.what);
assert_refused(&out, &h.id, &what);
assert_eq!(snapshot(tmp.path()), before, "{what} changed the tree");
assert_eq!(fs::read(repo.join("package.json")).unwrap(), VICTIM_IN_REPO);
assert_eq!(
fs::read(tmp.path().join("victim.json")).unwrap(),
VICTIM_OUTSIDE
);
}
}
}
#[test]
fn a_refused_first_build_creates_nothing() {
for verb in ["compile", "index"] {
for case in 0..hostile_ids(Path::new("/")).len() {
let (tmp, repo) = fixture(false);
let h = hostile_ids(tmp.path()).swap_remove(case);
write_spec(&repo, "002-b", &h.id);
let before = snapshot(tmp.path());
let out = run_in(&repo, &[verb]);
let what = format!("first `{verb}` with id {:?} ({})", h.id, h.what);
assert_refused(&out, &h.id, &what);
assert!(!repo.join(".derived").exists(), "{what} created .derived");
assert_eq!(snapshot(tmp.path()), before, "{what} changed the tree");
}
}
}
#[test]
fn a_valid_first_build_still_creates_its_whole_tree() {
let (_tmp, repo) = fixture(true);
for dir in [
".derived/spec-registry/by-spec",
".derived/codebase-index/by-spec",
".derived/codebase-index/by-package",
".derived/attestation/by-spec",
] {
assert!(repo.join(dir).is_dir(), "a valid first build creates {dir}");
}
assert!(
repo.join(".derived/spec-registry/build-meta.json")
.is_file(),
"compile still writes build-meta.json beside the shards"
);
assert_eq!(
code(&run_in(&repo, &["check"])),
0,
"the first build is fresh"
);
}
#[test]
fn attest_spec_refuses_an_id_that_is_not_a_file_name() {
for case in 0..hostile_ids(Path::new("/")).len() {
for built in [true, false] {
let (tmp, repo) = fixture(built);
let h = hostile_ids(tmp.path()).swap_remove(case);
if !h.addressable {
continue;
}
write_spec(&repo, "002-b", &h.id);
let key = tmp.path().join("signing.key");
write(&key, &[7u8; 32]);
let key = key.to_string_lossy().into_owned();
let before = snapshot(tmp.path());
let what = format!("`attest --spec {:?}` ({}, built: {built})", h.id, h.what);
let out = run_in(&repo, &["attest", "--spec", &h.id]);
assert_refused(&out, &h.id, &what);
assert_eq!(snapshot(tmp.path()), before, "{what} changed the tree");
let out = run_in(&repo, &["attest", "--spec", &h.id, "--sign", "--key", &key]);
assert_refused(&out, &h.id, &format!("{what} --sign"));
assert_eq!(
snapshot(tmp.path()),
before,
"{what} --sign changed the tree"
);
let out = run_in(&repo, &["attest", "--spec", &h.id, "--json"]);
assert_eq!(code(&out), 2, "{what} --json");
let v: serde_json::Value = serde_json::from_slice(&out.stdout)
.unwrap_or_else(|e| panic!("{what} --json: stdout is one envelope: {e}"));
assert_ne!(v["outcome"], "ok", "{what} --json");
assert_eq!(v["exitCode"], 2, "{what} --json");
let message = v["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("nothing was written")
&& message.contains(&format!("{:?}", format!("{}.json", h.id))),
"{what} --json: the envelope carries the prose refusal: {message}"
);
assert_eq!(
snapshot(tmp.path()),
before,
"{what} --json changed the tree"
);
}
}
}
#[test]
fn an_invalid_but_plain_id_keeps_its_behavior() {
let (_tmp, repo) = fixture(true);
write_spec(&repo, "002-b", "002-B");
let out = run_in(&repo, &["compile"]);
assert_eq!(code(&out), 1, "V-012 is still exit 1");
assert!(String::from_utf8_lossy(&out.stderr).contains("V-012"));
assert!(
repo.join(".derived/spec-registry/by-spec/002-B.json")
.is_file()
);
assert!(
repo.join(".derived/spec-registry/by-spec/001-a.json")
.is_file()
);
assert_eq!(code(&run_in(&repo, &["index"])), 0);
assert!(
repo.join(".derived/codebase-index/by-spec/002-B.json")
.is_file()
);
assert_eq!(code(&run_in(&repo, &["attest", "--spec", "002-B"])), 0);
assert!(
repo.join(".derived/attestation/by-spec/002-B.json")
.is_file()
);
}