1use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use speccoder_core::verify::{CheckResult, RunnerSummary};
12
13use crate::git::Git;
14use crate::integrity::{JudgeSnapshot, sha256_hex};
15use crate::tools::policy::Policy;
16use crate::{sandbox, worktree};
17
18pub const LIMIT: usize = 4;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct HeldOutFile {
24 pub path: String,
26 pub sha256: String,
28 pub scenarios: Vec<String>,
30 pub argv: Vec<String>,
32 pub composition: bool,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub red_tests: Option<usize>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Discarded {
42 pub path: String,
44 pub reason: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Manifest {
52 pub version: u32,
54 pub feature: String,
56 pub task: String,
58 pub author_run: String,
60 pub checkpoint: String,
62 pub visible_scenarios: Vec<String>,
64 pub files: Vec<HeldOutFile>,
66 pub discarded: Vec<Discarded>,
68}
69
70impl Manifest {
71 #[must_use]
73 pub fn sha256(&self) -> String {
74 sha256_hex(&serde_json::to_vec(self).unwrap_or_default())
75 }
76}
77
78#[must_use]
80pub fn dir(project_state: &Path, feature: &str, task: &str) -> PathBuf {
81 project_state.join("held-out").join(feature).join(task)
82}
83
84pub fn store(
87 dir: &Path,
88 manifest: &Manifest,
89 contents: &[(String, String)],
90) -> Result<String, String> {
91 let _ = std::fs::remove_dir_all(dir);
92 for (path, content) in contents {
93 let file = dir.join("files").join(path);
94 if let Some(parent) = file.parent() {
95 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
96 }
97 std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
98 }
99 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
100 let text = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
101 std::fs::write(dir.join("manifest.json"), text).map_err(|e| e.to_string())?;
102 Ok(manifest.sha256())
103}
104
105#[must_use]
108pub fn sealed_dir(project_state: &Path, unit: &str, key: &str) -> PathBuf {
109 project_state
110 .join("held-out")
111 .join(unit)
112 .join(format!("req-{}", key.replace([':', '/'], "-")))
113}
114
115pub fn seal(
120 project_state: &Path,
121 unit: &str,
122 key: &str,
123 manifest: &Manifest,
124 contents: &[(String, String)],
125) -> Result<speccoder_core::acceptance::SealedRef, String> {
126 let sha = store(&sealed_dir(project_state, unit, key), manifest, contents)?;
127 Ok(speccoder_core::acceptance::SealedRef {
128 key: key.to_owned(),
129 manifest_sha256: sha,
130 count: manifest.files.len(),
131 })
132}
133
134pub fn open_sealed(
136 project_state: &Path,
137 unit: &str,
138 sealed: &speccoder_core::acceptance::SealedRef,
139) -> Result<(Manifest, Vec<(String, String)>), Tampered> {
140 load_verified(
141 &sealed_dir(project_state, unit, &sealed.key),
142 &sealed.manifest_sha256,
143 )
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Tampered(pub String);
149
150pub fn load_verified(
153 dir: &Path,
154 expected: &str,
155) -> Result<(Manifest, Vec<(String, String)>), Tampered> {
156 let text = std::fs::read_to_string(dir.join("manifest.json"))
157 .map_err(|e| Tampered(format!("the held-out manifest is unreadable: {e}")))?;
158 let manifest: Manifest = serde_json::from_str(&text)
159 .map_err(|e| Tampered(format!("the held-out manifest is not valid: {e}")))?;
160 if manifest.sha256() != expected {
161 return Err(Tampered(
162 "the held-out manifest changed since it was written".to_owned(),
163 ));
164 }
165 let mut contents = Vec::new();
166 for f in &manifest.files {
167 let content = std::fs::read_to_string(dir.join("files").join(&f.path))
168 .map_err(|_| Tampered(format!("held-out file {} is missing", f.path)))?;
169 if sha256_hex(content.as_bytes()) != f.sha256 {
170 return Err(Tampered(format!("held-out file {} changed", f.path)));
171 }
172 contents.push((f.path.clone(), content));
173 }
174 Ok((manifest, contents))
175}
176
177#[derive(Debug, Clone)]
179pub struct Ran {
180 pub path: String,
182 pub result: Option<(CheckResult, Option<RunnerSummary>)>,
184 pub without: Option<(CheckResult, Option<RunnerSummary>)>,
188}
189
190const OVERLAY_MARKER: &str = "speccoder-held-out-overlay.json";
195
196fn overlay_marker(wt_root: &Path) -> Option<PathBuf> {
197 crate::git::Git::at(wt_root)
198 .line(&["rev-parse", "--absolute-git-dir"])
199 .ok()
200 .filter(|d| !d.is_empty())
201 .map(|d| PathBuf::from(d).join(OVERLAY_MARKER))
202}
203
204#[must_use]
208pub fn overlaid(wt_root: &Path) -> Option<Vec<String>> {
209 let Some(marker) = overlay_marker(wt_root) else {
210 return Some(Vec::new());
211 };
212 match std::fs::read_to_string(&marker) {
213 Ok(t) => serde_json::from_str(&t).ok(),
214 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(Vec::new()),
215 Err(_) => None,
216 }
217}
218
219#[must_use]
221pub fn overlay_key(rel: &str) -> String {
222 sha256_hex(rel.as_bytes())
223}
224
225struct OverlayMarker(Option<PathBuf>);
227
228impl Drop for OverlayMarker {
229 fn drop(&mut self) {
230 if let Some(m) = &self.0 {
231 let _ = std::fs::remove_file(m);
232 }
233 }
234}
235
236pub fn overlay_run(
241 wt_root: &Path,
242 project: &Path,
243 tmp: &Path,
244 files: &[(HeldOutFile, String)],
245 runner: &sandbox::Runner,
246 policy: &Policy,
247) -> Result<Vec<Ran>, String> {
248 overlay(wt_root, project, tmp, files, runner, policy, false)
249}
250
251type Outcome = (CheckResult, Option<RunnerSummary>);
253
254pub fn overlay_red(
258 wt_root: &Path,
259 project: &Path,
260 tmp: &Path,
261 files: &[(HeldOutFile, String)],
262 runner: &sandbox::Runner,
263 policy: &Policy,
264) -> Result<Vec<Ran>, String> {
265 overlay(wt_root, project, tmp, files, runner, policy, true)
266}
267
268#[must_use]
271pub fn red_class(ran: &Ran) -> speccoder_core::verify::RedClass {
272 use speccoder_core::verify::{RedClass, classify_red, classify_red_added};
273 match (&ran.result, &ran.without) {
274 (None, _) => RedClass::Unclassified,
275 (Some((r, s)), Some((o, os))) => classify_red_added((r, s.as_ref()), (o, os.as_ref())),
276 (Some((r, s)), None) => classify_red(r, s.as_ref()),
277 }
278}
279
280fn overlay(
281 wt_root: &Path,
282 project: &Path,
283 tmp: &Path,
284 files: &[(HeldOutFile, String)],
285 runner: &sandbox::Runner,
286 policy: &Policy,
287 baseline: bool,
288) -> Result<Vec<Ran>, String> {
289 let before = worktree::checkpoint(wt_root, tmp).map_err(|e| e.0)?;
290 let judged = JudgeSnapshot::take(wt_root, policy);
291 let prefix = {
292 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
293 canonical(project)
294 .strip_prefix(canonical(wt_root))
295 .map(|r| r.to_string_lossy().replace('\\', "/"))
296 .unwrap_or_default()
297 };
298 let mut keys: Vec<String> = Vec::new();
301 for (f, _) in files {
302 let rel = if prefix.is_empty() {
303 f.path.clone()
304 } else {
305 format!("{prefix}/{}", f.path)
306 };
307 let mut at = rel.as_str();
308 loop {
309 let key = overlay_key(at);
310 if !keys.contains(&key) {
311 keys.push(key);
312 }
313 match at.rfind('/') {
314 Some(i) => at = &at[..i],
315 None => break,
316 }
317 }
318 }
319 let marker = OverlayMarker(overlay_marker(wt_root));
320 if let Some(m) = &marker.0 {
321 std::fs::write(m, serde_json::to_string(&keys).unwrap_or_default())
322 .map_err(|e| format!("the held-out overlay marker: {e}"))?;
323 }
324 let mut without: Vec<(Vec<String>, Outcome)> = Vec::new();
326 if baseline {
327 for (f, _) in files {
328 if !without.iter().any(|(argv, _)| *argv == f.argv) {
329 let ran = crate::pipeline::acceptance_run(project, &f.argv, runner);
330 without.push((f.argv.clone(), ran));
331 }
332 }
333 }
334 let without_of = |argv: &[String]| {
335 without
336 .iter()
337 .find(|(a, _)| a.as_slice() == argv)
338 .map(|(_, r)| r.clone())
339 };
340 let mut out = Vec::new();
341 for (f, content) in files {
342 let full = project.join(&f.path);
343 if full.exists() {
344 out.push(Ran {
345 path: f.path.clone(),
346 result: None,
347 without: None,
348 });
349 continue;
350 }
351 if let Some(parent) = full.parent() {
352 let _ = std::fs::create_dir_all(parent);
353 }
354 std::fs::write(&full, content).map_err(|e| format!("{}: {e}", f.path))?;
355 let ran = crate::pipeline::acceptance_run(project, &f.argv, runner);
356 let _ = std::fs::remove_file(&full);
357 out.push(Ran {
358 path: f.path.clone(),
359 result: Some(ran),
360 without: without_of(&f.argv),
361 });
362 }
363 worktree::restore(wt_root, tmp, &before).map_err(|e| e.0)?;
364 if let Some(scratch) = runner.scratch() {
366 let _ = std::fs::remove_dir_all(&scratch);
367 }
368 let after = worktree::checkpoint(wt_root, tmp).map_err(|e| e.0)?;
369 if after != before {
370 return Err("the held-out run left changes in the worktree".to_owned());
371 }
372 let drift = judged.drift(
373 &JudgeSnapshot::take(wt_root, policy),
374 policy,
375 &BTreeSet::new(),
376 );
377 if let Some(d) = drift.first() {
378 return Err(format!("the held-out run changed judge input {}", d.path));
379 }
380 Ok(out)
381}
382
383#[must_use]
386pub fn literals(text: &str) -> BTreeSet<String> {
387 let mut out = BTreeSet::new();
388 for quote in ['"', '\''] {
389 let mut parts = text.split(quote);
390 parts.next();
391 while let (Some(inside), Some(_)) = (parts.next(), parts.next().or(Some(""))) {
392 if inside.chars().count() >= 6 && !inside.contains('\n') {
393 out.insert(inside.to_owned());
394 }
395 }
396 }
397 let mut number = String::new();
398 for c in text.chars().chain(std::iter::once(' ')) {
399 if c.is_ascii_digit() || (c == '.' && !number.is_empty()) {
400 number.push(c);
401 } else {
402 let n = number.trim_end_matches('.');
403 let significant = n
404 .trim_start_matches(['0', '.'])
405 .chars()
406 .filter(char::is_ascii_digit)
407 .count();
408 if significant >= 4 {
409 out.insert(n.to_owned());
410 }
411 number.clear();
412 }
413 }
414 out
415}
416
417#[must_use]
422pub fn leaked(
423 held_out: &[(HeldOutFile, String)],
424 known: &str,
425 wt_root: &Path,
426 start_tree: &worktree::Checkpoint,
427 patch: &str,
428) -> Vec<String> {
429 let added: String = patch
430 .lines()
431 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
432 .collect::<Vec<_>>()
433 .join("\n");
434 let git = Git::at(wt_root);
435 held_out
436 .iter()
437 .flat_map(|(_, content)| literals(content))
438 .collect::<BTreeSet<_>>()
439 .into_iter()
440 .filter(|lit| added.contains(lit.as_str()) && !known.contains(lit.as_str()))
441 .filter(|lit| {
442 !git.succeeds(&["grep", "-q", "-F", "-e", lit, &start_tree.0])
443 .unwrap_or(true)
444 })
445 .collect()
446}
447
448pub const REF_PREFIX: &str = "refs/speccoder/held-out";
450
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
454pub struct Selection {
455 pub sets: Vec<(String, PathBuf)>,
457 pub tasks: Vec<String>,
459 pub withheld: usize,
461 pub tampered: Vec<String>,
463}
464
465#[must_use]
468pub fn named_digests(repo: &Path, commit: &str) -> Vec<String> {
469 let mut out: Vec<String> = crate::evidence::statements(repo, commit)
470 .iter()
471 .filter(|s| s["predicate"]["configuration"][0]["name"] == "speccoder:phase/held_out")
472 .filter_map(|s| {
473 s["predicate"]["configuration"][0]["digest"]["sha256"]
474 .as_str()
475 .map(str::to_owned)
476 })
477 .collect();
478 let trailer = Git::at(repo)
479 .line(&[
480 "log",
481 "-1",
482 "--format=%(trailers:key=Held-Out,valueonly)",
483 commit,
484 ])
485 .unwrap_or_default();
486 out.extend(
487 trailer
488 .split_whitespace()
489 .filter_map(|w| w.strip_prefix("manifest=sha256:"))
490 .map(str::to_owned),
491 );
492 out.sort();
493 out.dedup();
494 out
495}
496
497#[must_use]
503pub fn select_closed(
504 project_state: &Path,
505 unit: &str,
506 closed: &[(String, Vec<String>)],
507) -> Selection {
508 let base = project_state.join("held-out").join(unit);
509 let mut stored: Vec<(String, PathBuf)> = std::fs::read_dir(&base)
510 .into_iter()
511 .flatten()
512 .flatten()
513 .filter(|e| e.path().join("manifest.json").is_file())
514 .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
515 .collect();
516 stored.sort();
517 let mut out = Selection::default();
518 for (key, dir) in stored {
519 let digest = std::fs::read_to_string(dir.join("manifest.json"))
520 .ok()
521 .and_then(|t| serde_json::from_str::<Manifest>(&t).ok())
522 .map(|m| m.sha256());
523 let named_by = digest.as_ref().and_then(|d| {
524 closed
525 .iter()
526 .find(|(_, digests)| digests.contains(d))
527 .map(|(task, _)| task.clone())
528 });
529 match (named_by, digest) {
530 (Some(task), Some(d)) => {
531 if load_verified(&dir, &d).is_ok() {
532 if !out.tasks.contains(&task) {
533 out.tasks.push(task);
534 }
535 out.sets.push((key, dir));
536 } else {
537 out.tampered.push(key);
538 }
539 }
540 _ => out.withheld += 1,
541 }
542 }
543 out
544}
545
546pub fn publication_store(project_state: &Path, repo: &Path) -> Result<PathBuf, String> {
558 let dir = project_state.join("held-out.git");
559 if !dir.join("HEAD").is_file() {
560 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
561 Git::at(&dir)
562 .run(&["init", "-q", "--bare"])
563 .map_err(|e| e.0)?;
564 }
565 let common = Git::at(repo)
566 .line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
567 .map_err(|e| e.0)?;
568 let info = dir.join("objects/info");
569 std::fs::create_dir_all(&info).map_err(|e| format!("{}: {e}", info.display()))?;
570 std::fs::write(
571 info.join("alternates"),
572 format!("{}\n", Path::new(&common).join("objects").display()),
573 )
574 .map_err(|e| format!("{}: {e}", info.display()))?;
575 let project = Git::at(repo);
578 let legacy = project
579 .line(&[
580 "for-each-ref",
581 "--format=%(refname) %(objectname)",
582 REF_PREFIX,
583 ])
584 .map_err(|e| e.0)?;
585 let private = Git::at(&dir);
586 for line in legacy.lines() {
587 let Some((name, commit)) = line.split_once(' ') else {
588 continue;
589 };
590 if !private
591 .succeeds(&["rev-parse", "--verify", "-q", name])
592 .unwrap_or(false)
593 {
594 private
595 .run(&["update-ref", name, commit])
596 .map_err(|e| e.0)?;
597 }
598 project.run(&["update-ref", "-d", name]).map_err(|e| e.0)?;
599 }
600 Ok(dir)
601}
602
603pub fn push(
612 repo: &Path,
613 store: &Path,
614 remote: &str,
615 unit: &str,
616 commit: &str,
617) -> Result<(), String> {
618 let objects = store.join("objects");
619 Git::at(repo)
620 .run_with_env(
621 &[
622 "push",
623 "--quiet",
624 remote,
625 &format!("{commit}:{REF_PREFIX}/{unit}"),
626 ],
627 &[("GIT_ALTERNATE_OBJECT_DIRECTORIES", objects.as_os_str())],
628 )
629 .map(|_| ())
630 .map_err(|e| e.0)
631}
632
633pub fn publish_closed(
641 store: &Path,
642 unit: &str,
643 tip: &str,
644 selection: &Selection,
645 tmp: &Path,
646) -> Result<Option<String>, String> {
647 let mut files: Vec<(String, PathBuf)> = Vec::new();
648 for (key, dir) in &selection.sets {
649 let mut stack = vec![dir.clone()];
650 while let Some(d) = stack.pop() {
651 let Ok(entries) = std::fs::read_dir(&d) else {
652 continue;
653 };
654 for e in entries.flatten() {
655 let p = e.path();
656 if p.is_dir() {
657 stack.push(p);
658 } else if let Ok(rel) = p.strip_prefix(dir) {
659 let rel = rel.to_string_lossy().replace('\\', "/");
660 files.push((format!("{key}/{rel}"), p.clone()));
661 }
662 }
663 }
664 }
665 if files.is_empty() {
666 return Ok(None);
667 }
668 files.sort();
669 let git = Git::at(store);
670 std::fs::create_dir_all(tmp).map_err(|e| e.to_string())?;
671 let index = tmp.join(format!("held-out-index-{}", std::process::id()));
672 let _ = std::fs::remove_file(&index);
673 for (rel, path) in &files {
674 let blob = git
675 .line(&["hash-object", "-w", "--", &path.to_string_lossy()])
676 .map_err(|e| e.0)?;
677 git.run_with_index(
678 &index,
679 &[
680 "update-index",
681 "--add",
682 "--cacheinfo",
683 &format!("100644,{blob},{rel}"),
684 ],
685 None,
686 )
687 .map_err(|e| e.0)?;
688 }
689 let tree = git
690 .run_with_index(&index, &["write-tree"], None)
691 .map_err(|e| e.0)?;
692 let _ = std::fs::remove_file(&index);
693 let tree = String::from_utf8_lossy(&tree).trim().to_owned();
694 let reference = format!("{REF_PREFIX}/{unit}");
695 let previous = git
696 .line(&[
697 "rev-parse",
698 "--verify",
699 "-q",
700 &format!("{reference}^{{commit}}"),
701 ])
702 .ok()
703 .filter(|c| !c.is_empty());
704 if let Some(prev) = &previous
706 && git
707 .line(&["rev-parse", &format!("{prev}^{{tree}}")])
708 .ok()
709 .as_deref()
710 == Some(&tree)
711 && git
712 .succeeds(&["merge-base", "--is-ancestor", tip, prev])
713 .unwrap_or(false)
714 {
715 return Ok(Some(prev.clone()));
716 }
717 let message = format!(
718 "speccoder: held-out tests of {unit}\n\nThe tests that judged the tasks closed on {tip}: {}.\nEach <key>/manifest.json hashes to the digest its task's evidence names.\n\nSpec-Feature: {unit}\n",
719 selection.tasks.join(", ")
720 );
721 let mut args = crate::attempts::IDENTITY.to_vec();
722 args.extend(["commit-tree", &tree, "-p", tip]);
723 if let Some(prev) = &previous {
724 args.extend(["-p", prev]);
725 }
726 args.extend(["-m", &message]);
727 let commit = git.line(&args).map_err(|e| e.0)?;
728 git.run(&["update-ref", &reference, &commit])
729 .map_err(|e| e.0)?;
730 Ok(Some(commit))
731}
732
733pub fn publish(
738 repo: &Path,
739 project_state: &Path,
740 feature: &str,
741 tmp: &Path,
742) -> Result<Option<String>, String> {
743 let branch = format!("speccoder/{feature}");
744 let tip = Git::at(repo)
745 .line(&["rev-parse", "--verify", &format!("{branch}^{{commit}}")])
746 .map_err(|e| e.0)?;
747 let closed = closed_on(repo, &tip, feature);
748 let selection = select_closed(project_state, feature, &closed);
749 if selection.sets.is_empty() {
750 return Ok(None);
751 }
752 publish_closed(
753 &publication_store(project_state, repo)?,
754 feature,
755 &tip,
756 &selection,
757 tmp,
758 )
759}
760
761#[must_use]
765pub fn closed_on(repo: &Path, head: &str, unit: &str) -> Vec<(String, Vec<String>)> {
766 crate::attest::branch::closed(repo, head, unit)
767 .into_iter()
768 .map(|(task, commit)| {
769 let digests = named_digests(repo, &commit);
770 (task, digests)
771 })
772 .collect()
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778
779 #[test]
780 fn literals_are_long_strings_and_precise_numbers() {
781 let l = literals(
782 "assert_eq!(slug(\"Hello, World\"), \"hello-world\"); x(3.14159); y(42); z('ab')",
783 );
784 assert!(l.contains("Hello, World") && l.contains("hello-world"));
785 assert!(l.contains("3.14159"));
786 assert!(!l.contains("42") && !l.contains("ab"));
787 }
788
789 #[test]
790 fn a_changed_file_or_manifest_is_tampering() {
791 let d = tempfile::tempdir().unwrap();
792 let content = "fn t() {}\n".to_owned();
793 let m = Manifest {
794 version: 1,
795 feature: "f".to_owned(),
796 task: "T1".to_owned(),
797 author_run: "r".to_owned(),
798 checkpoint: "c".to_owned(),
799 visible_scenarios: vec!["US1-1".to_owned()],
800 files: vec![HeldOutFile {
801 path: "tests/h.rs".to_owned(),
802 sha256: sha256_hex(content.as_bytes()),
803 scenarios: vec!["US1-1".to_owned()],
804 argv: vec!["true".to_owned()],
805 composition: false,
806 red_tests: None,
807 }],
808 discarded: Vec::new(),
809 };
810 let sha = store(d.path(), &m, &[("tests/h.rs".to_owned(), content)]).unwrap();
811 assert_eq!(load_verified(d.path(), &sha).unwrap().1.len(), 1);
812 assert!(load_verified(d.path(), "other").is_err());
813 std::fs::write(d.path().join("files/tests/h.rs"), "fn t() { changed }").unwrap();
814 assert!(
815 load_verified(d.path(), &sha)
816 .unwrap_err()
817 .0
818 .contains("changed")
819 );
820 std::fs::remove_file(d.path().join("files/tests/h.rs")).unwrap();
821 assert!(
822 load_verified(d.path(), &sha)
823 .unwrap_err()
824 .0
825 .contains("missing")
826 );
827 }
828}