use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use speccoder_core::verify::{CheckResult, RunnerSummary};
use crate::git::Git;
use crate::integrity::{JudgeSnapshot, sha256_hex};
use crate::tools::policy::Policy;
use crate::{sandbox, worktree};
pub const LIMIT: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldOutFile {
pub path: String,
pub sha256: String,
pub scenarios: Vec<String>,
pub argv: Vec<String>,
pub composition: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discarded {
pub path: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
pub feature: String,
pub task: String,
pub author_run: String,
pub checkpoint: String,
pub visible_scenarios: Vec<String>,
pub files: Vec<HeldOutFile>,
pub discarded: Vec<Discarded>,
}
impl Manifest {
#[must_use]
pub fn sha256(&self) -> String {
sha256_hex(&serde_json::to_vec(self).unwrap_or_default())
}
}
#[must_use]
pub fn dir(project_state: &Path, feature: &str, task: &str) -> PathBuf {
project_state.join("held-out").join(feature).join(task)
}
pub fn store(
dir: &Path,
manifest: &Manifest,
contents: &[(String, String)],
) -> Result<String, String> {
let _ = std::fs::remove_dir_all(dir);
for (path, content) in contents {
let file = dir.join("files").join(path);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
}
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let text = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
std::fs::write(dir.join("manifest.json"), text).map_err(|e| e.to_string())?;
Ok(manifest.sha256())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tampered(pub String);
pub fn load_verified(
dir: &Path,
expected: &str,
) -> Result<(Manifest, Vec<(String, String)>), Tampered> {
let text = std::fs::read_to_string(dir.join("manifest.json"))
.map_err(|e| Tampered(format!("the held-out manifest is unreadable: {e}")))?;
let manifest: Manifest = serde_json::from_str(&text)
.map_err(|e| Tampered(format!("the held-out manifest is not valid: {e}")))?;
if manifest.sha256() != expected {
return Err(Tampered(
"the held-out manifest changed since it was written".to_owned(),
));
}
let mut contents = Vec::new();
for f in &manifest.files {
let content = std::fs::read_to_string(dir.join("files").join(&f.path))
.map_err(|_| Tampered(format!("held-out file {} is missing", f.path)))?;
if sha256_hex(content.as_bytes()) != f.sha256 {
return Err(Tampered(format!("held-out file {} changed", f.path)));
}
contents.push((f.path.clone(), content));
}
Ok((manifest, contents))
}
#[derive(Debug, Clone)]
pub struct Ran {
pub path: String,
pub result: Option<(CheckResult, Option<RunnerSummary>)>,
}
pub fn overlay_run(
wt_root: &Path,
project: &Path,
tmp: &Path,
files: &[(HeldOutFile, String)],
runner: &sandbox::Runner,
policy: &Policy,
) -> Result<Vec<Ran>, String> {
let before = worktree::checkpoint(wt_root, tmp).map_err(|e| e.0)?;
let judged = JudgeSnapshot::take(wt_root, policy);
let mut out = Vec::new();
for (f, content) in files {
let full = project.join(&f.path);
if full.exists() {
out.push(Ran {
path: f.path.clone(),
result: None,
});
continue;
}
if let Some(parent) = full.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&full, content).map_err(|e| format!("{}: {e}", f.path))?;
let ran = crate::pipeline::acceptance_run(project, &f.argv, runner);
let _ = std::fs::remove_file(&full);
out.push(Ran {
path: f.path.clone(),
result: Some(ran),
});
}
worktree::restore(wt_root, tmp, &before).map_err(|e| e.0)?;
if let Some(scratch) = runner.scratch() {
let _ = std::fs::remove_dir_all(&scratch);
}
let after = worktree::checkpoint(wt_root, tmp).map_err(|e| e.0)?;
if after != before {
return Err("the held-out run left changes in the worktree".to_owned());
}
let drift = judged.drift(
&JudgeSnapshot::take(wt_root, policy),
policy,
&BTreeSet::new(),
);
if let Some(d) = drift.first() {
return Err(format!("the held-out run changed judge input {}", d.path));
}
Ok(out)
}
#[must_use]
pub fn literals(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for quote in ['"', '\''] {
let mut parts = text.split(quote);
parts.next();
while let (Some(inside), Some(_)) = (parts.next(), parts.next().or(Some(""))) {
if inside.chars().count() >= 6 && !inside.contains('\n') {
out.insert(inside.to_owned());
}
}
}
let mut number = String::new();
for c in text.chars().chain(std::iter::once(' ')) {
if c.is_ascii_digit() || (c == '.' && !number.is_empty()) {
number.push(c);
} else {
let n = number.trim_end_matches('.');
let significant = n
.trim_start_matches(['0', '.'])
.chars()
.filter(char::is_ascii_digit)
.count();
if significant >= 4 {
out.insert(n.to_owned());
}
number.clear();
}
}
out
}
#[must_use]
pub fn leaked(
held_out: &[(HeldOutFile, String)],
known: &str,
wt_root: &Path,
start_tree: &worktree::Checkpoint,
patch: &str,
) -> Vec<String> {
let added: String = patch
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.collect::<Vec<_>>()
.join("\n");
let git = Git::at(wt_root);
held_out
.iter()
.flat_map(|(_, content)| literals(content))
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|lit| added.contains(lit.as_str()) && !known.contains(lit.as_str()))
.filter(|lit| {
!git.succeeds(&["grep", "-q", "-F", "-e", lit, &start_tree.0])
.unwrap_or(true)
})
.collect()
}
pub const REF_PREFIX: &str = "refs/speccoder/held-out";
pub fn publish(
repo: &Path,
project_state: &Path,
feature: &str,
tmp: &Path,
) -> Result<Option<String>, String> {
let base = project_state.join("held-out").join(feature);
let mut files: Vec<(String, PathBuf)> = Vec::new();
let mut stack = vec![base.clone()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if let Ok(rel) = p.strip_prefix(&base) {
files.push((rel.to_string_lossy().replace('\\', "/"), p.clone()));
}
}
}
if files.is_empty() {
return Ok(None);
}
files.sort();
let git = Git::at(repo);
std::fs::create_dir_all(tmp).map_err(|e| e.to_string())?;
let index = tmp.join(format!("held-out-index-{}", std::process::id()));
let _ = std::fs::remove_file(&index);
for (rel, path) in &files {
let blob = git
.line(&["hash-object", "-w", "--", &path.to_string_lossy()])
.map_err(|e| e.0)?;
git.run_with_index(
&index,
&[
"update-index",
"--add",
"--cacheinfo",
&format!("100644,{blob},{rel}"),
],
None,
)
.map_err(|e| e.0)?;
}
let tree = git
.run_with_index(&index, &["write-tree"], None)
.map_err(|e| e.0)?;
let _ = std::fs::remove_file(&index);
let tree = String::from_utf8_lossy(&tree).trim().to_owned();
let branch = format!("speccoder/{feature}");
let parent = git
.line(&["rev-parse", "--verify", &format!("{branch}^{{commit}}")])
.map_err(|e| e.0)?;
let message = format!(
"speccoder: held-out tests of {feature}\n\nThe tests that judged each task, published once the feature is done.\nEach <task>/manifest.json hashes to its task's Held-Out trailer.\n\nSpec-Feature: {feature}\n"
);
let mut args = crate::attempts::IDENTITY.to_vec();
args.extend(["commit-tree", &tree, "-p", &parent, "-m", &message]);
let commit = git.line(&args).map_err(|e| e.0)?;
git.run(&["update-ref", &format!("{REF_PREFIX}/{feature}"), &commit])
.map_err(|e| e.0)?;
Ok(Some(commit))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literals_are_long_strings_and_precise_numbers() {
let l = literals(
"assert_eq!(slug(\"Hello, World\"), \"hello-world\"); x(3.14159); y(42); z('ab')",
);
assert!(l.contains("Hello, World") && l.contains("hello-world"));
assert!(l.contains("3.14159"));
assert!(!l.contains("42") && !l.contains("ab"));
}
#[test]
fn a_changed_file_or_manifest_is_tampering() {
let d = tempfile::tempdir().unwrap();
let content = "fn t() {}\n".to_owned();
let m = Manifest {
version: 1,
feature: "f".to_owned(),
task: "T1".to_owned(),
author_run: "r".to_owned(),
checkpoint: "c".to_owned(),
visible_scenarios: vec!["US1-1".to_owned()],
files: vec![HeldOutFile {
path: "tests/h.rs".to_owned(),
sha256: sha256_hex(content.as_bytes()),
scenarios: vec!["US1-1".to_owned()],
argv: vec!["true".to_owned()],
composition: false,
}],
discarded: Vec::new(),
};
let sha = store(d.path(), &m, &[("tests/h.rs".to_owned(), content)]).unwrap();
assert_eq!(load_verified(d.path(), &sha).unwrap().1.len(), 1);
assert!(load_verified(d.path(), "other").is_err());
std::fs::write(d.path().join("files/tests/h.rs"), "fn t() { changed }").unwrap();
assert!(
load_verified(d.path(), &sha)
.unwrap_err()
.0
.contains("changed")
);
std::fs::remove_file(d.path().join("files/tests/h.rs")).unwrap();
assert!(
load_verified(d.path(), &sha)
.unwrap_err()
.0
.contains("missing")
);
}
}