1use std::collections::BTreeSet;
9use std::fmt::Write as _;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use super::catalog;
14use super::observation::{deterministic_filesystem_manifest, FilesystemManifest};
15use serde::de::DeserializeOwned;
16use serde::Deserialize;
17
18pub const CORPUS_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct Corpus {
23 pub schema_version: u32,
24 pub corpus_id: String,
25 pub rows: Vec<CorpusRow>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct CorpusRow {
31 pub id: String,
32 pub command: String,
33 pub route: String,
34 pub basis: String,
35 #[serde(default)]
36 pub normalizations: Vec<String>,
37 #[serde(default)]
38 pub expectations: Vec<String>,
39 #[serde(default)]
40 pub platform: PlatformGate,
41 pub decision_class: Option<String>,
42 #[serde(default)]
43 pub branch_ids: Vec<String>,
44 pub semantic_dimensions: Vec<String>,
45 #[serde(default = "default_characterization_mode")]
46 pub mode: String,
47 #[serde(default)]
48 pub mutating: bool,
49 #[serde(default)]
50 pub manifest: Vec<ManifestEntrySpec>,
51 #[serde(default)]
52 pub workdir: String,
53}
54
55fn default_characterization_mode() -> String {
56 "characterization-only".to_string()
57}
58
59#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "lowercase")]
61pub enum PlatformGate {
62 #[default]
63 All,
64 Unix,
65 Macos,
66 Linux,
67 Windows,
68}
69
70impl PlatformGate {
71 pub fn enabled_on_host(self) -> bool {
72 match self {
73 Self::All => true,
74 Self::Unix => cfg!(unix),
75 Self::Macos => cfg!(target_os = "macos"),
76 Self::Linux => cfg!(target_os = "linux"),
77 Self::Windows => cfg!(windows),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ManifestEntrySpec {
85 pub path: String,
86 #[serde(default = "default_file_kind")]
87 pub kind: String,
88 #[serde(default)]
89 pub content: String,
90 #[serde(default)]
91 pub content_base64: Option<String>,
92 #[serde(default)]
93 pub target: Option<String>,
94}
95
96fn default_file_kind() -> String {
97 "file".to_string()
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ParsedRoute {
102 pub native: bool,
103 pub rule_id: Option<String>,
104}
105
106impl CorpusRow {
107 pub fn parsed_route(&self) -> Result<ParsedRoute, String> {
108 if self.route == "native" {
109 return Ok(ParsedRoute {
110 native: true,
111 rule_id: None,
112 });
113 }
114 let Some(rule_id) = self.route.strip_prefix("rewritten:") else {
115 return Err(format!("row {} has invalid route {}", self.id, self.route));
116 };
117 if !catalog::rule_exists(rule_id) {
118 return Err(format!("row {} names unknown rule {rule_id}", self.id));
119 }
120 Ok(ParsedRoute {
121 native: false,
122 rule_id: Some(rule_id.to_string()),
123 })
124 }
125
126 pub fn materialize(&self, root: &Path) -> Result<(), String> {
127 validate_fixture_entries(&self.manifest)?;
128 for entry in &self.manifest {
129 let path = safe_fixture_path(root, &entry.path)?;
130 match entry.kind.as_str() {
131 "directory" => fs::create_dir_all(&path)
132 .map_err(|error| format!("create {}: {error}", entry.path))?,
133 "file" => {
134 if let Some(parent) = path.parent() {
135 fs::create_dir_all(parent)
136 .map_err(|error| format!("create parent {}: {error}", entry.path))?;
137 }
138 let bytes = entry_bytes(entry)?;
139 fs::write(&path, bytes)
140 .map_err(|error| format!("write {}: {error}", entry.path))?;
141 }
142 "symlink" => {
143 let target = entry
144 .target
145 .as_deref()
146 .ok_or_else(|| format!("symlink {} is missing target", entry.path))?;
147 if let Some(parent) = path.parent() {
148 fs::create_dir_all(parent)
149 .map_err(|error| format!("create parent {}: {error}", entry.path))?;
150 }
151 create_symlink(target, &path)?;
152 }
153 other => return Err(format!("row {} has unknown fixture kind {other}", self.id)),
154 }
155 }
156 Ok(())
157 }
158
159 pub fn initial_manifest(&self, root: &Path) -> Result<FilesystemManifest, String> {
160 self.materialize(root)?;
161 deterministic_filesystem_manifest(root).map_err(|error| error.to_string())
162 }
163}
164
165fn create_symlink(target: &str, path: &Path) -> Result<(), String> {
166 #[cfg(unix)]
167 {
168 std::os::unix::fs::symlink(target, path).map_err(|error| error.to_string())
169 }
170 #[cfg(windows)]
171 {
172 if target.ends_with('/') || target.ends_with('\\') {
176 std::os::windows::fs::symlink_dir(target, path).map_err(|error| error.to_string())
177 } else {
178 std::os::windows::fs::symlink_file(target, path).map_err(|error| error.to_string())
179 }
180 }
181}
182
183fn safe_fixture_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
184 let path = Path::new(relative);
185 if relative.is_empty()
186 || path.is_absolute()
187 || path
188 .components()
189 .any(|component| matches!(component, std::path::Component::ParentDir))
190 {
191 return Err(format!("fixture path is not root-relative: {relative:?}"));
192 }
193 Ok(root.join(path))
194}
195
196fn entry_bytes(entry: &ManifestEntrySpec) -> Result<Vec<u8>, String> {
197 if entry.content_base64.is_some() && !entry.content.is_empty() {
198 return Err(format!(
199 "fixture {} specifies both content and content_base64",
200 entry.path
201 ));
202 }
203 match entry.content_base64.as_deref() {
204 Some(encoded) => {
205 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
206 .map_err(|error| format!("fixture {} has invalid base64: {error}", entry.path))
207 }
208 None => Ok(entry.content.as_bytes().to_vec()),
209 }
210}
211
212fn validate_fixture_entries(entries: &[ManifestEntrySpec]) -> Result<(), String> {
213 let mut paths = BTreeSet::new();
214 for entry in entries {
215 safe_fixture_path(Path::new("."), &entry.path)?;
216 if !paths.insert(entry.path.clone()) {
217 return Err(format!("duplicate fixture path {}", entry.path));
218 }
219 match entry.kind.as_str() {
220 "directory" => {
221 if !entry.content.is_empty()
222 || entry.content_base64.is_some()
223 || entry.target.is_some()
224 {
225 return Err(format!(
226 "directory {} has file or symlink payload",
227 entry.path
228 ));
229 }
230 }
231 "file" => {
232 if entry.target.is_some() {
233 return Err(format!("file {} has symlink target", entry.path));
234 }
235 let _ = entry_bytes(entry)?;
236 }
237 "symlink" => {
238 if entry.target.is_none()
239 || !entry.content.is_empty()
240 || entry.content_base64.is_some()
241 {
242 return Err(format!("symlink {} has invalid payload", entry.path));
243 }
244 }
245 other => return Err(format!("unknown fixture kind {other}")),
246 }
247 }
248 Ok(())
249}
250
251pub fn parse_corpus_str<T: DeserializeOwned>(source: &str) -> Result<T, String> {
252 toml::from_str(source).map_err(|error| error.to_string())
253}
254
255pub fn parse_corpus(source: &str) -> Result<Corpus, String> {
256 let corpus: Corpus = parse_corpus_str(source)?;
257 validate_corpus(&corpus)?;
258 Ok(corpus)
259}
260
261pub fn validate_corpus(corpus: &Corpus) -> Result<(), String> {
262 catalog::validate_catalog()?;
263 if corpus.schema_version != CORPUS_SCHEMA_VERSION {
264 return Err(format!(
265 "unsupported corpus schema version {}; expected {}",
266 corpus.schema_version, CORPUS_SCHEMA_VERSION
267 ));
268 }
269 if corpus.corpus_id.trim().is_empty() {
270 return Err("corpus_id must not be empty".to_string());
271 }
272 if corpus.rows.is_empty() {
273 return Err("corpus must contain at least one row".to_string());
274 }
275
276 let mut row_ids = BTreeSet::new();
277 let mut covered_branches = BTreeSet::new();
278 let mut covered_dimensions = BTreeSet::new();
279 for row in &corpus.rows {
280 if row.id.trim().is_empty() || !row_ids.insert(row.id.clone()) {
281 return Err(format!("duplicate or empty row ID {:?}", row.id));
282 }
283 if row.command.trim().is_empty() {
284 return Err(format!("row {} has an empty command", row.id));
285 }
286 let route = row.parsed_route()?;
287 if row.mode != "characterization-only" {
288 return Err(format!("row {} must be characterization-only", row.id));
289 }
290 if row.semantic_dimensions.is_empty() {
291 return Err(format!("row {} has no semantic dimensions", row.id));
292 }
293 for dimension in &row.semantic_dimensions {
294 if !catalog::semantic_dimension_exists(dimension) {
295 return Err(format!(
296 "row {} names unknown dimension {dimension}",
297 row.id
298 ));
299 }
300 covered_dimensions.insert(dimension.clone());
301 }
302 if !catalog::COMPARISON_BASES.contains(&row.basis.as_str()) {
303 return Err(format!(
304 "row {} names unknown comparison basis {}",
305 row.id, row.basis
306 ));
307 }
308 if row.normalizations.iter().any(|normalization| {
309 !catalog::PRESENTATION_NORMALIZATIONS.contains(&normalization.as_str())
310 }) {
311 return Err(format!(
312 "row {} has unknown presentation normalization",
313 row.id
314 ));
315 }
316 if row
317 .expectations
318 .iter()
319 .any(|expectation| !catalog::EXPECTATIONS.contains(&expectation.as_str()))
320 {
321 return Err(format!("row {} has unknown expectation", row.id));
322 }
323 if row
324 .expectations
325 .iter()
326 .any(|expectation| expectation == &row.basis)
327 || row
328 .normalizations
329 .iter()
330 .any(|normalization| normalization == &row.basis)
331 {
332 return Err(format!(
333 "row {} places a basis ID in a disjoint vocabulary",
334 row.id
335 ));
336 }
337 for branch_id in &row.branch_ids {
338 if !catalog::branch_exists(branch_id) {
339 return Err(format!("row {} names unknown branch {branch_id}", row.id));
340 }
341 covered_branches.insert(branch_id.clone());
342 }
343 match route {
344 ParsedRoute {
345 native: true,
346 rule_id: None,
347 } => {
348 if row.decision_class.is_some() {
349 return Err(format!(
350 "native row {} must not have a decision class",
351 row.id
352 ));
353 }
354 }
355 ParsedRoute {
356 native: false,
357 rule_id: Some(ref rule_id),
358 } => {
359 let class_id = row
360 .decision_class
361 .as_deref()
362 .ok_or_else(|| format!("rewritten row {} is missing decision_class", row.id))?;
363 let class = catalog::decision_class(class_id).ok_or_else(|| {
364 format!("row {} names unknown decision class {class_id}", row.id)
365 })?;
366 if class.rule_id != rule_id {
367 return Err(format!(
368 "row {} decision class does not match route",
369 row.id
370 ));
371 }
372 }
373 _ => unreachable!(),
374 }
375 validate_fixture_entries(&row.manifest)?;
376 if row.mutating && !row.semantic_dimensions.iter().any(|dim| dim == "mutation") {
377 return Err(format!(
378 "mutating row {} must declare mutation dimension",
379 row.id
380 ));
381 }
382 if row.workdir.starts_with('/') || row.workdir.contains("..") {
383 return Err(format!("row {} workdir must be root-relative", row.id));
384 }
385 }
386
387 for branch in catalog::BRANCH_INVENTORY {
388 if !covered_branches.contains(branch.id) {
389 return Err(format!(
390 "branch inventory entry {} has no corpus row",
391 branch.id
392 ));
393 }
394 }
395 for dimension in catalog::SEMANTIC_DIMENSIONS {
396 if !covered_dimensions.contains(*dimension) {
397 return Err(format!("semantic dimension {dimension} has no corpus row"));
398 }
399 }
400 Ok(())
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct HarnessFailure {
405 pub row_id: String,
406 pub message: String,
407}
408
409#[derive(Debug, Default)]
410pub struct AggregateFailures {
411 failures: Vec<HarnessFailure>,
412}
413
414impl AggregateFailures {
415 pub fn push(&mut self, row_id: impl Into<String>, message: impl Into<String>) {
416 self.failures.push(HarnessFailure {
417 row_id: row_id.into(),
418 message: message.into(),
419 });
420 }
421
422 pub fn is_empty(&self) -> bool {
423 self.failures.is_empty()
424 }
425
426 pub fn failures(&self) -> &[HarnessFailure] {
427 &self.failures
428 }
429
430 pub fn finish(self) -> Result<(), String> {
431 if self.failures.is_empty() {
432 return Ok(());
433 }
434 let mut report = String::from("bash rewrite differential failures:\n");
435 for failure in self.failures {
436 let _ = writeln!(report, "- {}: {}", failure.row_id, failure.message);
437 }
438 Err(report)
439 }
440}
441
442pub fn manifest_for(root: &Path) -> Result<FilesystemManifest, String> {
443 deterministic_filesystem_manifest(root).map_err(|error| error.to_string())
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 const VALID: &str = r#"
451 schema_version = 1
452 corpus_id = "test"
453
454 [[rows]]
455 id = "native"
456 command = "printf ok"
457 route = "native"
458 basis = "bytes"
459 branch_ids = ["dispatch.native.no_rule"]
460 semantic_dimensions = ["shell-tokenization"]
461 mode = "characterization-only"
462 "#;
463
464 #[test]
465 fn schema_rejects_unknown_fields_and_bad_version() {
466 let unknown = VALID.replace("mode =", "unknown = true\nmode =");
467 assert!(parse_corpus(&unknown).is_err());
468 let bad_version = VALID.replace("schema_version = 1", "schema_version = 2");
469 assert!(parse_corpus(&bad_version).is_err());
470 }
471
472 #[test]
473 fn aggregate_failures_are_not_first_failure_only() {
474 let mut failures = AggregateFailures::default();
475 failures.push("r1", "first");
476 failures.push("r2", "second");
477 let report = failures.finish().unwrap_err();
478 assert!(report.contains("r1") && report.contains("r2"));
479 }
480}