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