Skip to main content

git_attest/
fingerprint.rs

1//! Fingerprints of a tree: a gate's declared inputs, and the canonical tree
2//! (SPEC.md, "Input fingerprints", "Canonical tree"), with the synthetic note
3//! keys they are filed under.
4
5use std::path::Path;
6
7use crate::format::is_oid;
8use crate::git;
9use crate::spec::{implicit_inputs, Spec};
10
11/// Why a fingerprint or a key could not be computed. Every one means "no
12/// fingerprint here", which can only cost a skip.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum Error {
16    /// The spec declares no such gate.
17    NotDeclared,
18    /// A required path does not exist in the tree.
19    MissingPath,
20    /// The listing was empty, which would hash alike on every tree.
21    EmptyListing,
22    /// The listing was not a well-formed `ls-tree` listing.
23    Malformed,
24    /// git failed, or printed something that is not an object id.
25    Git,
26}
27
28impl std::fmt::Display for Error {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.write_str(match self {
31            Error::NotDeclared => "the spec declares no such gate",
32            Error::MissingPath => "a declared path does not exist in this tree",
33            Error::EmptyListing => "the declared paths list nothing",
34            Error::Malformed => "the tree listing is malformed",
35            Error::Git => "git failed",
36        })
37    }
38}
39
40impl std::error::Error for Error {}
41
42/// `hash-object --stdin` of `bytes`, as an object id.
43fn hash(root: &Path, bytes: &[u8]) -> Result<String, Error> {
44    git::stdout_with_input_in(root, &["hash-object", "--stdin"], bytes)
45        .filter(|s| is_oid(s))
46        .ok_or(Error::Git)
47}
48
49/// The fingerprint of `gate` on `tree`. An empty listing would hash to the
50/// same value on every tree and is refused as an assertion.
51pub fn fingerprint(root: &Path, tree: &str, spec: &Spec, gate: &str) -> Result<String, Error> {
52    let toks = spec.paths_of(gate).ok_or(Error::NotDeclared)?;
53    let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
54    // Every REQUIRED token must resolve — one `cat-file --batch-check` for
55    // all of them, never one process per token. An optional one is listed if
56    // it is there and bound by its absence if it is not.
57    let required: Vec<&str> = toks
58        .iter()
59        .filter(|t| !t.optional)
60        .map(|t| t.path.as_str())
61        .collect();
62    if !required.is_empty() {
63        let names: String = required.iter().map(|p| format!("{tree}:{p}\n")).collect();
64        let answers =
65            git::stdout_with_input_in(root, &["cat-file", "--batch-check"], names.as_bytes())
66                .ok_or(Error::Git)?;
67        if answers.lines().count() != required.len() {
68            return Err(Error::Git);
69        }
70        if answers.lines().any(|l| l.ends_with(" missing")) {
71            return Err(Error::MissingPath);
72        }
73    }
74    let mut args: Vec<String> = vec![
75        "ls-tree".into(),
76        "-r".into(),
77        "-z".into(),
78        "--full-tree".into(),
79        tree.to_string(),
80        "--".into(),
81    ];
82    args.extend(implicit_inputs(&paths));
83    args.extend(paths.iter().cloned());
84    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
85    let listing = git::stdout_bytes_in(root, &argv).ok_or(Error::Git)?;
86    if listing.is_empty() {
87        return Err(Error::EmptyListing);
88    }
89    hash(root, &listing)
90}
91
92/// The bytes a fingerprint key is hashed from.
93pub fn key_preimage(gate: &str, fp: &str) -> String {
94    format!("amont-attest-input {gate} {fp}\n")
95}
96
97/// The synthetic note key for (gate, fingerprint): an oid that is not an
98/// object, which `git notes` accepts as a key all the same.
99pub fn key(root: &Path, gate: &str, fp: &str) -> Result<String, Error> {
100    hash(root, key_preimage(gate, fp).as_bytes())
101}
102
103/// The canonical text of an `ls-tree -r --full-tree` listing (paths quoted by
104/// `core.quotePath=true`, so one record is one line): the same lines, except
105/// that a regular file (`100644`, `100755`) under the top-level `docs/plans/`
106/// has its oid replaced by zeros of the same length. Existence stays; content
107/// does not count. Symlinks and gitlinks are never neutralised. `None` for an
108/// empty or malformed listing.
109pub fn canon_text(listing: &str) -> Option<String> {
110    let mut out = String::with_capacity(listing.len());
111    for line in listing.lines() {
112        let (head, path) = line.split_once('\t')?;
113        let mut f = head.split(' ');
114        let (mode, kind, oid) = (f.next()?, f.next()?, f.next()?);
115        if f.next().is_some() {
116            return None;
117        }
118        // A quoted path keeps its first characters: `"docs/plans/...`.
119        let under_plans = path.starts_with("docs/plans/") || path.starts_with("\"docs/plans/");
120        if matches!(mode, "100644" | "100755") && under_plans {
121            out.push_str(&format!(
122                "{mode} {kind} {}\t{path}\n",
123                "0".repeat(oid.len())
124            ));
125        } else {
126            out.push_str(line);
127            out.push('\n');
128        }
129    }
130    (!out.is_empty()).then_some(out)
131}
132
133/// The canonical fingerprint of `tree`: `canon_text` of its listing, hashed
134/// with `git hash-object`.
135pub fn canon(root: &Path, tree: &str) -> Result<String, Error> {
136    let listing = git::stdout_bytes_in(
137        root,
138        &[
139            "-c",
140            "core.quotePath=true",
141            "ls-tree",
142            "-r",
143            "--full-tree",
144            tree,
145        ],
146    )
147    .ok_or(Error::Git)?;
148    let listing = std::str::from_utf8(&listing).map_err(|_| Error::Malformed)?;
149    let text = canon_text(listing).ok_or(Error::Malformed)?;
150    hash(root, text.as_bytes())
151}
152
153/// The bytes the canonical key is hashed from.
154pub fn canon_key_preimage(canon: &str) -> String {
155    format!("amont-attest-canon {canon}\n")
156}
157
158/// The synthetic note key for a canonical fingerprint, in the inputs ref.
159pub fn canon_key(root: &Path, canon: &str) -> Result<String, Error> {
160    hash(root, canon_key_preimage(canon).as_bytes())
161}
162
163/// The optional paths `gate` declares that `tree` does not hold: their
164/// absence is part of its fingerprint, and a producer names them once so a
165/// typo stands out. `ls-tree`, not `cat-file -e`: a gitlink whose commit is
166/// not in this repository is present all the same.
167pub fn absent_optional(root: &Path, tree: &str, spec: &Spec, gate: &str) -> Vec<String> {
168    spec.paths_of(gate)
169        .unwrap_or(&[])
170        .iter()
171        .filter(|t| t.optional)
172        .filter(|t| git::stdout_in(root, &["ls-tree", tree, "--", &t.path]).is_none())
173        .map(|t| t.path.clone())
174        .collect()
175}