Skip to main content

majordomus_cli/evidence/
provenance.rs

1//! What a run measured about its own checkout when it ended.
2//!
3//! A recorder that stamps a report with the tree *it* sees vouches for a tree it did not
4//! see: CI records in a fresh checkout, and a local run may be recorded after its checkout
5//! changed. So the run measures and the recorder carries. [`stamp`] is the one measurement a
6//! runner takes at the end of its run: the commit, the tree through
7//! [`crate::git::working_tree_excluding`] (the evidence ledger ignored, the run's own
8//! untracked outputs excluded, a tracked change never hidden), the producer, the toolchain,
9//! the recorder's version, the report's digest, the host and the CI run. The result is an
10//! [`EvidenceProvenance`], which `evidence record --provenance` attaches to that report's
11//! executions.
12//!
13//! A stamp measures the checkout when it is taken, after the report was written, and not
14//! the tree while the run executed: a run on a dirty tree that is cleaned before the stamp
15//! reads clean.
16//!
17//! # A stamp, taken and read back
18//!
19//! ```
20//! use majordomus_cli::evidence::{stamp, EvidenceProducer, EvidenceProvenance, StampRequest, TreeState};
21//! use std::process::Command;
22//!
23//! let root = tempfile::tempdir().unwrap();
24//! let git = |args: &[&str]| {
25//!     Command::new("git").arg("-C").arg(root.path()).args(args).output().unwrap()
26//! };
27//! git(&["init", "-q"]);
28//! git(&["config", "user.email", "t@example.com"]);
29//! git(&["config", "user.name", "t"]);
30//! std::fs::write(root.path().join("a.md"), "a").unwrap();
31//! git(&["add", "-A"]);
32//! git(&["commit", "-qm", "init"]);
33//!
34//! // the run wrote its report into the checkout: it is one of the run's own outputs
35//! std::fs::write(root.path().join("suite.tsv"), "01_a\tok\t1\tparallel\n").unwrap();
36//! let req = StampRequest {
37//!     producer: Some(EvidenceProducer::Suite),
38//!     report: Some(root.path().join("suite.tsv")),
39//!     exclude: vec![],
40//!     run: None,
41//! };
42//! let measured = stamp(root.path(), &req).unwrap();
43//! assert_eq!(measured.working_tree, TreeState::Clean);
44//! assert_eq!(measured.excluded, ["suite.tsv"]);
45//! assert_eq!(measured.report.as_ref().unwrap().path, "suite.tsv");
46//!
47//! let out = tempfile::tempdir().unwrap();
48//! let file = out.path().join("p.json");
49//! std::fs::write(&file, serde_json::to_string(&measured).unwrap()).unwrap();
50//! assert_eq!(EvidenceProvenance::read(&file).unwrap(), measured);
51//! ```
52
53use std::collections::BTreeMap;
54use std::path::{Path, PathBuf};
55
56use schemars::JsonSchema;
57use serde::{Deserialize, Serialize};
58
59use super::freshness::TreeState;
60use super::run::EvidenceProducer;
61use super::{digest_of, RunRef, LEDGER_PATH};
62use crate::error::{Error, Result};
63
64/// How a toolchain's version was known: asked of the executable, or read from the pin the
65/// repository declares.
66///
67/// ```
68/// use majordomus_cli::evidence::EvidenceToolchainSource;
69/// assert_eq!(serde_json::to_value(EvidenceToolchainSource::Pinned).unwrap(), "pinned");
70/// ```
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum EvidenceToolchainSource {
74    /// The executable answered `--version`.
75    Measured,
76    /// The repository pins it (`rust-toolchain.toml`), and the pin is what ran.
77    Pinned,
78}
79
80/// The toolchain the run's producer used.
81///
82/// ```
83/// use majordomus_cli::evidence::EvidenceToolchain;
84/// let t: EvidenceToolchain =
85///     serde_json::from_str(r#"{"name":"rustc","version":"1.2.3","source":"pinned"}"#).unwrap();
86/// assert_eq!(t.version, "1.2.3");
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct EvidenceToolchain {
91    /// The executable: `rustc`, `bash`.
92    pub name: String,
93    /// Its version.
94    pub version: String,
95    /// How the version was known.
96    pub source: EvidenceToolchainSource,
97}
98
99/// The report a measurement belongs to: where it was and what its bytes were.
100///
101/// ```
102/// use majordomus_cli::evidence::EvidenceReportArtifact;
103/// let r: EvidenceReportArtifact =
104///     serde_json::from_str(r#"{"path":"suite.tsv","digest":"sha256:00","bytes":3}"#).unwrap();
105/// assert_eq!(r.bytes, 3);
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct EvidenceReportArtifact {
110    /// Repository-relative when the report is inside the checkout, else its file name alone.
111    /// Never a machine path.
112    pub path: String,
113    /// `sha256:<hex>` of the report's bytes.
114    pub digest: String,
115    /// How many bytes it held.
116    pub bytes: u64,
117}
118
119/// The machine the run measured on.
120///
121/// ```
122/// use majordomus_cli::evidence::EvidenceHost;
123/// let h = EvidenceHost { os: std::env::consts::OS.into(), arch: std::env::consts::ARCH.into() };
124/// assert!(!h.os.is_empty());
125/// ```
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
127#[serde(deny_unknown_fields)]
128pub struct EvidenceHost {
129    /// `std::env::consts::OS`.
130    pub os: String,
131    /// `std::env::consts::ARCH`.
132    pub arch: String,
133}
134
135/// What a run measured about its own checkout when it ended.
136///
137/// Only `commit` and `working_tree` are required: a producing job's tree file
138/// (`{"commit","working_tree","excluded"}`) is a valid, minimal measurement. It names no
139/// report, so it is accepted only for a CI recording.
140///
141/// ```
142/// use majordomus_cli::evidence::{EvidenceProvenance, TreeState};
143/// let head = "0123456789abcdef0123456789abcdef01234567";
144/// let p: EvidenceProvenance = serde_json::from_value(serde_json::json!({
145///     "commit": head, "working_tree": "clean", "excluded": ["suite.tsv"]
146/// })).unwrap();
147/// assert_eq!(p.working_tree, TreeState::Clean);
148/// assert!(p.report.is_none());
149/// ```
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(deny_unknown_fields)]
152pub struct EvidenceProvenance {
153    /// Which report this measures, when named.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub producer: Option<EvidenceProducer>,
156    /// The full commit the checkout was at.
157    pub commit: String,
158    /// The tree, the evidence ledger ignored and the run's own untracked outputs excluded.
159    pub working_tree: TreeState,
160    /// The run's own untracked outputs that were excluded, the report among them when it is
161    /// inside the checkout. The ledger is always ignored and not listed.
162    #[serde(default)]
163    pub excluded: Vec<String>,
164    /// When it was measured, RFC 3339 UTC.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub measured_at: Option<String>,
167    /// The producer's toolchain.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub toolchain: Option<EvidenceToolchain>,
170    /// The version of the executable that measured.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub recorder: Option<String>,
173    /// The report measured.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub report: Option<EvidenceReportArtifact>,
176    /// The machine.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub host: Option<EvidenceHost>,
179    /// The CI run, when there was one.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub run: Option<RunRef>,
182}
183
184/// What to measure: which producer's run, its report, its own untracked outputs, its CI run.
185///
186/// ```
187/// use majordomus_cli::evidence::StampRequest;
188/// let req = StampRequest { producer: None, report: None, exclude: vec!["dist".into()], run: None };
189/// assert_eq!(req.exclude, ["dist"]);
190/// ```
191#[derive(Debug, Clone, Default)]
192pub struct StampRequest {
193    /// The producer whose run this is.
194    pub producer: Option<EvidenceProducer>,
195    /// The report the run wrote.
196    pub report: Option<PathBuf>,
197    /// The run's own untracked outputs, repository-relative paths (files or directories).
198    pub exclude: Vec<String>,
199    /// The CI run, when there is one.
200    pub run: Option<RunRef>,
201}
202
203fn refused(reason: impl Into<String>) -> Error {
204    Error::InvalidSurface {
205        surface: "evidence".into(),
206        reason: reason.into(),
207    }
208}
209
210/// Measure the checkout at `root` as the run `req` describes left it.
211///
212/// Refused, before anything is measured, when there is no commit, when an exclude is not a
213/// repository path, is a pattern, names the whole checkout or holds tracked files (an
214/// exclude names only the run's own untracked outputs), and when the report is unreadable.
215///
216/// ```
217/// use majordomus_cli::evidence::{stamp, StampRequest};
218/// let empty = tempfile::tempdir().unwrap();
219/// let err = stamp(empty.path(), &StampRequest::default()).unwrap_err().to_string();
220/// assert!(err.contains("no commit to measure"), "{err}");
221/// ```
222pub fn stamp(root: &Path, req: &StampRequest) -> Result<EvidenceProvenance> {
223    let commit = match crate::git::inspect(root) {
224        crate::git::GitState::Available(i) => i.head,
225        crate::git::GitState::Unavailable { .. } => None,
226    }
227    .ok_or_else(|| {
228        refused("there is no commit to measure: a run measured here would carry no provenance")
229    })?;
230
231    let mut outputs = Vec::new();
232    for e in &req.exclude {
233        outputs.push(checked_exclude(root, e)?);
234    }
235
236    let report = match &req.report {
237        Some(path) => {
238            let bytes = std::fs::read(path).map_err(|e| {
239                refused(format!("the report {} cannot be read: {e}", path.display()))
240            })?;
241            let (name, inside) = report_path(root, path);
242            if inside {
243                outputs.push(name.clone());
244            }
245            Some(EvidenceReportArtifact {
246                path: name,
247                digest: digest_of(&bytes),
248                bytes: bytes.len() as u64,
249            })
250        }
251        None => None,
252    };
253    // the canonical order: the excluded outputs are shown, so they read as every list does
254    crate::order::canonical(&mut outputs);
255    outputs.dedup();
256
257    let refs: Vec<&str> = outputs.iter().map(String::as_str).collect();
258    let working_tree = TreeState::parse(&crate::git::working_tree_excluding(
259        root,
260        &[LEDGER_PATH],
261        &refs,
262    ));
263
264    Ok(EvidenceProvenance {
265        producer: req.producer,
266        commit,
267        working_tree,
268        excluded: outputs,
269        measured_at: Some(super::record::now_rfc3339()),
270        toolchain: req.producer.and_then(|p| toolchain(root, p)),
271        recorder: Some(crate::VERSION.to_string()),
272        report,
273        host: Some(EvidenceHost {
274            os: std::env::consts::OS.into(),
275            arch: std::env::consts::ARCH.into(),
276        }),
277        run: req.run.clone(),
278    })
279}
280
281/// An exclude as a repository path, or why it is not one the stamp accepts.
282fn checked_exclude(root: &Path, raw: &str) -> Result<String> {
283    let mut e = raw;
284    while let Some(rest) = e.strip_prefix("./") {
285        e = rest;
286    }
287    let e = e.trim_end_matches('/');
288    if e.is_empty() || e == "." {
289        return Err(refused(format!("`{raw}` names the whole checkout")));
290    }
291    let not_a_path = Path::new(e).is_absolute()
292        || e.split('/').any(|s| s == "..")
293        || e.starts_with(':')
294        || e.starts_with('-')
295        || e.contains('\0');
296    if not_a_path {
297        return Err(refused(format!("`{raw}` is not a repository path")));
298    }
299    if e.contains(['*', '?', '[', '\\']) {
300        return Err(refused(format!(
301            "`{raw}`: an exclude is a path, not a pattern"
302        )));
303    }
304    match crate::git::ls_files(root, &format!(":(literal){e}")) {
305        Ok(tracked) if tracked.is_empty() => Ok(e.to_string()),
306        Ok(_) => Err(refused(format!(
307            "`{raw}` holds tracked files: an exclude names only the run's own untracked \
308             outputs, and a change to a tracked file is always a change"
309        ))),
310        Err(err) => Err(refused(format!(
311            "`{raw}` could not be checked against the index: {err}"
312        ))),
313    }
314}
315
316/// The report's name as a measurement carries it, and whether it is inside the checkout:
317/// repository-relative when it is, else its file name alone, never a machine path.
318fn report_path(root: &Path, report: &Path) -> (String, bool) {
319    let canonical = |p: &Path| std::fs::canonicalize(p).ok();
320    if let (Some(r), Some(root)) = (canonical(report), canonical(root)) {
321        if let Ok(rel) = r.strip_prefix(&root) {
322            let parts: Vec<String> = rel
323                .components()
324                .map(|c| c.as_os_str().to_string_lossy().into_owned())
325                .collect();
326            return (parts.join("/"), true);
327        }
328    }
329    let name = report.file_name().map(|n| n.to_string_lossy().into_owned());
330    (name.unwrap_or_default(), false)
331}
332
333/// The toolchain `producer`'s run used: the repository's Rust pin for a crate or coverage
334/// run, else what `rustc` answers; `bash` for the suite.
335fn toolchain(root: &Path, producer: EvidenceProducer) -> Option<EvidenceToolchain> {
336    use crate::environment::toolchain as tc;
337    let measured = |name: &str, version: String| EvidenceToolchain {
338        name: name.into(),
339        version,
340        source: EvidenceToolchainSource::Measured,
341    };
342    match producer {
343        EvidenceProducer::Crate | EvidenceProducer::Coverage => {
344            let pinned = tc::declared(root).into_iter().find(|t| {
345                t.id == "rust"
346                    && matches!(
347                        t.declared_by.as_str(),
348                        "rust-toolchain.toml" | "rust-toolchain"
349                    )
350            });
351            if let Some(version) = pinned.and_then(|t| t.declared) {
352                return Some(EvidenceToolchain {
353                    name: "rustc".into(),
354                    version,
355                    source: EvidenceToolchainSource::Pinned,
356                });
357            }
358            version_of("rustc")
359                .as_deref()
360                .and_then(rustc_version)
361                .map(|v| measured("rustc", v))
362        }
363        EvidenceProducer::Suite => version_of("bash")
364            .as_deref()
365            .and_then(bash_version)
366            .map(|v| measured("bash", v)),
367    }
368}
369
370/// What `<exe> --version` printed, within the toolchain probe's bound; `None` when it could
371/// not be run, outlived the bound or failed.
372fn version_of(exe: &str) -> Option<String> {
373    use crate::environment::{probe::bounded_output, toolchain as tc};
374    let out = bounded_output(
375        std::process::Command::new(exe).arg("--version"),
376        tc::VERSION_TIMEOUT,
377    )?;
378    out.status
379        .success()
380        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
381}
382
383/// The version `rustc --version` states: its second word (`rustc 1.89.0 (...)`).
384fn rustc_version(text: &str) -> Option<String> {
385    text.split_whitespace().nth(1).map(str::to_string)
386}
387
388/// The version `bash --version` states on its first line, after `version `.
389fn bash_version(text: &str) -> Option<String> {
390    let first = text.lines().next()?;
391    let (_, rest) = first.split_once("version ")?;
392    rest.split_whitespace().next().map(str::to_string)
393}
394
395impl EvidenceProvenance {
396    /// The measurement a file holds, or why it holds none: an unreadable file, bad JSON, an
397    /// unknown field or a tree word that is not one, each naming the file.
398    ///
399    /// ```
400    /// use majordomus_cli::evidence::EvidenceProvenance;
401    /// let dir = tempfile::tempdir().unwrap();
402    /// let f = dir.path().join("p.json");
403    /// std::fs::write(&f, r#"{"commit":"c","working_tree":"clean","extra":1}"#).unwrap();
404    /// assert!(EvidenceProvenance::read(&f).unwrap_err().contains("p.json"));
405    /// ```
406    pub fn read(path: &Path) -> std::result::Result<EvidenceProvenance, String> {
407        let text = std::fs::read_to_string(path)
408            .map_err(|e| format!("the measurement {} cannot be read: {e}", path.display()))?;
409        let value: serde_json::Value = serde_json::from_str(&text)
410            .map_err(|e| format!("the measurement {} is not JSON: {e}", path.display()))?;
411        // TreeState reads any word as `unknown`; a measurement that says something else is
412        // a measurement nobody can read, and is refused rather than weakened in silence.
413        let tree = value.get("working_tree").and_then(|t| t.as_str());
414        if let Some(word) = tree {
415            if !matches!(word, "clean" | "dirty" | "unknown") {
416                return Err(format!(
417                    "the measurement {} states the tree `{word}`, which is not clean, dirty \
418                     or unknown",
419                    path.display()
420                ));
421            }
422        }
423        serde_json::from_value(value)
424            .map_err(|e| format!("the measurement {} is not one: {e}", path.display()))
425    }
426
427    /// The measurement of each report, from `--provenance` values (`<file>` or
428    /// `<producer>=<file>`) and the producers whose reports were given.
429    ///
430    /// A value's producer is its key, else the file's own, else the only report given;
431    /// refused when none of those names one, when a key and the file disagree, and when a
432    /// producer is given two measurements.
433    ///
434    /// ```
435    /// use majordomus_cli::evidence::{EvidenceProducer, EvidenceProvenance};
436    /// let dir = tempfile::tempdir().unwrap();
437    /// let f = dir.path().join("p.json");
438    /// std::fs::write(&f, r#"{"commit":"c","working_tree":"clean"}"#).unwrap();
439    /// let v = vec![f.display().to_string()];
440    /// let got = EvidenceProvenance::resolve(&v, &[EvidenceProducer::Suite]).unwrap();
441    /// assert_eq!(got[&EvidenceProducer::Suite].producer, Some(EvidenceProducer::Suite));
442    /// let two = [EvidenceProducer::Suite, EvidenceProducer::Crate];
443    /// assert!(EvidenceProvenance::resolve(&v, &two).unwrap_err().contains("<producer>=<file>"));
444    /// ```
445    pub fn resolve(
446        values: &[String],
447        given: &[EvidenceProducer],
448    ) -> std::result::Result<BTreeMap<EvidenceProducer, EvidenceProvenance>, String> {
449        let mut out = BTreeMap::new();
450        for value in values {
451            let (key, file) = match value.split_once('=') {
452                Some((k, f)) => match EvidenceProducer::parse(k) {
453                    Some(p) => (Some(p), f),
454                    None => (None, value.as_str()),
455                },
456                None => (None, value.as_str()),
457            };
458            if file.is_empty() {
459                return Err(format!("`{value}` names no measurement file"));
460            }
461            let mut prov = EvidenceProvenance::read(Path::new(file))?;
462            let producer = match (key, prov.producer, given) {
463                (Some(k), _, _) => k,
464                (None, Some(own), _) => own,
465                (None, None, [only]) => *only,
466                (None, None, _) => {
467                    return Err(format!(
468                        "`{file}` names no producer and more than one report was given: give \
469                         it as `<producer>=<file>`"
470                    ))
471                }
472            };
473            if let Some(own) = prov.producer.filter(|own| *own != producer) {
474                return Err(format!(
475                    "`{file}` measures the {} report, not the {} one",
476                    own.as_str(),
477                    producer.as_str()
478                ));
479            }
480            prov.producer = Some(producer);
481            if out.insert(producer, prov).is_some() {
482                return Err(format!(
483                    "the {} report was given two measurements",
484                    producer.as_str()
485                ));
486            }
487        }
488        Ok(out)
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use std::process::Command;
496
497    fn git(dir: &Path, args: &[&str]) -> String {
498        let out = Command::new("git")
499            .arg("-C")
500            .arg(dir)
501            .args(args)
502            .output()
503            .unwrap();
504        assert!(out.status.success(), "git {args:?}");
505        String::from_utf8(out.stdout).unwrap().trim().to_string()
506    }
507
508    fn repo(files: &[&str]) -> tempfile::TempDir {
509        let dir = tempfile::tempdir().unwrap();
510        git(dir.path(), &["init", "-q"]);
511        git(dir.path(), &["config", "user.email", "t@example.com"]);
512        git(dir.path(), &["config", "user.name", "t"]);
513        for f in files {
514            write(dir.path(), f, "committed");
515        }
516        git(dir.path(), &["add", "-A"]);
517        git(dir.path(), &["commit", "-qm", "init"]);
518        dir
519    }
520
521    fn write(root: &Path, path: &str, text: &str) {
522        let p = root.join(path);
523        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
524        std::fs::write(p, text).unwrap();
525    }
526
527    fn excluding(exclude: &[&str]) -> StampRequest {
528        StampRequest {
529            exclude: exclude.iter().map(|e| e.to_string()).collect(),
530            ..StampRequest::default()
531        }
532    }
533
534    fn refusal(root: &Path, req: &StampRequest) -> String {
535        stamp(root, req).unwrap_err().to_string()
536    }
537
538    #[test]
539    fn a_stamp_on_a_committed_tree_is_clean_and_names_its_commit() {
540        let dir = repo(&["a.md"]);
541        let p = stamp(dir.path(), &StampRequest::default()).unwrap();
542        assert_eq!(p.commit, git(dir.path(), &["rev-parse", "HEAD"]));
543        assert_eq!(p.working_tree, TreeState::Clean);
544        assert!(p.excluded.is_empty() && p.report.is_none() && p.producer.is_none());
545        assert_eq!(p.recorder.as_deref(), Some(crate::VERSION));
546        assert_eq!(p.host.as_ref().unwrap().os, std::env::consts::OS);
547        assert!(p.measured_at.as_ref().unwrap().ends_with('Z'));
548    }
549
550    #[test]
551    fn the_ledgers_own_working_copy_does_not_make_a_stamp_dirty() {
552        let dir = repo(&["a.md", LEDGER_PATH]);
553        write(dir.path(), LEDGER_PATH, "{\"changed\": true}");
554        assert_eq!(
555            stamp(dir.path(), &StampRequest::default())
556                .unwrap()
557                .working_tree,
558            TreeState::Clean
559        );
560        write(dir.path(), "a.md", "edited");
561        assert_eq!(
562            stamp(dir.path(), &StampRequest::default())
563                .unwrap()
564                .working_tree,
565            TreeState::Dirty
566        );
567    }
568
569    #[test]
570    fn an_excluded_directory_is_excluded_with_everything_under_it_and_nothing_else_is() {
571        let dir = repo(&["a.md"]);
572        write(dir.path(), "dist/out.bin", "x");
573        write(dir.path(), "dist/deep/more.bin", "x");
574        write(dir.path(), "timings.tsv", "x");
575        let got = stamp(dir.path(), &excluding(&["./dist/", "timings.tsv"])).unwrap();
576        assert_eq!(got.working_tree, TreeState::Clean);
577        assert_eq!(got.excluded, ["dist", "timings.tsv"], "normalised, sorted");
578        let got = stamp(dir.path(), &excluding(&["timings.tsv"])).unwrap();
579        assert_eq!(got.working_tree, TreeState::Dirty, "dist is not excluded");
580        write(dir.path(), "distx/y", "x");
581        let got = stamp(dir.path(), &excluding(&["dist", "timings.tsv"])).unwrap();
582        assert_eq!(
583            got.working_tree,
584            TreeState::Dirty,
585            "distx is not under dist"
586        );
587    }
588
589    #[test]
590    fn an_exclude_that_holds_tracked_files_is_refused() {
591        let dir = repo(&["a.md", "test/cases/01_a.sh"]);
592        write(dir.path(), "test/cases/01_a.sh", "edited");
593        for e in [".", "./", "test", "test/", "test/cases/01_a.sh"] {
594            let err = refusal(dir.path(), &excluding(&[e]));
595            assert!(err.contains(&format!("`{e}`")), "{e}: {err}");
596            assert!(
597                err.contains("whole checkout") || err.contains("tracked files"),
598                "{e}: {err}"
599            );
600        }
601    }
602
603    #[test]
604    fn an_exclude_that_is_a_pattern_or_not_a_repository_path_is_refused() {
605        let dir = repo(&["a.md"]);
606        for e in ["*", "a?", "[ab]", "a\\b"] {
607            let err = refusal(dir.path(), &excluding(&[e]));
608            assert!(err.contains("a path, not a pattern"), "{e}: {err}");
609        }
610        for e in ["/tmp", "../x", "a/../b", ":x", "-x", "a\0b"] {
611            let err = refusal(dir.path(), &excluding(&[e]));
612            assert!(err.contains("is not a repository path"), "{e}: {err}");
613        }
614        let err = refusal(dir.path(), &excluding(&[""]));
615        assert!(err.contains("whole checkout"), "{err}");
616    }
617
618    #[test]
619    fn an_exclude_that_cannot_be_checked_against_the_index_is_refused() {
620        // a checkout whose index git cannot read: HEAD resolves, ls-files does not
621        let dir = repo(&["a.md"]);
622        std::fs::write(dir.path().join(".git/index"), "not an index").unwrap();
623        let err = refusal(dir.path(), &excluding(&["dist"]));
624        assert!(
625            err.contains("could not be checked against the index"),
626            "{err}"
627        );
628    }
629
630    #[test]
631    fn a_report_inside_the_checkout_is_one_of_the_runs_outputs() {
632        let dir = repo(&["a.md"]);
633        write(dir.path(), "out/suite.tsv", "01_a\tok\t1\tparallel\n");
634        let req = StampRequest {
635            report: Some(dir.path().join("out/suite.tsv")),
636            ..StampRequest::default()
637        };
638        let got = stamp(dir.path(), &req).unwrap();
639        assert_eq!(got.working_tree, TreeState::Clean);
640        assert_eq!(got.excluded, ["out/suite.tsv"]);
641        assert_eq!(got.report.unwrap().path, "out/suite.tsv");
642    }
643
644    #[test]
645    fn a_stamp_without_a_commit_is_refused() {
646        let dir = tempfile::tempdir().unwrap();
647        assert!(refusal(dir.path(), &StampRequest::default()).contains("no commit to measure"));
648        git(dir.path(), &["init", "-q"]);
649        assert!(refusal(dir.path(), &StampRequest::default()).contains("no commit to measure"));
650    }
651
652    #[test]
653    fn the_report_is_named_without_a_machine_path_and_digested() {
654        let dir = repo(&["a.md"]);
655        let elsewhere = tempfile::tempdir().unwrap();
656        let report = elsewhere.path().join("all.tsv");
657        std::fs::write(&report, "01_a\tok\t1\tparallel\n").unwrap();
658        let req = StampRequest {
659            report: Some(report.clone()),
660            ..StampRequest::default()
661        };
662        let got = stamp(dir.path(), &req).unwrap();
663        let artifact = got.report.unwrap();
664        assert_eq!(artifact.path, "all.tsv");
665        assert_eq!(artifact.digest, digest_of(&std::fs::read(&report).unwrap()));
666        assert_eq!(artifact.bytes, 19);
667        assert!(
668            got.excluded.is_empty(),
669            "a report outside the checkout is no output of it"
670        );
671
672        let missing = StampRequest {
673            report: Some(elsewhere.path().join("absent.tsv")),
674            ..StampRequest::default()
675        };
676        assert!(refusal(dir.path(), &missing).contains("absent.tsv"));
677    }
678
679    #[test]
680    fn the_crate_toolchain_is_pinned_from_rust_toolchain_toml() {
681        let dir = repo(&["a.md"]);
682        write(
683            dir.path(),
684            "rust-toolchain.toml",
685            "[toolchain]\nchannel = \"1.2.3\"\n",
686        );
687        git(dir.path(), &["add", "-A"]);
688        git(dir.path(), &["commit", "-qm", "pin"]);
689        for p in [EvidenceProducer::Crate, EvidenceProducer::Coverage] {
690            let req = StampRequest {
691                producer: Some(p),
692                ..StampRequest::default()
693            };
694            let t = stamp(dir.path(), &req).unwrap().toolchain.unwrap();
695            assert_eq!(
696                t,
697                EvidenceToolchain {
698                    name: "rustc".into(),
699                    version: "1.2.3".into(),
700                    source: EvidenceToolchainSource::Pinned,
701                }
702            );
703        }
704    }
705
706    #[test]
707    fn an_unpinned_toolchain_is_measured() {
708        let dir = repo(&["a.md"]);
709        // a minimum is not a pin
710        write(
711            dir.path(),
712            "Cargo.toml",
713            "[package]\nrust-version = \"1.0\"\n",
714        );
715        if let Some(t) = toolchain(dir.path(), EvidenceProducer::Crate) {
716            assert_eq!(
717                (t.name.as_str(), t.source),
718                ("rustc", EvidenceToolchainSource::Measured)
719            );
720            assert_ne!(t.version, "1.0");
721        }
722        let bash = toolchain(dir.path(), EvidenceProducer::Suite).unwrap();
723        assert_eq!(
724            (bash.name.as_str(), bash.source),
725            ("bash", EvidenceToolchainSource::Measured)
726        );
727        assert!(
728            bash.version.chars().next().unwrap().is_ascii_digit(),
729            "{}",
730            bash.version
731        );
732    }
733
734    #[test]
735    fn a_stamp_without_a_producer_measures_no_toolchain() {
736        let dir = repo(&["a.md"]);
737        write(
738            dir.path(),
739            "rust-toolchain.toml",
740            "[toolchain]\nchannel = \"1.2.3\"\n",
741        );
742        assert!(stamp(dir.path(), &StampRequest::default())
743            .unwrap()
744            .toolchain
745            .is_none());
746    }
747
748    #[test]
749    fn a_minimal_measurement_reads_as_a_provenance() {
750        let dir = tempfile::tempdir().unwrap();
751        let f = dir.path().join("suite-tree.json");
752        let head = "0123456789abcdef0123456789abcdef01234567";
753        std::fs::write(
754            &f,
755            format!(r#"{{"commit":"{head}","working_tree":"clean","excluded":["suite.tsv"]}}"#),
756        )
757        .unwrap();
758        let p = EvidenceProvenance::read(&f).unwrap();
759        assert_eq!(
760            (p.commit.as_str(), p.working_tree),
761            (head, TreeState::Clean)
762        );
763        assert_eq!(p.excluded, ["suite.tsv"]);
764        assert!(p.producer.is_none() && p.report.is_none() && p.toolchain.is_none());
765    }
766
767    #[test]
768    fn a_measurement_with_an_unknown_field_is_refused() {
769        let dir = tempfile::tempdir().unwrap();
770        let f = dir.path().join("p.json");
771        for (text, why) in [
772            (
773                r#"{"commit":"c","working_tree":"clean","extra":1}"#,
774                "is not one",
775            ),
776            (r#"{"commit":"c","working_tree":"spotless"}"#, "`spotless`"),
777            (r#"{"working_tree":"clean"}"#, "is not one"),
778            ("not json", "is not JSON"),
779        ] {
780            std::fs::write(&f, text).unwrap();
781            let err = EvidenceProvenance::read(&f).unwrap_err();
782            assert!(err.contains("p.json") && err.contains(why), "{text}: {err}");
783        }
784        let err = EvidenceProvenance::read(&dir.path().join("absent.json")).unwrap_err();
785        assert!(err.contains("cannot be read"), "{err}");
786    }
787
788    fn measurement(dir: &Path, name: &str, producer: Option<&str>) -> String {
789        let f = dir.join(name);
790        let mut v = serde_json::json!({"commit": "c", "working_tree": "clean"});
791        if let Some(p) = producer {
792            v["producer"] = p.into();
793        }
794        std::fs::write(&f, v.to_string()).unwrap();
795        f.display().to_string()
796    }
797
798    #[test]
799    fn a_keyless_measurement_takes_its_producer_from_the_file_or_the_only_report() {
800        use EvidenceProducer::*;
801        let dir = tempfile::tempdir().unwrap();
802        let own = measurement(dir.path(), "own.json", Some("crate"));
803        let bare = measurement(dir.path(), "bare.json", None);
804        let got = EvidenceProvenance::resolve(std::slice::from_ref(&own), &[Suite, Crate]).unwrap();
805        assert_eq!(got.keys().copied().collect::<Vec<_>>(), [Crate]);
806        let got = EvidenceProvenance::resolve(std::slice::from_ref(&bare), &[Coverage]).unwrap();
807        assert_eq!(got[&Coverage].producer, Some(Coverage));
808        let keyed = format!("suite={bare}");
809        let got = EvidenceProvenance::resolve(&[keyed, own], &[Suite, Crate]).unwrap();
810        assert_eq!(got.keys().copied().collect::<Vec<_>>(), [Suite, Crate]);
811        assert!(got.values().all(|p| p.producer.is_some()));
812    }
813
814    #[test]
815    fn a_keyless_measurement_without_a_producer_among_several_reports_is_refused() {
816        use EvidenceProducer::*;
817        let dir = tempfile::tempdir().unwrap();
818        let bare = measurement(dir.path(), "bare.json", None);
819        for given in [&[Suite, Crate][..], &[][..]] {
820            let err = EvidenceProvenance::resolve(std::slice::from_ref(&bare), given).unwrap_err();
821            assert!(err.contains("give it as `<producer>=<file>`"), "{err}");
822        }
823        let err = EvidenceProvenance::resolve(&["suite=".to_string()], &[Suite]).unwrap_err();
824        assert!(err.contains("names no measurement file"), "{err}");
825    }
826
827    #[test]
828    fn a_key_that_disagrees_with_the_file_is_refused() {
829        let dir = tempfile::tempdir().unwrap();
830        let own = measurement(dir.path(), "own.json", Some("crate"));
831        let err =
832            EvidenceProvenance::resolve(&[format!("suite={own}")], &[EvidenceProducer::Suite])
833                .unwrap_err();
834        assert!(
835            err.contains("measures the crate report, not the suite one"),
836            "{err}"
837        );
838    }
839
840    #[test]
841    fn a_producer_given_twice_is_refused() {
842        let dir = tempfile::tempdir().unwrap();
843        let a = measurement(dir.path(), "a.json", Some("suite"));
844        let b = measurement(dir.path(), "b.json", None);
845        let err =
846            EvidenceProvenance::resolve(&[a, format!("suite={b}")], &[EvidenceProducer::Suite])
847                .unwrap_err();
848        assert!(
849            err.contains("the suite report was given two measurements"),
850            "{err}"
851        );
852    }
853
854    /// A measurement that states no tree at all is not one: the tree is what it measures.
855    #[test]
856    fn a_measurement_without_a_tree_is_refused() {
857        let dir = tempfile::tempdir().unwrap();
858        let f = dir.path().join("p.json");
859        std::fs::write(&f, r#"{"commit":"c"}"#).unwrap();
860        let err = EvidenceProvenance::read(&f).unwrap_err();
861        assert!(err.contains("is not one"), "{err}");
862    }
863
864    /// A value whose `=` names no producer is a file name that holds one, read whole.
865    #[test]
866    fn a_value_whose_key_is_no_producer_is_read_as_a_file_name() {
867        let dir = tempfile::tempdir().unwrap();
868        let value = format!("{}/nightly=p.json", dir.path().display());
869        let err = EvidenceProvenance::resolve(std::slice::from_ref(&value), &[]).unwrap_err();
870        assert!(
871            err.contains("cannot be read") && err.contains("nightly=p.json"),
872            "{err}"
873        );
874    }
875
876    /// A report outside the checkout, or one that does not exist, is named by its file name
877    /// alone and is not one of the run's outputs.
878    #[test]
879    fn a_report_outside_the_checkout_is_named_by_its_file_name() {
880        let dir = repo(&["a.md"]);
881        let elsewhere = tempfile::tempdir().unwrap();
882        let outside = elsewhere.path().join("suite.tsv");
883        std::fs::write(&outside, "x").unwrap();
884        assert_eq!(
885            report_path(dir.path(), &outside),
886            ("suite.tsv".to_string(), false)
887        );
888        assert_eq!(
889            report_path(dir.path(), &elsewhere.path().join("absent.tsv")),
890            ("absent.tsv".to_string(), false)
891        );
892    }
893
894    /// A toolchain that cannot be asked, or whose answer states no version, measures nothing.
895    #[test]
896    fn a_toolchain_that_states_no_version_measures_nothing() {
897        assert_eq!(
898            version_of("majordomus-no-such-executable-on-any-path"),
899            None
900        );
901        assert_eq!(
902            rustc_version("rustc 1.89.0 (abc 2025-01-01)"),
903            Some("1.89.0".into())
904        );
905        assert_eq!(rustc_version("rustc"), None);
906        assert_eq!(
907            bash_version("GNU bash, version 5.2.37(1)-release (aarch64)\nmore"),
908            Some("5.2.37(1)-release".into())
909        );
910        assert_eq!(bash_version(""), None);
911        assert_eq!(bash_version("GNU bash"), None);
912        assert_eq!(bash_version("GNU bash, version "), None);
913    }
914}