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, Names, WRITE};
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, under the written names ([`WRITE`]).
93pub fn key_preimage(gate: &str, fp: &str) -> String {
94    key_preimage_in(&WRITE, gate, fp)
95}
96
97/// The bytes a fingerprint key is hashed from, under `names`' domain.
98pub fn key_preimage_in(names: &Names, gate: &str, fp: &str) -> String {
99    format!("{} {gate} {fp}\n", names.input_domain)
100}
101
102/// The synthetic note key for (gate, fingerprint) under the written names ([`WRITE`]): an
103/// oid that is not an object, which `git notes` accepts as a key all the same.
104pub fn key(root: &Path, gate: &str, fp: &str) -> Result<String, Error> {
105    key_in(root, &WRITE, gate, fp)
106}
107
108/// The same key under `names`: each name set files its inputs under its own
109/// keys, in its own inputs ref.
110pub fn key_in(root: &Path, names: &Names, gate: &str, fp: &str) -> Result<String, Error> {
111    hash(root, key_preimage_in(names, gate, fp).as_bytes())
112}
113
114/// The canonical text of an `ls-tree -r --full-tree` listing (paths quoted by
115/// `core.quotePath=true`, so one record is one line): the same lines, except
116/// that a regular file (`100644`, `100755`) under the top-level `docs/plans/`
117/// has its oid replaced by zeros of the same length. Existence stays; content
118/// does not count. Symlinks and gitlinks are never neutralised. `None` for an
119/// empty or malformed listing.
120pub fn canon_text(listing: &str) -> Option<String> {
121    let mut out = String::with_capacity(listing.len());
122    for line in listing.lines() {
123        let (head, path) = line.split_once('\t')?;
124        let mut f = head.split(' ');
125        let (mode, kind, oid) = (f.next()?, f.next()?, f.next()?);
126        if f.next().is_some() {
127            return None;
128        }
129        // A quoted path keeps its first characters: `"docs/plans/...`.
130        let under_plans = path.starts_with("docs/plans/") || path.starts_with("\"docs/plans/");
131        if matches!(mode, "100644" | "100755") && under_plans {
132            out.push_str(&format!(
133                "{mode} {kind} {}\t{path}\n",
134                "0".repeat(oid.len())
135            ));
136        } else {
137            out.push_str(line);
138            out.push('\n');
139        }
140    }
141    (!out.is_empty()).then_some(out)
142}
143
144/// The canonical fingerprint of `tree`: `canon_text` of its listing, hashed
145/// with `git hash-object`.
146pub fn canon(root: &Path, tree: &str) -> Result<String, Error> {
147    let listing = git::stdout_bytes_in(
148        root,
149        &[
150            "-c",
151            "core.quotePath=true",
152            "ls-tree",
153            "-r",
154            "--full-tree",
155            tree,
156        ],
157    )
158    .ok_or(Error::Git)?;
159    let listing = std::str::from_utf8(&listing).map_err(|_| Error::Malformed)?;
160    let text = canon_text(listing).ok_or(Error::Malformed)?;
161    hash(root, text.as_bytes())
162}
163
164/// The bytes the canonical key is hashed from, under the written names ([`WRITE`]).
165pub fn canon_key_preimage(canon: &str) -> String {
166    canon_key_preimage_in(&WRITE, canon)
167}
168
169/// The bytes the canonical key is hashed from, under `names`' domain.
170pub fn canon_key_preimage_in(names: &Names, canon: &str) -> String {
171    format!("{} {canon}\n", names.canon_domain)
172}
173
174/// The synthetic note key for a canonical fingerprint, in the written inputs
175/// ref ([`WRITE`]).
176pub fn canon_key(root: &Path, canon: &str) -> Result<String, Error> {
177    canon_key_in(root, &WRITE, canon)
178}
179
180/// The same key under `names`.
181pub fn canon_key_in(root: &Path, names: &Names, canon: &str) -> Result<String, Error> {
182    hash(root, canon_key_preimage_in(names, canon).as_bytes())
183}
184
185/// The optional paths `gate` declares that `tree` does not hold: their
186/// absence is part of its fingerprint, and a producer names them once so a
187/// typo stands out. `ls-tree`, not `cat-file -e`: a gitlink whose commit is
188/// not in this repository is present all the same.
189pub fn absent_optional(root: &Path, tree: &str, spec: &Spec, gate: &str) -> Vec<String> {
190    spec.paths_of(gate)
191        .unwrap_or(&[])
192        .iter()
193        .filter(|t| t.optional)
194        .filter(|t| git::stdout_in(root, &["ls-tree", tree, "--", &t.path]).is_none())
195        .map(|t| t.path.clone())
196        .collect()
197}