Skip to main content

amont_agent_process/
provenance.rs

1//! The unsigned provenance record of a pushed commit (`amont-provenance-v1`,
2//! attest SPEC; decisions ADR-0036). When the implementation-review hook
3//! passes a push that carries a plan, it files what it knew about the push
4//! under `<config dir>/amont-agent/provenance/<repo>/<pushed sha>`. Only the
5//! hook writes there; a signer reads it later.
6//!
7//! It records only what the hook witnessed: the plan, the review and the
8//! preview. It writes no `canon` line: attest's canonical tree is a tree fact
9//! the signer computes and inserts itself, and a payload carrying one is
10//! refused there.
11
12use std::path::{Path, PathBuf};
13
14use amont_agent_guard::git;
15
16const MAGIC: &str = "amont-provenance-v1";
17
18/// How the implementation review bound to the pushed tree.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Review {
21    Reviewed,
22    ByPatch,
23    Overruled,
24}
25
26impl Review {
27    fn word(self) -> &'static str {
28        match self {
29            Review::Reviewed => "reviewed",
30            Review::ByPatch => "by-patch",
31            Review::Overruled => "overruled",
32        }
33    }
34}
35
36/// Everything a record states.
37pub struct Record<'a> {
38    pub commit: &'a str,
39    pub tree: &'a str,
40    pub patch: Option<&'a str>,
41    pub plan_path: &'a str,
42    pub plan_sha: &'a str,
43    pub review: Review,
44    /// The content id the review read (attest SPEC, "Content id").
45    pub review_content: &'a str,
46    pub preview: crate::preview::Outcome,
47    pub version: &'a str,
48}
49
50/// The payload: one datum per line, trailing newline.
51pub fn payload(r: &Record) -> String {
52    let mut out = format!("{MAGIC}\ncommit {}\ntree {}\n", r.commit, r.tree);
53    if let Some(p) = r.patch {
54        out.push_str(&format!("patch {p}\n"));
55    }
56    out.push_str(&format!(
57        "plan {} {}\nreview {} {}\npreview {}\nproducer amont-agent {}\n",
58        r.plan_path,
59        r.plan_sha,
60        r.review.word(),
61        r.review_content,
62        r.preview.word(),
63        r.version
64    ));
65    out
66}
67
68/// The record's file, or `None` when the repository name or the sha could
69/// climb out of the directory.
70pub fn record_file(repo: &str, commit: &str) -> Option<PathBuf> {
71    if repo.is_empty()
72        || repo.contains('/')
73        || repo.contains('\\')
74        || repo.contains("..")
75        || commit.is_empty()
76        || !commit.bytes().all(|b| b.is_ascii_hexdigit())
77    {
78        return None;
79    }
80    Some(
81        amont_agent_guard::journal::dir()?
82            .join("provenance")
83            .join(repo)
84            .join(commit),
85    )
86}
87
88/// Whether a plan file is a pointer: a non-empty `canonical:` in its front
89/// matter, as the preview gate reads it.
90fn is_pointer(text: &str) -> bool {
91    let (front, _) = crate::plans::split_front_matter(text);
92    crate::plans::field(front, "canonical").is_some_and(|c| !c.is_empty())
93}
94
95/// The plan file changed on the branch, read from the pushed tree: its path
96/// and body sha. `plans` are the non-empty, trimmed paths changed under
97/// `docs/plans`. A `.reviews.md` sidecar is never the plan; of several plan
98/// files the one that is not a pointer wins.
99pub fn plan_of(repo: &Path, commit: &str, plans: &[String]) -> Result<(String, String), String> {
100    let mut found: Vec<(&String, String)> = Vec::new();
101    for p in plans
102        .iter()
103        .filter(|p| p.ends_with(".md") && !p.ends_with(".reviews.md"))
104    {
105        if let Some(bytes) = git::bytes_in(repo, &["show", &format!("{commit}:{p}")], None)
106            .map_err(|e| format!("git could not be run: {e}"))?
107        {
108            found.push((p, String::from_utf8_lossy(&bytes).into_owned()));
109        }
110    }
111    let (path, text) = found
112        .iter()
113        .find(|(_, t)| !is_pointer(t))
114        .or_else(|| found.first())
115        .ok_or("no plan file under docs/plans/ exists in the pushed tree")?;
116    let shas =
117        crate::plan_text::shas(text).map_err(|e| format!("the plan has no body sha: {e}"))?;
118    Ok((path.to_string(), shas.sha))
119}
120
121/// Everything about the push the hook knows, before the review is bound.
122pub struct Push<'a> {
123    pub repo: &'a Path,
124    pub name: &'a str,
125    pub pushed: &'a str,
126    pub patch: Option<&'a str>,
127    pub plans: &'a [String],
128    /// The branch target the push publishes: the preview rule reads it.
129    pub target: &'a amont_agent_guard::push_target::Target,
130}
131
132/// What `write` did with the record.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Filed {
135    /// The record was written to this file.
136    Written(PathBuf),
137    /// The record of this repository and commit already exists and was kept.
138    Kept(PathBuf),
139}
140
141/// Write the record once per commit: a second push of the same commit finds
142/// the file and leaves it, so the first pass's origin (by-patch, say) stays.
143/// `Ok` says whether it wrote or kept the file, `Err` why it could not be.
144pub fn write(
145    p: &Push,
146    review: Review,
147    review_content: &str,
148    preview: crate::preview::Outcome,
149) -> Result<Filed, String> {
150    let commit = git::stdout_in(
151        p.repo,
152        &[
153            "rev-parse",
154            "--verify",
155            "--end-of-options",
156            &format!("{}^{{commit}}", p.pushed),
157        ],
158    )
159    .ok_or("the pushed commit cannot be resolved")?;
160    // The path needs the full sha, so resolving it is the one git call before
161    // the check: a kept record never reads the tree or the plan again.
162    let file = record_file(p.name, &commit).ok_or("no provenance directory is known")?;
163    if file.exists() {
164        return Ok(Filed::Kept(file));
165    }
166    let tree = git::stdout_in(
167        p.repo,
168        &[
169            "rev-parse",
170            "--verify",
171            "--end-of-options",
172            &format!("{commit}^{{tree}}"),
173        ],
174    )
175    .ok_or("the pushed tree cannot be resolved")?;
176    let (plan_path, plan_sha) = plan_of(p.repo, &commit, p.plans)?;
177    let body = payload(&Record {
178        commit: &commit,
179        tree: &tree,
180        patch: p.patch,
181        plan_path: &plan_path,
182        plan_sha: &plan_sha,
183        review,
184        review_content,
185        preview,
186        version: env!("CARGO_PKG_VERSION"),
187    });
188    let dir = file.parent().ok_or("no provenance directory is known")?;
189    std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
190    amont_agent_guard::journal::private(dir, 0o700);
191    amont_agent_guard::atomic::write_atomic(&file, &body)
192        .map_err(|e| format!("{}: {e}", file.display()))?;
193    amont_agent_guard::journal::private(&file, 0o600);
194    Ok(Filed::Written(file))
195}
196
197/// What filing a pass's record came to.
198pub enum Filing {
199    /// The preview has not stood yet; the next pass files it.
200    Deferred,
201    Written(PathBuf),
202    Kept(PathBuf),
203    Failed(String),
204}
205
206/// Files the provenance record of a pass, asking the preview rule what it
207/// knew about the push. The caller journals the result; a failure never
208/// changes its judgment.
209pub fn file(push: &Push, review: Review, review_content: &str) -> Filing {
210    let Some(preview) = crate::preview::outcome(push.repo, push.target) else {
211        return Filing::Deferred;
212    };
213    match write(push, review, review_content, preview) {
214        Ok(Filed::Written(f)) => Filing::Written(f),
215        Ok(Filed::Kept(f)) => Filing::Kept(f),
216        Err(why) => Filing::Failed(why),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn rec(patch: Option<&'static str>) -> Record<'static> {
225        Record {
226            commit: "c0",
227            tree: "t0",
228            patch,
229            plan_path: "docs/plans/p.md",
230            plan_sha: "s0",
231            review: Review::ByPatch,
232            review_content: "k1",
233            preview: crate::preview::Outcome::NotRequired,
234            version: "1.2.3",
235        }
236    }
237
238    #[test]
239    fn payload_lines_are_in_order() {
240        assert_eq!(
241            payload(&rec(Some("p0"))),
242            "amont-provenance-v1\ncommit c0\ntree t0\npatch p0\nplan docs/plans/p.md s0\nreview by-patch k1\npreview not-required\nproducer amont-agent 1.2.3\n"
243        );
244    }
245
246    #[test]
247    fn payload_omits_patch_without_one() {
248        assert!(!payload(&rec(None)).lines().any(|l| l.starts_with("patch ")));
249    }
250
251    #[test]
252    fn record_file_refuses_escapes() {
253        assert!(record_file("../x", "ab").is_none());
254        assert!(record_file("r", "../ab").is_none());
255        assert!(record_file("r/s", "ab").is_none());
256    }
257
258    fn git_in(dir: &Path, args: &[&str]) {
259        let ok = std::process::Command::new("git")
260            .current_dir(dir)
261            .args(args)
262            .env("GIT_CONFIG_GLOBAL", "/dev/null")
263            .env("GIT_CONFIG_SYSTEM", "/dev/null")
264            .env("GIT_AUTHOR_NAME", "t")
265            .env("GIT_AUTHOR_EMAIL", "t@example.com")
266            .env("GIT_COMMITTER_NAME", "t")
267            .env("GIT_COMMITTER_EMAIL", "t@example.com")
268            .status()
269            .unwrap()
270            .success();
271        assert!(ok, "git {args:?}");
272    }
273
274    #[test]
275    fn a_reviews_sidecar_is_never_the_plan() {
276        let dir = std::env::temp_dir().join(format!("amont-plan-of-{}", std::process::id()));
277        let _ = std::fs::remove_dir_all(&dir);
278        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
279        git_in(&dir, &["init", "-q", "-b", "main"]);
280        std::fs::write(
281            dir.join("docs/plans/a.md"),
282            "---\nstatus: active\n---\n# A\n\nbody\n",
283        )
284        .unwrap();
285        std::fs::write(
286            dir.join("docs/plans/a.reviews.md"),
287            "---\nstatus: active\n---\n# R\n\nreviews\n",
288        )
289        .unwrap();
290        std::fs::write(
291            dir.join("docs/plans/b.md"),
292            "---\ncanonical: x/b.md\n---\n# B\n",
293        )
294        .unwrap();
295        git_in(&dir, &["add", "-A"]);
296        git_in(&dir, &["commit", "-q", "-m", "x"]);
297        let plans: Vec<String> = [
298            "docs/plans/a.reviews.md",
299            "docs/plans/b.md",
300            "docs/plans/a.md",
301        ]
302        .map(String::from)
303        .to_vec();
304        let (path, _) = plan_of(&dir, "HEAD", &plans).unwrap();
305        let _ = std::fs::remove_dir_all(&dir);
306        assert_eq!(path, "docs/plans/a.md");
307    }
308}