speccoder_runtime/
pre_frozen.rs1use std::path::{Path, PathBuf};
7
8use speccoder_core::acceptance::PreFrozenAcceptance;
9
10use crate::integrity::sha256_hex;
11
12pub type Loaded = (PreFrozenAcceptance, Vec<(String, String)>);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Tampered(pub String);
18
19#[must_use]
21pub fn dir(project_state: &Path, unit: &str, requirement: &str) -> PathBuf {
22 project_state
23 .join("pre-frozen")
24 .join(unit)
25 .join(requirement.replace([':', '/'], "-"))
26}
27
28pub fn store(
32 project_state: &Path,
33 record: &PreFrozenAcceptance,
34 contents: &[(String, String)],
35) -> Result<PathBuf, String> {
36 record.validate()?;
37 for t in &record.visible {
38 let content = contents
39 .iter()
40 .find(|(p, _)| *p == t.path)
41 .map(|(_, c)| c)
42 .ok_or_else(|| format!("{} has no content", t.path))?;
43 if format!("sha256:{}", sha256_hex(content.as_bytes())) != t.sha256 {
44 return Err(format!("{} does not match its digest", t.path));
45 }
46 }
47 let d = dir(project_state, &record.unit, &record.requirement);
48 let _ = std::fs::remove_dir_all(&d);
49 for (path, content) in contents {
50 let file = d.join("files").join(path);
51 if let Some(parent) = file.parent() {
52 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
53 }
54 std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
55 }
56 let text = serde_json::to_string_pretty(record).map_err(|e| e.to_string())?;
57 std::fs::write(d.join("record.json"), text).map_err(|e| e.to_string())?;
58 Ok(d)
59}
60
61pub fn load(
64 project_state: &Path,
65 unit: &str,
66 requirement: &str,
67) -> Result<Option<Loaded>, Tampered> {
68 let d = dir(project_state, unit, requirement);
69 let Ok(text) = std::fs::read_to_string(d.join("record.json")) else {
70 return Ok(None);
71 };
72 let record: PreFrozenAcceptance = serde_json::from_str(&text)
73 .map_err(|e| Tampered(format!("the pre-frozen record is not valid: {e}")))?;
74 let mut contents = Vec::new();
75 for t in &record.visible {
76 let content = std::fs::read_to_string(d.join("files").join(&t.path))
77 .map_err(|_| Tampered(format!("pre-frozen test {} is missing", t.path)))?;
78 if format!("sha256:{}", sha256_hex(content.as_bytes())) != t.sha256 {
79 return Err(Tampered(format!("pre-frozen test {} changed", t.path)));
80 }
81 contents.push((t.path.clone(), content));
82 }
83 Ok(Some((record, contents)))
84}
85
86pub fn remove(project_state: &Path, unit: &str, requirement: &str) {
88 let _ = std::fs::remove_dir_all(dir(project_state, unit, requirement));
89}
90
91#[must_use]
93pub fn manifest_digest(record: &PreFrozenAcceptance) -> String {
94 format!(
95 "sha256:{}",
96 sha256_hex(speccoder_core::acceptance::manifest_text(&record.visible).as_bytes())
97 )
98}
99
100#[cfg(test)]
101mod tests {
102 use speccoder_core::acceptance::{Decided, Producer, VisibleTest};
103 use speccoder_core::actions::Via;
104
105 use super::*;
106
107 fn record(content: &str) -> PreFrozenAcceptance {
108 PreFrozenAcceptance {
109 unit: "007-bugfixes".to_owned(),
110 requirement: "FR-001".to_owned(),
111 wording_digest: "sha256:w".to_owned(),
112 approval_digest: "sha256:a".to_owned(),
113 visible: vec![VisibleTest {
114 path: "tests/repro_e1.rs".to_owned(),
115 sha256: format!("sha256:{}", sha256_hex(content.as_bytes())),
116 cites: vec!["E1".to_owned()],
117 }],
118 held_out_sealed: None,
119 producer: Producer::BugConfirmation,
120 decided: Decided {
121 by: "Ada".to_owned(),
122 via: Via::Terminal,
123 at: 1,
124 },
125 }
126 }
127
128 #[test]
129 fn a_stored_set_loads_back_and_a_changed_file_is_tampering() {
130 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
131 let r = record("#[test] fn t() {}");
132 let files = vec![(
133 "tests/repro_e1.rs".to_owned(),
134 "#[test] fn t() {}".to_owned(),
135 )];
136 let d = store(state.path(), &r, &files).unwrap_or_else(|e| panic!("{e}"));
137 let (back, contents) = load(state.path(), "007-bugfixes", "FR-001")
138 .unwrap_or_else(|e| panic!("{e:?}"))
139 .unwrap_or_else(|| panic!("stored"));
140 assert_eq!(back, r);
141 assert_eq!(contents, files);
142 std::fs::write(d.join("files/tests/repro_e1.rs"), "changed")
143 .unwrap_or_else(|e| panic!("{e}"));
144 assert!(load(state.path(), "007-bugfixes", "FR-001").is_err());
145 assert!(
146 store(
147 state.path(),
148 &r,
149 &[("tests/repro_e1.rs".to_owned(), "other".to_owned())]
150 )
151 .is_err()
152 );
153 assert!(
154 load(state.path(), "007-bugfixes", "FR-002")
155 .unwrap_or_default()
156 .is_none()
157 );
158 }
159}