Skip to main content

aft/bash_rewrite/
observation.rs

1//! Canonical observations used by the bash differential campaign.
2//!
3//! The adapters keep raw process output available for failure reports while
4//! reducers compare structured values. No reducer is applied implicitly: the
5//! corpus names every basis and every presentation normalization it permits.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ManifestKind {
18    Directory,
19    File,
20    Symlink,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ManifestEntry {
25    pub kind: ManifestKind,
26    pub size: u64,
27    pub sha256: Option<String>,
28    pub link_target: Option<String>,
29}
30
31pub type FilesystemManifest = BTreeMap<String, ManifestEntry>;
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34pub struct StructuredObservation {
35    pub stdout: Vec<u8>,
36    pub stderr: Vec<u8>,
37    pub exit_code: Option<i32>,
38    pub entries: Vec<(String, bool)>,
39    pub selected_paths: Vec<String>,
40    pub matches: Vec<(String, u32, String)>,
41    pub values: Vec<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45pub struct Observation {
46    pub raw_stdout: Vec<u8>,
47    pub raw_stderr: Vec<u8>,
48    pub exit_code: Option<i32>,
49    pub structured: StructuredObservation,
50    pub filesystem: FilesystemManifest,
51}
52
53pub trait ObservationAdapter {
54    fn adapt(&self, stdout: &[u8], stderr: &[u8], exit_code: Option<i32>) -> Observation;
55}
56
57#[derive(Debug, Clone, Copy, Default)]
58pub struct ByteObservationAdapter;
59
60impl ObservationAdapter for ByteObservationAdapter {
61    fn adapt(&self, stdout: &[u8], stderr: &[u8], exit_code: Option<i32>) -> Observation {
62        Observation {
63            raw_stdout: stdout.to_vec(),
64            raw_stderr: stderr.to_vec(),
65            exit_code,
66            structured: StructuredObservation {
67                stdout: stdout.to_vec(),
68                stderr: stderr.to_vec(),
69                exit_code,
70                ..StructuredObservation::default()
71            },
72            filesystem: FilesystemManifest::default(),
73        }
74    }
75}
76
77pub fn observation_from_process(
78    stdout: &[u8],
79    stderr: &[u8],
80    exit_code: Option<i32>,
81    root: &Path,
82) -> Observation {
83    let mut observation = ByteObservationAdapter.adapt(stdout, stderr, exit_code);
84    observation.filesystem = deterministic_filesystem_manifest(root).unwrap_or_default();
85    observation
86}
87
88/// Adapt structured fields returned by an AFT command without losing the raw
89/// rendered output. Unknown fields remain in `response` for report callers.
90pub fn observation_from_aft_response(
91    response: &Value,
92    root: &Path,
93    exit_code: Option<i32>,
94) -> Observation {
95    let output = response
96        .get("output")
97        .or_else(|| response.get("text"))
98        .or_else(|| response.get("content"))
99        .and_then(Value::as_str)
100        .unwrap_or_default()
101        .as_bytes()
102        .to_vec();
103    let mut observation = observation_from_process(&output, &[], exit_code, root);
104    if let Some(entries) = response.get("entries").and_then(Value::as_array) {
105        observation.structured.entries = entries
106            .iter()
107            .filter_map(|entry| {
108                if let Some(name) = entry.as_str() {
109                    Some((name.to_string(), false))
110                } else {
111                    let object = entry.as_object()?;
112                    Some((
113                        object.get("name")?.as_str()?.to_string(),
114                        object
115                            .get("is_dir")
116                            .and_then(Value::as_bool)
117                            .unwrap_or(false),
118                    ))
119                }
120            })
121            .collect();
122    }
123    if let Some(files) = response.get("files").and_then(Value::as_array) {
124        let canonical_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
125        observation.structured.selected_paths = files
126            .iter()
127            .filter_map(Value::as_str)
128            .map(|path| {
129                let path = Path::new(path);
130                let relative = path
131                    .strip_prefix(root)
132                    .or_else(|_| path.strip_prefix(&canonical_root))
133                    .unwrap_or(path);
134                relative
135                    .to_string_lossy()
136                    .replace(std::path::MAIN_SEPARATOR, "/")
137            })
138            .collect();
139    }
140    observation
141}
142
143pub fn deterministic_filesystem_manifest(root: &Path) -> std::io::Result<FilesystemManifest> {
144    let mut manifest = FilesystemManifest::new();
145    if !root.exists() {
146        return Ok(manifest);
147    }
148    walk_manifest(root, root, &mut manifest)?;
149    Ok(manifest)
150}
151
152fn walk_manifest(
153    root: &Path,
154    current: &Path,
155    manifest: &mut FilesystemManifest,
156) -> std::io::Result<()> {
157    let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
158    entries.sort_by(|left, right| left.file_name().cmp(&right.file_name()));
159    for entry in entries {
160        let path = entry.path();
161        let relative = path
162            .strip_prefix(root)
163            .unwrap_or(&path)
164            .to_string_lossy()
165            .replace(std::path::MAIN_SEPARATOR, "/");
166        let metadata = fs::symlink_metadata(&path)?;
167        let file_type = metadata.file_type();
168        if file_type.is_symlink() {
169            manifest.insert(
170                relative,
171                ManifestEntry {
172                    kind: ManifestKind::Symlink,
173                    size: metadata.len(),
174                    sha256: None,
175                    link_target: fs::read_link(&path)
176                        .ok()
177                        .map(|target| target.to_string_lossy().into_owned()),
178                },
179            );
180        } else if file_type.is_dir() {
181            manifest.insert(
182                relative,
183                ManifestEntry {
184                    kind: ManifestKind::Directory,
185                    size: 0,
186                    sha256: None,
187                    link_target: None,
188                },
189            );
190            walk_manifest(root, &path, manifest)?;
191        } else if file_type.is_file() {
192            let bytes = fs::read(&path)?;
193            let digest = Sha256::digest(&bytes);
194            manifest.insert(
195                relative,
196                ManifestEntry {
197                    kind: ManifestKind::File,
198                    size: bytes.len() as u64,
199                    sha256: Some(hex_digest(&digest)),
200                    link_target: None,
201                },
202            );
203        }
204    }
205    Ok(())
206}
207
208fn hex_digest(bytes: &[u8]) -> String {
209    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
210}
211
212pub fn manifests_equal(left: &FilesystemManifest, right: &FilesystemManifest) -> bool {
213    left == right
214}
215
216pub fn manifest_unchanged(before: &FilesystemManifest, after: &FilesystemManifest) -> bool {
217    manifests_equal(before, after)
218}
219
220pub fn apply_presentation_normalizations(
221    bytes: &[u8],
222    normalizations: &[&str],
223) -> Result<Vec<u8>, String> {
224    let mut output = bytes.to_vec();
225    for normalization in normalizations {
226        output = match *normalization {
227            "footer-removal" => remove_footer(&output),
228            "gutter-removal" => remove_gutter(&output),
229            other => return Err(format!("unknown presentation normalization: {other}")),
230        };
231    }
232    Ok(output)
233}
234
235fn remove_footer(bytes: &[u8]) -> Vec<u8> {
236    let had_newline = bytes.ends_with(b"\n");
237    let text = String::from_utf8_lossy(bytes);
238    let mut lines = text.lines().collect::<Vec<_>>();
239    if let Some(index) = lines.iter().position(|line| {
240        line.contains("Prefer `") || line.contains("DO NOT search code by running grep/rg in bash")
241    }) {
242        lines.truncate(index);
243        while lines.last().is_some_and(|line| line.is_empty()) {
244            lines.pop();
245        }
246    }
247    let mut output = lines.join("\n").into_bytes();
248    if had_newline {
249        output.push(b'\n');
250    }
251    output
252}
253
254fn remove_gutter(bytes: &[u8]) -> Vec<u8> {
255    let text = String::from_utf8_lossy(bytes);
256    let had_newline = bytes.ends_with(b"\n")
257        || text.lines().any(|line| {
258            line.contains("Prefer `")
259                || line.contains("DO NOT search code by running grep/rg in bash")
260        });
261    let mut output = text
262        .lines()
263        .map(|line| {
264            let Some((number, content)) = line.split_once(": ") else {
265                return line;
266            };
267            if !number.is_empty() && number.chars().all(|char| char.is_ascii_digit()) {
268                content
269            } else {
270                line
271            }
272        })
273        .collect::<Vec<_>>()
274        .join("\n")
275        .into_bytes();
276    if had_newline {
277        output.push(b'\n');
278    }
279    output
280}
281
282pub fn reduce_observation(
283    observation: &Observation,
284    basis: &str,
285    normalizations: &[&str],
286) -> Result<Value, String> {
287    let stdout = apply_presentation_normalizations(&observation.raw_stdout, normalizations)?;
288    let stderr = apply_presentation_normalizations(&observation.raw_stderr, normalizations)?;
289    let stdout_text = String::from_utf8_lossy(&stdout);
290    match basis {
291        "bytes" => Ok(json!({
292            "stdout": stdout,
293            "stderr": stderr,
294            "exit_code": observation.exit_code,
295        })),
296        "ls-entry-set" => {
297            let mut entries = if observation.structured.entries.is_empty() {
298                stdout_text
299                    .lines()
300                    .filter(|line| !line.is_empty())
301                    .map(|line| (line.to_string(), false))
302                    .collect::<Vec<_>>()
303            } else {
304                observation.structured.entries.clone()
305            };
306            entries.sort();
307            entries.dedup();
308            Ok(json!(entries))
309        }
310        "ls-entry-sequence" => {
311            let entries = if observation.structured.entries.is_empty() {
312                stdout_text
313                    .lines()
314                    .filter(|line| !line.is_empty())
315                    .map(|line| (line.to_string(), false))
316                    .collect::<Vec<_>>()
317            } else {
318                observation.structured.entries.clone()
319            };
320            Ok(json!(entries))
321        }
322        "find-path-set" => {
323            let paths = if observation.structured.selected_paths.is_empty() {
324                stdout_text
325                    .lines()
326                    .filter(|line| !line.is_empty())
327                    .map(str::to_owned)
328                    .collect::<Vec<_>>()
329            } else {
330                observation.structured.selected_paths.clone()
331            };
332            let paths = paths
333                .into_iter()
334                .map(|path| path.strip_prefix("./").unwrap_or(&path).to_string())
335                .collect::<Vec<_>>();
336            let mut paths = paths;
337            paths.sort();
338            paths.dedup();
339            Ok(json!(paths))
340        }
341        "grep-match-set" => {
342            let mut matches = if observation.structured.matches.is_empty() {
343                parse_grep_matches(&stdout_text)
344            } else {
345                observation.structured.matches.clone()
346            };
347            matches.sort();
348            matches.dedup();
349            Ok(json!(matches))
350        }
351        "grep-value-multiset" => {
352            let mut values = if observation.structured.values.is_empty() {
353                stdout_text.lines().map(str::to_owned).collect::<Vec<_>>()
354            } else {
355                observation.structured.values.clone()
356            };
357            values.sort();
358            Ok(json!(values))
359        }
360        other => Err(format!("unknown comparison basis: {other}")),
361    }
362}
363
364fn parse_grep_matches(text: &str) -> Vec<(String, u32, String)> {
365    text.lines()
366        .filter_map(|line| {
367            let (file, rest) = line.split_once(':')?;
368            let (number, content) = rest.split_once(':')?;
369            Some((file.to_string(), number.parse().ok()?, content.to_string()))
370        })
371        .collect()
372}
373
374/// Return a compact, JSON-safe value for failure reports without discarding
375/// the exact raw bytes that the caller keeps in `Observation`.
376pub fn observation_summary(observation: &Observation) -> Value {
377    json!({
378        "stdout": String::from_utf8_lossy(&observation.raw_stdout),
379        "stderr": String::from_utf8_lossy(&observation.raw_stderr),
380        "exit_code": observation.exit_code,
381        "filesystem_entries": observation.filesystem.len(),
382        "structured": observation.structured,
383    })
384}
385
386#[allow(dead_code)]
387fn _normalize_path(root: &Path, path: &Path) -> PathBuf {
388    path.strip_prefix(root).unwrap_or(path).to_path_buf()
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn footer_and_gutter_normalizations_are_explicit() {
397        let raw = b"1: alpha\nPrefer `read` tool over bash.\n";
398        let normalized =
399            apply_presentation_normalizations(raw, &["gutter-removal", "footer-removal"]).unwrap();
400        assert_eq!(normalized, b"alpha\n");
401    }
402
403    #[test]
404    fn filesystem_manifest_is_sorted_and_exact() {
405        let root = tempfile::tempdir().unwrap();
406        fs::create_dir(root.path().join("z")).unwrap();
407        fs::write(root.path().join("z/a"), b"a").unwrap();
408        fs::write(root.path().join("b"), b"b").unwrap();
409        let manifest = deterministic_filesystem_manifest(root.path()).unwrap();
410        assert_eq!(manifest.keys().collect::<Vec<_>>(), vec!["b", "z", "z/a"]);
411        assert!(manifest_unchanged(&manifest, &manifest));
412    }
413
414    #[test]
415    fn reducers_do_not_accept_unknown_vocabularies() {
416        let observation = ByteObservationAdapter.adapt(b"x\n", b"", Some(0));
417        assert!(reduce_observation(&observation, "not-a-basis", &[]).is_err());
418        assert!(apply_presentation_normalizations(b"x", &["bytes"]).is_err());
419    }
420}