Skip to main content

supercov_engine/
source_capture.rs

1//! What a run captures of its sources: each file's text at the time of the
2//! run, fingerprinted into the manifest archived with the evidence, and the
3//! check that a later query reads the same bytes.
4use crate::{
5    evidence_archive::EvidenceArchiveEntry,
6    source_manifest::{FileFingerprint, Files, InputManifest, Inputs, local_path},
7    workspace::{canonicalize_simplified, simplified},
8};
9use std::{
10    collections::BTreeSet,
11    fs,
12    path::{Path, PathBuf},
13};
14
15/// The archive entry holding the run's source manifest.
16pub const ARCHIVE_PATH: &str = "source-inputs.json";
17
18/// The text of every given file under `root`, keyed project-relative.
19pub fn capture(
20    root: &Path,
21    language: &str,
22    paths: impl IntoIterator<Item = PathBuf>,
23) -> Result<Inputs, String> {
24    let supplied_root = simplified(root.to_owned());
25    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
26    let mut inputs = Inputs {
27        language: language.into(),
28        files: Files::new(),
29    };
30    for path in paths.into_iter().map(simplified).collect::<BTreeSet<_>>() {
31        let full = if path.is_absolute() {
32            root.join(path.strip_prefix(&supplied_root).unwrap_or(&path))
33        } else {
34            root.join(&path)
35        };
36        if !full.exists() {
37            continue;
38        }
39        let relative = full
40            .strip_prefix(&root)
41            .map_err(|_| format!("source input outside project: {}", full.display()))?
42            .to_string_lossy()
43            .replace('\\', "/");
44        if !local_path(&relative)
45            || !canonicalize_simplified(&full)
46                .map_err(|e| e.to_string())?
47                .starts_with(&root)
48        {
49            return Err(format!("source input outside project: {relative}"));
50        }
51        if inputs.files.contains_key(&relative) {
52            continue;
53        }
54        let bytes = fs::read(&full).map_err(|e| format!("{relative}: {e}"))?;
55        // A file that is not UTF-8 has no text to fingerprint by declaration
56        // or to show; it is left out.
57        let Ok(text) = String::from_utf8(bytes) else {
58            continue;
59        };
60        inputs.files.insert(relative, text);
61    }
62    Ok(inputs)
63}
64
65pub fn append(
66    mut entries: Vec<EvidenceArchiveEntry>,
67    inputs: &Inputs,
68) -> Result<Vec<EvidenceArchiveEntry>, String> {
69    if entries.iter().any(|e| e.path == ARCHIVE_PATH) {
70        return Err("duplicate source inputs".into());
71    }
72    entries.push(EvidenceArchiveEntry {
73        path: ARCHIVE_PATH.into(),
74        contents: serde_json::to_vec(&inputs.manifest()).map_err(|e| e.to_string())?,
75    });
76    Ok(entries)
77}
78
79/// Read project files only when their exact bytes still match the run manifest.
80pub fn current_sources(root: &Path, manifest: &InputManifest) -> Result<Inputs, String> {
81    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
82    let mut files = Files::new();
83    for (file, expected) in &manifest.files {
84        if !local_path(file) {
85            return Err(format!("Invalid source path: {file}"));
86        }
87        let path = root.join(file);
88        let source = (|| {
89            let canonical = canonicalize_simplified(&path).ok()?;
90            if !canonical.starts_with(&root) || !canonical.is_file() {
91                return None;
92            }
93            let text = fs::read_to_string(canonical).ok()?;
94            FileFingerprint::of(&text)
95                .same_bytes(expected)
96                .then_some(text)
97        })();
98        let Some(source) = source else {
99            return Err(format!(
100                "Current source differs from the run or is unavailable: {file}; rerun tests for the current checkout"
101            ));
102        };
103        files.insert(file.clone(), source);
104    }
105    Ok(manifest.with_sources(files))
106}