From 2d42d52e868b84d4776915236a239c155099af92 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:27:33 +0900 Subject: [PATCH 01/21] Read the paths and the messages a guard publishes, not only the blobs A guard's subject is what a commit or a push makes public, and several parts of that were never opened. A file NAME is published exactly as a file's contents are, so a private repository's name in a directory name, or a zero-width character in a file name, went out under a guard that reported the tree clean; the tree-wide guards now scan the path as well as the blob under it, and a tab or a newline -- legal inside a file, never inside a path -- is a finding there. At a push the guards also read the commit MESSAGES the push publishes, which is the one surface no earlier seam can reach for a commit written under --no-verify. The staged half had three ways to see nothing at all. A repository, global or system diff.external or textconv driver emptied `git diff --cached` and the guard passed on an empty diff, so the staged scan now runs with --no-ext-diff --no-textconv --no-color and core.quotepath=false. A committed `* -diff` attribute hid a plain-ASCII file the same way, so paths git will not diff as text are found with --numstat, read through their staged oid, and re-diffed with --text; a NUL in the first 8000 bytes is the one honest skip left. And a rename introduces a path while adding no line, so newly introduced paths are read from --diff-filter=ACR rather than inferred from the diff body. Each source is now one PATH rather than one blob labelled "staged changes", which also means [rule.files] finally bounds the staged guard and every finding names the file it arrived in. A submodule ended both tree-wide guards before they started: a gitlink is enumerated by `git ls-tree` and `git cat-file blob` cannot read one, so any tree with a submodule exited 2. Blob now carries git's mode and answers has_content(), so a gitlink is enumerated by path and never read, and scope::read refuses one with a sentence rather than letting cat-file fail. The two halves of a pre-push range also went through two separate resolutions that were free to drift; range_of() is the single answer both now use. A blob that will not decode is exit 2 rather than a silent skip, and the same for `scan --text`, where from_utf8_lossy searched U+FFFD where the bytes were and printed "policy checks passed" over text nobody had read. The identity fallback is chosen by testing for the identity rule rather than for its check KIND, because declaring an unrelated forbidden_literals rule is not a decision to stop checking what host the author is standing on. --- src/config.rs | 212 ++++++++++++++++- src/guard/names.rs | 262 +++++++++++++++++++-- src/guard/scope.rs | 266 +++++++++++++++++---- src/guard/unicode.rs | 199 ++++++++++++++-- src/text.rs | 55 ++++- tests/guard_recovered_halves.rs | 394 ++++++++++++++++++++++++++++++++ 6 files changed, 1303 insertions(+), 85 deletions(-) create mode 100644 tests/guard_recovered_halves.rs diff --git a/src/config.rs b/src/config.rs index 0b10944..2325d40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,7 +13,7 @@ //! repository describing a constant in another. One enum in one crate makes the //! whole class of drift unrepresentable, and [`Kind::ALL`] is the only list. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use serde::Deserialize; @@ -860,6 +860,42 @@ impl Rule { ))); } + // And the third place, missing for exactly the reason the second one + // was. `shim::run` filters the rules it consults to `Check::Exec`, so a + // built-in -- or a regexp, or anything else -- whose only declared + // place is `command.before` is consulted by nothing, runs nowhere, and + // reports clean. The refusal below makes it worse rather than catching + // it: "nothing says where it runs" is SATISFIED by the very field that + // cannot be used, so the one check that exists to find a rule with no + // place is the check this rule slips past. + if check != Check::Exec && self.command.is_some() { + return Err(Fatal::new(format!( + "rule {:?}: only an `exec` checker stands in front of a command, so \ + `command.before` on a `{check}` rule would be read by nothing and would \ + look like configuration that works.\n\ + A rule that searches the tree says so with `files.*`, and a built-in \ + that fires at a git hook says so with `git.hooks`.", + self.id + ))); + } + + // The same idea one level down: a `command` table that names no command + // line stands in front of nothing, because `CommandWhere::matches` + // answers false for an empty list -- while the refusal below reads the + // table as a declared place and lets the rule through. + if self + .command + .as_ref() + .is_some_and(|where_| where_.before.is_empty()) + { + return Err(Fatal::new(format!( + "rule {:?}: `command.before` names no command line, so this rule stands \ + in front of nothing. Name the command as typed -- \ + `command.before = [\"gh pr create\"]`", + self.id + ))); + } + if self.files.is_none() && self.git.is_none() && self.command.is_none() { return Err(Fatal::new(format!( "rule {:?}: nothing says where it runs, so it runs nowhere -- which \ @@ -967,6 +1003,17 @@ impl Policy { self.rules.iter().filter(move |rule| rule.is(check)) } + /// Whether any rule uses one check kind. + /// + /// Test-only, and it is worth saying why rather than deleting it. Its one + /// caller in the binary was `text::check`, which used "does any + /// `forbidden_literals` rule exist?" to decide whether to add the built-in + /// host-identity fallback -- so a repository that declared a literal rule + /// about something else silently lost the identity check. That question was + /// the defect, and the fix asks about the identity rule itself instead. + /// What remains is a fair question for a test about what a set inherits, + /// and a helper that reads a policy's shape belongs beside the policy. + #[cfg(test)] pub(crate) fn has_check(&self, check: Check) -> bool { self.of_check(check).next().is_some() } @@ -1081,6 +1128,7 @@ pub(crate) fn load(root: &Path, policy_path: &Path) -> Result { for rule in &rules { rule.validate()?; } + validate_shims(policy_path, &rules, &file.shims)?; Ok(Policy { redact_matches: file.redact_matches, @@ -1146,6 +1194,65 @@ fn validate_unique(policy_path: &Path, rules: &[Rule]) -> Result<()> { Ok(()) } +/// Every shim has a checker, and every checker has a shim. +/// +/// The two halves of one seam, and neither end was checked. A `[[shim]]` no +/// `exec` rule names collects the subjects of an invocation, consults an empty +/// list of checkers, refuses nothing and execs the command: a publication that +/// passed because nothing looked at it, reported as a pass. A +/// `command.before` naming a command no `[[shim]]` declares is the mirror -- +/// the shim is the only thing that invokes a checker, so the rule runs nowhere, +/// and `uphold shim` refuses that command outright as undeclared. +/// +/// Refused here, beside the refusal of an unknown built-in name and for the +/// same reason: a name that resolves to nothing is a decision that looks made. +/// A load-time refusal is also the only place either can be seen at all -- +/// at run time both are silence. +fn validate_shims(policy_path: &Path, rules: &[Rule], shims: &[crate::shim::Shim]) -> Result<()> { + let declared: BTreeSet<&str> = shims.iter().map(|shim| shim.command.as_str()).collect(); + // The first word of a `before` entry is the command itself; the rest is as + // much of the subcommand path as the rule wanted to scope itself to, which + // is not the shim's business -- `[[shim]] command = "gh"` stands in front + // of `gh pr create` and of every other `gh`. + let checked: BTreeSet<&str> = rules + .iter() + .filter(|rule| rule.is(Check::Exec)) + .filter_map(|rule| rule.command.as_ref()) + .flat_map(|where_| where_.before.iter()) + .filter_map(|line| line.split_whitespace().next()) + .collect(); + + for shim in shims { + if !checked.contains(shim.command.as_str()) { + return Err(Fatal::at( + policy_path, + format!( + "the shim for {:?} is named by no checker, so that command would be \ + collected, checked by nothing, and run anyway -- an invocation that \ + passed because nothing looked at it. Name it in an `exec` rule's \ + `command.before`, or delete the shim", + shim.command + ), + )); + } + } + + for name in checked { + if !declared.contains(name) { + return Err(Fatal::at( + policy_path, + format!( + "`command.before` names {name:?}, which no `[[shim]]` declares. A \ + shim is the only thing that invokes a checker, so this rule runs \ + nowhere -- which reads exactly like a rule that passes. Declare \ + `[[shim]]` with `command = {name:?}`, or drop the entry" + ), + )); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1294,6 +1401,12 @@ mod tests { [rule.body.command] before = ["gh pr create"] + + [[shim]] + command = "gh" + match = ["pr:create"] + text_flags = ["-b"] + scope = "always" "#, ) .unwrap(); @@ -1406,6 +1519,103 @@ mod tests { } } + #[test] + /// `command.before` on a check no shim can consult. + /// + /// The third member of the same family, and the one that was missing. + /// `shim::run` filters its checkers to `exec` rules, so a built-in whose + /// only declared place is `command.before` is consulted by nothing and runs + /// nowhere -- and the "nothing says where it runs" refusal is satisfied by + /// the very field that cannot be used, so the check meant to catch a rule + /// with no place is the one this rule walked past. + fn a_command_place_the_check_cannot_use_is_refused() { + // The regexp case carries `files.*` too: it is a rule that really does + // run, by the scan, and the `command.before` beside it is the part that + // reaches nothing. + for check in [ + "builtin = \"prevent-ai-author\"", + "message = \"no\"\nregexp = \"TODO\"\nfiles.include = [\".\"]", + ] { + let error = policy_from(&format!( + "[rule.wrong]\n{check}\n\n[rule.wrong.command]\nbefore = [\"gh\"]\n" + )) + .unwrap_err(); + assert!( + error.to_string().contains("read by nothing"), + "{check}: {error}" + ); + } + } + + /// A `command` table that names no command line is a place that selects + /// nothing, and the "where does it run" check reads it as a place. + #[test] + fn a_command_before_that_names_nothing_is_refused() { + let error = policy_from( + r#" + [rule.body] + message = "no" + exec = "checker" + + [rule.body.command] + before = [] + "#, + ) + .unwrap_err(); + assert!( + error.to_string().contains("names no command line"), + "{error}" + ); + } + + /// A shim with no checker execs the command with nothing checked. + /// + /// Silence at run time -- the subjects are collected, the empty checker + /// list is iterated, and the command runs -- so the only place this can be + /// said is here, at load. + #[test] + fn a_shim_no_checker_names_is_refused() { + let error = policy_from( + r#" + [[shim]] + command = "gh" + match = ["pr:create"] + text_flags = ["-b"] + scope = "always" + "#, + ) + .unwrap_err(); + let text = error.to_string(); + assert!(text.contains("named by no checker"), "{text}"); + assert!(text.contains("gh"), "{text}"); + } + + /// And the mirror: a checker standing in front of a command nothing shims + /// is never invoked, because the shim is what invokes it. + #[test] + fn a_checker_naming_a_command_no_shim_declares_is_refused() { + let error = policy_from( + r#" + [rule.body] + message = "no" + exec = "checker" + + [rule.body.command] + before = ["gh pr create", "glab mr create"] + + [[shim]] + command = "gh" + match = ["pr:create"] + text_flags = ["-b"] + scope = "always" + "#, + ) + .unwrap_err(); + let text = error.to_string(); + assert!(text.contains("glab"), "{text}"); + assert!(text.contains("no `[[shim]]` declares"), "{text}"); + } + /// The verified bug: two parameters that look enforced, read by nothing. /// /// This exact config loaded and ran without complaint -- `allowed_owners` diff --git a/src/guard/names.rs b/src/guard/names.rs index 62d57ba..e43d817 100644 --- a/src/guard/names.rs +++ b/src/guard/names.rs @@ -11,11 +11,24 @@ //! //! Three modes, because git is not the only way a private name reaches a public //! place and it is not the way it usually does. `in_message` judges a commit -//! message. `in_staged` judges the lines a commit ADDS, which is the right unit -//! at commit time and blind by construction to a line already there. -//! `in_tracked` closes that half: a name that arrived under `--no-verify`, -//! through a merge, or in a checkout where no hook was installed is never looked -//! at again otherwise. +//! message. `in_staged` judges what a commit ADDS, which is the right unit at +//! commit time and blind by construction to a line already there. `in_tracked` +//! closes that half: a name that arrived under `--no-verify`, through a merge, +//! or in a checkout where no hook was installed is never looked at again +//! otherwise. +//! +//! What a name is written INTO is not only file content, and each of these +//! reads every carrier it can reach: +//! +//! * the bytes of a blob, and a symlink's blob IS its target path; +//! * the PATH itself, for every kind of entry -- `docs/why-acme-secret-broke.md` +//! discloses a repository in every listing and every search of the history +//! without one line of its content saying anything at all, and a gitlink has +//! nothing else to disclose; +//! * the messages a push publishes, because `commit-msg` fires only when +//! `git commit` writes one. `git commit-tree`, a rebase, a cherry-pick, +//! `git am`, `--no-verify` and a fast import each record a message no hook +//! has read, and everything else at pre-push reads the tree. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; @@ -535,18 +548,212 @@ pub(crate) fn in_message(request: &Request<'_>) -> Result> { decide(request, &[(String::from("commit message"), text)]) } -/// The lines this commit ADDS. -pub(crate) fn in_staged(request: &Request<'_>) -> Result> { - let diff = git::run(request.root, &["diff", "--cached", "--unified=0"])?; - let added: String = diff +/// Git's own answer to "was this path diffed as text", per `--numstat`. +/// +/// `-` for both counts is git saying it did not, and it is the only signal +/// there is: it means the binary heuristic and the `diff` ATTRIBUTE alike, +/// which is exactly why the blob has to be consulted next. +struct Staged { + path: String, + added: bool, + as_text: bool, +} + +/// Every path the index changes, with git's verdict on each. +/// +/// `--numstat -z` rather than the headers of the diff itself, because `-z` +/// prints a path verbatim -- no quoting, no escaping -- and a path read out of +/// a `+++ b/...` header is a path this reader would have to unquote correctly +/// to attribute a finding to the right file. +fn staged_paths(root: &Path) -> Result> { + let records = git::run_z( + root, + &[ + "-c", + "core.quotepath=false", + "diff", + "--cached", + "--numstat", + "-z", + ], + )?; + let mut staged = Vec::new(); + let mut records = records.into_iter(); + while let Some(record) = records.next() { + let mut fields = record.split('\t'); + let added = fields.next().unwrap_or_default().to_owned(); + let deleted = fields.next().unwrap_or_default().to_owned(); + let path = fields.next().unwrap_or_default().to_owned(); + // A rename or a copy: `-z` spells it as this record with an empty path + // and then the source and the destination, each its own record. + let path = if path.is_empty() { + let Some(_source) = records.next() else { break }; + let Some(destination) = records.next() else { + break; + }; + destination + } else { + path + }; + let as_text = added != "-" || deleted != "-"; + staged.push(Staged { + path, + added: added != "0", + as_text, + }); + } + Ok(staged) +} + +/// The lines one staged path ADDS. +/// +/// Every flag here closes a way this diff was reported as empty over a file +/// that was not: +/// +/// * `--no-ext-diff` and `--no-textconv`, because `git diff` honours +/// `diff.external` and a per-path `textconv` from the repository's config AND +/// from the global and system files. A difftastic or delta setup -- somebody +/// else's, on their own machine, made for reading diffs and not for this -- +/// emits `EXTERNAL a.txt ...` and not one `+` line, and the guard reported a +/// pass over a diff it never saw. +/// * `--no-color`, one step down from the same class: `color.diff = always` in +/// a personal config wraps every line in escape sequences, and `+` stops +/// being the first byte of an added line. +/// * `core.quotepath=false`, so a non-ASCII path is spelled here the way every +/// other listing in this file spells it. +/// * `--text` on the second pass, which is what makes a `diff` attribute stop +/// deciding whether the bytes get read. +fn added_lines(root: &Path, path: &str, force_text: bool) -> Result { + let mut argv: Vec<&str> = vec![ + "-c", + "core.quotepath=false", + "diff", + "--cached", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "-U0", + ]; + if force_text { + argv.push("--text"); + } + let spec = format!(":(literal){path}"); + argv.push("--"); + argv.push(&spec); + let diff = git::run(root, &argv)?; + Ok(diff .lines() .filter(|line| line.starts_with('+') && !line.starts_with("+++")) .collect::>() - .join("\n"); - decide(request, &[(String::from("staged changes"), added)]) + .join("\n")) } -/// Every blob the operation is introducing. +/// The paths this commit INTRODUCES, whatever is inside them. +/// +/// A path is committed text: `docs/why-acme-secret-broke.md` names a private +/// repository in every listing, every diff and every search of the history, +/// and no line of its content has to say anything at all. Added and renamed +/// only -- a path that was already there is the tree-wide guard's business, +/// and reporting it at every commit that touches the file would be a wall +/// somebody bypasses by reflex rather than a finding they act on. +fn introduced_paths(root: &Path) -> Result> { + git::run_z( + root, + &[ + "-c", + "core.quotepath=false", + "diff", + "--cached", + "--name-only", + "--diff-filter=ACR", + "-z", + ], + ) +} + +/// The lines this commit ADDS, and the paths it introduces. +/// +/// One source per path rather than one blob for the whole commit: the rule's +/// `[rule.files]` scope is a question about a PATH, so a single blob labelled +/// "staged changes" could not be scoped at all -- and the finding it produced +/// named neither the file it came from nor anything a reader could open. +pub(crate) fn in_staged(request: &Request<'_>) -> Result> { + let mut sources: Vec<(String, String)> = Vec::new(); + + for path in introduced_paths(request.root)? { + if !scope::in_file_scope(request.rule, &path)? { + continue; + } + sources.push((format!("{path} (the path itself)"), path)); + } + + for staged in staged_paths(request.root)? { + if !scope::in_file_scope(request.rule, &staged.path)? { + continue; + } + if staged.as_text { + if staged.added { + sources.push(( + staged.path.clone(), + added_lines(request.root, &staged.path, false)?, + )); + } + continue; + } + + // The second pass, for the paths git would not diff as text. + // + // `git diff` consults the `diff` ATTRIBUTE before it looks at a single + // byte. A committed `*.log -diff`, `* -diff` or `*.csv binary` -- two + // lines, in this very commit if you like -- reduces a plain-ASCII file + // to "Binary files a/x and b/x differ", so not one of its added lines + // reached the first pass and this guard exited 0 with nothing printed. + // The attribute is a claim about how to RENDER a change; whether there + // is readable text in there is a question about the blob, and this asks + // it of the blob. + let Some(oid) = git::try_run( + request.root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!(":{}", staged.path), + ], + )? + else { + // Not in the index at all: the change is a deletion, and a deletion + // adds no line to any commit. + continue; + }; + let oid = oid.trim(); + if oid.is_empty() { + continue; + } + // Not a skip. git named this path as changed and then would not show + // it, so failing to read the object is could-not-look -- the one thing + // this guard must never report as a clean commit. + let bytes = scope::read_object(request.root, oid, &staged.path)?; + // git's own binary test, asked of the content this time: a NUL in the + // first 8000 bytes. A file that really is binary holds no repository + // name a reader could act on. + if bytes.iter().take(8000).any(|byte| *byte == 0) { + continue; + } + sources.push(( + staged.path.clone(), + added_lines(request.root, &staged.path, true)?, + )); + } + + decide(request, &sources) +} + +/// Every blob the operation is introducing, every path it arrives under, and -- +/// at a push -- every commit message it publishes. +/// +/// Four of the five things the upstream scanned; the fifth is the symlink +/// target, which arrives here already, because a symlink's blob IS its target +/// path and `scope` hands that blob over like any other. pub(crate) fn in_tracked(request: &Request<'_>) -> Result> { let blobs = scope::blobs( request.root, @@ -555,17 +762,46 @@ pub(crate) fn in_tracked(request: &Request<'_>) -> Result> { request.push_source, request.remote_name, )?; - let mut sources = Vec::with_capacity(blobs.len()); + let mut sources = Vec::with_capacity(blobs.len() * 2); for blob in &blobs { if !scope::in_file_scope(request.rule, &blob.path)? { continue; } + // THE PATH ITSELF, for every kind of entry, because it is the one thing + // every entry has. A gitlink has no blob in this repository at all and + // its path is the whole of what it publishes -- and a path the pushed + // range introduced and the tip no longer holds published that name just + // as permanently as one that survived. + sources.push(( + format!("{} (the path itself)", blob.path), + blob.path.clone(), + )); + if !blob.has_content() { + continue; + } let bytes = scope::read(request.root, blob)?; sources.push(( blob.path.clone(), String::from_utf8_lossy(&bytes).into_owned(), )); } + + // The messages of the commits this push publishes. `commit-msg` fires only + // when `git commit` writes a message -- not for `git commit-tree`, a + // rebase, a cherry-pick, `git am`, `--no-verify` or a fast import -- and + // everything else at pre-push reads the TREE, so a subject line naming a + // private repository reached a remote with every hook green. It costs no + // network beyond the name lookups this guard already makes. + for (sha, body) in scope::pushed_messages( + request.root, + request.stage, + request.push_refs, + request.push_source, + )? { + let short: String = sha.chars().take(12).collect(); + sources.push((format!("commit {short} (its MESSAGE)"), body)); + } + decide(request, &sources) } diff --git a/src/guard/scope.rs b/src/guard/scope.rs index 66cb638..4587db2 100644 --- a/src/guard/scope.rs +++ b/src/guard/scope.rs @@ -38,7 +38,7 @@ //! and removed in the next is in the remote's history permanently, is in no //! tip tree, and was read by nothing. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use globset::Glob; @@ -121,11 +121,43 @@ pub(crate) fn in_file_scope(rule: &Rule, path: &str) -> Result { Ok(true) } -/// One blob the operation is introducing, and the path it arrived under. +/// One entry the operation is introducing: the path it arrived under, the +/// object sitting at it, and the MODE git recorded beside the two. +/// +/// The mode is carried rather than inferred, because inferring it means finding +/// out by trying to read. A gitlink -- mode 160000, one line per submodule in +/// every index in this workspace -- names ANOTHER repository's commit, and +/// `git cat-file blob ` fails on it. `read` reported that failure +/// the way it reports any other, so a tree with a submodule in it aborted both +/// tree-wide guards with exit 2 and neither of them ever finished a scan here. +/// +/// The entry is still enumerated, and deliberately: its PATH is this +/// repository's own committed text whatever the object at it turns out to be. +/// What the mode decides is whether there are BYTES here to read, which is what +/// `has_content` answers. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Blob { pub path: String, pub sha: String, + /// git's six-digit mode, or empty for an object `rev-list` named -- that + /// listing gives an object and a path it once appeared at, and no mode. + pub mode: String, +} + +/// A submodule: another repository's commit, recorded at a path in this one. +const GITLINK: &str = "160000"; + +impl Blob { + /// Whether this repository holds bytes at this entry. + /// + /// False for exactly one thing, and it is not a judgement about the file: + /// a submodule's commit belongs to a repository with its own object + /// database and its own hooks. There is nothing here to read, and nothing + /// hidden by not reading it -- the path above is this repository's and is + /// scanned by the callers either way. + pub(crate) fn has_content(&self) -> bool { + self.mode != GITLINK + } } const ZERO: &str = "0000000000000000000000000000000000000000"; @@ -211,14 +243,18 @@ fn index_blobs(root: &Path) -> Result> { continue; }; let fields: Vec<&str> = meta.split_whitespace().collect(); - let [_, sha, _, ..] = fields.as_slice() else { + let [mode, sha, _, ..] = fields.as_slice() else { continue; }; // Mode 120000 is a symlink, and its blob is the TARGET PATH. Kept, for // the reason in the module docstring: that path is committed bytes. + // Mode 160000 is a gitlink, whose object is not in this repository at + // all -- kept too, for its path, and marked by the mode so that nobody + // downstream asks git for bytes that are somebody else's. blobs.push(Blob { path: path.to_owned(), sha: (*sha).to_owned(), + mode: (*mode).to_owned(), }); } Ok(blobs) @@ -232,17 +268,64 @@ fn tree_blobs(root: &Path, rev: &str) -> Result> { continue; }; let fields: Vec<&str> = meta.split_whitespace().collect(); - let [_, "blob", sha, ..] = fields.as_slice() else { + // `blob` and `commit` alike. A commit here is a gitlink, and it is kept + // for the same reason the index keeps one: the path it sits at is this + // repository's committed text even though the object is not this + // repository's to read. `-r` lists nothing else, and a kind this reader + // does not know is dropped rather than guessed at. + let [mode, "blob" | "commit", sha, ..] = fields.as_slice() else { continue; }; blobs.push(Blob { path: path.to_owned(), sha: (*sha).to_owned(), + mode: (*mode).to_owned(), }); } Ok(blobs) } +/// WHICH commits one pushed ref publishes, as arguments for `rev-list` and for +/// `log`. +/// +/// One answer, because two readers working the range out separately are two +/// ranges that agree until they do not -- and the two readers here are the +/// blobs the push introduces and the MESSAGES it publishes, which have to be +/// the same push or the report is about two different acts. +/// +/// A remote sha this clone does not have is NOT an empty range, and it used to +/// become one: `^` fails on an unknown object, and the failure was read as +/// "this push introduces nothing", so the whole range half of the scope +/// disappeared without a word. It is not a rare state -- anyone else pushing +/// since the last fetch produces it, as do a rewritten upstream ref and a +/// shallow clone. So the sha is RESOLVED first, and a range that cannot be +/// anchored falls back to subtracting what is already known to be on a remote. +/// Over-subtracting is the safe direction: those commits are reachable from a +/// ref that was itself pushed under a hook. +fn range_of(root: &Path, local_sha: &str, remote_sha: &str) -> Result> { + let new_branch = remote_sha.chars().all(|character| character == '0') || remote_sha == ZERO; + let anchored = !new_branch + && git::try_run( + root, + &[ + "rev-parse", + "-q", + "--verify", + &format!("{remote_sha}^{{commit}}"), + ], + )? + .is_some(); + Ok(if anchored { + vec![local_sha.to_owned(), format!("^{remote_sha}")] + } else { + vec![ + local_sha.to_owned(), + String::from("--not"), + String::from("--remotes"), + ] + }) +} + /// Every blob the pushed range introduces, including ones later deleted. fn range_blobs( root: &Path, @@ -250,48 +333,16 @@ fn range_blobs( remote_sha: &str, remote: Option<&str>, ) -> Result> { - let new_branch = remote_sha.chars().all(|character| character == '0') || remote_sha == ZERO; - let listed = if new_branch { - // Nothing on the remote to subtract, so subtract everything already - // known to be there. `--not --all` over-subtracts if the branch shares - // commits with another local branch, which is the safe direction: those - // commits are reachable from a ref that was itself pushed under a hook. - git::try_run( - root, - &["rev-list", "--objects", local_sha, "--not", "--remotes"], - )? - .or(git::try_run(root, &["rev-list", "--objects", local_sha])?) - } else { - // A remote sha this clone does not have is NOT an empty range, and it - // used to become one: `^` fails on an unknown object, and the - // failure was read as "this push introduces nothing", so the whole - // range half of the scope disappeared without a word. It is not a rare - // state -- anyone else pushing since the last fetch produces it, as do - // a rewritten upstream ref and a shallow clone -- and the half it drops - // is the one that catches a blob added in one pushed commit and removed - // in the next, which is on the remote permanently and in no tip tree. - // - // So fall back the way the new-branch arm does: subtract what is known - // to be on a remote already. Over-subtracting is the safe direction for - // the same stated reason -- those commits are reachable from a ref that - // was itself pushed under a hook. - match git::try_run( - root, - &[ - "rev-list", - "--objects", - local_sha, - &format!("^{remote_sha}"), - ], - )? { - Some(listed) => Some(listed), - None => git::try_run( - root, - &["rev-list", "--objects", local_sha, "--not", "--remotes"], - )?, - } + let range = range_of(root, local_sha, remote_sha)?; + let mut argv: Vec<&str> = vec!["rev-list", "--objects"]; + argv.extend(range.iter().map(String::as_str)); + let listed = match git::try_run(root, &argv)? { + Some(listed) => Some(listed), + // The whole history reachable from the tip, which is what is left when + // nothing can be subtracted from it. Reading too much is the safe + // direction; reading nothing is the one this guard exists to refuse. + None => git::try_run(root, &["rev-list", "--objects", local_sha])?, }; - let _ = remote; // Neither the range nor the fallback could be listed. Returning no blobs // here reports a push nobody read as a push with nothing in it. let Some(listed) = listed else { @@ -318,11 +369,83 @@ fn range_blobs( candidates.push(Blob { path: path.to_owned(), sha: sha.to_owned(), + // No mode: this listing names an object and a path it appeared at, + // and `keep_blobs` below settles the only question the mode would + // have answered here -- whether there are bytes to read. + mode: String::new(), }); } keep_blobs(root, candidates) } +/// The messages of the commits this push publishes. +/// +/// Empty at every other stage, and that is the answer rather than a shrug: an +/// index is a commit that does not exist yet, and its message is what the +/// commit-msg guards read at the moment it is written. +/// +/// This half exists because `commit-msg` only fires when `git commit` writes a +/// message. `git commit-tree`, a rebase, a cherry-pick, `git am`, `--no-verify` +/// and a fast-forward carrying somebody else's commit in from a hookless clone +/// all record a message that no hook ever read -- and until this, everything at +/// pre-push read the TREE. A subject line naming a private repository reached a +/// remote with every hook green and no override of any kind. +pub(crate) fn pushed_messages( + root: &Path, + stage: Stage, + push_refs: &str, + push_source: crate::runner::Source, +) -> Result> { + if stage != Stage::PrePush { + return Ok(Vec::new()); + } + if push_source == crate::runner::Source::Absent { + // The same refusal `blobs` makes, and for the same reason: without a + // ref line there is no range, and a range nobody named is not an empty + // one. `blobs` says it at length; a caller reaches this only by asking + // for the messages without asking for the blobs. + return Err(Fatal::new( + "pre-push: no ref line reached this guard, so which commits are being \ + published is unknown -- refusing to report their messages as read", + )); + } + + let mut messages: Vec<(String, String)> = Vec::new(); + let mut seen: BTreeSet = BTreeSet::new(); + for line in push_refs.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + let [_, local_sha, _, remote_sha, ..] = fields.as_slice() else { + continue; + }; + // A deletion publishes no commit and so no message. + if local_sha.chars().all(|character| character == '0') { + continue; + } + let range = range_of(root, local_sha, remote_sha)?; + // `%B` is the raw body -- subject and body exactly as stored, with + // nothing stripped, because git has already stripped the `#` comment + // lines by the time a message is in a commit. The sha travels with it + // so a finding can name the commit rather than only quote the text, and + // `-z` separates the records because a message holds newlines and blank + // lines by construction. + let mut argv: Vec<&str> = vec!["log", "-z", "--format=%H%x09%B"]; + argv.extend(range.iter().map(String::as_str)); + // Not `try_run`: a range that cannot be read is a set of messages + // nobody looked at, and an empty list of them would read as a push that + // published nothing to say. + let listed = git::run(root, &argv)?; + for record in listed.split('\0').filter(|field| !field.is_empty()) { + let Some((sha, body)) = record.split_once('\t') else { + continue; + }; + if seen.insert(sha.to_owned()) { + messages.push((sha.to_owned(), body.to_owned())); + } + } + } + Ok(messages) +} + fn keep_blobs(root: &Path, candidates: Vec) -> Result> { use std::io::Write; use std::process::{Command, Stdio}; @@ -386,15 +509,34 @@ fn keep_blobs(root: &Path, candidates: Vec) -> Result> { /// The bytes of one blob. Read through git rather than off disk, because the /// blob is the artifact and the file beside it may differ or not exist. pub(crate) fn read(root: &Path, blob: &Blob) -> Result> { + if !blob.has_content() { + // A caller that got here skipped `has_content`, and the object is + // another repository's commit. Said as a refusal rather than as a git + // failure, because the two mean different things: this one is a bug in + // the caller, and the git failure it used to produce was read as "this + // tree cannot be scanned" and ended the run at exit 2. + return Err(Fatal::new(format!( + "{}: a gitlink has no blob in this repository -- its content belongs to \ + the submodule, which carries its own guards", + blob.path + ))); + } + read_object(root, &blob.sha, &blob.path) +} + +/// The bytes of one object, named by oid rather than by an enumerated entry. +/// +/// The staged-blob reader needs this: what it holds is a path and the oid the +/// INDEX has at it, which is not one of the entries any scope enumerated. +pub(crate) fn read_object(root: &Path, sha: &str, path: &str) -> Result> { let output = std::process::Command::new("git") - .args(["cat-file", "blob", &blob.sha]) + .args(["cat-file", "blob", sha]) .current_dir(root) .output() - .map_err(|error| Fatal::new(format!("git cat-file blob {}: {error}", blob.sha)))?; + .map_err(|error| Fatal::new(format!("git cat-file blob {sha}: {error}")))?; if !output.status.success() { return Err(Fatal::new(format!( - "git cat-file blob {} ({}) failed", - blob.sha, blob.path + "git cat-file blob {sha} ({path}) failed" ))); } Ok(output.stdout) @@ -454,6 +596,32 @@ mod tests { assert!(in_file_scope(&rule, "anywhere/at/all.rs").unwrap()); } + fn entry(path: &str, mode: &str) -> Blob { + Blob { + path: path.to_owned(), + sha: String::from("0123456789abcdef0123456789abcdef01234567"), + mode: mode.to_owned(), + } + } + + #[test] + fn a_gitlink_is_enumerated_and_never_read() { + // The live one: this workspace tracks submodules, and every tree-wide + // guard exited 2 over the first of them because a gitlink was handed + // downstream as a blob and `git cat-file blob ` fails. + // Enumerated all the same -- the PATH is this repository's committed + // text whatever the object at it belongs to. + assert!(!entry("sub", "160000").has_content()); + assert!(entry("src/main.rs", "100644").has_content()); + assert!(entry("run.sh", "100755").has_content()); + // A symlink's blob IS its target path, which is committed text and is + // read like any other blob. + assert!(entry("link", "120000").has_content()); + // An object `rev-list` named carries no mode, and `keep_blobs` has + // already settled that it is a blob. + assert!(entry("gone.txt", "").has_content()); + } + #[test] fn a_glob_without_a_slash_matches_a_basename_anywhere() { // ripgrep's meaning, which is what every other `glob` in this config diff --git a/src/guard/unicode.rs b/src/guard/unicode.rs index 041b044..b3a8cef 100644 --- a/src/guard/unicode.rs +++ b/src/guard/unicode.rs @@ -208,6 +208,56 @@ fn refused(character: char, base: Option, next: Option) -> bool { false } +/// What a blob's bytes turned out to be. +/// +/// Three answers rather than two, because "there is no text here" and "the text +/// here could not be read" are the difference between a skip and a refusal. A +/// binary file has no lines for a codepoint to hide in and is skipped on +/// purpose; a file that is text except for one byte is the most literal +/// could-not-look there is, and treating it as a skip buys the whole file a +/// pass on the strength of the very byte that should have stopped it. +#[derive(Debug)] +enum Decoded { + Text(String), + Binary, + Unreadable(String), +} + +/// A blob's text, or the reason there is none. +/// +/// The byte-order mark is consulted first because a UTF-16 file is full of NUL +/// bytes: read as UTF-8 it fails, and the NUL test below would then dismiss a +/// perfectly ordinary text file as an image, taking its content out of the scan +/// while looking exactly like a skipped binary. +/// +/// A UTF-8 mark is deliberately NOT consumed there. It decodes as U+FEFF, which +/// this guard already refuses by name, and stripping it would quietly grant an +/// exemption to the one invisible codepoint that turns up in committed files +/// most often. +fn decode_for_scan(bytes: &[u8]) -> Decoded { + if let Some((encoding, _)) = encoding_rs::Encoding::for_bom(bytes) { + if encoding != encoding_rs::UTF_8 { + let (text, _, had_errors) = encoding.decode(bytes); + if had_errors { + return Decoded::Unreadable(format!( + "declares a {} byte-order mark and does not decode as one", + encoding.name() + )); + } + return Decoded::Text(text.into_owned()); + } + } + match std::str::from_utf8(bytes) { + Ok(text) => Decoded::Text(text.to_owned()), + // git's own test for a binary file, and the reason it is applied to the + // BYTES rather than to git's verdict: a `diff` or `text` attribute is a + // claim about how to render a change, and whether there is readable + // text in here is a question about the object. + Err(_) if bytes.contains(&0) => Decoded::Binary, + Err(_) => Decoded::Unreadable(String::from("not valid UTF-8, and not binary either")), + } +} + pub(crate) fn in_files(request: &Request<'_>) -> Result> { let allowances: Vec = request .rule @@ -234,14 +284,36 @@ pub(crate) fn in_files(request: &Request<'_>) -> Result> { if !scope::in_file_scope(request.rule, &blob.path)? { continue; } - let bytes = scope::read(request.root, blob)?; - // A blob that is not UTF-8 is not text somebody typed, and the - // characters this guard is about cannot be identified in it. - let Ok(text) = String::from_utf8(bytes) else { + // THE NAME, before anything is opened and whatever the content turns + // out to be. A filename is committed text: it is read by reviewers, by + // importers and by build rules, and a zero-width space in one is the + // same attack in the one place nobody thinks to look. It is also the + // only thing a gitlink has here -- the submodule's content is its own + // repository's business, and its guards run there. + findings.extend(scan_name(&blob.path, &allowances)); + if !blob.has_content() { continue; - }; - looked += 1; - findings.extend(scan(&text, &blob.path, &allowances)); + } + let bytes = scope::read(request.root, blob)?; + match decode_for_scan(&bytes) { + Decoded::Text(text) => { + looked += 1; + findings.extend(scan(&text, &blob.path, &allowances)); + } + // No lines for a character to hide in. The one skip this guard + // makes, and it is made on the bytes. + Decoded::Binary => {} + // Silently skipped before this, which is a file nobody read + // reported as a file with nothing in it -- `explicit-unknown` by + // name, in the guard that reports it about everyone else. + Decoded::Unreadable(why) => { + return Err(Fatal::new(format!( + "{}: cannot be read as text ({why}); refusing to report it clean \ + over content that was never examined", + blob.path + ))); + } + } } if findings.is_empty() { @@ -258,12 +330,9 @@ pub(crate) fn in_files(request: &Request<'_>) -> Result> { })) } -fn scan(text: &str, path: &str, allowances: &[Allowance]) -> Vec { - let characters: Vec = text.chars().collect(); - let mut findings = Vec::new(); - let mut line = 1usize; - let mut column = 1usize; - let granted: BTreeSet = allowances +/// The codepoints admitted at one path. +fn granted_at(path: &str, allowances: &[Allowance]) -> BTreeSet { + allowances .iter() .filter(|allowance| { allowance @@ -272,7 +341,49 @@ fn scan(text: &str, path: &str, allowances: &[Allowance]) -> Vec { .is_none_or(|glob| glob.is_match(path)) }) .map(|allowance| allowance.codepoint) - .collect(); + .collect() +} + +/// The path itself, judged as the committed text it is. +/// +/// Stricter than the content rule by exactly two characters, and they are the +/// two the content rule exempts: a tab and a newline are legal INSIDE a file +/// and are never legitimate in a path. Everything else this guard refuses is +/// refused here for the same reasons, under the same `allow` list -- a +/// codepoint admitted under a glob is admitted in the names that glob matches. +fn scan_name(path: &str, allowances: &[Allowance]) -> Vec { + let characters: Vec = path.chars().collect(); + let granted = granted_at(path, allowances); + let mut findings = Vec::new(); + for (index, &character) in characters.iter().enumerate() { + if granted.contains(&character) { + continue; + } + let base = index + .checked_sub(1) + .and_then(|previous| characters.get(previous).copied()); + let next = characters.get(index + 1).copied(); + let offending = character == '\t' || character == '\n' || refused(character, base, next); + if !offending { + continue; + } + findings.push(format!( + "{path}:1:{}: U+{:04X} {} in the FILE NAME", + index + 1, + character as u32, + unicode_names2::name(character) + .map_or_else(|| String::from("UNKNOWN"), |name| name.to_string()), + )); + } + findings +} + +fn scan(text: &str, path: &str, allowances: &[Allowance]) -> Vec { + let characters: Vec = text.chars().collect(); + let mut findings = Vec::new(); + let mut line = 1usize; + let mut column = 1usize; + let granted: BTreeSet = granted_at(path, allowances); for (index, &character) in characters.iter().enumerate() { if character == '\n' { @@ -370,4 +481,64 @@ mod tests { assert!(parse_allowance("00A0").is_err()); assert!(parse_allowance("U+ZZZZ").is_err()); } + + #[test] + fn a_filename_is_committed_text_too() { + // The half that did not survive the port. A zero-width space in a path + // is read by reviewers, importers and build rules, and nothing here + // looked at a path at all -- so the one place a reader cannot see the + // character was the one place the guard did not check. + let found = scan_name("docs/re\u{200B}adme.md", &[]); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].contains("U+200B"), "{found:?}"); + assert!(found[0].contains("FILE NAME"), "{found:?}"); + assert!(scan_name("docs/readme.md", &[]).is_empty()); + } + + #[test] + fn a_tab_is_legal_in_a_file_and_never_in_a_path() { + // The two characters the content rule exempts, which is why the path + // cannot simply be handed to `scan`. + assert!(findings("a\tb\n").is_empty()); + assert_eq!(scan_name("a\tb", &[]).len(), 1); + assert_eq!(scan_name("a\nb", &[]).len(), 1); + } + + #[test] + fn an_allowance_scoped_to_a_path_reaches_that_paths_name() { + let allowances = vec![parse_allowance("U+00A0:docs/**").unwrap()]; + assert!(scan_name("docs/a\u{00A0}b.md", &allowances).is_empty()); + assert_eq!(scan_name("src/a\u{00A0}b.rs", &allowances).len(), 1); + } + + #[test] + fn a_blob_that_is_not_text_is_told_apart_from_one_that_is_binary() { + // The direction that matters: an undecodable blob used to be skipped in + // silence, so a file nobody read was counted as a file with nothing in + // it. Binary is the one honest skip -- there are no lines in it for a + // codepoint to hide in. + assert!(matches!(decode_for_scan(b"plain\n"), Decoded::Text(_))); + assert!(matches!( + decode_for_scan(&[0x89, b'P', b'N', b'G', 0x00, 0x1A]), + Decoded::Binary + )); + assert!(matches!( + decode_for_scan(b"caf\xe9 latin1\n"), + Decoded::Unreadable(_) + )); + } + + #[test] + fn a_utf16_file_is_read_rather_than_dismissed_as_binary() { + // It is full of NUL bytes, so the binary test alone takes an ordinary + // text file out of the scan while looking exactly like a skipped image. + let mut bytes = vec![0xFF, 0xFE]; + for unit in "a\u{200B}b".encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + let Decoded::Text(text) = decode_for_scan(&bytes) else { + unreachable!("a UTF-16 file with a byte-order mark is text"); + }; + assert_eq!(scan(&text, "a.txt", &[]).len(), 1); + } } diff --git a/src/text.rs b/src/text.rs index e0f9fee..a26756e 100644 --- a/src/text.rs +++ b/src/text.rs @@ -20,6 +20,11 @@ use crate::error::{Exit, Fatal, Result}; use crate::report::Failure; use crate::sources; +/// The built-in literal source that reads the running host: its username, its +/// home path, its hostname. Named once, because the fallback below and the test +/// for whether anything already covers it have to mean the same string. +const RUNNING_OS_IDENTITY: &str = "running-os-identity"; + /// Used when the caller's repository declares no dynamic rules of its own, or /// has no policy file at all. /// @@ -37,19 +42,42 @@ fn fallback_rule() -> Rule { send. Use neutral placeholders such as example-user, example-host, example.test, \ and /srv/example instead.", )); - rule.forbidden_literals = Some(String::from("running-os-identity")); + rule.forbidden_literals = Some(String::from(RUNNING_OS_IDENTITY)); rule } +/// The text to judge, or a refusal. +/// +/// `from_utf8_lossy` stood here, and it is the quiet version of the failure +/// this whole tool is about: an invalid sequence became U+FFFD without a word, +/// so `printf 'caf\xe9 latin1\n' | uphold scan --text -` printed "policy checks +/// passed (text)" and exited 0 over bytes that were never the text they were +/// searched as. Every other reader in this binary already refuses this -- +/// `scan` says "clean would mean unexamined" about a non-UTF-8 file, and +/// `guard --text` errors out -- so this is the one place the answer differed. +/// +/// It is exit 2 rather than exit 1: nothing was found and nothing was cleared. +/// The bytes could not be looked at. +fn decode(bytes: Vec, source: &str) -> Result { + String::from_utf8(bytes).map_err(|error| { + Fatal::new(format!( + "{source}: is not UTF-8 (invalid byte at offset {}), so it cannot be searched \ + as text and \"clean\" would mean \"unexamined\". Re-encode it as UTF-8, or \ + hand this checker the text rather than the bytes", + error.utf8_error().valid_up_to() + )) + }) +} + fn read(source: &str) -> Result { if source == "-" { let mut buffer = Vec::new(); std::io::stdin().read_to_end(&mut buffer)?; - return Ok(String::from_utf8_lossy(&buffer).into_owned()); + return decode(buffer, "standard input"); } let path = PathBuf::from(source); let bytes = std::fs::read(&path).map_err(|error| Fatal::at(&path, error))?; - Ok(String::from_utf8_lossy(&bytes).into_owned()) + decode(bytes, source) } pub(crate) fn check(found: Option<&(PathBuf, PathBuf)>, source: &str) -> Result { @@ -60,11 +88,22 @@ pub(crate) fn check(found: Option<&(PathBuf, PathBuf)>, source: &str) -> Result< None => (std::env::current_dir()?, Policy::default()), }; - let owned: Vec = if policy.has_check(Check::ForbiddenLiterals) { - policy.of_check(Check::ForbiddenLiterals).cloned().collect() - } else { - vec![fallback_rule()] - }; + // The test is for the identity rule itself, not for the CHECK KIND it + // happens to use. Asking whether any `forbidden_literals` rule existed made + // an unrelated one -- a repository's own list of literals, a command + // source, anything at all -- silently remove the fallback, which exists per + // its own docstring so that the guard is not absent "in exactly the places + // nobody thought to configure it, which is how identity gets published". + // Declaring a rule about something else is not a decision to stop checking + // this, so both run: the declared rules, and the fallback when nothing + // among them reads the running host's identity. + let mut owned: Vec = policy.of_check(Check::ForbiddenLiterals).cloned().collect(); + if !owned + .iter() + .any(|rule| rule.forbidden_literals.as_deref() == Some(RUNNING_OS_IDENTITY)) + { + owned.push(fallback_rule()); + } let mut failures: Vec = Vec::new(); for rule in &owned { diff --git a/tests/guard_recovered_halves.rs b/tests/guard_recovered_halves.rs new file mode 100644 index 0000000..31da167 --- /dev/null +++ b/tests/guard_recovered_halves.rs @@ -0,0 +1,394 @@ +//! The halves of the upstream rules that did not survive the port. +//! +//! Every test here is a case the guard reported as a PASS. Not a wrong finding, +//! not a crash: a green tick over bytes nobody read, which is the one failure +//! this binary exists to make impossible. They are driven through the CLI for +//! the reason `guard_cli.rs` states -- the artifact a guard reads is decided by +//! the stage it is told it is at, and a test calling the function directly +//! would be choosing that artifact itself. + +#![expect( + clippy::let_underscore_must_use, + clippy::tests_outside_test_module, + clippy::unwrap_used, + reason = "A CLI test asserts on the outcome; a panic in the harness that builds the fixture IS the failure report, and there is no caller to hand a Result to" +)] + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn repository(policy: &str) -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "uphold-halves-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("policy")).unwrap(); + std::fs::write(root.join("policy/principles.toml"), policy).unwrap(); + + git(&root, &["init", "-q", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.test"]); + root +} + +fn git(root: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); +} + +fn guard(root: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_uphold")) + .arg("guard") + .args(args) + .current_dir(root) + .env_remove("UPHOLD_ALLOW") + .output() + .unwrap() +} + +fn code(output: &Output) -> i32 { + output.status.code().unwrap() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn head(root: &Path) -> String { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .unwrap(); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +const ZERO: &str = "0000000000000000000000000000000000000000"; + +/// A declared private owner needs no network and cannot be contradicted by one, +/// which is what lets these tests judge a real refusal without asking a forge. +const STAGED: &str = r#" +[rule.no-private-repo-names-staged] +builtin = "no-private-repo-names-staged" +visibility = "public" +private_owners = ["acme-private"] + +[rule.no-private-repo-names-staged.git] +hooks = ["pre-commit"] +"#; + +const TRACKED: &str = r#" +[rule.no-private-repo-names-in-files] +builtin = "no-private-repo-names-in-files" +visibility = "public" +private_owners = ["acme-private"] + +[rule.no-private-repo-names-in-files.git] +hooks = ["pre-push", "manual"] +"#; + +const IN_FILES: &str = r#" +[rule.prevent-unusual-unicode-in-files] +builtin = "prevent-unusual-unicode-in-files" + +[rule.prevent-unusual-unicode-in-files.git] +hooks = ["pre-commit", "pre-merge-commit", "pre-push", "manual"] +"#; + +// ── the staged scan ────────────────────────────────────────────────── + +#[test] +fn a_staged_finding_names_the_file_it_arrived_in() { + // Every added line used to be concatenated into one blob labelled "staged + // changes", so the report named nothing a reader could open -- and the + // rule's `[rule.files]` scope, which is a question about a PATH, could not + // be applied to it at all. + let root = repository(STAGED); + write( + &root, + "docs/note.md", + "we hit this in acme-private/secret\n", + ); + git(&root, &["add", "docs/note.md"]); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!( + stderr(&output).contains("docs/note.md"), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_files_scope_written_on_the_staged_guard_is_obeyed() { + // Accepted and ignored before this: the scan had no path to apply it to. + let root = repository( + "[rule.no-private-repo-names-staged]\n\ + builtin = \"no-private-repo-names-staged\"\n\ + visibility = \"public\"\n\ + private_owners = [\"acme-private\"]\n\ + files.exclude = [\"**/vendor/**\"]\n\n\ + [rule.no-private-repo-names-staged.git]\nhooks = [\"pre-commit\"]\n", + ); + write( + &root, + "vendor/upstream.md", + "shipped from acme-private/secret\n", + ); + git(&root, &["add", "vendor/upstream.md"]); + assert_eq!(code(&guard(&root, &["--stage", "pre-commit"])), 0); + + write(&root, "docs/note.md", "shipped from acme-private/secret\n"); + git(&root, &["add", "docs/note.md"]); + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); +} + +#[test] +fn an_external_diff_driver_cannot_blind_the_staged_scan() { + // `git diff` honours `diff.external` from the repository's config and from + // the global and system files alike, so somebody's difftastic or delta + // setup -- made for reading diffs, not for this -- emitted no `+` line at + // all and the guard reported a pass over a diff it never saw. + let root = repository(STAGED); + git(&root, &["config", "diff.external", "true"]); + write( + &root, + "docs/note.md", + "we hit this in acme-private/secret\n", + ); + git(&root, &["add", "docs/note.md"]); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); +} + +#[test] +fn a_diff_attribute_cannot_blind_the_staged_scan() { + // git consults the `diff` ATTRIBUTE before it looks at a byte, so a + // committed `* -diff` reduces a plain-ASCII file to "Binary files differ" + // and not one added line reaches the first pass. + let root = repository(STAGED); + write(&root, ".gitattributes", "* -diff\n"); + git(&root, &["add", ".gitattributes"]); + git(&root, &["commit", "-qm", "attributes", "--no-verify"]); + + write( + &root, + "docs/note.md", + "we hit this in acme-private/secret\n", + ); + git(&root, &["add", "docs/note.md"]); + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!( + stderr(&output).contains("docs/note.md"), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_rename_publishes_a_path_and_adds_no_line() { + // The whole disclosure is the NAME, and a rename adds no line for any + // line-based scan to read. + let root = repository(STAGED); + write(&root, "notes.md", "nothing to see here\n"); + git(&root, &["add", "notes.md"]); + git(&root, &["commit", "-qm", "one", "--no-verify"]); + git(&root, &["mv", "notes.md", "acme-private-notes.md"]); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!( + stderr(&output).contains("the path itself"), + "{}", + stderr(&output) + ); +} + +// ── the tree-wide scan ─────────────────────────────────────────────── + +#[test] +fn a_path_names_a_private_repository_with_no_help_from_its_content() { + let root = repository(TRACKED); + write(&root, "acme-private/readme.md", "nothing to see here\n"); + git(&root, &["add", "acme-private/readme.md"]); + + let output = guard(&root, &["--stage", "manual"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!( + stderr(&output).contains("the path itself"), + "{}", + stderr(&output) + ); +} + +#[test] +fn the_messages_a_push_publishes_are_read() { + // `commit-msg` only fires when `git commit` writes a message. This one was + // written under `--no-verify`, which is one of six ways to record a message + // no hook has read -- and everything else at pre-push reads the TREE. + let root = repository(TRACKED); + write(&root, "a.txt", "nothing to see here\n"); + git(&root, &["add", "a.txt"]); + git( + &root, + &[ + "commit", + "-qm", + "Fix the thing we hit in acme-private/secret", + "--no-verify", + ], + ); + let pushed = head(&root); + + let output = Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["guard", "--stage", "pre-push"]) + .current_dir(&root) + .env_remove("UPHOLD_ALLOW") + .env("PRE_COMMIT_REMOTE_NAME", "origin") + .env("PRE_COMMIT_LOCAL_BRANCH", "main") + .env("PRE_COMMIT_REMOTE_BRANCH", "refs/heads/main") + .env("PRE_COMMIT_TO_REF", &pushed) + .env("PRE_COMMIT_FROM_REF", ZERO) + .stdin(Stdio::null()) + .output() + .unwrap(); + + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!(stderr(&output).contains("MESSAGE"), "{}", stderr(&output)); +} + +#[test] +fn a_submodule_does_not_end_the_scan_before_it_starts() { + // A gitlink's object is another repository's COMMIT, and `git cat-file blob` + // on it fails. The failure was reported the way any read failure is, so + // every tree-wide guard exited 2 in any workspace that tracks a submodule -- + // and this workspace is full of them. + let root = repository(IN_FILES); + write(&root, "a.txt", "clean\n"); + git(&root, &["add", "a.txt"]); + git(&root, &["commit", "-qm", "one", "--no-verify"]); + let commit = head(&root); + git( + &root, + &[ + "update-index", + "--add", + "--cacheinfo", + &format!("160000,{commit},sub"), + ], + ); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +// ── hidden Unicode ─────────────────────────────────────────────────── + +#[test] +fn a_filename_is_committed_text_too() { + let root = repository(IN_FILES); + write(&root, "docs/re\u{200b}adme.md", "clean\n"); + git(&root, &["add", "-A"]); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!(stderr(&output).contains("FILE NAME"), "{}", stderr(&output)); + assert!(stderr(&output).contains("U+200B"), "{}", stderr(&output)); +} + +#[test] +fn a_blob_that_cannot_be_read_as_text_is_never_reported_clean() { + // Skipped in silence before this: a file nobody read, counted as a file + // with nothing in it. Exit 2, because nothing was found and nothing was + // cleared -- the bytes could not be looked at. + let root = repository(IN_FILES); + std::fs::write(root.join("mixed.txt"), b"caf\xe9 latin1\n").unwrap(); + git(&root, &["add", "mixed.txt"]); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("never examined"), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_binary_file_is_still_the_one_honest_skip() { + let root = repository(IN_FILES); + std::fs::write(root.join("image.bin"), b"\x89PNG\x00\x1a\x0a\xff\xfe\x01").unwrap(); + git(&root, &["add", "image.bin"]); + + let output = guard(&root, &["--stage", "pre-commit"]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +// ── text mode ──────────────────────────────────────────────────────── + +fn scan_text(root: &Path, stdin: &[u8], home: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["scan", "--text", "-"]) + .current_dir(root) + .env_remove("UPHOLD_ALLOW") + .env("HOME", home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.as_mut().unwrap().write_all(stdin).unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn text_that_is_not_utf8_is_could_not_look_and_not_a_pass() { + // `printf 'caf\xe9 latin1\n' | uphold scan --text -` printed "policy checks + // passed (text)" and exited 0 over bytes that were never the text they were + // searched as: `from_utf8_lossy` had already replaced the byte that should + // have stopped the run. + let root = std::env::temp_dir(); + let output = scan_text(&root, b"caf\xe9 latin1\n", "/srv/example"); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!(stderr(&output).contains("not UTF-8"), "{}", stderr(&output)); +} + +#[test] +fn an_unrelated_literal_rule_does_not_remove_the_identity_fallback() { + // The test was for the check KIND, so declaring any forbidden-literals rule + // at all -- about anything at all -- silently deleted the one rule that + // stops the running host's identity being published. + let home = "/srv/example-home-4d1f2a"; + let root = repository( + "[rule.no-default-route-in-text]\n\ + message = \"Do not publish this machine's default route.\"\n\ + forbidden_literals = \"running-default-route\"\n\ + files.include = [\".\"]\n", + ); + let subject = format!("the log said {home}/work/output.txt\n"); + let output = scan_text(&root, subject.as_bytes(), home); + assert_eq!(code(&output), 1, "{}", stderr(&output)); +} From 9b0b69ea884ea26af0669b7a1b92b209abbb6e7d Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:28:30 +0900 Subject: [PATCH 02/21] Select from what git tracks, and hand the exec the bytes it was given A content rule is a claim about what this repository carries, and what it carries is what git tracks. Selection walked the tree instead, honouring every ignore file it found -- so a tracked file matched by a .gitignore line, a .git/info/exclude entry, or the operator's own global ignore file, which is not in the repository at all, was searched by no rule and reported clean. Git ignore rules do not apply to a file git already tracks; a walker's do. The globs now apply to `git ls-files`, and where there is no index to read the walk consults no ignore file at all, which selects a superset -- over- reporting is the direction a checker may fail in, and hiding a file is not. A path a rule could not open was dropped from the list on the way in, so the rule searched what was left, found nothing there, and printed "policy checks passed" over a tree it had not finished reading. Those paths are collected now, named on stderr with the cures, and turn exit 0 into exit 2 after every rule has reported. Collected rather than fatal on purpose: a tree with one unstaged deletion still has an answer for every other rule, and a reader who only ever sees "restore this file" never learns what was waiting behind it. A finding still outranks them, as it does in audit and in the pin guard. `files.include` naming a path outside the repository is refused at load. Such a root selects files that have no repository-relative name, so every hit was dropped on the way out and the rule reported a pass over a search that had found things. Both spellings are caught, absolute and climbing out with `..`. `git check-attr --stdin` was written to and then read from, in that order, which deadlocks on any repository whose answer exceeds a pipe buffer: measured at 3000 tracked paths, 150 KiB in and 200 KiB out against a 64 KiB pipe, the check hangs with no output and no exit code. The two pipes now move at once. The same deadlock in the shim's checker consultation is gone the same way, and a checker that exits 0 without draining the subject is exit 2 rather than a pass over the part it read. The shim ends in a real exec now, so pid, process group, terminal control and death by signal survive instead of being flattened to exit 1; the stdin a `-F -` invocation ate is handed back to the command as a descriptor, because a guard that silently eats the body it approved publishes an empty one. The subcommand is found by walking argv for the first two words that are neither an option nor an option's value, so `gh --repo owner/name issue create` is examined rather than execed unseen. The editor case is closed rather than warned about: the shim installs itself in the command's declared editor variable, runs the real editor, and checks what the editor leaves in the file when it closes. argv keeps its bytes from `main` to the exec. `std::env::args()` panics at exit 101 on an argument that is not UTF-8, out of a binary installed in front of `git` exactly where a latin-1 file name gets typed; the words the shim compares are a lossy copy, the exec gets the originals, and an invocation whose text is actually checked refuses the untranslatable argument with a sentence instead of checking U+FFFD. Three smaller holes on the same theme. The policy walk stops at the repository boundary, so a repository with no policy no longer borrows an enclosing superproject's and reports on another tree under this repository's name. `--policy PATH` asserts the /policy/.toml layout instead of taking the file's grandparent unchecked, which had rooted a scan at the repository's parent. dedent counts the common indent in chars rather than bytes, which panicked on a page whose indentation was not all ASCII. And `uphold rules --effective [--json]` prints what inheritance resolved to, so nothing outside the loader has to re-derive which rules a repository runs. --- src/main.rs | 489 ++++++++++++++++++++----- src/report.rs | 48 ++- src/scan.rs | 32 +- src/selection.rs | 676 ++++++++++++++++++++++++++++------ src/shim.rs | 746 ++++++++++++++++++++++++++++++++------ tests/scan_cli.rs | 175 +++++++++ tests/shim_cli.rs | 97 ++++- tests/shim_handoff_cli.rs | 532 +++++++++++++++++++++++++++ 8 files changed, 2467 insertions(+), 328 deletions(-) create mode 100644 tests/shim_handoff_cli.rs diff --git a/src/main.rs b/src/main.rs index d1e44dc..77aa2d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ mod shim; mod sources; mod text; +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use crate::error::{Exit, Fatal, Result}; @@ -48,6 +49,8 @@ usage: uphold guard --text [FILE|-] run the text-capable guards over text uphold audit --for-publication what a private->public flip would republish uphold rules --set NAME what a bundled rule set refuses, rule by rule + uphold rules --effective [--json] every rule this repository resolves to, and + the git hooks each one fires at uphold shim [args...] check what a command would publish, then run it Invoked under a command's own name -- a link called `gh` on PATH ahead of the @@ -78,7 +81,29 @@ UPHOLD_ALLOW=, bypasses named guards for one invocation. /// binary shipped rather than on the day they chose. const POLICY_NAMES: [&str; 2] = ["principles.toml", "rg-policy.toml"]; -/// Walk up from the working directory until a policy file appears. +/// Whether this directory is where a repository begins. +/// +/// `.git` is a directory in an ordinary clone and a FILE in a linked worktree +/// and in a submodule, so the question is whether the name is there at all and +/// never what kind of thing it is. `symlink_metadata` rather than +/// `Path::exists` because a `.git` that cannot be followed is still a boundary: +/// reading it as absent would resume the climb into the enclosing superproject, +/// which is the one thing the boundary exists to stop. +fn is_repository_root(directory: &Path) -> bool { + directory.join(".git").symlink_metadata().is_ok() +} + +/// Walk up from the working directory until a policy file appears, stopping at +/// the repository boundary. +/// +/// The stop is the whole of the difference between a policy and somebody else's +/// policy. Without it, a repository with no policy of its own kept climbing, +/// loaded the enclosing superproject's, and adopted the SUPERPROJECT'S +/// directory as root -- so the run scanned another tree and the report named +/// files that are not in the repository the command was run in, under this +/// repository's name. Nine repositories in the workspace this was found in have +/// no policy and sit inside superprojects that do, so every one of them was +/// being reported on by proxy. fn discover(start: &Path) -> Option<(PathBuf, PathBuf)> { let mut candidate = start.to_path_buf(); loop { @@ -88,89 +113,156 @@ fn discover(start: &Path) -> Option<(PathBuf, PathBuf)> { return Some((candidate.clone(), policy)); } } - if !candidate.pop() { + // Asked AFTER the lookup: a repository root carrying its own policy is + // the ordinary case, and it has to be found rather than stopped at. + if is_repository_root(&candidate) || !candidate.pop() { return None; } } } +/// The refusal every entry point shares when no policy is within reach. +/// +/// It says "in this repository" because that is now the whole of what was +/// looked at. The alternative is not a pass and never was: a repository with no +/// policy has nothing to check against, and borrowing a parent's was a report +/// about a different tree. +fn no_policy_here(working: &Path) -> Fatal { + Fatal::new(format!( + "no policy in this repository (looked for policy/{} from {} up to the \ + repository root). A repository's policy is its own -- an enclosing \ + superproject's is not borrowed, because a report naming files outside \ + this repository is a report about something else", + POLICY_NAMES.join(" or policy/"), + working.display() + )) +} + +/// The root that an explicitly named policy file is the policy for. +/// +/// `--policy PATH` used to take the file's grandparent and check nothing, which +/// is the root only when the file really is at `/policy/.toml`: +/// `uphold scan --policy principles.toml` made the root the repository's +/// PARENT, and a policy one directory below `/` made it `/`. The default +/// include of `["."]` then walks whatever that came out as. So the layout +/// `discover` looks for is asserted here rather than assumed, and a root that +/// cannot be established is exit 2 -- scanning the wrong tree and reporting on +/// it is worse than saying the layout was not understood. +fn root_of(policy: &Path) -> Result { + let directory = policy.parent(); + let inside_policy_directory = directory + .and_then(Path::file_name) + .is_some_and(|name| name == "policy"); + match directory.and_then(Path::parent) { + Some(root) if inside_policy_directory => Ok(root.to_path_buf()), + _ => Err(Fatal::at( + policy, + "a policy file says which tree it is about by where it sits, and this one \ + is not at /policy/.toml, so there is no root to scan. Move it \ + under a `policy` directory, or drop --policy and let `uphold scan` find \ + the one belonging to the repository you are standing in", + )), + } +} + +/// The text of an argument that has to be text to mean anything. +/// +/// argv on Unix is arbitrary bytes: a file named in latin-1 is a perfectly good +/// argument, and `std::env::args()` PANICS on one -- exit 101, which is not one +/// of the three codes this tool promises, out of a binary designed to stand in +/// front of `git`, `gh` and `npm` and be handed exactly such paths. So argv is +/// read as `OsString` and only the names that are compared against literals +/// here -- an option, a subcommand, a rule-set name, the command a shim stands +/// in front of -- are converted, each with this. A name that is not UTF-8 names +/// nothing this binary has, and saying so is exit 2. +fn text_of(argument: &OsStr) -> Result<&str> { + argument.to_str().ok_or_else(|| { + Fatal::new(format!( + "the argument {:?} is not valid UTF-8, and an option name, a subcommand \ + name and a command name are all read as text", + argument.to_string_lossy() + )) + }) +} + fn run() -> Result { // argv[0] first. Invoked through a link named for a command it shims, the // binary IS that shim -- which is what ends `install.sh` and the // sibling-checkout coupling: there is nothing to install but a link, and // nothing to find but this binary. - let invoked_as = std::env::args() - .next() - .map(PathBuf::from) - .and_then(|path| { - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - }) - .unwrap_or_default(); - if !invoked_as.is_empty() && invoked_as != "uphold" { - let argv: Vec = std::env::args().skip(1).collect(); - return shim_command(&invoked_as, &argv); + let mut argv = std::env::args_os(); + let program = argv.next().unwrap_or_default(); + let arguments: Vec = argv.collect(); + if let Some(name) = Path::new(&program) + .file_name() + .filter(|name| !name.is_empty() && name.to_str() != Some("uphold")) + { + return shim_command(text_of(name)?, &arguments); } - let argv: Vec = std::env::args().skip(1).collect(); - let mut arguments = argv.iter().map(String::as_str); + let Some((first, rest)) = arguments.split_first() else { + print!("{USAGE}"); + return Ok(Exit::Clean); + }; - match arguments.next() { - Some("--version" | "-V") => { + match text_of(first)? { + "--version" | "-V" => { println!("uphold {}", env!("CARGO_PKG_VERSION")); Ok(Exit::Clean) } - Some("--help" | "-h") | None => { + "--help" | "-h" => { print!("{USAGE}"); Ok(Exit::Clean) } - Some("scan") => scan_command(&arguments.collect::>()), - Some("guard") => guard_command(&arguments.collect::>()), - Some("audit") => audit_command(&arguments.collect::>()), - Some("rules") => { - let rest: Vec<&str> = arguments.collect(); - match rest.as_slice() { - ["--set", name] => rules_command(name), - _ => Err(Fatal::new(format!( - "usage: uphold rules --set NAME\n\n{USAGE}" - ))), + "scan" => scan_command(rest), + "guard" => guard_command(rest), + "audit" => audit_command(rest), + "rules" => match rest { + [flag, name] if flag == "--set" => rules_command(text_of(name)?), + [flag] if flag == "--effective" => effective_rules_command(false), + [flag, format] if flag == "--effective" && format == "--json" => { + effective_rules_command(true) } - } - Some("shim") => { - let rest: Vec<&str> = arguments.collect(); + _ => Err(Fatal::new(format!( + "usage: uphold rules --set NAME | uphold rules --effective [--json]\n\n{USAGE}" + ))), + }, + "shim" => { let (name, shimmed) = rest .split_first() .ok_or_else(|| Fatal::new(format!("shim needs a command\n\n{USAGE}")))?; - shim_command( - name, - &shimmed - .iter() - .map(ToString::to_string) - .collect::>(), - ) + shim_command(text_of(name)?, shimmed) } - Some(other) => Err(Fatal::new(format!( + other => Err(Fatal::new(format!( "unknown subcommand {other:?}\n\n{USAGE}" ))), } } -fn scan_command(arguments: &[&str]) -> Result { +fn scan_command(arguments: &[OsString]) -> Result { let mut explicit_policy: Option = None; let mut text_source: Option = None; let mut index = 0; - while let Some(argument) = arguments.get(index).copied() { - match argument { + while let Some(argument) = arguments.get(index) { + match text_of(argument)? { "--policy" => { index += 1; let value = arguments .get(index) .ok_or_else(|| Fatal::new("--policy needs a path"))?; + // A path keeps its bytes. Only the flag NAME above had to be + // text, and a policy file whose name is not UTF-8 opens exactly + // as well as one whose name is. explicit_policy = Some(PathBuf::from(value)); } "--text" => { index += 1; - text_source = Some(arguments.get(index).copied().unwrap_or("-").to_owned()); + // This one is a path or `-`, and what reads it takes text, so + // it converts here -- as a sentence and exit 2, never a panic. + text_source = Some(match arguments.get(index) { + Some(value) => text_of(value)?.to_owned(), + None => String::from("-"), + }); } other => return Err(Fatal::new(format!("unknown option {other:?}\n\n{USAGE}"))), } @@ -183,10 +275,7 @@ fn scan_command(arguments: &[&str]) -> Result { let policy = path .canonicalize() .map_err(|error| Fatal::at(path, error))?; - let root = policy - .parent() - .and_then(Path::parent) - .map_or_else(|| working.clone(), Path::to_path_buf); + let root = root_of(&policy)?; Some((root, policy)) } None => discover(&working), @@ -197,11 +286,7 @@ fn scan_command(arguments: &[&str]) -> Result { } let Some((root, policy_path)) = found else { - return Err(Fatal::new(format!( - "no policy file found (looked for policy/{} walking up from {})", - POLICY_NAMES.join(" or policy/"), - working.display() - ))); + return Err(no_policy_here(&working)); }; let policy = config::load(&root, &policy_path)?; @@ -226,35 +311,68 @@ fn scan_command(arguments: &[&str]) -> Result { } } - if failures.is_empty() { - println!("policy checks passed"); - return Ok(Exit::Clean); + // The other half of that distinction, and the one nobody declared. A path + // a rule selected and could not open was dropped from the list on the way + // in, so the rule searched what was left, found nothing there, and the run + // said `policy checks passed` over a tree it had not finished reading. + // Named here, one line each, and exit 2 -- the same shape `audit + // --for-publication` uses for a forge surface it could not list. + let unreadable = scanner.unreadable(); + if !unreadable.is_empty() { + eprintln!( + "{} path(s) could not be read, so this run searched part of the tree and not the \ + rest -- which is not the same as finding nothing:", + unreadable.len() + ); + for note in &unreadable { + eprintln!(" {note}"); + } + } + + if !failures.is_empty() { + // A violation outranks an unreadable path, the way it does in `audit` + // and in the pin guard: something was found, and exit 1 is the answer + // to "is this tree publishable" that the reader has to act on first. + // The unreadable list is printed either way, so nothing is hidden by + // the ranking -- only the exit code is decided by it. + return Ok(Exit::Violations); } - Ok(Exit::Violations) + if !unreadable.is_empty() { + return Ok(Exit::Broken); + } + println!("policy checks passed"); + Ok(Exit::Clean) } -fn guard_command(arguments: &[&str]) -> Result { +fn guard_command(arguments: &[OsString]) -> Result { let mut stage: Option = None; let mut message: Option = None; let mut remote_name: Option = None; let mut remote_url: Option = None; let mut text_source: Option = None; let mut index = 0; - while let Some(argument) = arguments.get(index).copied() { + while let Some(argument) = arguments.get(index) { + // A stage name, a remote name, a remote URL and a text source are all + // read as text by what they are handed to, so those values convert + // here. `--message` does not: it is a path, and a path does not have to + // be text to be opened. let value = |at: usize, flag: &str| -> Result { - arguments + let given = arguments .get(at) - .map(ToString::to_string) - .ok_or_else(|| Fatal::new(format!("{flag} needs a value"))) + .ok_or_else(|| Fatal::new(format!("{flag} needs a value")))?; + Ok(text_of(given)?.to_owned()) }; - match argument { + match text_of(argument)? { "--stage" => { index += 1; stage = Some(guard::Stage::parse(&value(index, "--stage")?)?); } "--message" => { index += 1; - message = Some(PathBuf::from(value(index, "--message")?)); + let path = arguments + .get(index) + .ok_or_else(|| Fatal::new("--message needs a value"))?; + message = Some(PathBuf::from(path)); } "--remote" => { index += 1; @@ -266,7 +384,10 @@ fn guard_command(arguments: &[&str]) -> Result { } "--text" => { index += 1; - text_source = Some(arguments.get(index).copied().unwrap_or("-").to_owned()); + text_source = Some(match arguments.get(index) { + Some(given) => text_of(given)?.to_owned(), + None => String::from("-"), + }); } other => return Err(Fatal::new(format!("unknown option {other:?}\n\n{USAGE}"))), } @@ -274,13 +395,7 @@ fn guard_command(arguments: &[&str]) -> Result { } let working = std::env::current_dir()?; - let (root, policy_path) = discover(&working).ok_or_else(|| { - Fatal::new(format!( - "no policy file found (looked for policy/{} walking up from {})", - POLICY_NAMES.join(" or policy/"), - working.display() - )) - })?; + let (root, policy_path) = discover(&working).ok_or_else(|| no_policy_here(&working))?; let policy = config::load(&root, &policy_path)?; if let Some(source) = text_source { @@ -342,11 +457,11 @@ fn guard_command(arguments: &[&str]) -> Result { ) } -fn audit_command(arguments: &[&str]) -> Result { +fn audit_command(arguments: &[OsString]) -> Result { // No default mode. `audit` on its own would have to pick a question, and // the one it would pick is the one this tool exists because nothing asks. - match arguments.first().copied() { - Some("--for-publication") if arguments.len() == 1 => {} + match arguments { + [only] if only == "--for-publication" => {} _ => { return Err(Fatal::new(format!( "audit needs --for-publication\n\n{USAGE}" @@ -354,13 +469,7 @@ fn audit_command(arguments: &[&str]) -> Result { } } let working = std::env::current_dir()?; - let (root, policy_path) = discover(&working).ok_or_else(|| { - Fatal::new(format!( - "no policy file found (looked for policy/{} walking up from {})", - POLICY_NAMES.join(" or policy/"), - working.display() - )) - })?; + let (root, policy_path) = discover(&working).ok_or_else(|| no_policy_here(&working))?; let policy = config::load(&root, &policy_path)?; audit::for_publication(&root, &policy) } @@ -383,16 +492,104 @@ fn rules_command(name: &str) -> Result { Ok(Exit::Clean) } -fn shim_command(name: &str, argv: &[String]) -> Result { +/// One JSON string, escaped. +/// +/// Hand-written rather than pulled in with a serialization crate, because this +/// is the only JSON this binary emits and a rule id is the only thing in it +/// that is not a fixed literal. The escapes are the ones RFC 8259 requires: the +/// two structural characters, and every control character below U+0020, which +/// a `\u` escape covers whatever it is. +fn json_string(value: &str, into: &mut String) { + into.push('"'); + for character in value.chars() { + match character { + '"' => into.push_str("\\\""), + '\\' => into.push_str("\\\\"), + '\n' => into.push_str("\\n"), + '\r' => into.push_str("\\r"), + '\t' => into.push_str("\\t"), + control if control < ' ' => { + // Two digits is the whole range: everything below U+0020 fits + // in a byte, and `from_digit` is total for a value under 16, so + // the fallback below is unreachable rather than a guess. + let code = u32::from(control); + into.push_str("\\u00"); + into.push(char::from_digit(code >> 4, 16).unwrap_or('0')); + into.push(char::from_digit(code & 0xf, 16).unwrap_or('0')); + } + ordinary => into.push(ordinary), + } + } + into.push('"'); +} + +/// Every rule this repository actually resolves to, after inheritance. +/// +/// It exists so that nothing else has to re-implement `config::load`. What a +/// repository runs is the bundled sets it names, plus the extra policy files +/// `inherit.paths` merges, minus `inherit.disabled_rules`, with its own rules +/// shadowing an inherited id -- five interacting fields, and every second +/// reader of them is a reader free to disagree with the engine about which +/// rules run. The reconciler in `uphold_check.py` is that second reader today, +/// and this is what ends it: one loader answers, and everything else asks. +/// +/// `--json` because the caller is a program. The human form is the same answer +/// for a person standing in a repository asking what it is holding itself to. +fn effective_rules_command(as_json: bool) -> Result { + let working = std::env::current_dir()?; + let (root, policy_path) = discover(&working).ok_or_else(|| no_policy_here(&working))?; + let policy = config::load(&root, &policy_path)?; + + if !as_json { + println!("{} rule(s) in effect", policy.rules.len()); + for rule in &policy.rules { + let hooks = rule.hooks(); + let at = if hooks.is_empty() { + String::from("no git hook") + } else { + hooks.join(", ") + }; + println!(" {} ({at})", rule.id); + } + return Ok(Exit::Clean); + } + + let mut document = String::from("["); + for (index, rule) in policy.rules.iter().enumerate() { + if index > 0 { + document.push(','); + } + document.push_str("\n {\"id\": "); + json_string(&rule.id, &mut document); + document.push_str(", \"git_hooks\": ["); + for (position, hook) in rule.hooks().iter().enumerate() { + if position > 0 { + document.push_str(", "); + } + json_string(hook, &mut document); + } + document.push_str("]}"); + } + if !policy.rules.is_empty() { + document.push('\n'); + } + document.push(']'); + println!("{document}"); + Ok(Exit::Clean) +} + +fn shim_command(name: &str, argv: &[OsString]) -> Result { let working = std::env::current_dir()?; - let (root, policy_path) = discover(&working).ok_or_else(|| { - Fatal::new(format!( - "no policy file found (looked for policy/{} walking up from {})", - POLICY_NAMES.join(" or policy/"), - working.display() - )) - })?; + let (root, policy_path) = discover(&working).ok_or_else(|| no_policy_here(&working))?; let policy = config::load(&root, &policy_path)?; + // The shimmed command's arguments stay bytes all the way to the exec. On + // Unix an argument is an arbitrary byte string -- `git add` on a file named + // in latin-1 is an ordinary thing to type, and this binary is installed in + // front of `git` exactly where that happens. `shim::run` reads a lossy copy + // to decide what the invocation is, and hands these to the exec, so the + // command that runs is the command that was typed. Where the shim has + // something to CHECK, it refuses the untranslatable argument itself, in the + // words of what it could not read. shim::run(&root, &policy, name, argv) } @@ -406,3 +603,117 @@ fn main() { }; std::process::exit(exit.code()); } + +#[cfg(test)] +mod tests { + use super::{discover, root_of}; + use std::path::{Path, PathBuf}; + + /// One directory per case. The suite runs in parallel threads of a single + /// process, so a path keyed on the process id alone is the SAME path for + /// every case, and one case reads the tree another just built. + fn workspace() -> PathBuf { + use std::sync::atomic::{AtomicUsize, Ordering}; + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "uphold-discover-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write_policy(directory: &Path) -> PathBuf { + std::fs::create_dir_all(directory.join("policy")).unwrap(); + let path = directory.join("policy/principles.toml"); + std::fs::write(&path, "allowed_scripts = [\"Latin\"]\n").unwrap(); + path + } + + /// The live bug: nine repositories with no policy of their own, each inside + /// a superproject that has one. + /// + /// The old walk climbed past the inner repository's own root, loaded the + /// superproject's policy and adopted the SUPERPROJECT'S directory as root, + /// so the report named files outside the repository the command was run in. + #[test] + fn a_repository_with_no_policy_does_not_borrow_the_superprojects() { + let superproject = workspace(); + write_policy(&superproject); + let inner = superproject.join("inner"); + std::fs::create_dir_all(inner.join("src")).unwrap(); + std::fs::create_dir_all(inner.join(".git")).unwrap(); + + assert!(discover(&inner).is_none(), "borrowed the superproject"); + assert!( + discover(&inner.join("src")).is_none(), + "climbed out of the repository from a subdirectory" + ); + // And the superproject itself still finds its own, from any depth: the + // stop is a boundary, not a ban on walking up. + assert_eq!( + discover(&superproject).map(|(root, _)| root), + Some(superproject.clone()) + ); + } + + /// A `.git` FILE is the boundary too -- that is what a linked worktree and + /// a submodule have where a clone has a directory. + #[test] + fn a_git_file_stops_the_walk_the_way_a_git_directory_does() { + let superproject = workspace(); + write_policy(&superproject); + let inner = superproject.join("submodule"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(inner.join(".git"), "gitdir: ../.git/modules/submodule\n").unwrap(); + + assert!(discover(&inner).is_none()); + } + + /// The boundary is where the walk STOPS, not where it refuses to look: a + /// repository root carrying its own policy is the ordinary case. + #[test] + fn a_repository_root_with_its_own_policy_is_still_found() { + let superproject = workspace(); + write_policy(&superproject); + let inner = superproject.join("inner"); + std::fs::create_dir_all(inner.join(".git")).unwrap(); + let policy = write_policy(&inner); + std::fs::create_dir_all(inner.join("src")).unwrap(); + + assert_eq!( + discover(&inner.join("src")), + Some((inner.clone(), policy)), + "a repository's own policy is what it is checked against" + ); + } + + /// `--policy` derived the root by taking the file's grandparent and + /// checking nothing, so `--policy principles.toml` rooted the scan at the + /// repository's PARENT and a policy one directory below `/` rooted it at + /// `/`. The default include of `["."]` then walked that. + #[test] + fn an_explicit_policy_off_the_layout_has_no_root_to_scan() { + for off_layout in [ + "principles.toml", + "/principles.toml", + "/srv/example/rules/principles.toml", + ] { + let error = root_of(Path::new(off_layout)) + .expect_err("a root derived from a layout that is not there is the wrong tree"); + assert!( + error.to_string().contains("/policy/.toml"), + "{off_layout}: {error}" + ); + } + } + + #[test] + fn an_explicit_policy_in_the_layout_roots_at_the_repository() { + assert_eq!( + root_of(Path::new("/srv/example/policy/principles.toml")).unwrap(), + Path::new("/srv/example") + ); + } +} diff --git a/src/report.rs b/src/report.rs index 639cdcc..a1b43c0 100644 --- a/src/report.rs +++ b/src/report.rs @@ -35,22 +35,40 @@ impl Failure { } /// Strip the common leading indentation a TOML multi-line string carries. +/// +/// The indent is counted in CHARACTERS and never in bytes. It used to be a byte +/// count minimised over the non-blank lines while `trim_start` stripped Unicode +/// whitespace, so one line indented with a wide space -- U+3000 is three bytes +/// and one character -- put the minimum inside a character of another line, and +/// `&line[indent..]` panicked. That is exit 101 out of the function whose whole +/// job is printing a violation: the report the run exists to produce, replaced +/// by a crash, on text a policy author is free to write. Whitespace-only lines +/// were the second way in -- excluded from the minimum and sliced anyway. fn dedent(text: &str) -> String { let lines: Vec<&str> = text.lines().collect(); let indent = lines .iter() .filter(|line| !line.trim().is_empty()) - .map(|line| line.len() - line.trim_start().len()) + .map(|line| line.chars().count() - line.trim_start().chars().count()) .min() .unwrap_or(0); lines .iter() .map(|line| { - if line.len() >= indent { - &line[indent..] - } else { - line + // Walking the characters and keeping what is left is what makes + // this safe where the slice was not: the remainder always begins on + // a character boundary, whatever the line is made of. A line with + // fewer characters than the indent is whitespace-only by + // construction -- every other line carries at least this much + // leading whitespace -- so running the iterator out and keeping the + // empty remainder is the right answer for it. + let mut characters = line.chars(); + for _ in 0..indent { + if characters.next().is_none() { + break; + } } + characters.as_str() }) .collect::>() .join("\n") @@ -123,6 +141,26 @@ mod tests { assert!(!body.contains("secret")); } + /// The verified crash: a byte index into a character. + /// + /// U+3000 IDEOGRAPHIC SPACE is one character, three bytes, and stripped by + /// `trim_start`, so the byte minimum taken from the ASCII line landed in + /// the middle of it and the slice panicked. A message is policy-author + /// text, so this is a message a rule may legitimately carry -- and the + /// panic replaced the violation report with exit 101. + #[test] + fn a_wide_whitespace_indent_does_not_split_a_character() { + assert_eq!(dedent(" ascii\n\u{3000}wide"), "ascii\nwide"); + } + + /// The second way in: a whitespace-only line is excluded from the minimum + /// and was sliced by it anyway. + #[test] + fn a_whitespace_only_line_shorter_than_the_indent_survives() { + assert_eq!(dedent(" ascii\n\u{3000}\n more"), "ascii\n\nmore"); + assert_eq!(dedent(" first\n \n second"), "first\n\nsecond"); + } + #[test] fn a_long_redacted_report_says_how_much_it_withheld() { let hits: Vec = (1..=25).map(|line| hit("a.txt", line)).collect(); diff --git a/src/scan.rs b/src/scan.rs index 966c3f3..bcb1cf7 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,5 +1,6 @@ //! The seven rule kinds. +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -45,6 +46,15 @@ pub(crate) struct Scan<'a> { root: &'a Path, policy: &'a Policy, not_text: Vec, + /// Every path any rule's selection knew about and could not open. + /// + /// Interior mutability because `run` takes `&self` and every check arm + /// under it does too, and because this is the one thing a scan accumulates + /// that is not a finding. A `BTreeSet` because the rules overlap: one + /// unreadable file is one line in the report however many rules selected + /// it, and sorted because a report whose order depends on rule order diffs + /// against itself between runs. + unreadable: RefCell>, } impl<'a> Scan<'a> { @@ -53,6 +63,7 @@ impl<'a> Scan<'a> { root, policy, not_text: not_text_paths(root), + unreadable: RefCell::new(BTreeSet::new()), } } @@ -60,6 +71,18 @@ impl<'a> Scan<'a> { &self.not_text } + /// The paths this scan could not read, each with its reason. + /// + /// Reported beside the findings rather than instead of them, and that is + /// the point of collecting rather than failing: a tree with one unreadable + /// path still has an answer for every other rule, and refusing to give it + /// makes the fix for the unreadable path the only thing anybody ever sees. + /// Non-empty is exit 2 -- "could not look" is not a pass -- but every rule + /// has already reported by the time the caller asks. + pub(crate) fn unreadable(&self) -> Vec { + self.unreadable.borrow().iter().cloned().collect() + } + /// Evaluate every rule that declares `[rule.files]`, in check order. /// /// The table is the filter, and that is the change: a rule used to be here @@ -124,7 +147,14 @@ impl<'a> Scan<'a> { } fn select(&self, rule: &Rule) -> Result> { - Selection::build(self.root, rule, &self.not_text).map(|selection| selection.files()) + let selection = Selection::build(self.root, rule, &self.not_text)?; + // Gathered here, at the one place every rule's selection passes + // through, so no future check kind can acquire its own way of dropping + // a path it could not open. + self.unreadable + .borrow_mut() + .extend(selection.unreadable().iter().cloned()); + Ok(selection.files()) } const fn redact(&self) -> bool { diff --git a/src/selection.rs b/src/selection.rs index cafdc01..045d101 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -11,10 +11,24 @@ //! There is one implementation now, and it is ripgrep's: the `ignore` crate's //! `Override`, which is the exact type ripgrep builds its own `--glob` handling //! on, including the rule that the LAST matching glob wins. +//! +//! What the globs are applied TO is git's index. A content rule is a claim +//! about what this repository carries, and what it carries is what git tracks: +//! a tracked file that some ignore pattern also matches -- a `.gitignore` line, +//! a `.git/info/exclude` entry, or the operator's own global ignore file, which +//! is not in the repository at all -- is still tracked, still pushed, and still +//! read by everyone who clones it. Git ignore rules do not apply to a file git +//! already tracks; a walker's do, so a walk cannot see that file, and a rule +//! that cannot see a file reports it clean. Where there is no index to read, +//! the tree is walked with no ignore rules consulted at all, which selects a +//! SUPERSET of what is tracked -- over-reporting is the direction a checker is +//! allowed to fail in, and hiding a file is not. use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Once; use ignore::overrides::{Override, OverrideBuilder}; use ignore::WalkBuilder; @@ -31,42 +45,57 @@ use crate::error::{Fatal, Result}; /// did not check these" and "these were clean" must never look the same on the /// way out. pub(crate) fn not_text_paths(root: &Path) -> Vec { - let listed = Command::new("git") - .args(["ls-files", "-z"]) - .current_dir(root) - .output(); - let Ok(listed) = listed else { + let Some(listed) = index_bytes(root) else { // No git, or no repository. The declaration is optional, and its absence // means nothing is declared -- not that something failed. return Vec::new(); }; - if !listed.status.success() || listed.stdout.is_empty() { + if listed.is_empty() { return Vec::new(); } let Ok(mut child) = Command::new("git") .args(["check-attr", "--stdin", "-z", "text"]) .current_dir(root) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) .spawn() else { return Vec::new(); }; - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - stdin.write_all(&listed.stdout).ok(); - } - let Ok(output) = child.wait_with_output() else { + let (Some(mut sink), Some(mut source)) = (child.stdin.take(), child.stdout.take()) else { return Vec::new(); }; - if !output.status.success() { + + // The two pipes move at the same time, on two threads, and that is not a + // style preference. `check-attr` answers each path as it reads it, so on a + // repository with a few thousand tracked files it fills its stdout pipe -- + // 64 KiB on Linux -- long before it has read the last path off stdin. A + // parent that writes the whole list before reading a byte is then blocked + // in `write_all` on a full stdin pipe while the child is blocked writing to + // a stdout pipe nobody is draining, and neither ever moves again: the check + // hangs with no output, no exit code, and nothing in a log to say why. + let mut answered: Vec = Vec::new(); + let drained = std::thread::scope(|scope| { + scope.spawn(move || { + // Whatever git makes of the list, the handle is dropped when this + // closure ends, and closing stdin is what tells `--stdin` the list + // is finished. + sink.write_all(&listed).ok(); + }); + source.read_to_end(&mut answered) + }); + // Reaped either way. The child holds a slot in the process table until + // somebody waits for it, and its status is the only thing that separates a + // complete answer from a truncated one. + let finished = child.wait(); + if drained.is_err() || !finished.is_ok_and(|status| status.success()) { return Vec::new(); } // `check-attr -z` emits path, attribute, value as three NUL-separated fields. - let fields: Vec<&[u8]> = output.stdout.split(|byte| *byte == 0).collect(); + let fields: Vec<&[u8]> = answered.split(|byte| *byte == 0).collect(); let mut found = Vec::new(); for chunk in fields.chunks(3) { let [path, _, value] = chunk else { @@ -79,67 +108,159 @@ pub(crate) fn not_text_paths(root: &Path) -> Vec { found } -/// The files one rule searches, and the globs that chose them. +/// Every path in git's index, NUL separated, exactly as git wrote them. +/// +/// `None` where there is no index to read: no git on PATH, or a directory that +/// is not a repository. That is a different answer from `Some` of an empty +/// list, which is a repository tracking nothing -- and the two must not fold +/// together, because one of them means this tool could not ask the question. +fn index_bytes(root: &Path) -> Option> { + let listed = Command::new("git") + .args(["ls-files", "-z"]) + .current_dir(root) + .stderr(Stdio::null()) + .output() + .ok()?; + listed.status.success().then_some(listed.stdout) +} + +/// The same listing, decoded. A path git cannot spell in UTF-8 keeps a lossy +/// name rather than disappearing: the readers downstream open it by that name +/// and report the failure, where dropping it here would report nothing at all. +fn index_paths(root: &Path) -> Option> { + Some( + index_bytes(root)? + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .map(|field| String::from_utf8_lossy(field).into_owned()) + .collect(), + ) +} + +/// The files one rule searches, chosen once, at build time. +/// +/// Chosen at build time because every way choosing them can fail -- an +/// `include` that points outside the tree, a directory the walk could not read +/// -- and a list of file names has no way to say anything but "these". A short +/// list that lost a subtree on the way in is exactly "could not look" wearing +/// "looked and found nothing"'s clothes, and this tool exists to keep those two +/// apart. +/// +/// The two failures are carried differently, and the difference is what a +/// reader can do about them. An `include` outside the repository is a policy +/// that cannot mean anything, so it is a `Fatal` from `build` and the run stops +/// -- there is no partial answer to report. A path that could not be READ is a +/// fact about this tree rather than about the policy, so it rides out beside +/// the files as `unreadable`: every other rule still runs and still reports, +/// and the caller prints the list and exits 2. Failing the whole run at the +/// first unreadable path would hide every finding the remaining rules had. +#[derive(Debug)] pub(crate) struct Selection { - root: PathBuf, - roots: Vec, - overrides: Override, + files: Vec, + unreadable: Vec, } impl Selection { pub(crate) fn build(root: &Path, rule: &Rule, not_text: &[String]) -> Result { - let mut builder = OverrideBuilder::new(root); - for glob in &rule.files().glob { - builder.add(glob).map_err(|error| { - Fatal::new(format!("rule {:?}: glob {glob:?}: {error}", rule.id)) - })?; - } - for glob in &rule.files().exclude { - builder.add(&format!("!{glob}")).map_err(|error| { - Fatal::new(format!("rule {:?}: exclude {glob:?}: {error}", rule.id)) - })?; - } - // LAST, because the last matching glob wins: an exclusion placed first is - // undone by any later glob the file happens to match. The old engine - // carried the same ordering and the same comment, found by a test that - // reported a file as skipped and searched it anyway. Everything below - // this line is unconditional, which is why it goes here and not above. - for path in not_text { - builder - .add(&format!("!{path}")) - .map_err(|error| Fatal::new(format!("not-text path {path:?}: {error}")))?; - } - // The object store is not repository content. It holds every version of - // every file, so a rule that fired on a line somebody deleted years ago - // would report a violation with no working-tree fix. + let overrides = overrides_for(root, rule, not_text)?; + let roots = search_roots(root, rule)?; + // An index if there is one, and a walk only where there is not. + let (files, unreadable) = index_paths(root).map_or_else( + || by_walking(root, &roots, &overrides), + |tracked| from_index(root, &roots, &overrides, &tracked), + ); + Ok(Self { files, unreadable }) + } + + /// Repository-relative paths, sorted, deduplicated. + /// + /// Sorted because a report whose order depends on directory iteration is a + /// report that diffs against itself between runs, and deduplicated because + /// overlapping `include` roots would otherwise search a file twice and + /// report it twice. + pub(crate) fn files(&self) -> Vec { + self.files.clone() + } + + /// Paths this selection knows about and could not open, each with the + /// reason. Never empty for a reason that is not worth exit 2. + pub(crate) fn unreadable(&self) -> &[String] { + &self.unreadable + } +} + +/// The rule's globs, as ripgrep would read them. +fn overrides_for(root: &Path, rule: &Rule, not_text: &[String]) -> Result { + let mut builder = OverrideBuilder::new(root); + for glob in &rule.files().glob { + builder + .add(glob) + .map_err(|error| Fatal::new(format!("rule {:?}: glob {glob:?}: {error}", rule.id)))?; + } + for glob in &rule.files().exclude { + builder.add(&format!("!{glob}")).map_err(|error| { + Fatal::new(format!("rule {:?}: exclude {glob:?}: {error}", rule.id)) + })?; + } + // LAST, because the last matching glob wins: an exclusion placed first is + // undone by any later glob the file happens to match. The old engine + // carried the same ordering and the same comment, found by a test that + // reported a file as skipped and searched it anyway. Everything below + // this line is unconditional, which is why it goes here and not above. + for path in not_text { builder - .add("!.git/**") - .map_err(|error| Fatal::new(format!("{error}")))?; - let overrides = builder - .build() - .map_err(|error| Fatal::new(format!("rule {:?}: {error}", rule.id)))?; - - let include = rule.include(); - let roots = if include.is_empty() { - vec![root.to_path_buf()] + .add(&format!("!{path}")) + .map_err(|error| Fatal::new(format!("not-text path {path:?}: {error}")))?; + } + // The object store is not repository content. It holds every version of + // every file, so a rule that fired on a line somebody deleted years ago + // would report a violation with no working-tree fix. git never lists it in + // the index either; the glob is what keeps the walk honest where there is + // no index to read. + builder + .add("!.git/**") + .map_err(|error| Fatal::new(format!("{error}")))?; + builder + .build() + .map_err(|error| Fatal::new(format!("rule {:?}: {error}", rule.id))) +} + +/// The roots one rule searches under, refusing any that leaves the repository. +fn search_roots(root: &Path, rule: &Rule) -> Result> { + let include = rule.include(); + if include.is_empty() { + return Ok(vec![root.to_path_buf()]); + } + + let mut roots: Vec = Vec::new(); + for spec in include { + let search_root = if spec == "." { + root.to_path_buf() } else { - include - .iter() - .map(|spec| { - if spec == "." { - root.to_path_buf() - } else { - root.join(spec) - } - }) - .collect() + root.join(spec) }; + // Refused, and refused here rather than survived downstream. A + // selection reports repository-relative paths, so a root outside the + // repository has no name to report a hit under: every file found there + // was dropped for lack of one, the rule saw an empty selection, and an + // empty selection reads as `policy checks passed`. The two ways to + // write it are an absolute path and one that climbs out with `..`, and + // neither is a thing a policy about this repository can mean. + if !under(root, &search_root) { + return Err(Fatal::new(format!( + "rule {:?}: `files.include` names {spec:?}, which is outside {}. An include \ + names a path inside the repository, relative to its root -- a root outside it \ + selects files this rule cannot name, and reports them as nothing at all.", + rule.id, + root.display() + ))); + } + // An `include` root that is not there searched nothing and said nothing. - // `files()` skips a missing root, so a rule whose directory had since - // been renamed selected no files and reported `policy checks passed` -- - // indistinguishable from a rule that looked everywhere and found - // nothing. + // A rule whose directory had since been renamed selected no files and + // reported `policy checks passed` -- indistinguishable from a rule that + // looked everywhere and found nothing. // // Reported rather than refused, and the difference is that this tool // cannot tell the two cases apart: a root that was renamed away leaves a @@ -151,64 +272,168 @@ impl Selection { // // The default root is the repository itself, so this can only fire on an // `include` somebody wrote. - for (spec, search_root) in rule.include().iter().zip(&roots) { - if !search_root.exists() { - eprintln!( - "rule {:?}: `files.include` names {spec:?}, which does not \ - exist -- that root selected no files. If the directory moved, \ - this rule is not running.", - rule.id - ); - } + if !search_root.exists() { + eprintln!( + "rule {:?}: `files.include` names {spec:?}, which does not \ + exist -- that root selected no files. If the directory moved, \ + this rule is not running.", + rule.id + ); } + roots.push(search_root); + } + Ok(roots) +} + +/// Whether `candidate` is `root` itself or something under it, decided +/// lexically -- the answer must not depend on what exists yet, because a +/// missing `include` root is reported rather than refused. +fn under(root: &Path, candidate: &Path) -> bool { + candidate + .strip_prefix(root) + .is_ok_and(|rest| !rest.components().any(|part| part == Component::ParentDir)) +} - Ok(Self { - root: root.to_path_buf(), - roots, - overrides, - }) +/// Select from what git tracks: the files, and the paths that could not be read. +fn from_index( + root: &Path, + roots: &[PathBuf], + overrides: &Override, + tracked: &[String], +) -> (Vec, Vec) { + if tracked.is_empty() { + // Said once per run, not once per rule: this is one fact about the + // repository, and repeating it under every rule in the policy would + // bury the findings beneath it. Said at all, because "git tracks + // nothing here" and "every rule looked and found nothing" are the two + // facts this tool exists to keep apart. + static SAID: Once = Once::new(); + SAID.call_once(|| { + eprintln!( + "git tracks no files under {} -- every content rule selected nothing. Stage or \ + commit the files the policy is about; an untracked file is not something this \ + repository carries.", + root.display() + ); + }); + return (Vec::new(), Vec::new()); } - /// Repository-relative paths, sorted, deduplicated. - /// - /// Sorted because a report whose order depends on directory iteration is a - /// report that diffs against itself between runs, and deduplicated because - /// overlapping `include` roots would otherwise search a file twice and - /// report it twice. - pub(crate) fn files(&self) -> Vec { - let mut found: BTreeSet = BTreeSet::new(); - for search_root in &self.roots { - if !search_root.exists() { - continue; + // `search_roots` has already refused anything outside `root`, so each root + // has a repository-relative name; the empty one is the repository itself + // and covers every tracked path. + let prefixes: Vec<&Path> = roots + .iter() + .filter_map(|search_root| search_root.strip_prefix(root).ok()) + .collect(); + + let mut found: BTreeSet = BTreeSet::new(); + let mut unreadable: Vec = Vec::new(); + for path in tracked { + let relative = Path::new(path); + if !prefixes + .iter() + .any(|prefix| prefix.as_os_str().is_empty() || relative.starts_with(prefix)) + { + continue; + } + if overrides.matched(relative, false).is_ignore() { + continue; + } + match std::fs::symlink_metadata(root.join(path)) { + Ok(entry) if entry.is_file() => { + found.insert(path.clone()); } - let mut walker = WalkBuilder::new(search_root); - walker - .overrides(self.overrides.clone()) - // Dotfiles ARE repository content. ripgrep skips them by - // default and the old engine inherited that, so the security - // base set's `.env` rules -- whose globs are `.env`, `.env.*` -- - // could not match the files they name, while `path` and - // `require` rules, which enumerated through `git ls-files` - // instead, saw them. One engine has to pick, and skipping - // `.github/workflows` and `.env` is not a policy anyone would - // write down; it is a terminal-ergonomics default arriving - // where it was never meant to decide anything. - .hidden(false) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .parents(true); - for entry in walker.build().flatten() { - if !entry.file_type().is_some_and(|kind| kind.is_file()) { - continue; - } - if let Ok(relative) = entry.path().strip_prefix(&self.root) { - found.insert(relative.to_string_lossy().into_owned()); + // A gitlink is another repository's content, and a symlink is a + // pointer rather than text somebody wrote here -- the walk yielded + // neither, and reading one would either fail or report the target's + // text under the link's name. + Ok(_) => {} + // The index names it and the tree does not have it, so this rule + // cannot read a file the repository still carries. Collected rather + // than dropped, because dropping it is the whole defect: the rule + // would search everything else, find nothing, and report a tree it + // never finished reading as clean. The wording carries the cures, + // because the reader of this line is holding a working tree and + // three of the four causes are things they can act on. + Err(error) => unreadable.push(format!( + "{path}: {error} -- git tracks it and the working tree does not have it, which \ + is an unstaged deletion, a sparse checkout, or a directory this process may \ + not enter. Stage the deletion, restore the file, or exclude the path from the \ + rules that select it." + )), + } + } + (found.into_iter().collect(), unreadable) +} + +/// Select by walking the tree, for a directory git has no index for. +fn by_walking(root: &Path, roots: &[PathBuf], overrides: &Override) -> (Vec, Vec) { + let mut found: BTreeSet = BTreeSet::new(); + let mut unreadable: Vec = Vec::new(); + for search_root in roots { + if !search_root.exists() { + continue; + } + let mut walker = WalkBuilder::new(search_root); + walker + .overrides(overrides.clone()) + // Dotfiles ARE repository content. ripgrep skips them by + // default and the old engine inherited that, so the security + // base set's `.env` rules -- whose globs are `.env`, `.env.*` -- + // could not match the files they name, while `path` and + // `require` rules, which enumerated through `git ls-files` + // instead, saw them. One engine has to pick, and skipping + // `.github/workflows` and `.env` is not a policy anyone would + // write down; it is a terminal-ergonomics default arriving + // where it was never meant to decide anything. + .hidden(false) + // No ignore file of any kind is consulted. This walk runs where + // there is no index to contradict one, so nothing an ignore file + // hides here could be tracked -- and what an ignore file hides is + // precisely what a rule would then report as clean without ever + // opening it. The operator's global ignore file is the sharpest + // case: it is not in the repository, so nothing a reviewer can + // read explains why a rule stopped covering a file. + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false); + for entry in walker.build() { + match entry { + // A subtree nobody searched: a directory the process cannot + // read, a symlink loop, an ignore file that would not parse. + // Every one of these used to be dropped on the floor, which + // left a tree half of it could not enter looking exactly like a + // small repository -- and the rules over it said `policy checks + // passed` at exit 0. Collected here and reported by the caller, + // which is exit 2: the run could not look. + Err(error) => unreadable.push(error.to_string()), + Ok(entry) => { + if !entry.file_type().is_some_and(|kind| kind.is_file()) { + continue; + } + match entry.path().strip_prefix(root) { + Ok(relative) => { + found.insert(relative.to_string_lossy().into_owned()); + } + // `search_roots` refuses an `include` that leaves the + // tree, so nothing should reach this arm. A path that + // does is still a file this selection walked and cannot + // name, and dropping it quietly is the defect the + // refusal exists to end. + Err(_) => unreadable.push(format!( + "{}: sits outside {} and has no repository-relative name", + entry.path().display(), + root.display() + )), + } } } } - found.into_iter().collect() } + (found.into_iter().collect(), unreadable) } /// Strip the `./` the old engine's file listing could emit. @@ -227,3 +452,218 @@ pub(crate) fn normalize_rel(path: &str) -> &str { } normalized } + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::time::Duration; + + use crate::config::{Check, Files}; + + /// A directory of this test's own, named for what the test is about so a + /// leftover on a failure says which one left it. + fn workspace(label: &str) -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "uphold-selection-{label}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::remove_dir_all(&root).ok(); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn git(root: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); + } + + fn repository(label: &str) -> PathBuf { + let root = workspace(label); + git(&root, &["init", "-q", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.test"]); + root + } + + fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + + fn rule(files: Files) -> Rule { + let mut rule = Rule::synthetic("selection-test", Check::Regexp); + rule.files = Some(files); + rule + } + + fn selected(root: &Path, files: Files) -> Vec { + Selection::build(root, &rule(files), &[]).unwrap().files() + } + + fn unreadable(root: &Path, files: Files) -> Vec { + Selection::build(root, &rule(files), &[]) + .unwrap() + .unreadable() + .to_vec() + } + + #[test] + fn a_tracked_file_an_ignore_rule_hides_is_still_selected() { + // The rule that made this invisible is git's own: ignore patterns do + // not apply to a file git already tracks. A walker's do, so a tracked + // file matched by any pattern -- a `.gitignore` line here, and the + // operator's own global ignore file in the case that named this -- was + // searched by no content rule and reported as clean. + let root = repository("tracked"); + write(&root, ".gitignore", "hidden.txt\n"); + write(&root, "hidden.txt", "content\n"); + write(&root, "stray.txt", "content\n"); + git(&root, &["add", "-f", ".gitignore", "hidden.txt"]); + + let files = selected( + &root, + Files { + glob: vec!["*.txt".to_owned()], + ..Files::default() + }, + ); + assert!(files.contains(&"hidden.txt".to_owned()), "{files:?}"); + // And the other half of "what git tracks": a file nobody staged is not + // something this repository carries, so no rule speaks about it. + assert!(!files.contains(&"stray.txt".to_owned()), "{files:?}"); + } + + #[test] + fn a_tracked_path_the_working_tree_does_not_have_is_named_and_not_dropped() { + // The other end of selecting from the index: git says the repository + // carries this file and the tree cannot produce it, so the rule reads + // some of what is tracked and not the rest. Named -- a rule that + // searched the remainder and found nothing would otherwise report a + // tree it never finished reading as clean. It rides out beside the + // files rather than ending the run, because the rest of the tree still + // has an answer and the caller is what turns this list into exit 2. + let root = repository("deleted"); + write(&root, "a.txt", "content\n"); + write(&root, "gone.txt", "content\n"); + git(&root, &["add", "-f", "a.txt", "gone.txt"]); + std::fs::remove_file(root.join("gone.txt")).unwrap(); + + let selection = Selection::build(&root, &rule(Files::default()), &[]).unwrap(); + let notes = selection.unreadable(); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert!( + notes.iter().any(|note| note.contains("gone.txt")), + "{notes:?}" + ); + // And the file that IS there was still selected: the point of carrying + // the failure alongside is that the rest of the rule still runs. + assert_eq!(selection.files(), vec![String::from("a.txt")]); + } + + #[test] + fn an_include_names_a_path_inside_the_repository_or_the_rule_is_refused() { + // Both spellings of leaving the tree. Each one selected files whose + // paths could not be made repository-relative, so every hit was dropped + // on the way out and the rule reported `policy checks passed` over a + // search that produced findings. + let root = repository("outside"); + write(&root, "a.txt", "content\n"); + git(&root, &["add", "a.txt"]); + + for spec in ["../elsewhere", "/etc"] { + let error = Selection::build( + &root, + &rule(Files { + include: Some(vec![spec.to_owned()]), + ..Files::default() + }), + &[], + ) + .unwrap_err(); + assert!(error.to_string().contains("outside"), "{error}"); + assert!(error.to_string().contains(spec), "{error}"); + } + } + + #[test] + #[cfg(unix)] + fn a_directory_the_walk_cannot_enter_is_named_and_not_a_short_list() { + use std::os::unix::fs::PermissionsExt; + + // No repository here on purpose: this is the walk that runs where there + // is no index to read, and the walk is where an unreadable directory + // arrives as an error nobody was collecting. + let root = workspace("blocked"); + write(&root, "visible.txt", "content\n"); + write(&root, "locked/buried.txt", "content\n"); + let locked = root.join("locked"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + + // A process that can read it anyway -- root, or a filesystem that does + // not carry the mode -- is not the situation under test, and asserting + // into it would report the harness rather than the code. + if std::fs::read_dir(&locked).is_ok() { + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let notes = unreadable(&root, Files::default()); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert!( + notes.iter().any(|note| note.contains("locked")), + "the subtree nobody could enter went unnamed: {notes:?}" + ); + } + + #[test] + fn several_thousand_tracked_paths_are_read_without_deadlocking() { + // The proof for the pipe, and the numbers are the proof: 3000 paths of + // about 50 bytes is 150 KiB written to stdin, and `check-attr` answers + // each one as it reads it, which comes to 200 KiB back. Both are + // several times the 64 KiB a pipe holds, so a parent that wrote the + // whole list before reading a byte stopped here and never came back. + let root = repository("many"); + write(&root, ".gitattributes", "*.bin -text\n"); + write(&root, "capture.bin", "bytes\n"); + for index in 0..3000 { + write( + &root, + &format!("tracked/fixture-with-a-name-long-enough-{index:05}.txt"), + "content\n", + ); + } + git(&root, &["add", "-f", "-A", "."]); + + // On a thread with a deadline, because a test that proves a deadlock is + // gone has to FAIL when it is not, and a test that hangs reports + // nothing at all -- it stops the suite with no failing test named. + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + sender.send(not_text_paths(&root)).ok(); + }); + let declared = receiver + .recv_timeout(Duration::from_secs(60)) + .expect("`git check-attr` did not answer: the pipes deadlocked"); + + assert!(declared.contains(&"capture.bin".to_owned()), "{declared:?}"); + assert!( + !declared.iter().any(|path| path.starts_with("tracked/")), + "{declared:?}" + ); + } +} diff --git a/src/shim.rs b/src/shim.rs index a751261..3d10697 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -30,7 +30,8 @@ //! first two examples. use std::collections::BTreeMap; -use std::io::{Read, Write}; +use std::ffi::OsString; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -121,9 +122,11 @@ pub(crate) struct Shim { pub argv_subject: bool, /// The environment variable this command reads to find its editor. /// - /// Declared so the shim can tell the one case it genuinely cannot see: no - /// body on the command line, no `--web`, and a command that is about to - /// open an editor. What gets typed there has not been written yet. + /// Declared so the shim can stand in the one place argv cannot reach: no + /// body on the command line, no `--web`, and a command about to open an + /// editor. What gets typed there has not been written yet, so the shim puts + /// itself in this variable, runs the user's real editor, and reads the file + /// back -- the checkpoint `commit-msg` is for a commit. #[serde(default)] pub editor_env: Option, #[serde(default)] @@ -150,21 +153,83 @@ fn in_list(list: &[String], needle: &str) -> bool { } impl Shim { + /// Whether a flag this table names takes the word after it as its value. + fn takes_value(&self, flag: &str) -> bool { + in_list(&self.target_flags, flag) + || in_list(&self.text_flags, flag) + || in_list(&self.file_flags, flag) + || in_list(&self.path_flags, flag) + } + + /// The verb and the noun of an invocation: the first two words that are + /// neither an option nor an option's value. + /// + /// Reading `argv[0]` and `argv[1]` is not the same question. Every one of + /// these CLIs takes options before the subcommand, and `gh --repo + /// owner/name issue create -t ...` positionally yields the pair + /// `--repo:owner/name` -- which no `match` list contains, so the shim + /// decides the invocation is none of its business and execs a publishing + /// command unexamined. Nothing is printed and the exit code is 0, which is + /// the shape of failure this tool exists to refuse. + fn verb_noun(&self, argv: &[String]) -> (String, String) { + let mut words: Vec<&str> = Vec::new(); + let mut index = 0; + while let Some(argument) = argv.get(index) { + index += 1; + // `--` ends the options. Everything after it is positional however + // it is spelt. + if argument == "--" { + words.extend( + argv.get(index..) + .unwrap_or_default() + .iter() + .map(String::as_str), + ); + break; + } + if argument.starts_with('-') && argument != "-" { + // `--flag=value` carries its value in the same word; `--flag + // value` takes the next one, and only this table knows which + // flags do. A flag it does not name is assumed to take none, + // which is the safe way to be wrong: the worst case is reading + // a value as a subcommand and checking an invocation that + // needed no checking. + let inline = argument.starts_with("--") && argument.contains('='); + let flag = if inline { + argument + .split_once('=') + .map_or(argument.as_str(), |(flag, _)| flag) + } else { + argument.as_str() + }; + if !inline && self.takes_value(flag) { + index += 1; + } + continue; + } + words.push(argument); + if words.len() == 2 { + break; + } + } + let mut words = words.into_iter(); + ( + words.next().unwrap_or_default().to_owned(), + words.next().unwrap_or_default().to_owned(), + ) + } + /// Whether this invocation is one the shim has anything to say about. pub(crate) fn matches(&self, argv: &[String]) -> bool { - let verb = argv.first().map_or("", String::as_str); - let noun = argv.get(1).map_or("", String::as_str); + let (verb, noun) = self.verb_noun(argv); in_list(&self.match_, "*") || in_list(&self.match_, &format!("{verb}:{noun}")) || in_list(&self.match_, &format!("{verb}:*")) } /// Walk argv once, reading the flags this table names. - fn collect_flags(&self, argv: &[String]) -> Result<(Vec, Option, bool, bool)> { - let mut subjects = Vec::new(); - let mut target = None; - let mut body_given = false; - let mut web = false; + fn collect_flags(&self, argv: &[String]) -> Result { + let mut collected = Collected::default(); let mut index = 0; while let Some(argument) = argv.get(index) { @@ -187,36 +252,51 @@ impl Shim { // argument follows it -- that argument may be the very flag whose // value is about to be published. let took_value = if in_list(&self.target_flags, &flag) { - target = Some(value); + collected.target = Some(value); true } else if in_list(&self.text_flags, &flag) { - subjects.push(Subject { + collected.subjects.push(Subject { kind: "text", value, }); - body_given = true; + collected.body_given = true; true } else if in_list(&self.path_flags, &flag) { - subjects.push(Subject { + collected.subjects.push(Subject { kind: "path", value, }); true } else if in_list(&self.file_flags, &flag) { - body_given = true; + collected.body_given = true; if value == "-" { // Reading stdin here means the real command can no longer - // read it, so it is kept and replayed on the way through. A - // guard that silently eats the body it approved is worse - // than no guard. - let mut buffer = String::new(); - std::io::stdin().read_to_string(&mut buffer)?; - subjects.push(Subject { + // read it, so the bytes are kept whole and handed back on + // the way through -- see `replayed`, which is where they + // become a descriptor the command inherits. A guard that + // silently eats the body it approved is worse than no + // guard: the invocation still runs, and what it publishes + // is empty. + let mut buffer = Vec::new(); + std::io::stdin().read_to_end(&mut buffer)?; + // Not text is not a pass. A checker reads a subject as + // text, so bytes that are not text cannot be checked, and + // saying so is the only honest answer available here. + let text = std::str::from_utf8(&buffer) + .map_err(|error| { + Fatal::new(format!( + "{flag} named stdin, which is not UTF-8 text ({error}), so no \ + checker could read what would be published" + )) + })? + .to_owned(); + collected.subjects.push(Subject { kind: "text", - value: buffer, + value: text, }); + collected.stdin = Some(buffer); } else if Path::new(&value).is_file() { - subjects.push(Subject { + collected.subjects.push(Subject { kind: "text", value: std::fs::read_to_string(&value) .map_err(|error| Fatal::at(Path::new(&value), error))?, @@ -233,10 +313,10 @@ impl Shim { } true } else if in_list(&self.skip_flags, &flag) { - body_given = true; + collected.body_given = true; false } else if in_list(&self.web_flags, &flag) { - web = true; + collected.web = true; false } else { index += 1; @@ -244,7 +324,7 @@ impl Shim { }; index += if paired || !took_value { 1 } else { 2 }; } - Ok((subjects, target, body_given, web)) + Ok(collected) } /// Branch and tag names, which appear nowhere as a flag value. @@ -325,45 +405,34 @@ impl Shim { } pub(crate) fn collect(&self, root: &Path, argv: &[String]) -> Result { - let mut collected = match self.collect { - Collect::Flags => { - let (subjects, target, body_given, web) = self.collect_flags(argv)?; - Collected { - subjects, - target, - body_given, - web, - } - } + // Every arm walks the flags first: the target flag and any stdin the + // shim consumed belong to the invocation rather than to one collector, + // and a collector that dropped the stdin it had already read would + // leave the command publishing an empty body. + let mut collected = self.collect_flags(argv)?; + match self.collect { + Collect::Flags => {} Collect::GitRefs => { - let (_, target, _, _) = self.collect_flags(argv)?; - Collected { - subjects: self.collect_git_refs(root, argv)?, - target, - // Never hand git an editor: a message written in one passes - // through commit-msg already. - body_given: true, - web: false, - } + collected.subjects = self.collect_git_refs(root, argv)?; + // Never hand git an editor: a message written in one passes + // through commit-msg already. + collected.body_given = true; + collected.web = false; } Collect::NpmPackage => { - let (_, target, _, _) = self.collect_flags(argv)?; let dry_run = argv.iter().any(|argument| argument == "--dry-run"); - Collected { - // A dry run publishes nothing, and refusing one would stop - // the very command somebody runs to find out what they are - // about to publish. - subjects: if dry_run { - Vec::new() - } else { - self.collect_npm(root)? - }, - target, - body_given: true, // npm opens no editor - web: false, - } + // A dry run publishes nothing, and refusing one would stop the + // very command somebody runs to find out what they are about to + // publish. + collected.subjects = if dry_run { + Vec::new() + } else { + self.collect_npm(root)? + }; + collected.body_given = true; // npm opens no editor + collected.web = false; } - }; + } if self.argv_subject { collected.subjects.push(Subject { kind: "argv", @@ -399,7 +468,7 @@ impl Shim { ); return Ok(false); }; - match forge_visibility(&target).as_deref() { + match self.visibility(root, &target).as_deref() { Some("public") => Ok(true), Some(_) => Ok(false), None => { @@ -445,6 +514,61 @@ impl Shim { } } + /// Which forge can answer for this repository, where that has an answer. + fn forge(&self, root: &Path) -> Option { + // The command is the strongest evidence there is: somebody running + // `glab` is publishing to GitLab whatever else is configured. + match self.command.as_str() { + "gh" => return Some(Forge::GitHub), + "glab" => return Some(Forge::GitLab), + _ => {} + } + // Otherwise the remote decides, which is what the `git` shim needs: + // one shim stands in front of a command that pushes to either. + let url = git::remote_url(root, "origin")?.to_lowercase(); + if url.contains("gitlab") { + Some(Forge::GitLab) + } else if url.contains("github") { + Some(Forge::GitHub) + } else { + // Not a guess. An unrecognised host means no resolver applies, and + // the caller says so rather than reporting a pass over a + // visibility nobody read. + None + } + } + + /// What the forge says the target's visibility is, in the forge's own word. + /// + /// Asking `gh` for every target is why the shipped `glab` shim could never + /// resolve one: `gh api repos//` answers about GitHub and + /// about nothing else, so a GitLab remote fell through the `None` arm on + /// every invocation and a shim declared `public-target` was inert. + /// + /// Both vocabularies come back unchanged, and GitLab's `internal` is why: + /// it means public to everyone with an account on the instance, which is + /// neither public to the internet nor private. Only `public` is treated as + /// public by the caller, so a forge that grows a fourth word does not + /// quietly become one of the three. + fn visibility(&self, root: &Path, target: &str) -> Option { + match self.forge(root)? { + Forge::GitHub => forge_field( + "gh", + &["api", &format!("repos/{target}"), "--jq", ".visibility"], + None, + ), + // Deliberately not `--jq`: `glab api` is not `gh api`, and a shim + // that is inert the day one flag differs is the defect this arm + // exists to end. The project id is a path, so its separators are + // escaped -- `owner/name` and `group/sub/name` are each one id. + Forge::GitLab => forge_field( + "glab", + &["api", &format!("projects/{}", target.replace('/', "%2F"))], + Some("visibility"), + ), + } + } + fn resolve_target(&self, root: &Path, collected: &Collected) -> Result> { if let Some(explicit) = collected.target.as_deref() { if !explicit.is_empty() { @@ -475,21 +599,40 @@ pub(crate) struct Collected { pub target: Option, pub body_given: bool, pub web: bool, + /// The bytes this shim read off its own stdin to make a subject of them. + /// + /// Kept whole rather than as the subject's `String`, because what the + /// command publishes must be what was submitted to it byte for byte, and + /// the subject is a decoded copy. + pub stdin: Option>, } -fn forge_visibility(target: &str) -> Option { - let output = Command::new("gh") - .args(["api", &format!("repos/{target}"), "--jq", ".visibility"]) - .output() - .ok()?; +/// The forges whose visibility question this tool knows how to ask. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Forge { + GitHub, + GitLab, +} + +/// Run a forge CLI and read one word out of what it printed. +/// +/// `field` names a JSON key to pull out where the CLI cannot be asked to do it; +/// `None` means the whole of stdout is the answer. +fn forge_field(program: &str, args: &[&str], field: Option<&str>) -> Option { + let output = Command::new(program).args(args).output().ok()?; if !output.status.success() { return None; } - Some( - String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(), - ) + let text = String::from_utf8_lossy(&output.stdout); + let value = match field { + Some(field) => json_string_field(&text, field)?, + None => text.trim().to_owned(), + }; + let value = value.trim().to_lowercase(); + // An empty answer is not an answer. `--jq` on a field that is not there + // prints a blank line and exits 0, and treating that as a visibility would + // be a lookup that did not happen wearing the face of one that did. + (!value.is_empty() && value != "null").then_some(value) } fn json_string_field(text: &str, field: &str) -> Option { @@ -520,6 +663,14 @@ fn json_bool_field(text: &str, field: &str) -> bool { /// The contract cmd-shims documented, unchanged and now the only one: the /// subject on stdin, its kind in the environment, 0 to pass, 1 to refuse, 2 to /// say it could not look. A checker written in anything satisfies it. +/// +/// All three pipes are worked at once, and that is not tidiness. A pipe holds +/// about 64 KiB: writing a longer subject blocks until the checker reads it, +/// and a checker that writes more than a bufferful blocks until this process +/// reads THAT. Writing the whole subject first and reading afterwards means +/// each side is waiting for the other and neither ever moves -- on exactly the +/// long bodies a guard most needs to see, and with no output at all to say +/// what happened. fn consult(root: &Path, rule: &Rule, subject: &Subject) -> Result> { // `exec`, not `values_from`. They were one field called `run` in v2, and a // checker whose command reads empty passes everything it is asked about. @@ -534,28 +685,79 @@ fn consult(root: &Path, rule: &Rule, subject: &Subject) -> Result .stdout(Stdio::piped()) .spawn() .map_err(|error| Fatal::new(format!("{}: {error}", rule.id)))?; - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(subject.value.as_bytes()).ok(); - } - let output = child - .wait_with_output() + + let mut stdin = child.stdin.take(); + let mut stdout = child.stdout.take(); + let mut stderr = child.stderr.take(); + let body = subject.value.as_bytes(); + + let (written, report) = + std::thread::scope(|scope| -> Result<(std::io::Result<()>, Vec)> { + let writer = scope.spawn(move || -> std::io::Result<()> { + let Some(pipe) = stdin.as_mut() else { + return Ok(()); + }; + pipe.write_all(body)?; + pipe.flush() + // Dropped on the way out of this closure, which is the end of + // input the checker is waiting for. + }); + // Drained rather than read: the contract puts a checker's report on + // stderr, but a checker that writes to stdout still fills a pipe, + // and a full pipe nobody empties is the same deadlock from the + // other side. + let drain = scope.spawn(move || { + if let Some(pipe) = stdout.as_mut() { + drop(pipe.read_to_end(&mut Vec::new())); + } + }); + let mut report = Vec::new(); + if let Some(pipe) = stderr.as_mut() { + drop(pipe.read_to_end(&mut report)); + } + let written = writer.join().map_err(|_| { + Fatal::new(format!( + "{}: the thread feeding it the subject died", + rule.id + )) + })?; + drain.join().map_err(|_| { + Fatal::new(format!("{}: the thread draining its output died", rule.id)) + })?; + Ok((written, report)) + })?; + + let status = child + .wait() .map_err(|error| Fatal::new(format!("{}: {error}", rule.id)))?; - match output.status.code() { - Some(0) => Ok(None), - Some(1) => Ok(Some(format!( - "{} refused a {} subject: {}", - rule.id, - subject.kind, - String::from_utf8_lossy(&output.stderr).trim() + let report = String::from_utf8_lossy(&report); + let report = report.trim(); + match (status.code(), written) { + // A refusal stands even where the write did not finish. A checker that + // stopped reading and then said no had already seen enough to say it, + // and turning that into an infrastructure error would teach people to + // re-run until the refusal went away. + (Some(1), _) => Ok(Some(format!( + "{} refused a {} subject: {report}", + rule.id, subject.kind ))), + // The write result used to be dropped with `.ok()`, and a subject that + // never arrived is the one case where a 0 means nothing at all: the + // checker approved whatever part of it got through, which is not what + // this invocation is about to publish. + (_, Err(error)) => Err(Fatal::new(format!( + "{} did not take the whole {} subject ({error}), so its answer is not about what \ + would be published: {report}", + rule.id, subject.kind + ))), + (Some(0), Ok(())) => Ok(None), // 2 is could-not-look, and it is not a pass. A checker that could not // read what it was handed has established nothing. - other => Err(Fatal::new(format!( - "{} exited {} on a {} subject: {}", + (other, Ok(())) => Err(Fatal::new(format!( + "{} exited {} on a {} subject: {report}", rule.id, other.unwrap_or(-1), - subject.kind, - String::from_utf8_lossy(&output.stderr).trim() + subject.kind ))), } } @@ -601,8 +803,248 @@ fn real_command(name: &str, own: Option<&Path>) -> Option { None } +/// The command this process was re-entered for, when it was re-entered as that +/// command's editor. +const EDITOR_MARKER: &str = "UPHOLD_SHIM_EDITOR"; +/// The editor the user actually has, remembered while this shim stands in the +/// variable that used to name it. +const EDITOR_REAL: &str = "UPHOLD_SHIM_EDITOR_REAL"; +/// The command line the editor was opened for, so the checkers consulted on the +/// way back are the ones that stand in front of THAT command line. +const EDITOR_ARGV: &str = "UPHOLD_SHIM_EDITOR_ARGV"; + +/// An environment variable that is set to something, which is not the same as +/// set. `EDITOR=` is how a person turns one off. +fn nonempty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +/// One word for a shell that is going to split what it is handed. +/// +/// The editor variable holds a command LINE rather than a path -- `code --wait` +/// and `emacsclient -nw` are ordinary values -- so the command runs it through a +/// shell, and this binary's own path would otherwise arrive as two words the +/// first time somebody installs it under a directory with a space in it. +fn shell_word(word: &str) -> String { + format!("'{}'", word.replace('\'', "'\\''")) +} + +/// The stdin this shim consumed, in something the real command can inherit. +/// +/// A pipe cannot carry it. The bytes have to be written by somebody, and after +/// `exec` there is no somebody -- this process IS the command by then, and a +/// thread left behind to feed it does not survive the call. A file holds them +/// already, seeks back to the start, and needs nobody alive. It is unlinked the +/// moment it is open, so what the child inherits is the descriptor and the disk +/// keeps nothing, however the process ends. +fn replayed(bytes: &[u8]) -> Result { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let path = + std::env::temp_dir().join(format!("uphold-shim-stdin-{}-{stamp}", std::process::id())); + let mut file = std::fs::OpenOptions::new() + // Never adopt a file somebody else left on this path: a shared + // temporary directory is writable by everyone on the machine, and the + // body of a publishing command is exactly the thing not to hand over. + .create_new(true) + .read(true) + .write(true) + .open(&path) + .map_err(|error| Fatal::at(&path, error))?; + drop(std::fs::remove_file(&path)); + file.write_all(bytes) + .map_err(|error| Fatal::at(&path, error))?; + file.seek(SeekFrom::Start(0)) + .map_err(|error| Fatal::at(&path, error))?; + Ok(file) +} + +/// Become the command's editor, so the body typed into it is read after all. +/// +/// This is the one case a flag table cannot see: no body in argv, no `--web`, +/// and a command about to open an editor, which is how most bodies are actually +/// written. Warning about it -- all this did before -- leaves the text +/// unchecked and tells somebody who did nothing wrong to do it differently. +/// cmd-shims installed itself in the command's own editor variable, ran the +/// user's real editor, then read the file back and consulted the same checkers; +/// that is the checkpoint `commit-msg` is for a commit, and none of it survived +/// the port. It does now. +fn install_editor( + command: &mut Command, + name: &str, + variable: &str, + own: Option<&Path>, + argv: &[String], +) { + let Some(exe) = own else { + eprintln!( + "{name}: the body will be composed in an editor, and this shim could not find its \ + own path to stand in front of it, so nothing was checked. This is not a pass." + ); + return; + }; + let editor = nonempty_env(variable) + .or_else(|| nonempty_env("GIT_EDITOR")) + .or_else(|| nonempty_env("VISUAL")) + .or_else(|| nonempty_env("EDITOR")) + .unwrap_or_else(|| String::from("vi")); + command.env(EDITOR_REAL, editor); + command.env(EDITOR_MARKER, name); + // Only the words that decide WHICH checkers stand in front of this command + // line. They are matched as a subsequence and never re-executed, so joining + // them is enough and quoting them would be pretending otherwise. + command.env(EDITOR_ARGV, argv.join(" ")); + command.env( + variable, + format!( + "{} shim {}", + shell_word(&exe.to_string_lossy()), + shell_word(name) + ), + ); + eprintln!( + "{name}: the body will be composed in an editor, so the editor is the checkpoint: what \ + it leaves in the file is checked when it closes." + ); +} + +/// Run the user's editor, then judge what it produced. +/// +/// Refusing here is what makes it a checkpoint rather than a report: `gh` and +/// `glab` abandon what they were doing when their editor exits non-zero, +/// exactly as git abandons a commit when `commit-msg` does. +fn edit_and_check(root: &Path, policy: &Policy, name: &str, argv: &[String]) -> Result { + // The command appends the file it wants written to the editor command line, + // so the last word is that path however this process was routed back here. + let Some(file) = argv.last() else { + return Err(Fatal::new(format!( + "{name}: re-entered as an editor with no file to edit" + ))); + }; + let editor = nonempty_env(EDITOR_REAL).unwrap_or_else(|| String::from("vi")); + // Through a shell, exactly the way the command would have run it, and with + // the marker removed: the child here is the user's own editor, and a second + // pass through this function is not what it is being asked for. + let status = Command::new("sh") + .arg("-c") + .arg(format!("{editor} \"$1\"")) + .arg("sh") + .arg(file) + .current_dir(root) + .env_remove(EDITOR_MARKER) + .env_remove(EDITOR_REAL) + .env_remove(EDITOR_ARGV) + .status() + .map_err(|error| Fatal::new(format!("{name}: editor: {error}")))?; + if !status.success() { + // The editor is how the text was going to be written, so an editor that + // failed is neither a clean pass nor a violation: nothing was looked at, + // and the command aborts on any non-zero anyway. + eprintln!("{name}: the editor exited without success, so nothing was checked."); + return Ok(Exit::Broken); + } + let path = Path::new(file); + if !path.is_file() { + // No file means nothing was written, which is nothing to publish. + return Ok(Exit::Clean); + } + let text = std::fs::read_to_string(path).map_err(|error| Fatal::at(path, error))?; + if text.trim().is_empty() { + return Ok(Exit::Clean); + } + + let opened_for: Vec = nonempty_env(EDITOR_ARGV) + .unwrap_or_default() + .split_whitespace() + .map(str::to_owned) + .collect(); + let subject = Subject { + kind: "text", + value: text, + }; + let mut refusals: Vec = Vec::new(); + for rule in policy + .before_command(name, &opened_for) + .filter(|rule| rule.is(Check::Exec)) + { + if crate::guard::bypassed(&rule.id) { + continue; + } + if let Some(refusal) = consult(root, rule, &subject)? { + refusals.push(format!("{refusal}\n{}", rule.message())); + } + } + if refusals.is_empty() { + return Ok(Exit::Clean); + } + for refusal in &refusals { + eprintln!("{name}: {refusal}"); + } + eprintln!( + "Nothing was published, and what you wrote is still in {file}. Fix it there, or \ + override once with UPHOLD_ALLOW." + ); + Ok(Exit::Violations) +} + +/// Hand the process over to the real command. +/// +/// A real `exec`, not a spawn and a wait. The shim is not a supervisor: exec +/// keeps the pid, the process group, terminal control and every signal +/// disposition the command was started with, and the status the caller reads is +/// the command's own. Waiting on a child and calling +/// `exit(status.code().unwrap_or(1))` loses all of it -- `code()` is `None` for +/// every death by a signal, so a command killed by SIGINT reported a plain exit +/// 1, which in this tool's own vocabulary is a policy violation. +#[cfg(unix)] +fn hand_off(command: &mut Command, name: &str) -> Result { + use std::os::unix::process::CommandExt; + // `arg0` so the command sees the name it was invoked under rather than the + // path this shim found it at. + let error = command.arg0(name).exec(); + Err(Fatal::new(format!("{name}: {error}"))) +} + +#[cfg(not(unix))] +fn hand_off(command: &mut Command, name: &str) -> Result { + // No exec to hand off to, so the closest thing: run it and carry its code + // out. What this platform cannot preserve, it cannot preserve. + let status = command + .status() + .map_err(|error| Fatal::new(format!("{name}: {error}")))?; + std::process::exit(status.code().unwrap_or(1)); +} + /// Stand in front of one command. -pub(crate) fn run(root: &Path, policy: &Policy, name: &str, argv: &[String]) -> Result { +/// +/// argv arrives as bytes and LEAVES as bytes. On Unix an argument is an +/// arbitrary byte string -- a file named in latin-1 is a perfectly good +/// argument to `git add` -- and this binary is installed in front of `git`, +/// `gh` and `npm` precisely where such paths are typed. Converting the whole of +/// argv to text on the way in meant one of two failures: a panic at exit 101 on +/// a code path designed to be transparent, or a lossy conversion that execs a +/// command DIFFERENT from the one that was typed. So the words below are a +/// lossy copy used only to decide things -- which subcommand this is, which +/// flags were given -- while `command.args(argv)` hands the original bytes to +/// the exec. The two cannot disagree about a decision, because every string +/// this shim compares against is ASCII, and lossy conversion only ever replaces +/// a sequence that was not text to begin with. +pub(crate) fn run(root: &Path, policy: &Policy, name: &str, argv: &[OsString]) -> Result { + let words: Vec = argv + .iter() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect(); + + // Re-entered as the command's own editor, which is answered before anything + // else: in this pass argv is an editor's argv -- one file path -- and none + // of the flag reading below applies to it. + if let Some(shimmed) = nonempty_env(EDITOR_MARKER) { + return edit_and_check(root, policy, &shimmed, &words); + } + let shims: BTreeMap<&str, &Shim> = policy .shims .iter() @@ -625,26 +1067,32 @@ pub(crate) fn run(root: &Path, policy: &Policy, name: &str, argv: &[String]) -> // publish` -- because the only thing selecting it was `kind = "command"`, // which says nothing about which command. let checkers: Vec<&Rule> = policy - .before_command(name, argv) + .before_command(name, &words) .filter(|rule| rule.is(Check::Exec)) .collect(); let mut refusals: Vec = Vec::new(); - if shim.matches(argv) { - let collected = shim.collect(root, argv)?; - if shim.in_scope(root, &collected, argv)? { - // The one case a shim genuinely cannot see. No body on the command - // line, no `--web`, and a command about to open an editor: what - // gets typed there has not been written yet, so there is nothing to - // hand a checker. Said aloud rather than passed over -- a shim that - // reports nothing here reports a pass over text it never saw, which - // is the failure `explicit-unknown` names. - if !collected.body_given && !collected.web && shim.editor_env.is_some() { - eprintln!( - "{name}: the body will be composed in an editor, so nothing was checked. \ - Pass it with a flag to have it read." - ); - } + let mut collected = Collected::default(); + let mut in_scope = false; + if shim.matches(&words) { + // The one place the bytes have to be text. This invocation is one the + // shim reads values out of, and a value that is not UTF-8 cannot be + // read as text -- checking the lossy copy would report a pass over + // U+FFFD where the bytes were. Exit 2 rather than a lossy check, and + // rather than a refusal: nothing was found, the subject could not be + // looked at. An invocation the shim has nothing to say about is not + // affected, which is what keeps `git add ` working. + if let Some(bytes) = argv.iter().find(|argument| argument.to_str().is_none()) { + return Err(Fatal::new(format!( + "{name}: the argument {:?} is not UTF-8 text, and this invocation is one whose \ + text is checked before it is published. No checker can read bytes that are not \ + text, so nothing here can be called clean", + bytes.to_string_lossy() + ))); + } + collected = shim.collect(root, &words)?; + in_scope = shim.in_scope(root, &collected, &words)?; + if in_scope { for subject in &collected.subjects { if subject.value.trim().is_empty() { continue; @@ -677,11 +1125,22 @@ pub(crate) fn run(root: &Path, policy: &Policy, name: &str, argv: &[String]) -> "checked {name} and then could not find the real one on PATH" ))); }; - let status = Command::new(real) - .args(argv) - .status() - .map_err(|error| Fatal::new(format!("{name}: {error}")))?; - std::process::exit(status.code().unwrap_or(1)); + let mut command = Command::new(&real); + command.args(argv); + // Everything the command needs that this shim took from it, arranged before + // the hand-off because after it there is no arranging anything: the body + // read off stdin, and the editor it is about to open. + if let Some(bytes) = collected.stdin.as_deref() { + command.stdin(Stdio::from(replayed(bytes)?)); + } + let editor_env = shim + .editor_env + .as_deref() + .filter(|_| in_scope && !collected.body_given && !collected.web); + if let Some(variable) = editor_env { + install_editor(&mut command, name, variable, own.as_deref(), &words); + } + hand_off(&mut command, name) } #[cfg(test)] @@ -846,6 +1305,73 @@ mod tests { .all(|subject| subject.kind == "ref")); } + #[test] + fn an_option_before_the_subcommand_does_not_disable_the_shim() { + // The defect this pair exists for: read positionally, `gh --repo + // acme/widget issue create` has the verb `--repo` and the noun + // `acme/widget`, no `match` entry contains that pair, and a publishing + // command execs unexamined with nothing printed and an exit code of 0. + for line in [ + "--repo acme/widget issue create", + "-R acme/widget pr create", + "--repo=acme/widget pr create", + "-w issue comment", + "-- pr create", + ] { + assert!(gh().matches(&argv(line)), "{line}"); + } + // And it still says no to what it has nothing to say about, which is + // the half a looser matcher would lose. + for line in [ + "--repo acme/widget pr checkout", + "-R acme/widget repo clone", + ] { + assert!(!gh().matches(&argv(line)), "{line}"); + } + } + + #[test] + fn a_flags_value_is_never_read_as_a_subcommand() { + // `--title pr` puts the word `pr` in argv without the invocation being + // about a pull request, and only this table knows that `--title` took + // it. + let (verb, noun) = gh().verb_noun(&argv("--title pr create issue")); + assert_eq!((verb.as_str(), noun.as_str()), ("create", "issue")); + } + + #[test] + fn the_visibility_question_goes_to_the_forge_that_can_answer_it() { + // Asking `gh` about a GitLab remote is why the shipped `glab` shim + // could never resolve a target: `gh api repos//` answers + // about GitHub and about nothing else. + let mut glab = gh(); + glab.command = String::from("glab"); + assert_eq!(glab.forge(Path::new(".")), Some(Forge::GitLab)); + assert_eq!(gh().forge(Path::new(".")), Some(Forge::GitHub)); + } + + #[test] + fn a_word_with_a_quote_in_it_survives_the_shell_that_splits_it() { + // The editor variable is handed to a shell, so this binary's own path + // has to arrive as one word whatever is in it. + assert_eq!(shell_word("/opt/my tools/uphold"), "'/opt/my tools/uphold'"); + assert_eq!(shell_word("it's"), r"'it'\''s'"); + } + + #[test] + fn the_stdin_a_shim_ate_is_handed_back_whole() { + // Well past a pipe's 64 KiB, because a pipe is exactly what cannot + // carry this: after `exec` there is nobody left to write into one. + let body: Vec = std::iter::repeat_n(b"ordinary text\n", 20_000) + .flatten() + .copied() + .collect(); + let mut file = replayed(&body).unwrap(); + let mut read_back = Vec::new(); + std::io::copy(&mut file, &mut read_back).unwrap(); + assert_eq!(read_back, body); + } + #[test] fn a_private_field_that_is_false_does_not_make_a_package_private() { assert!(json_bool_field(r#"{"private": true}"#, "private")); diff --git a/tests/scan_cli.rs b/tests/scan_cli.rs index 9a0d761..99b6753 100644 --- a/tests/scan_cli.rs +++ b/tests/scan_cli.rs @@ -981,3 +981,178 @@ fn the_promoted_sets_refuse_what_they_were_promoted_for() { // fired on everything would also satisfy. assert!(!text.contains("docs/setup.md -> "), "{text}"); } + +/// A repository, because the two cases below are about git's index and a +/// directory without one takes the walk instead. +fn repository(root: &Path) { + for arguments in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.name", "Test"][..], + &["config", "user.email", "test@example.test"][..], + ] { + let status = Command::new("git") + .args(arguments) + .current_dir(root) + .status() + .unwrap(); + assert!(status.success(), "git {arguments:?} failed"); + } +} + +fn add(root: &Path) { + let status = Command::new("git") + .args(["add", "-A", "."]) + .current_dir(root) + .status() + .unwrap(); + assert!(status.success()); +} + +/// A path git tracks and the working tree cannot produce is exit 2, named. +/// +/// It used to be dropped from the selection without a word, so every rule +/// searched what was left, found nothing there, and the run printed `policy +/// checks passed` at exit 0 over a tree it had not finished reading. The four +/// ways in are an unstaged deletion, a sparse checkout, a directory this +/// process may not enter, and a filesystem that lost the file; the report has +/// to name the path in all of them, because that is the only part the reader +/// can act on. +#[test] +fn a_tracked_path_the_tree_cannot_produce_is_named_and_is_not_a_pass() { + let root = workspace(); + write( + &root, + "policy/principles.toml", + r#" + [rule.no-todo] + message = "no TODO" + regexp = 'TODO' + + [rule.no-todo.files] + exclude = ["policy/**"] +"#, + ); + write(&root, "kept.txt", "fine\n"); + write(&root, "gone.txt", "fine\n"); + repository(&root); + add(&root); + std::fs::remove_file(root.join("gone.txt")).unwrap(); + + let output = scan(&root); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + let text = stderr(&output); + assert!(text.contains("gone.txt"), "{text}"); + assert!(text.contains("could not be read"), "{text}"); + // Not a pass, and it must not read like one either. + assert!(!stdout(&output).contains("policy checks passed"), "{text}"); +} + +/// The other half: the unreadable path does not swallow the findings. +/// +/// This is why the list rides out beside the files instead of ending the run at +/// the first rule that hits it. A tree with one missing path still has an +/// answer for every other rule, and a reader who only ever sees "restore this +/// file" never learns there was a violation waiting behind it. +#[test] +fn an_unreadable_path_is_reported_beside_the_findings_and_not_instead_of_them() { + let root = workspace(); + write( + &root, + "policy/principles.toml", + r#" + [rule.no-todo] + message = "no TODO" + regexp = 'TODO' + + [rule.no-todo.files] + exclude = ["policy/**"] +"#, + ); + write(&root, "offender.txt", "TODO: later\n"); + write(&root, "gone.txt", "fine\n"); + repository(&root); + add(&root); + std::fs::remove_file(root.join("gone.txt")).unwrap(); + + let output = scan(&root); + let text = stderr(&output); + // Exit 1: something was found, and that is the answer the reader acts on + // first. The unreadable path is still named. + assert_eq!(code(&output), 1, "{text}"); + assert!(text.contains("offender.txt:1:TODO: later"), "{text}"); + assert!(text.contains("gone.txt"), "{text}"); +} + +/// The one loader, asked rather than re-implemented. +/// +/// Which rules a repository runs is five interacting fields -- the bundled sets +/// it inherits, the extra policy files `inherit.paths` merges, the ids +/// `inherit.disabled_rules` drops, and its own rules shadowing an inherited id +/// -- and every second reader of them is a reader free to disagree with the +/// engine about what runs. `uphold_check.py` was that second reader. This is +/// the answer it can ask for instead, so the assertions here are exactly the +/// interactions a re-implementation gets wrong. +#[test] +fn the_effective_rules_are_what_inheritance_resolved_to() { + let root = workspace(); + write( + &root, + "policy/extra.toml", + r#" + [rule.from-a-path] + message = "inherited through inherit.paths" + regexp = 'nothing-matches-this' + files.include = ["."] +"#, + ); + write( + &root, + "policy/principles.toml", + r#" + [inherit] + sets = ["process-residue"] + paths = ["policy/extra.toml"] + disabled_rules = ["no-task-tracker-references"] + + [rule.of-its-own] + message = "declared here" + regexp = 'nothing-matches-this-either' + files.include = ["."] + + [rule.no-local-merge] + builtin = "no-local-merge" + git.hooks = ["pre-merge-commit", "manual"] +"#, + ); + + let output = Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["rules", "--effective", "--json"]) + .current_dir(&root) + .output() + .unwrap(); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + let text = stdout(&output); + + // Inherited from a bundled set, merged from a path, and declared here. + assert!(text.contains("\"no-merge-conflict-markers\""), "{text}"); + assert!(text.contains("\"from-a-path\""), "{text}"); + assert!(text.contains("\"of-its-own\""), "{text}"); + // Dropped by name, which is the field a reader of `[rule.*]` tables alone + // never sees. + assert!(!text.contains("no-task-tracker-references"), "{text}"); + // And the hooks travel with the rule, because "which rules run" cannot be + // answered without saying WHEN -- a claim on a guard is supplied only where + // the seam it fires at is installed. + assert!( + text.contains( + "{\"id\": \"no-local-merge\", \"git_hooks\": [\"pre-merge-commit\", \"manual\"]}" + ), + "{text}" + ); + // A content rule fires at no git hook, and says so rather than being + // reported under whichever stage happened to be installed. + assert!( + text.contains("{\"id\": \"of-its-own\", \"git_hooks\": []}"), + "{text}" + ); +} diff --git a/tests/shim_cli.rs b/tests/shim_cli.rs index 70724af..da83847 100644 --- a/tests/shim_cli.rs +++ b/tests/shim_cli.rs @@ -189,15 +189,41 @@ fn a_body_file_is_read_and_a_skip_flag_is_not() { } #[test] -fn a_body_typed_into_an_editor_is_said_to_be_unchecked_rather_than_passed() { - // What gets typed there has not been written yet, so there is nothing to - // hand a checker. A shim that says nothing here reports a pass over text it - // never saw. +fn a_body_composed_in_an_editor_makes_the_editor_the_checkpoint() { + // This case used to be the one thing a shim admitted it could not see: no + // body in argv, no `--web`, and a command about to open an editor, so there + // was nothing to hand a checker and the shim said so and execed. Saying so + // leaves the text unchecked and tells somebody who did nothing wrong to do + // it differently, so the shim now installs itself in the command's own + // editor variable and reads the file back when the editor closes. + // + // The assertion is on what is HANDED to the command, because that is what + // decides whether the checkpoint exists: the stub prints its environment, + // and the shim's declared `editor_env` has to be pointing at this binary by + // the time the command runs. `tests/shim_handoff_cli.rs` drives the whole + // round trip with a real editor; this holds the near end of it. let root = workspace(POLICY); + let stub = root.join("bin/faux"); + std::fs::write( + &stub, + "#!/bin/sh\necho \"faux ran: $*\"\necho \"editor: $FAUX_EDITOR\"\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&stub).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o755); + std::fs::set_permissions(&stub, permissions).unwrap(); + let output = shim(&root, &["faux", "pr", "create"]); assert_eq!(code(&output), 0, "{}", stderr(&output)); assert!( - stderr(&output).contains("composed in an editor"), + stdout(&output).contains("editor: ") && stdout(&output).contains("shim 'faux'"), + "the command was handed no checkpoint to open: {}", + stdout(&output) + ); + // And it says which checkpoint it is, rather than reporting a pass over + // text nothing has read yet. + assert!( + stderr(&output).contains("the editor is the checkpoint"), "{}", stderr(&output) ); @@ -298,3 +324,64 @@ fn the_binary_run_under_a_commands_name_is_that_commands_shim() { stderr(&output) ); } + +/// argv is bytes, and a shim that stands in front of `git` will be handed some. +/// +/// `std::env::args()` PANICS on an argument that is not UTF-8 -- exit 101, out +/// of a binary whose whole promise is three exit codes and transparency. A file +/// named in latin-1 is an ordinary argument to `git add`, and this binary is +/// installed exactly where such a name gets typed. So the bytes go through +/// untouched where there is nothing to check, and where there IS something to +/// check the shim says it could not read them rather than checking a lossy copy. +#[test] +fn an_argument_that_is_not_text_reaches_the_command_it_was_typed_for() { + use std::os::unix::ffi::OsStringExt; + + let root = workspace(POLICY); + let path = format!( + "{}:{}", + root.join("bin").display(), + std::env::var("PATH").unwrap_or_default() + ); + // `caf\xe9`, which is a perfectly good file name and is not UTF-8. + let latin1 = std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()); + + // `repo clone` is not in this shim's `match` list, so there is nothing to + // check and nothing to stop: the command must run. + let output = Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["shim", "faux", "repo", "clone"]) + .arg(&latin1) + .current_dir(&root) + .env("PATH", &path) + .env_remove("UPHOLD_ALLOW") + .output() + .unwrap(); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!( + stdout(&output).contains("faux ran: repo clone"), + "{}", + stdout(&output) + ); + + // `pr create` is, so the same bytes are now part of an invocation whose text + // is checked. Exit 2: nothing was found and nothing was cleared. + let output = Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["shim", "faux", "pr", "create", "-t"]) + .arg(&latin1) + .current_dir(&root) + .env("PATH", &path) + .env_remove("UPHOLD_ALLOW") + .output() + .unwrap(); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("is not UTF-8 text"), + "{}", + stderr(&output) + ); + assert!( + !stdout(&output).contains("faux ran:"), + "{}", + stdout(&output) + ); +} diff --git a/tests/shim_handoff_cli.rs b/tests/shim_handoff_cli.rs new file mode 100644 index 0000000..0516f07 --- /dev/null +++ b/tests/shim_handoff_cli.rs @@ -0,0 +1,532 @@ +//! CLI-level tests for the seams where `uphold shim` hands off. +//! +//! Kept apart from `shim_cli.rs`, which asks what the shim decides. These ask +//! what it does with the process afterwards -- the stdin it consumed, the pipes +//! it holds a checker on, the editor the body is really written in, the exec it +//! disappears into, and which forge it asks about a target. Every one of them +//! was a path where the shim reported 0 without having looked. + +#![expect( + clippy::expect_used, + clippy::let_underscore_must_use, + clippy::tests_outside_test_module, + clippy::unwrap_used, + reason = "A CLI test asserts on the outcome; a panic in the harness that builds the fixture IS the failure report, and there is no caller to hand a Result to" +)] + +use std::io::Write; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// The rule every case here shares: the binary consulting itself over text, +/// which is the point of a checker -- the rule that judges a commit message and +/// the rule that judges a pull-request body are the same rule. +const MARKER_RULE: &str = r#" +[rule.no-published-markers] +message = "remove the marker" +exec = "uphold guard --text -" + +[rule.no-published-markers.command] +before = ["faux"] + +[rule.prevent-ai-author] +builtin = "prevent-ai-author" + +[rule.prevent-ai-author.git] +hooks = ["commit-msg"] +"#; + +/// A workspace with a policy, this binary on PATH under its own name, and the +/// stub commands one case needs. +fn workspace(policy: &str, stubs: &[(&str, &str)]) -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "uphold-shim-handoff-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("policy")).unwrap(); + std::fs::create_dir_all(root.join("bin")).unwrap(); + std::fs::write(root.join("policy/principles.toml"), policy).unwrap(); + std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_uphold"), root.join("bin/uphold")).unwrap(); + + for (name, script) in stubs { + let path = root.join("bin").join(name); + std::fs::write(&path, script).unwrap(); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o755); + std::fs::set_permissions(&path, permissions).unwrap(); + } + + Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(&root) + .stdout(Stdio::null()) + .status() + .unwrap(); + root +} + +/// One invocation of the shim, with everything a case needs to vary about it. +#[derive(Debug)] +struct Run<'a> { + args: &'a [&'a str], + envs: &'a [(&'a str, &'a str)], + stdin: Option<&'a [u8]>, + /// Run under `timeout`, so a case that regresses into a deadlock reports a + /// failure instead of hanging the suite forever. Switched off for the case + /// that asserts on HOW the command died: `timeout` waits on a child and + /// reports a signal death as an ordinary exit code, which is the very thing + /// that case exists to catch. + guarded: bool, +} + +impl Default for Run<'_> { + fn default() -> Self { + Self { + args: &[], + envs: &[], + stdin: None, + guarded: true, + } + } +} + +impl Run<'_> { + fn go(&self, root: &Path) -> Output { + let path = format!( + "{}:{}", + root.join("bin").display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut command = if self.guarded { + let mut guarded = Command::new("timeout"); + guarded.arg("60").arg(env!("CARGO_BIN_EXE_uphold")); + guarded + } else { + Command::new(env!("CARGO_BIN_EXE_uphold")) + }; + command + .arg("shim") + .args(self.args) + .current_dir(root) + .env("PATH", path) + .env_remove("UPHOLD_ALLOW") + // The editor variables this shim sets on its way through. A test + // machine that has them set for its own reasons would otherwise be + // answering the question instead of the code. + .env_remove("UPHOLD_SHIM_EDITOR") + .env_remove("UPHOLD_SHIM_EDITOR_REAL") + .env_remove("UPHOLD_SHIM_EDITOR_ARGV") + .env_remove("GIT_EDITOR") + .env_remove("VISUAL") + .env_remove("EDITOR"); + for (name, value) in self.envs { + command.env(name, value); + } + let Some(bytes) = self.stdin else { + return command.output().unwrap(); + }; + // `output()` pipes these for us; `spawn()` does not, and inheriting + // them here would hand the assertions an empty stdout while the real + // one went to the test harness. + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(bytes) + .expect("the shim reads its stdin whole before it writes anything"); + child.wait_with_output().unwrap() + } +} + +fn code(output: &Output) -> i32 { + // 124 is `timeout` saying the run never finished, which is a deadlock + // reported rather than waited on. + output.status.code().unwrap_or(-1) +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +#[test] +fn the_stdin_a_shim_read_is_handed_to_the_command_it_took_it_from() { + // The shim reads stdin to have a subject at all, and the real command reads + // the same stdin to have a body. Only one of them can, so the bytes have to + // be handed on -- a guard that eats the body it approved publishes an empty + // one under a title nobody notices is alone. + let root = workspace( + &format!( + r#"{MARKER_RULE} +[[shim]] +command = "faux" +match = ["pr:create"] +file_flags = ["-F", "--body-file"] +scope = "always" +"# + ), + &[( + "faux", + "#!/bin/sh\necho \"faux ran: $*\"\necho \"faux body bytes: $(wc -c)\"\n", + )], + ); + + // Well past the ~64 KiB a pipe holds, because that is the size at which a + // replay through one would have quietly become a deadlock instead. + let body = "ordinary release note text\n".repeat(4_000); + let output = Run { + args: &["faux", "pr", "create", "-F", "-"], + stdin: Some(body.as_bytes()), + ..Run::default() + } + .go(&root); + + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!( + stdout(&output).contains(&format!("faux body bytes: {}", body.len())), + "{}", + stdout(&output) + ); +} + +#[test] +fn a_body_that_is_refused_on_stdin_never_reaches_the_command() { + let root = workspace( + &format!( + r#"{MARKER_RULE} +[[shim]] +command = "faux" +match = ["pr:create"] +file_flags = ["-F", "--body-file"] +scope = "always" +"# + ), + &[("faux", "#!/bin/sh\necho \"faux ran: $*\"\n")], + ); + let output = Run { + args: &["faux", "pr", "create", "-F", "-"], + stdin: Some(b"Generated with Claude Code\n"), + ..Run::default() + } + .go(&root); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!( + !stdout(&output).contains("faux ran:"), + "{}", + stdout(&output) + ); +} + +#[test] +fn a_long_subject_and_a_loud_checker_do_not_wait_on_each_other() { + // A pipe holds about 64 KiB. Writing the subject first and reading the + // checker's output afterwards means each side blocks on the other, forever, + // with nothing printed -- and the sizes that trigger it are ordinary: a + // release note and a checker that echoes what it read. + let root = workspace( + r#" +[rule.loud] +message = "x" +exec = "yes noise | head -n 40000; cat > /dev/null" + +[rule.loud.command] +before = ["faux"] + +[[shim]] +command = "faux" +match = ["pr:create"] +file_flags = ["-F"] +scope = "always" +"#, + &[("faux", "#!/bin/sh\necho \"faux ran: $*\"\n")], + ); + std::fs::write( + root.join("body.md"), + "an ordinary paragraph of release note\n".repeat(6_000), + ) + .unwrap(); + + let output = Run { + args: &["faux", "pr", "create", "-F", "body.md"], + ..Run::default() + } + .go(&root); + assert_eq!( + code(&output), + 0, + "124 means it never finished: {}", + stderr(&output) + ); + assert!(stdout(&output).contains("faux ran:"), "{}", stdout(&output)); +} + +#[test] +fn a_command_killed_by_a_signal_reports_a_signal_and_not_an_exit_code() { + // `exit(status.code().unwrap_or(1))` flattened every death by a signal into + // a plain exit 1, which in this tool's vocabulary is a policy violation -- + // so a caller that pressed Ctrl-C read "the guard refused". A real exec has + // nothing to flatten: the shim IS the command by then. + let root = workspace( + &format!( + r#"{MARKER_RULE} +[[shim]] +command = "faux" +match = ["pr:create"] +text_flags = ["-t"] +scope = "always" +"# + ), + &[("faux", "#!/bin/sh\nkill -TERM $$\n")], + ); + let output = Run { + args: &["faux", "pr", "create", "-t", "An ordinary title"], + guarded: false, + ..Run::default() + } + .go(&root); + assert_eq!(output.status.code(), None, "{}", stderr(&output)); + assert_eq!(output.status.signal(), Some(15), "{}", stderr(&output)); +} + +#[test] +fn an_option_before_the_subcommand_does_not_switch_the_shim_off() { + // Read positionally, `faux --repo acme/widget issue create` has the verb + // `--repo`, matches no entry, and execs a publishing command unexamined -- + // silently, and with an exit code of 0. + let root = workspace( + &format!( + r#"{MARKER_RULE} +[[shim]] +command = "faux" +match = ["pr:create", "issue:*"] +text_flags = ["-t", "--title"] +target_flags = ["-R", "--repo"] +scope = "always" +"# + ), + &[("faux", "#!/bin/sh\necho \"faux ran: $*\"\n")], + ); + for form in [ + vec![ + "faux", + "--repo", + "acme/widget", + "issue", + "create", + "-t", + "Generated with Claude Code", + ], + vec![ + "faux", + "--repo=acme/widget", + "pr", + "create", + "-t", + "Generated with Claude Code", + ], + ] { + let output = Run { + args: &form, + ..Run::default() + } + .go(&root); + assert_eq!(code(&output), 1, "{form:?}: {}", stderr(&output)); + assert!(!stdout(&output).contains("faux ran:"), "{form:?}"); + } +} + +/// A command that writes its body in an editor, which is how most bodies are +/// actually written: no text in argv, and none until the editor closes. +const EDITING_COMMAND: &str = "#!/bin/sh\nfile=\"$PWD/body.md\"\n: > \"$file\"\nsh -c \"$FAUX_EDITOR \\\"$file\\\"\" || exit $?\necho \"faux published: $(cat \"$file\")\"\n"; + +const EDITOR_POLICY: &str = r#" +[[shim]] +command = "faux" +match = ["pr:create"] +text_flags = ["-t", "--title", "-b", "--body"] +skip_flags = ["--fill"] +web_flags = ["-w", "--web"] +editor_env = "FAUX_EDITOR" +scope = "always" +"#; + +#[test] +fn a_body_typed_into_an_editor_is_read_when_the_editor_closes() { + // The path uphold only warned about. cmd-shims installed itself as the + // command's own editor variable and read the file back; warning instead + // leaves the text unchecked and tells somebody who did nothing wrong to do + // it differently. + let root = workspace( + &format!("{MARKER_RULE}{EDITOR_POLICY}"), + &[ + ("faux", EDITING_COMMAND), + ( + "dirty-editor", + "#!/bin/sh\nprintf 'Generated with Claude Code\\n' > \"$1\"\n", + ), + ], + ); + let editor = root.join("bin/dirty-editor"); + let output = Run { + args: &["faux", "pr", "create"], + envs: &[("EDITOR", &editor.to_string_lossy())], + ..Run::default() + } + .go(&root); + + assert_ne!(code(&output), 0, "{}", stderr(&output)); + assert!( + !stdout(&output).contains("faux published:"), + "{}", + stdout(&output) + ); + assert!( + stderr(&output).contains("no-published-markers"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_editor_that_writes_something_ordinary_is_left_alone() { + // The half that is easy to lose. A checkpoint that refuses everything is + // not a checkpoint, and the command still has to run. + let root = workspace( + &format!("{MARKER_RULE}{EDITOR_POLICY}"), + &[ + ("faux", EDITING_COMMAND), + ( + "clean-editor", + "#!/bin/sh\nprintf 'An ordinary body\\n' > \"$1\"\n", + ), + ], + ); + let editor = root.join("bin/clean-editor"); + let output = Run { + args: &["faux", "pr", "create"], + envs: &[("EDITOR", &editor.to_string_lossy())], + ..Run::default() + } + .go(&root); + + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!( + stdout(&output).contains("faux published: An ordinary body"), + "{}", + stdout(&output) + ); +} + +#[test] +fn a_body_given_on_the_command_line_does_not_open_an_editor_at_all() { + // The editor is installed for the one case that needs it. A body already in + // argv has been read, and re-entering through an editor that nobody opened + // would be a second checkpoint on text that passed the first. + let root = workspace( + &format!("{MARKER_RULE}{EDITOR_POLICY}"), + &[("faux", "#!/bin/sh\necho \"faux editor: [$FAUX_EDITOR]\"\n")], + ); + let output = Run { + args: &["faux", "pr", "create", "-b", "An ordinary body"], + ..Run::default() + } + .go(&root); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!( + stdout(&output).contains("faux editor: []"), + "{}", + stdout(&output) + ); +} + +/// The GitLab visibility endpoint, and a stub that publishes. +const GITLAB_COMMAND: &str = "#!/bin/sh\nif [ \"$1\" = api ]; then\n printf '{\"id\":7,\"visibility\":\"%s\"}\\n' \"${FAKE_VISIBILITY:-public}\"\n exit 0\nfi\necho \"glab ran: $*\"\n"; + +const GITLAB_POLICY: &str = r#" +[rule.no-published-markers] +message = "remove the marker" +exec = "uphold guard --text -" + +[rule.no-published-markers.command] +before = ["glab"] + +[rule.prevent-ai-author] +builtin = "prevent-ai-author" + +[rule.prevent-ai-author.git] +hooks = ["commit-msg"] + +[[shim]] +command = "glab" +match = ["mr:create"] +text_flags = ["-t", "--title", "-d", "--description"] +target_flags = ["-R", "--repo"] +target = "forge-repo" +scope = "public-target" +"#; + +#[test] +fn a_gitlab_target_is_asked_of_gitlab() { + // `gh api repos//` answers about GitHub and about nothing + // else, so the shipped `glab` shim -- declared `public-target` -- resolved + // nothing on every invocation and was inert. + let root = workspace(GITLAB_POLICY, &[("glab", GITLAB_COMMAND)]); + let output = Run { + args: &[ + "glab", + "mr", + "create", + "-R", + "acme/widget", + "-t", + "Generated with Claude Code", + ], + envs: &[("FAKE_VISIBILITY", "public")], + ..Run::default() + } + .go(&root); + assert_eq!(code(&output), 1, "{}", stderr(&output)); + assert!( + !stdout(&output).contains("glab ran:"), + "{}", + stdout(&output) + ); +} + +#[test] +fn a_gitlab_project_that_is_internal_is_not_public() { + // `internal` is not public to the internet but is public to everyone with + // an account, and that distinction is the reason this scope reads one word + // rather than a boolean. + let root = workspace(GITLAB_POLICY, &[("glab", GITLAB_COMMAND)]); + let output = Run { + args: &[ + "glab", + "mr", + "create", + "-R", + "acme/widget", + "-t", + "Generated with Claude Code", + ], + envs: &[("FAKE_VISIBILITY", "internal")], + ..Run::default() + } + .go(&root); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!(stdout(&output).contains("glab ran:"), "{}", stdout(&output)); +} From 6b3f78375bdd240d2e01722ac69c6238e1129037 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:28:56 +0900 Subject: [PATCH 03/21] Make the clean answer reachable for audit --for-publication The command exists to say whether a private-to-public flip would republish anything, and it could not say no. A note about comment edit history -- true of every run, on every repository, and nothing this run could change -- was pushed into the unreadable list, so the list was unconditionally non-empty and exit 0 was unreachable. It is a standing caveat now, stated in the body of every report, and the unreadable list is reserved for surfaces this run actually failed to open. The verdict is one const fn over two counts, so the clean answer can be asserted from a unit test rather than only from a live forge. What it reads was also short in three places. It scanned HEAD's tree, but a name committed and deleted before HEAD is served by the forge forever and survives a rewrite of the default branch, so it now reads every blob reachable from HEAD, from origin's branches and from the retained pull-request refs, deduplicated by sha through one cat-file --batch-check. On the forge side it requested bodies and not titles, which is the field the `gh` shim guards with -t, and it never asked for review bodies or review-thread comments at all; all four are read now, and a failure becomes a named unreadable note carrying gh's own stderr as the reason. And `--limit 200` silently truncated a listing at 200, so the cap is 5000 and a listing that comes back at exactly the cap is reported as truncated rather than quietly cut short. --- src/audit.rs | 504 ++++++++++++++++++++++++++------- tests/audit_publication_cli.rs | 198 +++++++++++++ 2 files changed, 606 insertions(+), 96 deletions(-) create mode 100644 tests/audit_publication_cli.rs diff --git a/src/audit.rs b/src/audit.rs index 1ce5806..d1c8f5a 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -26,17 +26,45 @@ //! * Comment EDIT HISTORY. Editing a comment does not remove what it said; the //! previous revision stays readable, and there is no API route to delete one. //! -//! The first is scanned. The second cannot be, and is reported as unreadable -//! rather than passed over, because a clean report over a surface nobody looked -//! at is the `explicit-unknown` failure on this tool's own output. +//! The first is scanned. The second cannot be scanned by anyone, from anywhere, +//! and so it is printed as a STANDING CAVEAT in every report rather than counted +//! as a surface this run failed to read. +//! +//! That difference is the whole exit code. Pushed into the unreadable list, a +//! caveat true of every run made that list non-empty on every run: this +//! subcommand returned 2 unconditionally, the clean arm at the bottom of +//! `for_publication` was unreachable code, and the reference documentation went +//! on describing an exit 0 nobody could ever observe. A permanent property of +//! the tool and a surface that went unread TODAY are different facts and a +//! reader acts on them differently -- the first is worth knowing once, the +//! second is worth fixing before the flip. So `unreadable`, and with it exit 2, +//! is reserved for what this run actually failed to open, and a clean report +//! still carries the caveat in its body. +use std::collections::BTreeSet; +use std::io::Write as _; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; use crate::config::{Check, Policy, Rule}; use crate::error::{Exit, Fatal, Result}; +use crate::guard::scope::{self, Blob}; use crate::guard::{names, Refusal}; +/// True of every run of this subcommand, on every repository, forever. +/// +/// Printed in the body of every report, including a clean one. Not in the +/// unreadable list: see the module docstring -- a caveat that never varies +/// carries no information about THIS run, and putting it there made the one +/// exit code that means "something went unread today" fire on every run and +/// therefore mean nothing. +const STANDING_CAVEATS: &[&str] = &[ + "comment edit history cannot be read, by this audit or by anything else. Editing a \ + comment does not remove what it said -- the previous revision stays readable to anyone \ + who can read the comment, and there is no API route to delete one. A name published \ + there is published; the fix is the forge's support desk, not a rewrite.", +]; + /// One place a private name could already be written. struct Surface { label: String, @@ -165,101 +193,325 @@ fn retained_pull_refs(root: &Path) -> Result<(Vec, Vec)> { Ok((surfaces, unreadable)) } -/// Issue and pull-request bodies and comments. -fn forge_conversations(root: &Path) -> Result<(Vec, Vec)> { +/// How many issues or pull requests one `gh list` is asked for. +/// +/// High on purpose, and compared against rather than trusted: see +/// `forge_conversations`. `gh` has no "all of them" for these listings, so the +/// only honest thing an audit can do is ask for more than any repository is +/// likely to hold and then say so out loud when the answer comes back at exactly +/// the number it asked for. +const FORGE_LIMIT: usize = 5000; + +/// Run `gh`, keeping the reason a call failed rather than the fact that it did. +/// +/// `Err` carries what the reader has to act on -- not logged in, no such +/// repository, rate limited -- because "could not be read" with nothing beside +/// it is a line nobody can do anything about. +fn gh(root: &Path, args: &[&str]) -> std::result::Result { + let output = Command::new("gh") + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("gh is not available: {error}"))?; + if !output.status.success() { + let reason = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(if reason.is_empty() { + format!( + "gh {} exited {}", + args.join(" "), + output.status.code().unwrap_or(-1) + ) + } else { + reason + }); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Every field a forge renders on one issue or pull request. +/// +/// The TITLE is read, and its absence was the largest hole in this subcommand. +/// `-t/--title` is the exact field the `gh` cmd-shim guards on `gh issue +/// create`, so a title is text this repository already refuses to publish +/// knowingly -- and a visibility flip republishes it in the same breath as the +/// body it sits above. Asking for `body,comments` and then printing the +/// conversation as read was a coverage claim over a field nobody opened. +/// +/// REVIEWS and REVIEW-THREAD COMMENTS are separate objects from issue comments +/// and arrive on neither `.body` nor `.comments`. A review body is where the +/// reasoning goes on a pull request -- which is to say where a private sibling +/// gets named -- and a review comment is pinned to a diff line, which is exactly +/// the context in which someone quotes a path, a host or an internal repository. +fn read_conversation( + root: &Path, + kind: &str, + number: &str, + surfaces: &mut Vec, + unreadable: &mut Vec, +) { + match gh( + root, + &[ + kind, + "view", + number, + "--json", + "title,body,comments", + "--jq", + ".title, .body, (.comments[]? | .body)", + ], + ) { + Ok(text) => surfaces.push(Surface { + label: format!("{kind} #{number} title, body and comments"), + text, + }), + Err(reason) => unreadable.push(format!("{kind} #{number} could not be read: {reason}")), + } + if kind != "pr" { + return; + } + match gh( + root, + &[ + "pr", + "view", + number, + "--json", + "reviews", + "--jq", + ".reviews[]? | .body", + ], + ) { + Ok(text) => surfaces.push(Surface { + label: format!("pr #{number} review bodies"), + text, + }), + Err(reason) => unreadable.push(format!( + "pr #{number} review bodies could not be read: {reason}" + )), + } + // Through the API, because `gh pr view` has no field for the comments on a + // review thread. `{owner}` and `{repo}` are gh's own placeholders, resolved + // from the repository this audit is standing in, so the route cannot drift + // from the remote the rest of the audit reads. + let route = format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments"); + match gh(root, &["api", &route, "--paginate", "--jq", ".[].body"]) { + Ok(text) => surfaces.push(Surface { + label: format!("pr #{number} review-thread comments"), + text, + }), + Err(reason) => unreadable.push(format!( + "pr #{number} review-thread comments could not be read: {reason}" + )), + } +} + +/// Issue and pull-request conversations, and what could not be listed. +fn forge_conversations(root: &Path) -> (Vec, Vec) { let mut surfaces = Vec::new(); let mut unreadable = Vec::new(); + let cap = FORGE_LIMIT.to_string(); for kind in ["issue", "pr"] { - let listed = Command::new("gh") - .args([ - kind, "list", "--state", "all", "--limit", "200", "--json", "number", - ]) - .current_dir(root) - .output(); - let Ok(listed) = listed else { - unreadable.push(format!("{kind}s could not be listed: gh is not available")); - continue; + let listed = match gh( + root, + &[ + kind, + "list", + "--state", + "all", + "--limit", + &cap, + "--json", + "number", + "--jq", + ".[].number", + ], + ) { + Ok(text) => text, + Err(reason) => { + unreadable.push(format!("{kind}s could not be listed: {reason}")); + continue; + } }; - if !listed.status.success() { + let numbers: Vec<&str> = listed + .lines() + .map(str::trim) + .filter(|number| !number.is_empty()) + .collect(); + // A truncated listing and a short one used to produce the same output. + // At `--limit 200` the two hundred and first issue was not read, not + // counted and not mentioned: the audit reported over whatever fell + // inside a number nobody had chosen for this repository. The cap cannot + // be removed -- the forge paginates -- so it is compared against + // instead. A listing that comes back at exactly the cap was cut off, and + // where it was cut is unknown. + if numbers.len() >= FORGE_LIMIT { unreadable.push(format!( - "{kind}s could not be listed: {}", - String::from_utf8_lossy(&listed.stderr).trim() + "the {kind} listing came back with {} item(s), which is exactly the --limit \ + of {FORGE_LIMIT} it was asked for -- so it was TRUNCATED, and every {kind} \ + past that cap was neither listed nor read.", + numbers.len() )); - continue; } - let text = String::from_utf8_lossy(&listed.stdout); - for number in text - .split("\"number\":") - .skip(1) - .filter_map(|tail| tail.trim_start().split(['}', ',']).next()) - .map(str::trim) - { - let viewed = Command::new("gh") - .args([ - kind, - "view", - number, - "--json", - "body,comments", - "--jq", - ".body, (.comments[]? | .body)", - ]) - .current_dir(root) - .output(); - match viewed { - Ok(output) if output.status.success() => surfaces.push(Surface { - label: format!("{kind} #{number}"), - text: String::from_utf8_lossy(&output.stdout).into_owned(), - }), - _ => unreadable.push(format!("{kind} #{number} could not be read")), - } + for number in numbers { + read_conversation(root, kind, number, &mut surfaces, &mut unreadable); } } - // No route exists to read it, so it is named rather than counted clean. - unreadable.push(String::from( - "comment edit history could not be read. Editing a comment does not remove what it \ - said -- the previous revision stays readable to anyone who can read the comment, \ - and there is no API route to delete one.", - )); + (surfaces, unreadable) +} - Ok((surfaces, unreadable)) +/// Which of these objects git says are blobs, asked once rather than once each. +fn blob_shas(root: &Path, shas: &[String]) -> Result> { + let mut blobs = BTreeSet::new(); + if shas.is_empty() { + return Ok(blobs); + } + let mut child = Command::new("git") + .args(["cat-file", "--batch-check=%(objectname) %(objecttype)"]) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; + { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| Fatal::new("git cat-file: no stdin"))?; + for sha in shas { + writeln!(stdin, "{sha}") + .map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; + } + } + let output = child + .wait_with_output() + .map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; + // Reported rather than swallowed, for the reason `keep_blobs` in + // `guard::scope` gives: no stdout means no known kinds, the filter below + // then keeps nothing, and a repository whose objects could not be identified + // would be audited as a repository with nothing in it. + if !output.status.success() { + return Err(Fatal::new(format!( + "git cat-file --batch-check exited {}: cannot tell which of {} reachable \ + object(s) are blobs, and reading none of them would report as a clean tree", + output.status.code().unwrap_or(-1), + shas.len() + ))); + } + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if let [name, "blob", ..] = fields.as_slice() { + blobs.insert((*name).to_owned()); + } + } + Ok(blobs) } -/// Every tracked file, as committed. -fn tree(root: &Path) -> Result<(Vec, Vec)> { - let listed = git_lines(root, &["ls-tree", "-r", "-z", "--name-only", "HEAD"])?; - let mut surfaces = Vec::new(); - let mut unreadable = Vec::new(); - for path in listed.split('\0').filter(|path| !path.is_empty()) { - let blob = Command::new("git") - .args(["show", &format!("HEAD:{path}")]) - .current_dir(root) - .output() - .map_err(|error| Fatal::new(format!("git show HEAD:{path}: {error}")))?; - // A file the audit could not open is not a file the audit found clean. - // It used to be dropped here with a bare `continue`, which kept it out - // of the "could NOT be read" list -- the list that drives this - // subcommand's exit code and the paragraph telling the reader their - // coverage is incomplete. A submodule gitlink is the ordinary case; a - // missing object is the one that matters. - if !blob.status.success() { - unreadable.push(format!( - "{path} is in HEAD's tree and `git show HEAD:{path}` exited {} -- it was \ - not read, and it is not covered by the count below.", - blob.status.code().unwrap_or(-1) - )); +/// Every blob a flip would serve, not every path HEAD still names. +/// +/// This read `git ls-tree -r HEAD` and `git show HEAD:`, which is the tree +/// as it stands and not what publication exposes. A flip republishes every +/// REACHABLE object: a name or a credential committed on Monday and deleted on +/// Tuesday is still in Monday's commit, is served forever from the forge's blob +/// route by sha, and -- this is the half that matters -- SURVIVES the +/// default-branch rewrite this audit exists to trigger. Reporting clean over it +/// is precisely the failure that rewrite was supposed to fix. +/// +/// The ref set is `history`'s, plus HEAD and the retained pull refs already +/// fetched: what the forge holds, not what this machine happens to have. A +/// `refs/original` left by a rewrite and a local backup branch are on no other +/// computer, and findings whose only fix is deleting something that was never +/// published are how a report earns a reader who skims it. +/// +/// Deduplicated by sha rather than by path, because one blob reachable under +/// five paths and forty commits is one piece of content and reads once. +fn reachable_blobs(root: &Path) -> Result<(Vec, Vec)> { + let listed = git_lines( + root, + &[ + "rev-list", + "--objects", + "HEAD", + "--remotes=origin", + "--glob=refs/audit/pull/*", + ], + )?; + let mut seen: BTreeSet = BTreeSet::new(); + let mut candidates: Vec = Vec::new(); + for line in listed.lines() { + // A commit is listed with no path beside it; a tree and a blob both + // carry one, which is why `cat-file` still has to say which is which. + let Some((sha, path)) = line.split_once(' ') else { + continue; + }; + if path.is_empty() || !seen.insert(sha.to_owned()) { continue; } - surfaces.push(Surface { - label: path.to_owned(), - text: String::from_utf8_lossy(&blob.stdout).into_owned(), + candidates.push(Blob { + path: path.to_owned(), + sha: sha.to_owned(), + // `rev-list --objects` names an object and a path it once appeared + // at, and no mode. A gitlink cannot arrive here mislabelled as a + // blob regardless: `cat-file` calls it a commit and the filter below + // drops it. + mode: String::new(), }); } + let shas: Vec = candidates.iter().map(|blob| blob.sha.clone()).collect(); + let blobs = blob_shas(root, &shas)?; + + let mut surfaces = Vec::new(); + let mut unreadable = Vec::new(); + for candidate in candidates + .into_iter() + .filter(|candidate| blobs.contains(&candidate.sha)) + { + // An object the audit could not open is not an object the audit found + // clean. The path this replaces dropped one with a bare `continue`, + // which kept it out of the "could NOT be read" list -- the list that + // drives this subcommand's exit code and the paragraph telling the + // reader their coverage is incomplete. A missing object in a shallow or + // partial clone is the ordinary case here, and it is exactly the case + // where a reader has to know the audit answered about less than the + // whole repository. + match scope::read(root, &candidate) { + Ok(bytes) => surfaces.push(Surface { + label: format!( + "{} (blob {})", + candidate.path, + &candidate.sha[..8.min(candidate.sha.len())] + ), + text: String::from_utf8_lossy(&bytes).into_owned(), + }), + Err(error) => unreadable.push(format!( + "{} (blob {}) is reachable and could not be read: {error}", + candidate.path, candidate.sha + )), + } + } Ok((surfaces, unreadable)) } +/// The exit code this run owes its reader, in one place. +/// +/// A function rather than three bare `return`s so that the clean answer is +/// something a test can reach at all. It was unreachable in practice and there +/// was no way to say so short of standing in front of a forge: `unreadable` +/// carried a caveat true of every run, so the branch above it always won. +const fn verdict(refusals: usize, unreadable: usize) -> Exit { + if refusals > 0 { + Exit::Violations + } else if unreadable > 0 { + Exit::Broken + } else { + Exit::Clean + } +} + pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { // The rule is the repository's own. An audit that invented its own idea of // a private name would be a second definition of a rule that already @@ -302,12 +554,15 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { it has." ); - let (mut surfaces, tree_unreadable) = tree(root)?; - surfaces.extend(history(root)?); + // The pull refs are fetched FIRST, because `reachable_blobs` walks them: + // a blob that only ever existed on a pull-request head is served by the + // forge for good, and it is in no branch this clone has otherwise. let (retained, mut unreadable) = retained_pull_refs(root)?; - unreadable.extend(tree_unreadable); + let (mut surfaces, blob_unreadable) = reachable_blobs(root)?; + unreadable.extend(blob_unreadable); + surfaces.extend(history(root)?); surfaces.extend(retained); - let (conversations, more) = forge_conversations(root)?; + let (conversations, more) = forge_conversations(root); surfaces.extend(conversations); unreadable.extend(more); @@ -346,6 +601,19 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { eprintln!(); } + // Printed in every report, clean or not, and deliberately NOT counted as a + // surface this run failed to read. A reader needs both facts and they are + // not the same fact: this one is a property of every audit anyone will ever + // run, and the list below it is what went wrong today. + println!(); + println!( + "{} standing caveat(s), true of every run and not measured here:", + STANDING_CAVEATS.len() + ); + for caveat in STANDING_CAVEATS { + println!(" - {caveat}"); + } + if !unreadable.is_empty() { // SAID ALOUD, ALWAYS, and this is the half that matters most. The point // of an audit before a flip is that it covers the surfaces the flip @@ -359,21 +627,27 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { } } - if !refusals.is_empty() { - return Ok(Exit::Violations); - } - if !unreadable.is_empty() { - // Not clean. Nothing was found in what could be read, and something - // could not be read. - println!(); - println!( - "Nothing found in what could be read. That is not the same as clean: see the \ - unreadable surfaces above." - ); - return Ok(Exit::Broken); + let exit = verdict(refusals.len(), unreadable.len()); + match exit { + Exit::Violations => {} + Exit::Broken => { + // Not clean. Nothing was found in what could be read, and something + // could not be read. + println!(); + println!( + "Nothing found in what could be read. That is not the same as clean: see the \ + unreadable surfaces above." + ); + } + // Every surface this run could name, it opened. The caveats above still + // stand, and the sentence says so rather than letting a green exit read + // as a claim about the one surface nothing can reach. + Exit::Clean => println!( + "every surface a flip would republish was read, and every one of them is clean, \ + subject to the standing caveat(s) above" + ), } - println!("every surface a flip would republish is clean"); - Ok(Exit::Clean) + Ok(exit) } #[cfg(test)] @@ -393,4 +667,42 @@ mod tests { assert_eq!(published.private_owners, rule.private_owners); assert_eq!(published.id, rule.id); } + + /// Exit 0 exists. + /// + /// It did not: a caveat true of every run sat in the unreadable list, that + /// list decided the exit code, and so `audit --for-publication` returned 2 + /// on a repository with nothing wrong with it -- while the reference + /// documentation described a 0 the code could not produce. A check that + /// cannot pass gets read as noise and then gets switched off. + #[test] + fn a_run_that_read_everything_and_found_nothing_exits_clean() { + assert_eq!(verdict(0, 0), Exit::Clean); + assert_eq!(verdict(0, 3), Exit::Broken); + // A violation outranks an unread surface: something WAS found, and the + // reader has a fix to make either way. + assert_eq!(verdict(1, 3), Exit::Violations); + assert_eq!(verdict(1, 0), Exit::Violations); + } + + /// The caveat is a caveat, not a measurement. + /// + /// Stated as a test because the two lists are ordinary `Vec`s and + /// nothing in the type system stops the next person from pushing a standing + /// caveat back into the measured one -- which is exactly how this subcommand + /// came to have an unreachable clean arm. + #[test] + fn the_standing_caveats_are_not_surfaces_this_run_failed_to_read() { + assert!(!STANDING_CAVEATS.is_empty()); + for caveat in STANDING_CAVEATS { + assert!( + caveat.contains("cannot"), + "a standing caveat states what is impossible, not what went wrong today: \ + {caveat}" + ); + } + // The exit code is decided by the measured list alone, so a report + // carrying only caveats is a clean one. + assert_eq!(verdict(0, 0), Exit::Clean); + } } diff --git a/tests/audit_publication_cli.rs b/tests/audit_publication_cli.rs new file mode 100644 index 0000000..09d3397 --- /dev/null +++ b/tests/audit_publication_cli.rs @@ -0,0 +1,198 @@ +//! CLI-level tests for what `uphold audit --for-publication` COVERS. +//! +//! The sibling file, `audit_cli.rs`, tests the judgement: which names count and +//! under whose visibility. These test the other half, which is the half that +//! fails silently -- whether the surfaces a flip republishes were opened at all, +//! and whether this subcommand tells the truth about the ones it could not open. + +#![expect( + clippy::let_underscore_must_use, + clippy::tests_outside_test_module, + clippy::unwrap_used, + reason = "A CLI test asserts on the outcome; a panic in the harness that builds the fixture IS the failure report, and there is no caller to hand a Result to" +)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// A fictional owner, deliberately. A fixture that hardcoded the real one would +/// write a private organisation's name into the tree -- a surface a flip +/// republishes -- which is the exact thing the code under test refuses. +/// +/// The owners come from a file OUTSIDE the repository, for the reason the audit +/// reports when they do not: a list of names that must not be published cannot +/// live in a file that is about to be published. +fn repository() -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let serial = NEXT.fetch_add(1, Ordering::Relaxed); + let outside = std::env::temp_dir().join(format!( + "uphold-publication-owners-{}-{serial}.txt", + std::process::id() + )); + std::fs::write(&outside, "PrivateOrg\n").unwrap(); + + let root = std::env::temp_dir().join(format!( + "uphold-publication-{}-{serial}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("policy")).unwrap(); + std::fs::write( + root.join("policy/principles.toml"), + format!( + r#" +[rule.no-private-repo-names] +builtin = "no-private-repo-names" +visibility = "private" +private_owners_from = "cat {}" + +[rule.no-private-repo-names.git] +hooks = ["commit-msg"] +"#, + outside.display() + ), + ) + .unwrap(); + git(&root, &["init", "-q", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.test"]); + root +} + +fn git(root: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +/// The fixtures live in a temp directory with no forge, so the conversation half +/// of the audit fails locally and fast. That is not a gap: the surfaces the +/// audit cannot reach are themselves a thing it has to report, and this +/// exercises that path without a network or a logged-in account. +fn audit(root: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["audit", "--for-publication"]) + .current_dir(root) + .output() + .unwrap() +} + +fn text(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// A name deleted before HEAD is still published by the flip. +/// +/// This is the finding the audit exists to trigger a rewrite for, and the scan +/// it was built on could not see it: `git ls-tree -r HEAD` names the tree as it +/// stands, while a visibility flip republishes every REACHABLE object. The blob +/// stays in the commit that added it, the forge serves it by sha forever, and +/// it survives the default-branch rewrite the report asks for. +#[test] +fn a_name_deleted_before_head_is_still_found() { + let root = repository(); + std::fs::write(root.join("NOTES.md"), "we hit this in PrivateOrg first\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "notes", "--no-verify"]); + std::fs::remove_file(root.join("NOTES.md")).unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "drop the notes", "--no-verify"]); + + let output = audit(&root); + let report = text(&output); + assert_eq!( + output.status.code().unwrap(), + 1, + "the blob is gone from HEAD and is still on the forge:\n{report}" + ); + assert!(report.contains("NOTES.md"), "{report}"); + // Named as a blob, because that is how the reader will have to reach it: the + // path no longer exists to open. + assert!(report.contains("(blob "), "{report}"); +} + +/// One blob, read once, however many commits carry it. +/// +/// Deduplicated by sha rather than by path. Keyed the other way, a file +/// untouched for forty commits was read forty times and every finding in it was +/// reported forty times, which is a report nobody finishes. +#[test] +fn an_unchanged_file_is_read_once_and_not_once_per_commit() { + let root = repository(); + std::fs::write(root.join("KEEP.md"), "PrivateOrg\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "one", "--no-verify"]); + for round in ["two", "three", "four"] { + std::fs::write(root.join("other.txt"), format!("{round}\n")).unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", round, "--no-verify"]); + } + + let report = text(&audit(&root)); + assert_eq!( + report.matches("KEEP.md").count(), + 1, + "one blob, one finding:\n{report}" + ); +} + +/// The caveat that is true of every run is not a surface this run failed to +/// read. +/// +/// Pushed into the unreadable list it made that list non-empty on every run, so +/// this subcommand could never exit 0 and the clean arm was dead code -- while +/// the reference documentation went on describing an exit 0. The caveat still +/// has to be said; it just is not a measurement. +#[test] +fn the_standing_caveat_is_stated_without_being_counted_as_unread() { + let root = repository(); + std::fs::write(root.join("a.txt"), "nothing to see\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "one", "--no-verify"]); + + let report = text(&audit(&root)); + assert!(report.contains("standing caveat"), "{report}"); + assert!(report.contains("comment edit history"), "{report}"); + + // Everything after the header of the measured list is what this run failed + // to open. The caveat must not be in it. + let (_, measured) = report.split_once("could NOT be read:").unwrap(); + assert!( + !measured.contains("comment edit history"), + "the standing caveat is being counted as a surface this run failed to \ + read:\n{measured}" + ); +} + +/// A forge that could not be reached is still reported, with the reason. +/// +/// "Could not be read" with nothing beside it is a line nobody can act on, and +/// the listing is where every conversation surface -- title, body, comments, +/// review bodies, review-thread comments -- is lost at once when it fails. +#[test] +fn a_forge_that_cannot_be_listed_is_named_with_its_reason() { + let root = repository(); + std::fs::write(root.join("a.txt"), "nothing to see\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "one", "--no-verify"]); + + let output = audit(&root); + let report = text(&output); + assert_eq!( + output.status.code().unwrap(), + 2, + "a surface that could not be read is never clean:\n{report}" + ); + assert!(report.contains("could not be listed"), "{report}"); + assert!(report.contains("not the same as clean"), "{report}"); +} From 66204dfdb9e20210f4d13c1a50d92f4cb9b4a1b2 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:29:43 +0900 Subject: [PATCH 04/21] Ask both pin questions of one answer, and exit 2 where neither fits Two checkers read the same `rev:` lines, reached the same remote, and were free to return different verdicts about the same pin. The no-stale-hook-pins guard asked whether a pin had fallen behind its upstream and counted a pin whose remote it could not reach as passed; scripts/check_hook_pins.py asked whether the ref a pin names still exists and called that same pin unresolvable. Which answer a repository got depended on which seam ran, and the guard's answer was the wrong one: a pin nobody could check exited 0 out of a guard whose whole subject is whether a pin is still what it claims to be. One `git ls-remote` answers both questions now, so the script is gone along with the chance of two contradictory verdicts. A pin that could not be checked is exit 2, and the refusal names UPHOLD_ALLOW as the deliberate bypass -- a runner with no network fails this guard where it used to pass it, loudly and with a cure. A violation still outranks it: a pin genuinely behind is exit 1, with the unchecked pins printed beside the refusal rather than replacing it. Three more holes closed while the guard is the only reader left. A tree with no .pre-commit-config.yaml is an answer rather than an ENOENT, because the documented lefthook-only install path pins nothing there. lefthook `remotes:` entries are pins like any other, and one with no `ref:` is refused in the same words as a `repo:` with no `rev:`. The work tree is walked, so a config below the root is checked too and every finding names the file holding the pin; and a config with no top-level `repos:` key is could-not-look rather than zero pins, which is the same distinction one level up. The explicit-unknown claim moves to the rule that carries it, the generated review tier is rebuilt without the retired id, and the manual sweep the deleted hook held is kept: guards-manual in .pre-commit-config.yaml and the uphold-manual group in lefthook.yml are what turn "whoever pushes next finds out" back into something a schedule finds first. --- .pre-commit-config.yaml | 42 ++-- AGENTS.md | 1 - REVIEW.md | 1 - lefthook.yml | 34 ++- policy/upheld.toml | 10 +- scripts/check_hook_pins.py | 388 ------------------------------- src/pins.rs | 458 +++++++++++++++++++++++++++++++------ tests/hook_pins_cli.rs | 232 +++++++++++++++++++ tests/test_hook_pins.py | 291 ----------------------- 9 files changed, 680 insertions(+), 777 deletions(-) delete mode 100644 scripts/check_hook_pins.py create mode 100644 tests/hook_pins_cli.rs delete mode 100644 tests/test_hook_pins.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f406b9e..54410be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -129,25 +129,6 @@ repos: pass_filenames: false always_run: true stages: [pre-commit, manual] - # Not pre-commit: one `git ls-remote` per pinned repo is a network round - # trip, and a check that adds one to every commit is a check that gets - # commented out. pre-push is the last local moment before the work is - # shared; manual is how CI reaches it, which turns "whoever pushes next - # finds out" into something a scheduled run finds first. - # - # always_run with no filenames, because at pre-push the runner passes the - # files in the push and .pre-commit-config.yaml is almost never one of - # them. A pin whose upstream tag was deleted after it landed changes no - # file here, so a check gated on the pin file changing is a check that - # never runs against exactly that case. - - id: hook-pins-resolve - name: hook pins name refs that exist - entry: python3 scripts/check_hook_pins.py - language: system - pass_filenames: false - always_run: true - stages: [pre-push, manual] - - id: catalog-tests name: catalog and checker tests entry: python3 -m unittest discover -s tests @@ -190,6 +171,29 @@ repos: pass_filenames: false always_run: true stages: [pre-push] + # The pin check used to be a second hook, `hook-pins-resolve`, running a + # Python script that asked whether every `rev:` still named a ref that + # exists while the `no-stale-hook-pins` guard asked whether the pin had + # fallen behind. Two checkers over one answer are two verdicts free to + # disagree, and they did: the guard counted a pin it could not reach as + # passed while the script called the same pin unresolvable. The guard asks + # both questions now, over one `git ls-remote`, and a pin it could not + # check is exit 2 -- so the script is gone and the guard stages carry it. + # + # It stays off pre-commit for the reason the script did: one network round + # trip per pinned repository in front of every commit is a check that gets + # commented out. pre-push is the last local moment before the work is + # shared; manual is how CI and a scheduled run reach it, which is what + # turns "whoever pushes next finds out" into something a schedule finds + # first -- and losing that sweep is why this entry exists rather than + # leaving `guards-pre-push` alone. + - id: guards-manual + name: guards (manual sweep) + entry: cargo run --quiet -- guard --stage manual + language: system + pass_filenames: false + always_run: true + stages: [manual] - id: engine-tests name: scan engine tests entry: cargo test --quiet diff --git a/AGENTS.md b/AGENTS.md index d041c71..82e911c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,6 @@ opinion nobody asked for. The rules already active here are: - `catalog-reference-current` - `catalog-tests` - `catalog-validate` -- `hook-pins-resolve` - `no-stale-hook-pins` - `prevent-ai-author` - `prevent-public-push` diff --git a/REVIEW.md b/REVIEW.md index d041c71..82e911c 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -11,7 +11,6 @@ opinion nobody asked for. The rules already active here are: - `catalog-reference-current` - `catalog-tests` - `catalog-validate` -- `hook-pins-resolve` - `no-stale-hook-pins` - `prevent-ai-author` - `prevent-public-push` diff --git a/lefthook.yml b/lefthook.yml index 0fb7f77..1d22fa3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -56,10 +56,11 @@ pre-commit: run: bashate --ignore E006 {staged_files} # Not git hooks. lefthook has no equivalent of pre-commit's manual stage, so the -# two checks that are too slow or too host-dependent to sit in front of a commit -# are named groups instead: `lefthook run preflight` and `lefthook run coverage`. -# Same entry points as the manual hooks in .pre-commit-config.yaml, so the three -# runners are still answering one question each. +# checks that are too slow or too host-dependent to sit in front of a commit are +# named groups instead: `lefthook run preflight`, `lefthook run coverage` and +# `lefthook run uphold-manual`. Same entry points as the manual hooks in +# .pre-commit-config.yaml, so the three runners are still answering one question +# each. preflight: commands: deps-check: @@ -70,9 +71,18 @@ coverage: coverage: run: scripts/coverage.sh -# One `git ls-remote` per pinned repo, so not on every commit. A pin naming a -# tag that was never cut fails at hook-init, before any hook runs, which is why -# no hook can report it after the fact -- this one asks before the pin ships. +# The guards that ask a network the same question once per distinct name or pin: +# too slow for a commit, and too important to leave to whoever pushes next. This +# group is what a scheduled run invokes, and it is the seam the retired +# `hook-pins-resolve` script used to hold at pre-commit's manual stage -- named +# `uphold-manual` because that is what hooks/lefthook.yml publishes to consumers, +# and a repository that runs itself under a different name is documenting a +# workflow nobody else has. +uphold-manual: + commands: + guards: + run: cargo run --quiet -- guard --stage manual + commit-msg: commands: guards: @@ -85,8 +95,14 @@ pre-merge-commit: pre-push: commands: - hook-pins-resolve: - run: python3 scripts/check_hook_pins.py + # The pin check is not a command of its own any more. It was + # `hook-pins-resolve`, a Python script asking whether every `rev:` still + # named a ref that exists, beside a `no-stale-hook-pins` guard asking + # whether the pin had fallen behind -- two checkers over one answer, free to + # disagree, and they did: the guard counted a pin it could not reach as + # passed. One `git ls-remote` answers both questions now, inside the guard + # the `guards` command below runs, and a pin it could not check is exit 2. + # # `use_stdin: true`, which this was missing. lefthook runs a command under a # pseudo-TTY by default and that stdin never closes, so git's ref lines # never arrived and the guards that read the pushed range were asked about diff --git a/policy/upheld.toml b/policy/upheld.toml index 0211265..dc3006f 100644 --- a/policy/upheld.toml +++ b/policy/upheld.toml @@ -40,11 +40,15 @@ rule = "catalog-tests" [[enforce]] principle = "explicit-unknown" -rule = "hook-pins-resolve" +rule = "no-stale-hook-pins" # A pin that could not be checked -- unreachable remote, unreadable config, a # bare sha with no ref to look up -- exits 2, which is not the 0 it would exit -# if the pin resolved. tests/test_hook_pins.py asserts the three exit codes are -# three, and that an unreachable remote is not a pass. +# if the pin resolved. The claim used to name `hook-pins-resolve`, a separate +# script asking that half of the question while this guard asked the other and +# counted a pin it could not reach as passed; one rule asks both now, so the +# claim moves to the rule that carries it. src/pins.rs::stale returns the +# "Could not look is not a pass" refusal, and tests/hook_pins_cli.rs asserts +# that an unreachable remote is exit 2 rather than the exit 0 it used to be. [[enforce]] principle = "single-authoritative-source" diff --git a/scripts/check_hook_pins.py b/scripts/check_hook_pins.py deleted file mode 100644 index 868f58f..0000000 --- a/scripts/check_hook_pins.py +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env python3 -r"""Refuse a hook pin that names a ref its upstream does not have. - -A `rev:` is resolved by the hook runner at hook-init, before any hook runs. A -pin naming a tag that was never cut therefore fails as a clone error rather -than as a policy refusal: - - error: Failed to init hooks - caused by: Failed to clone repo `https://github.com/HackingGate/rg-policy` - caused by: error: pathspec 'v1.1.0' did not match any file(s) known to git - -Nothing in the repository can report that, because nothing in the repository -has run: every hook, including the guards that watch the pins, lives behind the -clone that just failed. The message also reads like a broken workstation rather -than a broken config, which is how a bad pin survives being seen. It blocks -every commit from the moment it lands, and it lands as a one-line diff that -looks like routine maintenance. - -`no-stale-hook-pins` does not cover this and cannot be configured to. It asks -whether a pin has fallen BEHIND the upstream's newest tag; a pin bumped ahead of -a release that was never cut has not fallen behind, and comparing it against the -newest real tag says "current" -- the wrong answer, arrived at honestly. The two -questions share one `git ls-remote` and are opposite predicates over its output, -so this check runs beside that one rather than inside it. - -## Three outcomes, never folded together - - exit 0 every remote pin named a ref that exists on its upstream - exit 1 at least one pin named a ref that does not exist -- the finding - exit 2 at least one pin could not be checked at all - -Exit 2 is not exit 0. An unreachable remote, a config shape this reader does not -model, a rev that is a bare commit sha and so has no ref to look up -- none of -those is evidence that the pin resolves, and answering 0 for them would be the -silent pass this file exists to remove. `CATALOG_ALLOW_UNCHECKED_PINS=1` -downgrades exit 2 to a printed note, for the offline case, and it is a thing -somebody types on purpose. A confirmed finding outranks an unresolved one: a run -that is both missing a ref and unable to reach some other remote exits 1. - -This also answers the case where nothing changed locally. A tag deleted or moved -upstream after the pin landed produces the identical clone failure from a config -nobody touched, and the only way to notice is to ask again -- which is why this -runs at pre-push and manual on every run, not on a change to the pin file. - -## Why it does not parse YAML - -`language: system` gives this no environment and no guaranteed PyYAML, and a -grep for `^\s*rev:` reads a `rev:` inside a hook's `args:` or inside a comment, -which is a check whose denominator nobody can state. So the reader below models -the one shape a .pre-commit-config.yaml has -- a `repos:` sequence of mappings, -each with a `repo:` and a `rev:` -- and REFUSES, by name and line number, -anything outside that model: flow style, anchors, aliases, merge keys, tabs, a -second document, a duplicate key, a pin outside the block it read. A reader that -guessed would be a second rule agreeing with the first until it did not. A -reader that stops and says so is exit 2, which is already a defined outcome. - -Usage: - check_hook_pins.py [CONFIG ...] - -With no argument it reads every tracked .pre-commit-config.yaml in the work -tree, and says which files it read: a sweep that quietly fell back to one file -is a denominator nobody can see is short. -""" - -from __future__ import annotations - -import os -import re -import subprocess -import sys -from pathlib import Path -from typing import NamedTuple - -CONFIG_NAME = ".pre-commit-config.yaml" - -#: pre-commit's two non-remote repos. They pin nothing, so they are skipped -- -#: and counted, because a config that is all local entries has verified nothing -#: and must say so rather than printing the "all pins resolve" a real run prints. -NON_REMOTE_REPOS = frozenset({"local", "meta"}) - -#: Seconds for one `git ls-remote`. An unbounded network call in a pre-push -#: hook is a hang waiting for a bad day, and a hook that hangs gets uninstalled. -TIMEOUT_SECONDS = 20.0 - -EXIT_OK = 0 -EXIT_MISSING = 1 -EXIT_UNCHECKED = 2 - -SHA_RE = re.compile(r"^[0-9a-f]{40}$|^[0-9a-f]{64}$") - -#: YAML this reader does not model. Anchors and aliases mean a pin can be -#: written in one place and used in another, merge keys mean a mapping's keys -#: are not all on screen, and an explicit tag can change what a scalar is. -UNMODELLED = ( - ("&", "an anchor"), - ("*", "an alias"), - ("!", "an explicit tag"), -) - - -class Unreadable(Exception): - """The config could not be read as the shape this tool models.""" - - -class Pin(NamedTuple): - repo: str - rev: str - path: Path - line: int - - -class Outcome(NamedTuple): - pin: Pin - state: str # "ok", "missing", or "unchecked" - detail: str - - -# --------------------------------------------------------------------------- -# Reading the config -# --------------------------------------------------------------------------- - - -def _value_of(stripped: str) -> str: - _, _, value = stripped.partition(":") - value = value.strip() - if value.startswith("#"): - return "" - return value.split(" #", 1)[0].strip().strip("'\"") - - -def _reject_unmodelled(value: str, path: Path, number: int) -> None: - for prefix, name in UNMODELLED: - if value.startswith(prefix): - raise Unreadable(f"{path}:{number}: {name} is not modelled by this reader") - - -def read_pins(text: str, path: Path) -> list[Pin]: - """Every (repo, rev) in one config, or Unreadable naming the line.""" - pins: list[Pin] = [] - saw_repos = False - in_repos = False - ended_at = 0 - item_indent: int | None = None - entry: dict | None = None - - def flush() -> None: - nonlocal entry - if entry is None: - return - if entry["repo"] in NON_REMOTE_REPOS: - # Kept in the list rather than dropped: a run has to be able to say - # how many entries it did not ask about. - pins.append(Pin(entry["repo"], "", path, entry["line"])) - elif entry["rev"] is None: - raise Unreadable( - f"{path}:{entry['line']}: remote repo {entry['repo']} has no rev" - ) - else: - pins.append(Pin(entry["repo"], entry["rev"], path, entry["rev_line"])) - entry = None - - for number, raw in enumerate(text.splitlines(), start=1): - line = raw.rstrip() - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - - leading = line[: len(line) - len(line.lstrip())] - if "\t" in leading: - raise Unreadable(f"{path}:{number}: tab indentation") - if stripped in ("---", "..."): - if saw_repos: - raise Unreadable(f"{path}:{number}: a second document") - continue - - indent = len(leading) - key = stripped.lstrip("- ").partition(":")[0].strip() - - if indent == 0 and not stripped.startswith("-"): - if in_repos: - in_repos = False - ended_at = number - flush() - if key == "repos": - if saw_repos: - raise Unreadable(f"{path}:{number}: a second `repos:` key") - if _value_of(stripped): - raise Unreadable(f"{path}:{number}: flow-style `repos:`") - saw_repos = True - in_repos = True - continue - - if not in_repos: - if key in ("repo", "rev"): - raise Unreadable( - f"{path}:{number}: `{key}:` outside the `repos:` block that " - f"ended at line {ended_at or 'nowhere this reader saw'}" - ) - continue - - if stripped.startswith("- "): - if item_indent is None: - item_indent = indent - if key == "repo": - if indent != item_indent: - raise Unreadable( - f"{path}:{number}: `- repo:` at indent {indent}, but the " - f"repos list is at indent {item_indent}" - ) - flush() - value = _value_of(stripped) - _reject_unmodelled(value, path, number) - entry = {"repo": value, "rev": None, "line": number, "rev_line": number} - continue - - # Keys of a repos entry sit one level in from its `-`. Anything deeper - # belongs to `hooks:`, where a literal `rev:` in an args list is data. - if entry is not None and indent == item_indent + 2 and key == "rev": - if entry["rev"] is not None: - raise Unreadable(f"{path}:{number}: a second `rev:` in one entry") - value = _value_of(stripped) - _reject_unmodelled(value, path, number) - if not value: - raise Unreadable(f"{path}:{number}: empty `rev:`") - entry["rev"] = value - entry["rev_line"] = number - - if in_repos: - flush() - if not saw_repos: - raise Unreadable(f"{path}: no `repos:` key this reader could find") - return pins - - -# --------------------------------------------------------------------------- -# Asking the upstream -# --------------------------------------------------------------------------- - - -def git_ls_remote(args: list[str]) -> tuple[int, str, str]: - try: - done = subprocess.run( - ["git", "ls-remote", *args], - text=True, - capture_output=True, - timeout=TIMEOUT_SECONDS, - check=False, - ) - except FileNotFoundError: - return 127, "", "git is not on PATH" - except subprocess.TimeoutExpired: - return 124, "", f"timed out after {TIMEOUT_SECONDS:g}s" - return done.returncode, done.stdout, done.stderr.strip() - - -def resolve_pin(pin: Pin, run=git_ls_remote) -> Outcome: - """Does the upstream have a ref by this name?""" - if SHA_RE.match(pin.rev): - code, out, err = run([pin.repo]) - if code != 0: - return Outcome(pin, "unchecked", err or f"git ls-remote exited {code}") - tips = {line.split("\t", 1)[0] for line in out.splitlines() if line.strip()} - if pin.rev in tips: - return Outcome(pin, "ok", "a ref tip") - # Reachable-but-not-a-tip is the common, correct case for a sha pin, and - # settling it needs a fetch. Unchecked is the honest answer. - return Outcome( - pin, "unchecked", "a commit sha that is not a ref tip; needs a fetch" - ) - - code, out, err = run( - ["--refs", pin.repo, f"refs/tags/{pin.rev}", f"refs/heads/{pin.rev}"] - ) - if code not in (0, 2): - # 2 is ls-remote's "no matching refs", which is the finding, not a fault. - return Outcome(pin, "unchecked", err or f"git ls-remote exited {code}") - if out.strip(): - names = sorted( - line.split("\t", 1)[1] for line in out.splitlines() if "\t" in line - ) - return Outcome(pin, "ok", ", ".join(names)) - return Outcome(pin, "missing", "no tag or branch by that name on the remote") - - -# --------------------------------------------------------------------------- -# Driver -# --------------------------------------------------------------------------- - - -def tracked_configs() -> tuple[list[Path], str]: - """Every tracked config, and a sentence about how the list was found.""" - try: - done = subprocess.run( - ["git", "ls-files", "-z", "--", f"*{CONFIG_NAME}", CONFIG_NAME], - text=True, - capture_output=True, - timeout=TIMEOUT_SECONDS, - check=False, - ) - except (FileNotFoundError, subprocess.TimeoutExpired) as error: - done = None - note = f"could not sweep the work tree ({error})" - else: - if done.returncode == 0: - paths = sorted({Path(name) for name in done.stdout.split("\0") if name}) - return paths, "swept with `git ls-files`" - note = f"could not sweep the work tree ({done.stderr.strip()})" - - fallback = Path(CONFIG_NAME) - return ([fallback] if fallback.is_file() else []), note - - -def main(argv: list[str]) -> int: - if argv: - paths, how = [Path(name) for name in argv], "named on the command line" - else: - paths, how = tracked_configs() - - if not paths: - print(f"check-hook-pins: no {CONFIG_NAME} to read ({how})", file=sys.stderr) - return EXIT_UNCHECKED - - outcomes: list[Outcome] = [] - unreadable: list[str] = [] - skipped = 0 - for path in paths: - try: - pins = read_pins(path.read_text(encoding="utf-8"), path) - except OSError as error: - unreadable.append(f"{path}: {error}") - continue - except Unreadable as error: - unreadable.append(str(error)) - continue - for pin in pins: - if pin.repo in NON_REMOTE_REPOS: - skipped += 1 - continue - outcomes.append(resolve_pin(pin)) - - print(f"check-hook-pins: {len(paths)} config(s) {how}") - for outcome in outcomes: - mark = {"ok": "ok ", "missing": "MISS", "unchecked": "????"}[outcome.state] - print( - f" {mark} {outcome.pin.repo} @ {outcome.pin.rev} " - f"({outcome.pin.path}:{outcome.pin.line}) -- {outcome.detail}" - ) - if skipped: - print(f" {skipped} local/meta entr(y|ies) pin nothing and were skipped") - - missing = [item for item in outcomes if item.state == "missing"] - unchecked = [item for item in outcomes if item.state == "unchecked"] - - if missing: - print("", file=sys.stderr) - for item in missing: - print( - f"{item.pin.path}:{item.pin.line}: {item.pin.repo} has no ref " - f"{item.pin.rev!r}. Nothing can run until this pin names a ref " - f"that exists.", - file=sys.stderr, - ) - return EXIT_MISSING - - if unchecked or unreadable: - for item in unreadable: - print(f"could not read: {item}", file=sys.stderr) - if os.environ.get("CATALOG_ALLOW_UNCHECKED_PINS") == "1": - print( - f"{len(unchecked) + len(unreadable)} pin(s) unchecked; " - "CATALOG_ALLOW_UNCHECKED_PINS=1 is set, so this is a note", - file=sys.stderr, - ) - return EXIT_OK - print( - f"{len(unchecked) + len(unreadable)} pin(s) could not be checked. " - "Cannot look is not resolves; set CATALOG_ALLOW_UNCHECKED_PINS=1 to " - "accept that deliberately.", - file=sys.stderr, - ) - return EXIT_UNCHECKED - - print(f"every one of {len(outcomes)} remote pin(s) names a ref that exists") - return EXIT_OK - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/src/pins.rs b/src/pins.rs index 97480bd..503bec5 100644 --- a/src/pins.rs +++ b/src/pins.rs @@ -17,6 +17,18 @@ //! A pin ahead of every tag that exists has not fallen behind, so the first //! question answers `pass` for it. Both are asked here. //! +//! And a THIRD state, which is neither: a pin whose remote could not be reached +//! is not up to date, it is unestablished. That exits 2, the same way +//! `audit --for-publication` exits 2 over a surface it could not read, because +//! the alternative -- what this did -- is to print the pin to stderr and exit 0 +//! with the guard counted among the ones that passed. +//! +//! Both managers are read. pre-commit writes `repos:` with a `rev:`; lefthook +//! writes `remotes:` with a `ref:`, and that entry is the single version a +//! lefthook consumer pins. It was read by nothing here and there is no +//! Dependabot ecosystem for it either, so it was the one pin in the tree with +//! nobody watching it at all. +//! //! The configuration is parsed rather than scanned. A line regex over //! `.pre-commit-config.yaml` reads the block form and silently yields nothing //! for a flow-style file -- an absent pin and an unreadable one looking the @@ -25,9 +37,10 @@ //! dependencies; a binary has no such constraint. use std::collections::BTreeMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; +use ignore::WalkBuilder; use serde::Deserialize; use crate::error::{read_to_string, Fatal, Result}; @@ -35,8 +48,14 @@ use crate::guard::{Refusal, Request}; #[derive(Debug, Deserialize)] struct HookConfig { - #[serde(default)] - repos: Vec, + /// `Option`, not `#[serde(default)]`, and the difference is a whole + /// finding. A file with no `repos:` in it deserialized as a config with no + /// repositories in it, so a `.pre-commit-config.yaml` whose top-level key + /// had been renamed, indented into another mapping, or typed as `repo:` + /// reported zero pins and passed. Zero pins and "this is not a file I can + /// read pins out of" are different answers, and only one of them is + /// something a reader can act on. + repos: Option>, } #[derive(Debug, Deserialize)] @@ -46,48 +65,214 @@ struct RepoEntry { rev: Option, } -/// One pin, as written. +/// lefthook's own remote-config block: another repository's hook definitions, +/// fetched at run time. +#[derive(Debug, Deserialize)] +struct LefthookConfig { + #[serde(default)] + remotes: Vec, + /// lefthook's older singular spelling, still accepted by lefthook and still + /// in the wild. Read for the reason the plural one is: the pin a consumer + /// wrote is the pin that runs, whichever key they wrote it under. + #[serde(default)] + remote: Option, +} + +#[derive(Debug, Deserialize)] +struct LefthookRemote { + git_url: String, + /// `ref` is a keyword here and a field name there. + #[serde(rename = "ref", default)] + reference: Option, +} + +/// One pin, as written, and the file it was written in. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Pin { pub repo: String, pub rev: String, + /// Repository-relative, because a report that says a pin is behind without + /// saying which file holds it sends the reader looking through a tree that + /// may hold several. + pub source: String, +} + +/// Every pin in the tree, and what could not be read while collecting them. +#[derive(Debug)] +pub(crate) struct Pins { + pub pins: Vec, + /// Facts about coverage rather than about pins: which of the two hook + /// managers this tree even uses. Said aloud, never counted as a pass. + pub notes: Vec, +} + +const PRE_COMMIT_CONFIG: &str = ".pre-commit-config.yaml"; + +/// The names lefthook itself looks for. Enumerated because lefthook's loader +/// enumerates them; there is no pattern to parameterize over. +const LEFTHOOK_CONFIGS: &[&str] = &[ + "lefthook.yml", + "lefthook.yaml", + ".lefthook.yml", + ".lefthook.yaml", +]; + +/// Every hook configuration in the WORK TREE, sorted. +/// +/// The tree, not the root. This read `root/.pre-commit-config.yaml` and nothing +/// else, where the guard it replaced read every `.pre-commit-config.yaml` under +/// the tree on the stated grounds that a pin in `sub/.pre-commit-config.yaml` is +/// a pin a run touches -- a monorepo with a config per package had exactly one +/// of them checked, and which one depended on where the file happened to sit. +/// +/// gitignored files are skipped, since a config no commit carries is not one a +/// reviewer can see or a runner will find in a fresh clone. Sorted, because a +/// report whose order depends on directory iteration diffs against itself +/// between two runs that found the same thing. +fn hook_configs(root: &Path) -> Vec { + let mut found = Vec::new(); + let mut walker = WalkBuilder::new(root); + walker + // Hook configuration is dotted by convention -- `.pre-commit-config.yaml` + // is the whole point of this walk -- so the default that skips hidden + // files would skip everything being looked for. + .hidden(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .parents(true) + // The object database is not the work tree. With `hidden` off the walk + // would descend into `.git` and read a few thousand files that no hook + // manager has ever looked at. + .filter_entry(|entry| entry.file_name() != std::ffi::OsStr::new(".git")); + for entry in walker.build().flatten() { + if !entry.file_type().is_some_and(|kind| kind.is_file()) { + continue; + } + let Some(name) = entry.file_name().to_str() else { + continue; + }; + if name == PRE_COMMIT_CONFIG || LEFTHOOK_CONFIGS.contains(&name) { + found.push(entry.into_path()); + } + } + found.sort(); + found +} + +/// A `rev:` that names nothing is not a pin, in either manager's spelling. +fn unpinned(path: &Path, repo: &str, field: &str) -> Fatal { + // Dropped here by a `?`, once per manager, so the one state this guard + // exists to catch -- a hook repository nothing pins -- was the one state it + // could not see. It is not a stale pin; it is no pin, which is strictly + // worse, and it read as a config with one fewer repository in it. + Fatal::at( + path, + format!( + "{repo} is listed with no `{field}:`. An unpinned hook repository is not a pin \ + this guard can check -- it is code that can change under you between two runs \ + with no diff anywhere" + ), + ) } -pub(crate) fn read_pins(root: &Path) -> Result> { - let path = root.join(".pre-commit-config.yaml"); - let text = read_to_string(&path)?; +fn pre_commit_pins(path: &Path, source: &str, text: &str) -> Result> { let config: HookConfig = - serde_yaml_ng::from_str(&text).map_err(|error| Fatal::at(&path, error))?; + serde_yaml_ng::from_str(text).map_err(|error| Fatal::at(path, error))?; + let Some(repos) = config.repos else { + return Err(Fatal::at( + path, + "has no top-level `repos:` key, so this is not a file pins can be read out of. \ + Reporting zero pins here would be an empty answer where the honest one is \ + could-not-look", + )); + }; let mut pins = Vec::new(); - for entry in config.repos { + for entry in repos { // `repo: local` and `repo: meta` name no remote and carry no rev. if entry.repo == "local" || entry.repo == "meta" { continue; } - // A remote with no `rev:` was dropped here by a `?`, so the one state - // this guard exists to catch -- a hook repository nothing pins -- was - // the one state it could not see. It is not a stale pin; it is no pin, - // which is strictly worse, and it read as a config with one fewer repo - // in it. let Some(rev) = entry.rev else { - return Err(Fatal::at( - &path, - std::io::Error::other(format!( - "{} is listed with no `rev:`. An unpinned hook repository is not a \ - pin this guard can check -- it is code that can change under you \ - between two runs with no diff anywhere", - entry.repo - )), - )); + return Err(unpinned(path, &entry.repo, "rev")); }; pins.push(Pin { repo: entry.repo, rev, + source: source.to_owned(), + }); + } + Ok(pins) +} + +/// lefthook's `remotes:` are pins, and nothing was reading them. +/// +/// A lefthook consumer pins exactly one thing -- the remote config they inherit +/// their hooks from -- and it was invisible to this guard and to Dependabot +/// alike, which has no ecosystem for a lefthook remote. So the single version a +/// whole class of consumers pins was the one version nobody watched. +/// +/// An ABSENT `remotes:` is not the ambiguity a missing `repos:` is: it is +/// optional in lefthook and a config without one is an ordinary local +/// configuration, so it reads as zero pins rather than as unreadable. +fn lefthook_pins(path: &Path, source: &str, text: &str) -> Result> { + let config: LefthookConfig = + serde_yaml_ng::from_str(text).map_err(|error| Fatal::at(path, error))?; + let mut pins = Vec::new(); + for remote in config.remotes.into_iter().chain(config.remote) { + // No `ref:` means lefthook takes the remote's default branch, which is + // the moving-target state the `rev:` arm above refuses in the same + // words. Refused here rather than reported as a pin naming a branch, + // because there is no branch written down to report. + let Some(reference) = remote.reference else { + return Err(unpinned(path, &remote.git_url, "ref")); + }; + pins.push(Pin { + repo: remote.git_url, + rev: reference, + source: source.to_owned(), }); } Ok(pins) } +pub(crate) fn read_pins(root: &Path) -> Result { + let mut pins = Vec::new(); + let mut notes = Vec::new(); + let mut saw_pre_commit = false; + for path in hook_configs(root) { + let source = path + .strip_prefix(root) + .unwrap_or(&path) + .display() + .to_string(); + let text = read_to_string(&path)?; + if path + .file_name() + .is_some_and(|name| name == PRE_COMMIT_CONFIG) + { + saw_pre_commit = true; + pins.extend(pre_commit_pins(&path, &source, &text)?); + } else { + pins.extend(lefthook_pins(&path, &source, &text)?); + } + } + // An absent file, reported as a fact rather than as an io error. This + // opened `root/.pre-commit-config.yaml` unconditionally and `read_to_string` + // turns ENOENT into a `Fatal`, so this guard exited 2 -- "could not look" -- + // on every consumer who followed the documented lefthook-only install path. + // They have no pre-commit config because they were told not to make one, and + // that is an answer, not a failure to obtain one. + if !saw_pre_commit { + notes.push(format!( + "no `{PRE_COMMIT_CONFIG}` anywhere in this tree, so there are no pre-commit pins \ + to check. That is the documented lefthook-only install path, not a hole in the \ + answer; any `remotes:` a lefthook config pins were read." + )); + } + Ok(Pins { pins, notes }) +} + /// Compare two tags the way a person reads them. /// /// Numeric runs compare as numbers so `v10` sorts above `v9`, which a string @@ -180,7 +365,10 @@ fn remote_refs(repo: &str) -> Result> { /// Both questions, over every pin. pub(crate) fn stale(request: &Request<'_>) -> Result> { - let pins = read_pins(request.root)?; + let Pins { pins, notes } = read_pins(request.root)?; + for note in ¬es { + println!("{}: {note}", request.rule.id); + } let mut behind: Vec = Vec::new(); let mut missing: Vec = Vec::new(); let mut unchecked: Vec = Vec::new(); @@ -195,7 +383,11 @@ pub(crate) fn stale(request: &Request<'_>) -> Result> { looked }; let Some(Refs { tags, heads }) = refs else { - unchecked.push(format!("{}: could not reach the remote", pin.repo)); + unchecked.push(format!( + "{} (pinned in {}): could not reach the remote, so neither question was \ + answered about it", + pin.repo, pin.source + )); continue; }; // A branch resolves; it just does not stay put. Reported as its own @@ -205,8 +397,8 @@ pub(crate) fn stale(request: &Request<'_>) -> Result> { missing.push(format!( "{} pins {}, which is a BRANCH on the remote and moves. A pin that \ moves is not a pin: the hook you reviewed and the hook that runs \ - next month are different code. Name a tag or a sha", - pin.repo, pin.rev + next month are different code. Name a tag or a sha (in {})", + pin.repo, pin.rev, pin.source )); continue; } @@ -218,33 +410,21 @@ pub(crate) fn stale(request: &Request<'_>) -> Result> { } if !tags.contains(&pin.rev) { missing.push(format!( - "{} pins {}, which names no tag on the remote", - pin.repo, pin.rev + "{} pins {}, which names no tag on the remote (in {})", + pin.repo, pin.rev, pin.source )); continue; } if let Some(newest) = tags.last() { if newest != &pin.rev { behind.push(format!( - "{} pins {}, and {} is newer", - pin.repo, pin.rev, newest + "{} pins {}, and {} is newer (in {})", + pin.repo, pin.rev, newest, pin.source )); } } } - // Said aloud whatever else happened. A pin nobody could check is a hole in - // the answer, and reporting it only when something else also failed makes - // the hole invisible exactly when the rest is clean. - if !unchecked.is_empty() { - eprintln!( - "{}: {} pin(s) could not be checked:\n{}", - request.rule.id, - unchecked.len(), - unchecked.join("\n") - ); - } - let mut report = String::new(); if !missing.is_empty() { report.push_str(&missing.join("\n")); @@ -260,13 +440,50 @@ pub(crate) fn stale(request: &Request<'_>) -> Result> { report.push_str(&behind.join("\n")); report.push_str("\n\nThe upstream tag owns the version; a `rev:` here is a copy of it."); } - if report.is_empty() { - return Ok(None); + if !report.is_empty() { + // Said aloud beside the violation. The refusal below exits 1 on what was + // checked, and a reader has to know that number was measured over fewer + // pins than the file holds. + if !unchecked.is_empty() { + eprintln!( + "{}: {} pin(s) could not be checked, on top of the finding(s) below:\n{}", + request.rule.id, + unchecked.len(), + unchecked.join("\n") + ); + } + return Ok(Some(Refusal { + id: request.rule.id.clone(), + report, + })); } - Ok(Some(Refusal { - id: request.rule.id.clone(), - report, - })) + + // COULD NOT LOOK, which is exit 2, and it used to be exit 0. + // + // `remote_refs` returns `Ok(None)` for a remote it could not reach and says + // in a comment that this is never a pass -- and then the caller made it one. + // The pin went into `unchecked`, `unchecked` was printed to stderr and + // dropped, `stale` returned `Ok(None)`, and `guard::run` counted the guard + // among the ones that passed and exited 0. A network that was down, a token + // that had expired, a remote that had been renamed: every one of them read + // as a pin that was up to date. + // + // A `Fatal` rather than a `Refusal`, because this is not a violation: the + // repository may be perfectly pinned. It is this run failing to establish + // that, which is the same thing `audit --for-publication` reports with + // `Exit::Broken` over a surface it could not read. + if !unchecked.is_empty() { + return Err(Fatal::new(format!( + "{}: {} pin(s) could not be checked, so this guard established nothing about \ + them:\n{}\n\nCould not look is not a pass. Restore the remote's reachability, \ + or bypass this run deliberately with UPHOLD_ALLOW={}.", + request.rule.id, + unchecked.len(), + unchecked.join("\n"), + request.rule.id + ))); + } + Ok(None) } #[cfg(test)] @@ -282,35 +499,146 @@ mod tests { assert_eq!(tags.last().unwrap(), "v10.0.0"); } + /// A directory of its own per test, since `read_pins` now walks one. + fn tree(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("uphold-pins-{name}-{}", std::process::id())); + if dir.exists() { + std::fs::remove_dir_all(&dir).unwrap(); + } + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write(dir: &Path, relative: &str, contents: &str) { + let path = dir.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + #[test] fn a_flow_style_config_parses_rather_than_reading_as_empty() { - let dir = std::env::temp_dir().join(format!("uphold-pins-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join(".pre-commit-config.yaml"), + let dir = tree("flow"); + write( + &dir, + ".pre-commit-config.yaml", "{repos: [{repo: \"https://example.test/a\", rev: v1.0.0, hooks: [{id: x}]}]}\n", - ) - .unwrap(); - let pins = read_pins(&dir).unwrap(); + ); assert_eq!( - pins, + read_pins(&dir).unwrap().pins, vec![Pin { repo: "https://example.test/a".to_owned(), - rev: "v1.0.0".to_owned() + rev: "v1.0.0".to_owned(), + source: PRE_COMMIT_CONFIG.to_owned(), }] ); } #[test] fn a_local_repo_has_no_pin_to_check() { - let dir = std::env::temp_dir().join(format!("uphold-pins-l-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join(".pre-commit-config.yaml"), + let dir = tree("local"); + write( + &dir, + ".pre-commit-config.yaml", "repos:\n - repo: local\n hooks:\n - id: x\n", - ) - .unwrap(); - assert!(read_pins(&dir).unwrap().is_empty()); + ); + assert!(read_pins(&dir).unwrap().pins.is_empty()); + } + + /// The documented lefthook-only install path is not a broken repository. + /// + /// `read_pins` opened `root/.pre-commit-config.yaml` unconditionally and + /// `read_to_string` turns ENOENT into a `Fatal`, so this guard exited 2 for + /// every consumer who installed the way the documentation tells them to. + #[test] + fn an_absent_pre_commit_config_is_an_answer_and_not_an_error() { + let dir = tree("absent"); + let read = read_pins(&dir).unwrap(); + assert!(read.pins.is_empty()); + assert_eq!(read.notes.len(), 1, "{:?}", read.notes); + assert!( + read.notes + .first() + .is_some_and(|note| note.contains("lefthook")), + "{:?}", + read.notes + ); + } + + /// A pin in `sub/` is a pin a run touches. + /// + /// The retired upstream read every `.pre-commit-config.yaml` in the work + /// tree and this read only the root one, so a monorepo with a config per + /// package had exactly one of them checked. + #[test] + fn a_config_below_the_root_is_read_too() { + let dir = tree("nested"); + write( + &dir, + ".pre-commit-config.yaml", + "repos:\n - repo: https://example.test/a\n rev: v1.0.0\n hooks:\n - id: x\n", + ); + write( + &dir, + "sub/.pre-commit-config.yaml", + "repos:\n - repo: https://example.test/b\n rev: v2.0.0\n hooks:\n - id: y\n", + ); + let pins = read_pins(&dir).unwrap().pins; + assert_eq!(pins.len(), 2, "{pins:?}"); + assert!( + pins.iter() + .any(|pin| pin.repo == "https://example.test/b" && pin.source.contains("sub")), + "{pins:?}" + ); + } + + /// A config with no `repos:` is not a config with no pins in it. + #[test] + fn a_config_without_a_repos_key_is_unreadable_rather_than_empty() { + let dir = tree("norepos"); + write( + &dir, + ".pre-commit-config.yaml", + "default_stages: [commit]\n", + ); + let error = read_pins(&dir).unwrap_err().to_string(); + assert!(error.contains("repos"), "{error}"); + assert!(error.contains("could-not-look"), "{error}"); + } + + /// The one version a lefthook consumer pins, which nothing read. + #[test] + fn a_lefthook_remote_is_a_pin() { + let dir = tree("lefthook"); + write( + &dir, + "lefthook.yml", + "remotes:\n - git_url: https://example.test/hooks\n ref: v1.2.3\n configs:\n - lefthook.yml\n", + ); + let read = read_pins(&dir).unwrap(); + assert_eq!( + read.pins, + vec![Pin { + repo: "https://example.test/hooks".to_owned(), + rev: "v1.2.3".to_owned(), + source: "lefthook.yml".to_owned(), + }] + ); + } + + /// A lefthook remote with no `ref:` follows the default branch, which is the + /// moving target the `rev:` arm refuses in the same words. + #[test] + fn a_lefthook_remote_with_no_ref_is_not_a_pin() { + let dir = tree("lefthook-unpinned"); + write( + &dir, + "lefthook.yml", + "remotes:\n - git_url: https://example.test/hooks\n configs:\n - lefthook.yml\n", + ); + let error = read_pins(&dir).unwrap_err().to_string(); + assert!(error.contains("no `ref:`"), "{error}"); } /// A prerelease precedes the release it leads to. diff --git a/tests/hook_pins_cli.rs b/tests/hook_pins_cli.rs new file mode 100644 index 0000000..d6a94a2 --- /dev/null +++ b/tests/hook_pins_cli.rs @@ -0,0 +1,232 @@ +//! CLI-level tests for `no-stale-hook-pins`. +//! +//! Driven through the binary rather than through `pins::stale`, because the +//! thing under test in most of these is the EXIT CODE, and the exit code is the +//! one part of a guard a caller reads. A pin nobody could check reported itself +//! on stderr and exited 0 for exactly as long as nothing asserted on the number. + +#![expect( + clippy::let_underscore_must_use, + clippy::tests_outside_test_module, + clippy::unwrap_used, + reason = "A CLI test asserts on the outcome; a panic in the harness that builds the fixture IS the failure report, and there is no caller to hand a Result to" +)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const POLICY: &str = r#" +[rule.no-stale-hook-pins] +builtin = "no-stale-hook-pins" + +[rule.no-stale-hook-pins.git] +hooks = ["pre-push", "manual"] +"#; + +fn repository() -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "uphold-pins-cli-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("policy")).unwrap(); + std::fs::write(root.join("policy/principles.toml"), POLICY).unwrap(); + git(&root, &["init", "-q", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.test"]); + root +} + +/// A local repository standing in for the upstream, so these need no network +/// and no forge. `git ls-remote` reads a path exactly as it reads a URL. +fn upstream(root: &Path, tags: &[&str]) -> String { + let upstream = root.join("upstream"); + std::fs::create_dir_all(&upstream).unwrap(); + git(&upstream, &["init", "-q", "-b", "main"]); + git(&upstream, &["config", "user.name", "Test"]); + git(&upstream, &["config", "user.email", "test@example.test"]); + std::fs::write(upstream.join("a.txt"), "x\n").unwrap(); + git(&upstream, &["add", "-A"]); + git(&upstream, &["commit", "-qm", "one", "--no-verify"]); + for tag in tags { + git(&upstream, &["tag", tag]); + } + upstream.to_string_lossy().into_owned() +} + +fn git(root: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); +} + +fn guard(root: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_uphold")) + .args(["guard", "--stage", "manual"]) + .current_dir(root) + .env_remove("UPHOLD_ALLOW") + .output() + .unwrap() +} + +fn text(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// A pin nobody could check is not a pin that is up to date. +/// +/// `remote_refs` returns `Ok(None)` for a remote it could not reach and its +/// comment says that is never a pass -- and then the caller made it one. The +/// pin went into `unchecked`, `unchecked` was printed and dropped, and +/// `guard::run` counted the guard among the ones that passed and exited 0. A +/// network that was down, a token that had expired and a remote that had been +/// renamed all read as a current pin. +#[test] +fn a_pin_whose_remote_cannot_be_reached_is_could_not_look_and_not_a_pass() { + let root = repository(); + let nowhere = root.join("no-such-upstream"); + write( + &root, + ".pre-commit-config.yaml", + &format!( + "repos:\n - repo: {}\n rev: v1.0.0\n hooks:\n - id: x\n", + nowhere.display() + ), + ); + + let output = guard(&root); + let report = text(&output); + assert_eq!( + output.status.code().unwrap(), + 2, + "could not look is exit 2:\n{report}" + ); + assert!(report.contains("could not be checked"), "{report}"); + assert!(report.contains("Could not look is not a pass"), "{report}"); +} + +/// The documented lefthook-only install path is not a broken repository. +/// +/// `read_pins` opened `root/.pre-commit-config.yaml` unconditionally and +/// `read_to_string` turns ENOENT into a `Fatal`, so this guard exited 2 for +/// every consumer who installed the way the documentation tells them to. +#[test] +fn a_tree_with_no_pre_commit_config_passes_and_says_why() { + let root = repository(); + let output = guard(&root); + let report = text(&output); + assert_eq!(output.status.code().unwrap(), 0, "{report}"); + assert!(report.contains(".pre-commit-config.yaml"), "{report}"); + assert!(report.contains("lefthook-only"), "{report}"); +} + +/// The one version a lefthook consumer pins, which nothing was reading. +/// +/// A `remotes:` entry is a pin in every sense this guard means: it names +/// another repository's hook definitions and a ref to fetch them at. It was +/// invisible here and there is no Dependabot ecosystem for it either, so it was +/// the single pin in a lefthook tree with nobody watching it. +#[test] +fn a_lefthook_remote_is_checked_like_any_other_pin() { + let root = repository(); + let url = upstream(&root, &["v1.0.0", "v2.0.0"]); + write( + &root, + "lefthook.yml", + &format!( + "remotes:\n - git_url: {url}\n ref: v1.0.0\n configs:\n - lefthook.yml\n" + ), + ); + + let output = guard(&root); + let report = text(&output); + assert_eq!(output.status.code().unwrap(), 1, "{report}"); + assert!(report.contains("v2.0.0 is newer"), "{report}"); + assert!(report.contains("lefthook.yml"), "{report}"); +} + +/// A pin in `sub/` is a pin a run touches. +/// +/// The retired upstream read every `.pre-commit-config.yaml` in the work tree +/// and this read only the root one, so a monorepo with a config per package had +/// exactly one of them checked -- and which one depended on where the file +/// happened to sit. +#[test] +fn a_config_below_the_root_is_checked_too() { + let root = repository(); + let url = upstream(&root, &["v1.0.0", "v2.0.0"]); + write( + &root, + ".pre-commit-config.yaml", + &format!("repos:\n - repo: {url}\n rev: v2.0.0\n hooks:\n - id: x\n"), + ); + write( + &root, + "sub/.pre-commit-config.yaml", + &format!("repos:\n - repo: {url}\n rev: v1.0.0\n hooks:\n - id: y\n"), + ); + + let output = guard(&root); + let report = text(&output); + assert_eq!( + output.status.code().unwrap(), + 1, + "the root config is current and the nested one is not:\n{report}" + ); + assert!(report.contains("v2.0.0 is newer"), "{report}"); + assert!( + report.contains("sub/.pre-commit-config.yaml"), + "the report has to name the file holding the stale pin:\n{report}" + ); +} + +/// Zero pins and "this is not a file pins can be read out of" are different +/// answers, and only one of them is something a reader can act on. +#[test] +fn a_config_with_no_repos_key_is_unreadable_rather_than_empty() { + let root = repository(); + write( + &root, + ".pre-commit-config.yaml", + "default_stages: [commit]\n", + ); + + let output = guard(&root); + let report = text(&output); + assert_eq!(output.status.code().unwrap(), 2, "{report}"); + assert!(report.contains("`repos:`"), "{report}"); +} + +/// The behaviour every change above had to leave alone. +#[test] +fn a_current_pin_still_passes() { + let root = repository(); + let url = upstream(&root, &["v1.0.0"]); + write( + &root, + ".pre-commit-config.yaml", + &format!("repos:\n - repo: {url}\n rev: v1.0.0\n hooks:\n - id: x\n"), + ); + let output = guard(&root); + assert_eq!(output.status.code().unwrap(), 0, "{}", text(&output)); +} diff --git a/tests/test_hook_pins.py b/tests/test_hook_pins.py deleted file mode 100644 index d4b87c8..0000000 --- a/tests/test_hook_pins.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Behaviour tests for the hook-pin resolvability check. - -Two halves, for two different reasons. - -The reader is tested in-process because its contract is "model this shape, -refuse everything else by name and line" -- a refusal that does not say where -is the same silent skip a regex would have made, so the tests assert the line -number, not just the exit. - -The tool is tested through a subprocess against a real local git repository, -the way a hook runner invokes it. Local paths are remotes as far as -`git ls-remote` is concerned, so the whole path -- parse, ask, exit -- runs with -no network and no fixture pretending to be one. The exit-code contract is the -interface: 0 resolves, 1 does not exist, 2 could not look. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "check_hook_pins.py" - -sys.path.insert(0, str(ROOT / "scripts")) - -import check_hook_pins # noqa: E402 -from check_hook_pins import Pin, Unreadable, read_pins, resolve_pin # noqa: E402 - -GOOD = """\ -repos: - - repo: {remote} - rev: v1.0.0 - hooks: - - id: something - - repo: local - hooks: - - id: mine - entry: echo -""" - - -def parse(text: str) -> list[Pin]: - return read_pins(text, Path("config.yaml")) - - -def run(*args: str, **env: str) -> subprocess.CompletedProcess: - environ = dict(os.environ) - environ.update(env) - return subprocess.run( - [sys.executable, str(SCRIPT), *args], - capture_output=True, - text=True, - env=environ, - check=False, - ) - - -def git(*args: str, cwd: Path) -> None: - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def make_remote(directory: Path) -> Path: - """A real repository with one tag, v1.0.0, and no v1.1.0.""" - repo = directory / "remote" - repo.mkdir() - git("init", "-q", "-b", "main", cwd=repo) - git("config", "user.email", "test@example.invalid", cwd=repo) - git("config", "user.name", "test", cwd=repo) - (repo / "README").write_text("x", encoding="utf-8") - git("add", "README", cwd=repo) - git("commit", "-qm", "seed", cwd=repo) - git("tag", "v1.0.0", cwd=repo) - return repo - - -class Reader(unittest.TestCase): - def test_reads_repo_rev_and_the_line_the_rev_is_on(self): - pins = parse(GOOD.format(remote="https://example.invalid/a")) - self.assertEqual(pins[0].repo, "https://example.invalid/a") - self.assertEqual(pins[0].rev, "v1.0.0") - self.assertEqual(pins[0].line, 3) - - def test_local_entry_is_kept_with_no_rev(self): - """Counted, not dropped: a run must be able to state its denominator.""" - pins = parse(GOOD.format(remote="https://example.invalid/a")) - self.assertEqual( - [pin.repo for pin in pins], ["https://example.invalid/a", "local"] - ) - self.assertEqual(pins[1].rev, "") - - def test_a_rev_inside_hook_args_is_not_a_pin(self): - pins = parse( - "repos:\n" - " - repo: https://example.invalid/a\n" - " rev: v1.0.0\n" - " hooks:\n" - " - id: x\n" - " args: [--rev, 'rev: v9.9.9']\n" - ) - self.assertEqual([pin.rev for pin in pins], ["v1.0.0"]) - - def test_comments_and_quotes_do_not_reach_the_rev(self): - pins = parse( - "repos:\n" - " - repo: https://example.invalid/a\n" - " rev: 'v1.0.0' # pinned deliberately\n" - " hooks:\n" - " - id: x\n" - ) - self.assertEqual(pins[0].rev, "v1.0.0") - - def test_remote_repo_without_a_rev_is_unreadable(self): - with self.assertRaises(Unreadable) as caught: - parse( - "repos:\n - repo: https://example.invalid/a\n hooks:\n - id: x\n" - ) - self.assertIn("has no rev", str(caught.exception)) - - def test_refusals_name_the_line(self): - cases = { - "flow-style": "repos: [{repo: a, rev: v1}]\n", - "a second `repos:` key": ( - "repos:\n - repo: a\n rev: v1\nother: 1\nrepos:\n - repo: b\n rev: v2\n" - ), - "a second `rev:` in one entry": ( - "repos:\n - repo: a\n rev: v1\n rev: v2\n" - ), - "an anchor": "repos:\n - repo: a\n rev: &pin v1\n", - "an alias": "repos:\n - repo: a\n rev: *pin\n", - "tab indentation": "repos:\n\t- repo: a\n\t rev: v1\n", - "a second document": "repos:\n - repo: a\n rev: v1\n---\nrepos:\n - repo: b\n", - } - for expected, text in cases.items(): - with self.subTest(expected): - with self.assertRaises(Unreadable) as caught: - parse(text) - message = str(caught.exception) - self.assertIn(expected, message) - self.assertRegex(message, r"config\.yaml:\d+") - - def test_a_pin_outside_the_repos_block_is_refused_not_skipped(self): - with self.assertRaises(Unreadable) as caught: - parse( - "repos:\n" - " - repo: https://example.invalid/a\n" - " rev: v1.0.0\n" - "ci:\n" - " - repo: https://example.invalid/b\n" - " rev: v2.0.0\n" - ) - self.assertIn("outside the `repos:` block", str(caught.exception)) - - def test_a_file_with_no_repos_key_is_unreadable(self): - with self.assertRaises(Unreadable): - parse("fail_fast: true\n") - - -class Resolution(unittest.TestCase): - """`explicit-unknown`: three answers, and unchecked is not ok.""" - - pin = Pin("https://example.invalid/a", "v1.0.0", Path("config.yaml"), 3) - - def test_a_matching_ref_is_ok(self): - outcome = resolve_pin(self.pin, lambda args: (0, "abc\trefs/tags/v1.0.0\n", "")) - self.assertEqual(outcome.state, "ok") - - def test_no_matching_ref_is_the_finding(self): - outcome = resolve_pin(self.pin, lambda args: (0, "", "")) - self.assertEqual(outcome.state, "missing") - - def test_an_unreachable_remote_is_unchecked_not_missing(self): - outcome = resolve_pin( - self.pin, lambda args: (128, "", "could not read from remote") - ) - self.assertEqual(outcome.state, "unchecked") - self.assertIn("could not read", outcome.detail) - - def test_a_sha_that_is_not_a_ref_tip_is_unchecked_not_missing(self): - pin = Pin("https://example.invalid/a", "a" * 40, Path("config.yaml"), 3) - outcome = resolve_pin( - pin, lambda args: (0, "b" * 40 + "\trefs/heads/main\n", "") - ) - self.assertEqual(outcome.state, "unchecked") - - def test_a_sha_at_a_ref_tip_is_ok(self): - pin = Pin("https://example.invalid/a", "a" * 40, Path("config.yaml"), 3) - outcome = resolve_pin( - pin, lambda args: (0, "a" * 40 + "\trefs/heads/main\n", "") - ) - self.assertEqual(outcome.state, "ok") - - -class ExitCodeContract(unittest.TestCase): - def config_for(self, directory: Path, remote: str, rev: str) -> Path: - path = directory / "config.yaml" - path.write_text( - GOOD.format(remote=remote).replace("rev: v1.0.0", f"rev: {rev}"), - encoding="utf-8", - ) - return path - - def test_an_existing_tag_exits_zero(self): - with tempfile.TemporaryDirectory() as tmp: - remote = make_remote(Path(tmp)) - config = self.config_for(Path(tmp), str(remote), "v1.0.0") - result = run(str(config)) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("names a ref that exists", result.stdout) - - def test_a_tag_that_was_never_cut_exits_one(self): - """The failure from issue #6: a pin bumped ahead of any release.""" - with tempfile.TemporaryDirectory() as tmp: - remote = make_remote(Path(tmp)) - config = self.config_for(Path(tmp), str(remote), "v1.1.0") - result = run(str(config)) - self.assertEqual(result.returncode, 1, result.stdout) - self.assertIn("has no ref 'v1.1.0'", result.stderr) - self.assertIn("config.yaml:3", result.stderr) - - def test_a_deleted_tag_exits_one_with_nothing_changed_locally(self): - with tempfile.TemporaryDirectory() as tmp: - remote = make_remote(Path(tmp)) - config = self.config_for(Path(tmp), str(remote), "v1.0.0") - self.assertEqual(run(str(config)).returncode, 0) - git("tag", "-d", "v1.0.0", cwd=remote) - result = run(str(config)) - self.assertEqual(result.returncode, 1, result.stdout) - - def test_an_unreachable_remote_exits_two_not_zero(self): - with tempfile.TemporaryDirectory() as tmp: - config = self.config_for(Path(tmp), str(Path(tmp) / "nowhere"), "v1.0.0") - result = run(str(config)) - self.assertEqual(result.returncode, 2, result.stdout) - self.assertIn("Cannot look is not resolves", result.stderr) - - def test_unchecked_is_downgraded_only_when_somebody_asks(self): - with tempfile.TemporaryDirectory() as tmp: - config = self.config_for(Path(tmp), str(Path(tmp) / "nowhere"), "v1.0.0") - result = run(str(config), CATALOG_ALLOW_UNCHECKED_PINS="1") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("is set, so this is a note", result.stderr) - - def test_an_unreadable_config_exits_two(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "config.yaml" - path.write_text("repos: [{repo: a, rev: v1}]\n", encoding="utf-8") - result = run(str(path)) - self.assertEqual(result.returncode, 2, result.stdout) - self.assertIn("could not read", result.stderr) - - def test_a_finding_outranks_an_unresolved_one(self): - with tempfile.TemporaryDirectory() as tmp: - remote = make_remote(Path(tmp)) - path = Path(tmp) / "config.yaml" - path.write_text( - "repos:\n" - f" - repo: {remote}\n" - " rev: v1.1.0\n" - f" - repo: {Path(tmp) / 'nowhere'}\n" - " rev: v1.0.0\n", - encoding="utf-8", - ) - result = run(str(path)) - self.assertEqual(result.returncode, 1, result.stdout) - - def test_no_config_at_all_exits_two(self): - with tempfile.TemporaryDirectory() as tmp: - result = run(str(Path(tmp) / "absent.yaml")) - self.assertEqual(result.returncode, 2, result.stdout) - - -class SelfApplication(unittest.TestCase): - def test_this_repository_declares_the_pins_it_reads(self): - """The reader must find every pin in the config this repo actually ships.""" - text = (ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8") - pins = read_pins(text, ROOT / ".pre-commit-config.yaml") - remotes = [ - pin for pin in pins if pin.repo not in check_hook_pins.NON_REMOTE_REPOS - ] - self.assertTrue(remotes) - for pin in remotes: - self.assertTrue(pin.rev, f"{pin.repo} has an empty rev") - - -if __name__ == "__main__": - unittest.main() From c09ca8c71ae9e4115d5c8868daf6acce9a4c2d2a Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:30:16 +0900 Subject: [PATCH 05/21] Read the seams a repository publishes, not the ids it happens to pin The reconciler answered "does this rule run here" from the `- id:` lines in a runner's config, which is a list of what a repository PINNED rather than a list of what runs. Pinning the reconciler itself -- `uphold-check`, whose entry is this script -- was therefore evidence that content rules ran, and a guard claim was supplied by any pinned id at all rather than by the id that installs the stage the guard fires at. Evidence now comes from the `entry:` lines: an id counts when it actually runs `uphold scan` or `uphold guard --stage X`, the stage-to-id map is read out of those entries rather than written down beside them, and a rule is supplied only where a seam it declares is installed. A pre-push guard claim in a repository pinning only uphold-scan is refused, which is what it always should have been. `[inherit]` has three fields and this script read one. `inherit.paths` names a repository's own extra policy files, which config::load merges exactly as it merges the bundled sets, so every rule arriving that way was invisible and a claim on one was refused as supplied by nothing while the engine was running it -- a false negative in the direction that costs most, because the answer a person acts on is to delete a claim that was true. All three fields are read now, merged in the engine's own order. It is still a second reader of what config::load already resolves. The engine can be asked directly since `uphold rules --effective --json`, and the reason this script does not call it is written on the function: it is the hook other repositories install, and two of the three runners keep the binary inside their own environment directory rather than on PATH, so shelling out would turn a working reconcile into exit 2 for them. What keeps the duplication honest instead is a test that fails when the two readers disagree about this repository's own policy. Four smaller refusals, all of the same family. A lefthook key was read as a command by its indentation, so the `configs:` key README.md tells every lefthook consumer to write under `remotes:` was reported as a rule named `configs`; only valueless keys whose parent key is `commands:` count now. A file that is not UTF-8 is exit 2 with a sentence rather than a traceback at exit 1, for both the text and the TOML readers. The review settings are validated field by field instead of coerced, so a max_lines of "many" names the field it came from; and an `emit` name that is absolute, contains `..`, or resolves outside the root is refused before anything is rendered. Finally the coverage numerator no longer counts a claim the same report has just listed as supplied by nothing here. --- tests/test_review.py | 128 +++++++++++ tests/test_uphold_check.py | 317 ++++++++++++++++++++++++++- uphold_check.py | 432 ++++++++++++++++++++++++++++++++----- 3 files changed, 811 insertions(+), 66 deletions(-) diff --git a/tests/test_review.py b/tests/test_review.py index 48de297..77be8ac 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -147,6 +147,134 @@ def test_no_field_beyond_those_three_crosses_over(self): self.assertNotIn("the rationale", document) +class Settings(unittest.TestCase): + """`[review]` is configuration, so a field of the wrong type is exit 2. + + Two of the four fields were read by coercion -- `int(...)` and `list(...)` + -- which turns a wrong type into a traceback and exit 1, and exit 1 in this + tool means a claim is false. A declaration this tool could not read is + could-not-look; see the `explicit-unknown` record. + """ + + def review(self, body: str, *args: str) -> subprocess.CompletedProcess: + policy = Path(self.tmp) / "policy" + policy.mkdir(exist_ok=True) + (policy / "upheld.toml").write_text(textwrap.dedent(body), encoding="utf-8") + return subprocess.run( + [sys.executable, str(SCRIPT), "--review", *args], + cwd=self.tmp, + capture_output=True, + text=True, + check=False, + ) + + def setUp(self): + self._directory = tempfile.TemporaryDirectory() + self.tmp = self._directory.name + self.addCleanup(self._directory.cleanup) + + def test_a_max_lines_that_is_not_a_number_is_two_not_a_traceback(self): + result = self.review( + """ + [review] + max_lines = "nine hundred" + """ + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("review.max_lines", result.stderr) + + def test_a_max_lines_of_zero_is_refused_rather_than_silently_impossible(self): + result = self.review( + """ + [review] + max_lines = 0 + """ + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("review.max_lines", result.stderr) + + def test_include_domains_that_is_not_a_list_of_names_is_two(self): + result = self.review( + """ + [review] + include_domains = "security" + """ + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("review.include_domains", result.stderr) + + def test_an_emit_entry_that_is_not_a_file_name_is_two(self): + result = self.review( + """ + [review] + emit = [7] + """ + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("review.emit", result.stderr) + + def test_an_emit_name_that_escapes_the_repository_writes_nothing(self): + """`emit` is a name from the declaration handed straight to write_text. + + `emit = ["../ESCAPED.md"]` created a file one level ABOVE the repository + and reported "wrote ../ESCAPED.md" as though that were what was asked + for. A hook runs this unattended; the one place it may write is the + repository it describes. + """ + # `include_domains` names a domain no record carries, so nothing routes + # and nothing is refused before the write is reached. + result = self.review( + """ + [review] + include_domains = ["no-such-domain"] + emit = ["../ESCAPED.md"] + """, + "--emit", + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertFalse((Path(self.tmp).parent / "ESCAPED.md").exists()) + self.assertNotIn("wrote", result.stdout) + + def test_an_absolute_emit_name_writes_nothing(self): + target = Path(self.tmp) / "outside.md" + result = self.review( + f""" + [review] + include_domains = ["no-such-domain"] + emit = ["{target}"] + """, + "--emit", + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertFalse(target.exists()) + + def test_an_emit_name_under_a_directory_that_is_not_there_is_two_not_one(self): + """A missing parent is could-not-do-it, not a false claim.""" + result = self.review( + """ + [review] + include_domains = ["no-such-domain"] + emit = ["generated/REVIEW.md"] + """, + "--emit", + ) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("could not write", result.stderr) + + def test_an_emit_name_inside_the_repository_is_written(self): + """The refusals above are a narrower door, not a closed one.""" + result = self.review( + """ + [review] + include_domains = ["no-such-domain"] + emit = ["REVIEW.md"] + """, + "--emit", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((Path(self.tmp) / "REVIEW.md").is_file()) + + class SelfApplication(unittest.TestCase): def test_this_repository_routes_cleanly(self): result = subprocess.run( diff --git a/tests/test_uphold_check.py b/tests/test_uphold_check.py index a08f447..2e67d0a 100644 --- a/tests/test_uphold_check.py +++ b/tests/test_uphold_check.py @@ -29,12 +29,18 @@ # `content-policy`, which is a command in this repository's own lefthook.yml and # in no consumer's config anywhere, so it asserted a shape only this repository # had. +# +# One id per stage, and the fixture pins the stages the policy fixtures below +# declare. A guard id installs exactly ONE git stage, so pinning `uphold-scan` +# and `uphold-guard-push` is evidence about the file scan and about pre-push and +# about nothing else. PRE_COMMIT_WITH_PRINCIPLES = """\ repos: - repo: https://github.com/HackingGate/uphold rev: v2.0.0 hooks: - id: uphold-scan + - id: uphold-guard-commit-msg - id: uphold-guard-push - repo: local hooks: @@ -44,12 +50,24 @@ # The same repository, run by lefthook instead: no ids, no pins, just this # repository named as a remote. The seam has to be visible from here too. +# +# The `configs:` key is the shape README.md tells every lefthook consumer to +# write, and it is a valueless mapping key at exactly the indent a command name +# sits at -- so a scan keyed on indentation read it as a command called +# `configs`, and a claim naming that rule reconciled green against a file that +# defines no such thing. The real command below is what a command name looks +# like, and the two have to be told apart from here. LEFTHOOK_WITH_PRINCIPLES = """\ remotes: - git_url: https://github.com/HackingGate/uphold ref: v2.0.0 configs: - hooks/lefthook.yml + +pre-commit: + commands: + my-own-check: + run: ./check.sh """ # One guard, declared the way a repository declares one. @@ -155,6 +173,43 @@ def test_absent_tier_config_is_two_not_one(self): self.assertEqual(result.returncode, 2, result.stderr) self.assertIn("could not look", result.stderr) + def test_a_declaration_that_is_not_utf8_is_two_not_a_traceback(self): + """One byte that is not UTF-8 is a file this tool could not read. + + `UnicodeDecodeError` derives from `ValueError` and not from `OSError`, + so it escaped the handler that catches an unreadable file and left the + process on a traceback and exit 1 -- and exit 1 in this tool means a + claim is false. A repository whose declaration is mis-encoded was + reported as a repository that lies about what it enforces. + """ + with tempfile.TemporaryDirectory() as tmp: + build(Path(tmp), "# placeholder\n") + (Path(tmp) / "policy" / "upheld.toml").write_bytes( + b'[[enforce]]\nprinciple = "\xff"\n' + ) + result = run(Path(tmp)) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("could not look", result.stderr) + + def test_a_policy_file_that_is_not_utf8_is_two_not_a_traceback(self): + """The same byte in the file the claim is reconciled against.""" + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "fail-safe-defaults" + rule = "prevent-public-push" + """, + **{".pre-commit-config.yaml": PRE_COMMIT_WITH_PRINCIPLES}, + ) + (Path(tmp) / "policy" / "principles.toml").write_bytes( + b'[rule.prevent-public-push]\nmessage = "\xff"\n' + ) + result = run(Path(tmp)) + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("could not look", result.stderr) + def test_empty_declaration_is_zero(self): with tempfile.TemporaryDirectory() as tmp: build(Path(tmp), "# nothing enforced yet\n") @@ -207,20 +262,23 @@ def test_a_lefthook_consumer_reconciles_with_no_pre_commit_config(self): self.assertIn("enforced by uphold", result.stdout) def test_the_seam_is_found_by_a_published_id_not_by_one_repositorys_name(self): - """Every published hook id has to make the seam visible. + """Every published guard id has to make its own stage visible. The predicate was a single literal hook name that only this repository used, so a consumer pinning the ids this repository publishes was told the seam supplying every guard was absent. The manifest is the list of - ids now, so a new id cannot be added there and forgotten here. + ids now, so a new id cannot be added there and forgotten here -- and the + stage is read from the same manifest, so the pair + `pre-push -> uphold-guard-push` cannot drift either. """ - published = uphold_check.published_hook_ids() - self.assertIn("uphold-scan", published) - self.assertTrue( - {"uphold-guard", "uphold-guard-push"} <= published, - f"guard ids are not published: {sorted(published)}", + scans, guards = uphold_check.published_seams() + self.assertIn("uphold-scan", scans) + self.assertEqual( + guards.get("pre-push"), + "uphold-guard-push", + f"the published guard ids are {guards}", ) - for hook_id in sorted(published): + for stage, hook_id in sorted(guards.items()): with tempfile.TemporaryDirectory() as tmp: build( Path(tmp), @@ -237,13 +295,221 @@ def test_the_seam_is_found_by_a_published_id_not_by_one_repositorys_name(self): " hooks:\n" f" - id: {hook_id}\n" ), - "policy__principles.toml": GUARD_POLICY, + "policy__principles.toml": ( + "[rule.prevent-public-push]\n" + 'builtin = "prevent-public-push"\n' + f'git.hooks = ["{stage}"]\n' + ), }, ) result = run(Path(tmp)) self.assertEqual(result.returncode, 0, f"{hook_id}: {result.stderr}") self.assertIn("enforced by uphold", result.stdout, hook_id) + def test_the_reconciler_s_own_id_is_not_evidence_that_a_rule_runs(self): + """`uphold-check` runs this script, which enforces nothing. + + While every id in the manifest counted as evidence, a repository that + pinned the reconciler and nothing else was accepted as proof that every + content rule and every guard fires here -- the reconciler certifying + itself, and printing "reconciled 1 enforcement claims" over a repository + running no rule at all. + """ + self.assertNotIn("uphold-check", uphold_check.published_hook_ids()) + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "fail-safe-defaults" + rule = "prevent-public-push" + """, + **{ + ".pre-commit-config.yaml": ( + "repos:\n" + " - repo: https://github.com/HackingGate/uphold\n" + " rev: v2.0.0\n" + " hooks:\n" + " - id: uphold-check\n" + ), + "policy__principles.toml": GUARD_POLICY, + }, + ) + result = run(Path(tmp)) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("no seam here supplies", result.stderr) + + def test_a_guard_claim_fails_when_the_stage_it_fires_at_is_not_installed(self): + """A guard id installs one stage, and `uphold-scan` installs none. + + The seam was one repository-wide yes/no, so a repository that pinned + `uphold-scan` -- the file scan, which runs no guard -- reconciled a + claim on a rule declaring `git.hooks = ["pre-push"]`. The rule ran + nowhere: what installs it is `uphold-guard-push`, which nothing here + pinned. + """ + for hooks, expected in (("uphold-scan", 1), ("uphold-guard-push", 0)): + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "fail-safe-defaults" + rule = "prevent-public-push" + """, + **{ + ".pre-commit-config.yaml": ( + "repos:\n" + " - repo: https://github.com/HackingGate/uphold\n" + " rev: v2.0.0\n" + " hooks:\n" + f" - id: {hooks}\n" + ), + "policy__principles.toml": GUARD_POLICY, + }, + ) + result = run(Path(tmp)) + self.assertEqual(result.returncode, expected, f"{hooks}: {result.stderr}") + + def test_a_rule_inherited_through_inherit_paths_is_supplied(self): + """`[inherit]` has three fields and the reader used to see one. + + `inherit.paths` names the repository's own extra policy files, which + `config::load` merges exactly as it merges the bundled sets. Reading + only `inherit.sets` made every rule arriving that way invisible, so a + claim on one was refused as supplied by nothing while the engine was + running it -- and the action a person takes on that answer is to delete + a claim that was true. + """ + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "single-authoritative-source" + rule = "no-merge-conflict-markers" + """, + **{ + ".pre-commit-config.yaml": LOCAL_CONTENT_POLICY, + "policy__extra.toml": HYGIENE_BASE, + "policy__principles.toml": ( + '[inherit]\npaths = ["policy/extra.toml"]\n' + ), + }, + ) + result = run(Path(tmp)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("enforced by uphold", result.stdout) + + def test_the_two_readers_of_the_policy_agree(self): + """This script and the engine must resolve the same rules. + + `content_policy_rules` re-implements part of `config::load`: the bundled + sets, `inherit.paths`, `inherit.disabled_rules`, and a repository's own + rule shadowing an inherited id. The engine can now be asked directly -- + `uphold rules --effective --json` -- and the reason this script does not + simply call it is written on that function: it is the hook other + repositories install, and two of the three runners keep the binary + inside their own environment directory rather than on PATH. + + So the duplication stays, and this is what keeps it honest. Every field + the two readers disagree about is a rule reported to run where it does + not, or the other way round, and the answer a person acts on is to + delete a claim that was true. Asked of THIS repository's policy, which + is the one tree that exercises inheritance, disabling and shadowing at + once. + + Skipped where the binary has not been built, because a test that needs + a `cargo build` to be meaningful must not report a red suite to somebody + who has not run one. + """ + binary = ROOT / "target" / "debug" / "uphold" + if not binary.is_file(): + self.skipTest(f"{binary} is not built; `cargo build` first") + answered = subprocess.run( + [str(binary), "rules", "--effective", "--json"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(answered.returncode, 0, answered.stderr) + engine = { + entry["id"]: set(entry["git_hooks"]) + for entry in json.loads(answered.stdout) + } + declared, disabled, _sets, _paths = uphold_check.content_policy_rules(ROOT) + here = { + rule: stages for rule, stages in declared.items() if rule not in disabled + } + self.assertEqual(here, engine) + + def test_inherit_paths_naming_a_file_that_is_not_there_is_two_not_one(self): + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "single-authoritative-source" + rule = "no-merge-conflict-markers" + """, + **{ + ".pre-commit-config.yaml": LOCAL_CONTENT_POLICY, + "policy__principles.toml": ( + '[inherit]\npaths = ["policy/gone.toml"]\n' + ), + }, + ) + result = run(Path(tmp)) + coverage = run(Path(tmp), "--coverage") + # A policy file the engine merges and this reader cannot open is a seam + # that could not be read, which is exit 2 at both ends -- and the + # coverage report is where the file that could not be opened is named. + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn("could not look", result.stderr) + self.assertEqual(coverage.returncode, 2, coverage.stdout) + self.assertIn("inherit.paths", coverage.stdout) + + def test_a_lefthook_key_that_is_not_a_command_is_not_a_rule(self): + """`configs:` under `remotes:` is not a rule called `configs`. + + README.md tells every lefthook consumer to write that key, at exactly + the indent a command name sits at, so a scan keyed on indentation + accepted a claim on it -- a green reconcile over a rule that exists + nowhere. What makes a command name a command name is `commands:` above + it, which is what the scan reads now. + """ + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "fail-safe-defaults" + rule = "configs" + """, + **{ + "lefthook.yml": LEFTHOOK_WITH_PRINCIPLES, + "policy__principles.toml": GUARD_POLICY, + }, + ) + refused = run(Path(tmp)) + + build( + Path(tmp), + """ + [[enforce]] + principle = "fail-safe-defaults" + rule = "my-own-check" + """, + ) + command = run(Path(tmp)) + self.assertEqual(refused.returncode, 1, refused.stdout) + self.assertIn("no seam here supplies", refused.stderr) + # The command in the same file, which is a rule, still resolves -- the + # fix is a narrower scan and not a disabled one. + self.assertEqual(command.returncode, 0, command.stderr) + self.assertIn("enforced by local", command.stdout) + def test_a_consumer_inheriting_a_bundled_base_set_can_be_read(self): """`inherit.sets` names a set that ships HERE, not in the consumer. @@ -566,12 +832,16 @@ class Coverage(unittest.TestCase): rule = "prevent-public-push" """ + # Both guard stages the policy below declares are pinned, because a rule + # whose stage nothing installs is a rule that runs nowhere and does not + # belong in this denominator. PRE_COMMIT = """\ repos: - repo: https://github.com/HackingGate/uphold rev: v2.0.0 hooks: - id: uphold-scan + - id: uphold-guard - id: uphold-guard-push - repo: local hooks: @@ -657,6 +927,35 @@ def test_a_false_claim_is_reported_rather_than_refused(self): "claimed but supplied by nothing here: no-such-hook", result.stdout ) + def test_an_orphan_claim_is_not_counted_in_the_number_it_was_reported_under(self): + """The numerator counted the orphans the same report had just named. + + `records: N of M claimable records are claimed by a rule here` is the + one number a reader takes away, and it was computed from the claims + rather than from what any seam supplies -- so a declaration whose only + claim names a rule nothing runs reported one record as claimed by a rule + here, two lines under the line saying that rule is supplied by nothing. + """ + with tempfile.TemporaryDirectory() as tmp: + build( + Path(tmp), + """ + [[enforce]] + principle = "fail-safe-defaults" + rule = "no-such-hook" + """, + **{ + ".pre-commit-config.yaml": self.PRE_COMMIT, + "policy__principles.toml": "", + }, + ) + result = run(Path(tmp), "--coverage") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn( + "claimed but supplied by nothing here: no-such-hook", result.stdout + ) + self.assertIn("records: 0 of ", result.stdout) + def test_it_counts_records_against_what_can_be_claimed(self): result = run(ROOT, "--coverage") self.assertEqual(result.returncode, 0, result.stderr) diff --git a/uphold_check.py b/uphold_check.py index 1f586fc..fa67e2e 100755 --- a/uphold_check.py +++ b/uphold_check.py @@ -98,16 +98,41 @@ # rather than as an absent hook. REPO_LINE = re.compile(r"^\s*-\s*repo:\s*(\S+)") HOOK_ID_LINE = re.compile(r"^\s*-\s*id:\s*(\S+)") -LEFTHOOK_COMMAND = re.compile(r"^\s{4}([A-Za-z0-9._-]+):\s*$") + +# A valueless mapping key: `commands:` itself, a command name under it, or the +# `configs:` key a lefthook consumer writes under `remotes:`. WHICH of those it +# is cannot be read off the line, so the scan below tracks the key that encloses +# it instead of matching on indentation. Matching on indentation alone is what +# made `configs` a command name -- README.md tells every lefthook consumer to +# write that key verbatim at exactly the indent a command name sits at, so a +# claim on a rule called `configs` reconciled green in every repository that +# followed the documentation. +LEFTHOOK_KEY = re.compile(r"^(\s*)([A-Za-z0-9._-]+):\s*(?:#.*)?$") # The ids THIS repository publishes, read from the manifest that publishes them. # # Written out here as a literal it would be an enumeration describing a constant -# in another file -- the exact shape of the bug `_rule_ids` was rewritten to +# in another file -- the exact shape of the bug `_rule_stages` was rewritten to # delete, where a hardcoded list of six table names sat opposite an engine that # had seven and silently under-reported. The manifest is the list; this reads it. PUBLISHED_HOOKS = Path(".pre-commit-hooks.yaml") +# What an id RUNS, which is the part of the manifest that says whether pinning +# it is evidence of anything. `entry:` is the command the runner executes, so +# `uphold scan` is the file scan, `uphold guard --stage ` is the guard at +# exactly one git stage, and `uphold_check.py` is this script -- the reconciler +# itself, which runs no rule and enforces nothing. +# +# Reading the stage out of the entry rather than writing the five pairs down +# here is the same decision as reading the ids: the mapping pre-commit -> +# uphold-guard, commit-msg -> uphold-guard-commit-msg, pre-merge-commit -> +# uphold-guard-merge, pre-push -> uphold-guard-push and manual -> +# uphold-guard-manual is a fact the manifest already states, and a copy of it +# here is the copy that goes stale when a sixth stage is published. +ENTRY_LINE = re.compile(r"^\s+entry:\s*(.+?)\s*$") +ENTRY_GUARD = re.compile(r"(?:\buphold\b|--)\s+guard\b.*?--stage\s+([A-Za-z0-9-]+)") +ENTRY_SCAN = re.compile(r"(?:\buphold\b|--)\s+scan\b") + # A lefthook consumer pins nothing by id. It names this repository under # `remotes:` and lefthook merges the commands in, so the consumer's own file # contains neither an id nor a command name -- only the repository name and, @@ -119,6 +144,12 @@ # all three are the same seam, and a pattern anchored on the program name would # have recognised only the middle one. LEFTHOOK_RUN = re.compile(r"^\s*run:\s*.*(?:\buphold|--)\s+(?:scan|guard)\b") +# Which seam that hand-wired command line is, asked of the same line. A config +# that runs the guards and never runs the scan installs no file rules, and one +# that names `--stage pre-commit` says nothing about pre-push -- the same +# distinction the published ids make, arriving as an argument instead of an id. +LEFTHOOK_RUN_SCAN = re.compile(r"^\s*run:\s*.*(?:\buphold|--)\s+scan\b") +LEFTHOOK_RUN_STAGE = re.compile(r"--stage\s+([A-Za-z0-9-]+)") # Either the repository name or the config path it publishes. The path is the # more reliable of the two: `git_url` may be a mirror, an SSH form, or a local # clone, and none of those has to contain the repository's name -- but a remote @@ -196,17 +227,35 @@ def load_records() -> dict[str, dict]: def read_toml(path: Path) -> dict: + """Parse a TOML file, or say that it could not be parsed. + + `UnicodeDecodeError` for the same reason `read_text` catches it, and it + reaches here by a route that is easy to miss: `tomllib.load` takes a binary + handle and decodes the bytes itself, so a declaration or a policy file with + one byte that is not UTF-8 raises out of the decode rather than out of the + parse, and `TOMLDecodeError` never sees it. + """ try: with path.open("rb") as handle: return tomllib.load(handle) - except (OSError, tomllib.TOMLDecodeError) as error: + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error: raise CouldNotLook(f"{path}: {error}") from error def read_text(path: Path) -> str: + """Read a configuration file, or say that it could not be read. + + `UnicodeDecodeError` is caught beside `OSError` because it is the same + answer arriving by a different route, and it does NOT derive from it: it + derives from `ValueError`, so a config carrying one stray 0xff byte escaped + this handler entirely and left the process on a traceback and exit 1. Every + caller here is a could-not-look path, and the contract reads exit 1 as "a + claim is false" -- so an unreadable byte was reported as a repository whose + declaration lies. See the `explicit-unknown` record. + """ try: return path.read_text(encoding="utf-8") - except OSError as error: + except (OSError, UnicodeDecodeError) as error: raise CouldNotLook(f"{path}: {error}") from error @@ -250,23 +299,66 @@ def installed_hooks(root: Path) -> dict[str, list[str]]: return hooks -def published_hook_ids() -> set[str]: - """Every hook id this repository publishes, from the manifest publishing them.""" +def published_seams() -> tuple[set[str], dict[str, str]]: + """(the ids that run `uphold scan`, stage -> the id that runs the guard there). + + Read from the manifest's `entry:` lines rather than from its `- id:` lines, + because an id is not evidence that anything runs. `uphold-check` is this + very script: a repository that pins it and nothing else runs the reconcile + and no rule at all, and while every published id counted as evidence that + pin was accepted as proof that every scan rule and every guard fires here -- + the reconciler certifying itself, and printing "reconciled N enforcement + claims" over a repository enforcing nothing. + + The stage is read for the same reason it is asked for: a guard id installs + exactly one git stage, so `uphold-guard-push` says nothing about what fires + at commit-msg. + """ path = HERE / PUBLISHED_HOOKS if not path.is_file(): raise CouldNotLook( f"{PUBLISHED_HOOKS} not found beside this script; " f"cannot tell which hook ids run `uphold`" ) - return { - match.group(1) - for line in read_text(path).splitlines() - if (match := HOOK_ID_LINE.match(line)) - } + scans: set[str] = set() + guards: dict[str, str] = {} + current = "" + for line in read_text(path).splitlines(): + hook = HOOK_ID_LINE.match(line) + if hook: + current = hook.group(1) + continue + entry = ENTRY_LINE.match(line) + if not entry or not current: + continue + command = entry.group(1) + guard = ENTRY_GUARD.search(command) + if guard: + guards.setdefault(guard.group(1), current) + elif ENTRY_SCAN.search(command): + scans.add(current) + current = "" + + if not scans or not guards: + raise CouldNotLook( + f"{PUBLISHED_HOOKS} publishes no id whose `entry:` runs `uphold scan` " + f"or `uphold guard --stage`; cannot tell what pinning an id would run" + ) + return scans, guards -def runs_principles(root: Path) -> tuple[bool, str]: - """Does this repository actually run `uphold`, and how did we tell? + +def published_hook_ids() -> set[str]: + """The published ids that run a seam -- the evidence set, not the id list. + + Deliberately NOT every id in the manifest: see `published_seams`. + """ + scans, guards = published_seams() + return scans | set(guards.values()) + + +def runs_principles(root: Path) -> tuple[bool, set[str], str]: + """Which seams of `uphold` run here -- the file scan, and which git stages. The question used to be asked as "is there a hook called `content-policy`", which is the name of a command in THIS repository's own lefthook.yml. No @@ -279,39 +371,94 @@ def runs_principles(root: Path) -> tuple[bool, str]: Three ways in, one per runner, and the answer says which was taken -- a reconcile that passes for a reason the reader cannot see is one they cannot check. + + Two answers rather than one, and that is the second half of the same fix. A + single repository-wide yes/no said "uphold runs here" and let every rule in + the policy file resolve against it, so a repository that pinned `uphold-scan` + and no guard id at all reconciled a claim on a `pre-push` guard: the stage + that guard fires at is installed by `uphold-guard-push`, which nothing here + pinned, and the rule ran nowhere. What is returned is what was installed -- + the file scan, and the set of git stages some pinned id actually runs. + + Both runners are read and the answers unioned rather than the first one + winning: a repository may install pre-commit for the fast stages and drive + the slow ones from lefthook, and either file alone understates it. """ - published = published_hook_ids() - pinned = sorted(published & set(installed_hooks(root))) + scans, guards = published_seams() + installed = set(installed_hooks(root)) + + scan = bool(scans & installed) + stages = {stage for stage, hook in guards.items() if hook in installed} + how: list[str] = [] + pinned = sorted((scans | set(guards.values())) & installed) if pinned: - return True, f"{PRE_COMMIT_CONFIG} pins {', '.join(pinned)}" + how.append(f"{PRE_COMMIT_CONFIG} pins {', '.join(pinned)}") path = root / LEFTHOOK_CONFIG if path.is_file(): text = read_text(path) - if any(LEFTHOOK_RUN.match(line) for line in text.splitlines()): - return True, f"{LEFTHOOK_CONFIG} runs the binary directly" + direct = [line for line in text.splitlines() if LEFTHOOK_RUN.match(line)] + if direct: + scan = scan or any(LEFTHOOK_RUN_SCAN.match(line) for line in direct) + ran = { + match.group(1) + for line in direct + if (match := LEFTHOOK_RUN_STAGE.search(line)) + } + stages |= ran + how.append(f"{LEFTHOOK_CONFIG} runs the binary directly") if lefthook_remote().search(text): - return True, f"{LEFTHOOK_CONFIG} includes this repository as a remote" - - return ( - False, - "no runner configuration here runs `uphold scan` or `uphold guard`", - ) + # The remote config is this repository's `hooks/lefthook.yml`, which + # wires every stage the manifest publishes. A consumer that includes + # it has them all, which is why including it is the one form that + # needs no per-stage reading. + scan = True + stages |= set(guards) + how.append(f"{LEFTHOOK_CONFIG} includes this repository as a remote") + + if not scan and not stages: + return ( + False, + set(), + "no runner configuration here runs `uphold scan` or `uphold guard`", + ) + return scan, stages, "; ".join(how) def lefthook_commands(root: Path) -> set[str]: + """The command names a lefthook config defines, and nothing else. + + A command name is a valueless mapping key nested under `commands:`, and the + nesting is the whole of what distinguishes it. Matching indentation alone + accepted `configs:` -- the key under `remotes:` that README.md tells every + lefthook consumer to write verbatim -- as a rule named `configs`, so a claim + naming that rule reconciled green against a file that defines no such thing. + + The enclosing key is tracked with a stack of the valueless keys seen so far, + popped back to the current indent. A key that carries a value cannot enclose + anything, so it never joins the stack. + """ path = root / LEFTHOOK_CONFIG if not path.is_file(): return set() - return { - match.group(1) - for line in read_text(path).splitlines() - if (match := LEFTHOOK_COMMAND.match(line)) - } + + names: set[str] = set() + enclosing: list[tuple[int, str]] = [] + for line in read_text(path).splitlines(): + match = LEFTHOOK_KEY.match(line) + if not match: + continue + indent, key = len(match.group(1)), match.group(2) + while enclosing and enclosing[-1][0] >= indent: + enclosing.pop() + if enclosing and enclosing[-1][1] == "commands": + names.add(key) + enclosing.append((indent, key)) + return names -def _rule_ids(policy: dict) -> set[str]: - """Every rule id in one policy document. +def _rule_stages(policy: dict) -> dict[str, set[str]]: + """Every rule id in one policy document, mapped to the git stages it fires at. ONE table name, which is the whole point. This function used to walk a hardcoded list of six array-of-tables names against an engine that had @@ -322,28 +469,71 @@ def _rule_ids(policy: dict) -> set[str]: The id is the section header -- `[rule.]` -- so the ids are the keys of one table, and a duplicate cannot even parse. + + `git.hooks` is carried out rather than discarded because it is the field + that says WHERE a rule runs, and a caller that has only the ids cannot tell + a file rule from a pre-push guard. An empty set means no git hook runs it, + which is the file scan's rule and not a rule that runs nowhere. """ rules = policy.get("rule", {}) if not isinstance(rules, dict): raise CouldNotLook("policy: [rule] must be a table of [rule.] sections") - return set(rules) + + stages: dict[str, set[str]] = {} + for rule_id, body in rules.items(): + git = body.get("git", {}) if isinstance(body, dict) else {} + hooks = git.get("hooks", []) if isinstance(git, dict) else [] + if not isinstance(hooks, list): + raise CouldNotLook( + f"policy: [rule.{rule_id}] git.hooks must be an array of git hook names" + ) + stages[rule_id] = {value for value in hooks if isinstance(value, str)} + return stages -def content_policy_rules(root: Path) -> tuple[set[str], set[str], list[str]]: - """Return (declared rule ids, disabled rule ids, inherited base sets). +def content_policy_rules( + root: Path, +) -> tuple[dict[str, set[str]], set[str], list[str], list[str]]: + """Return (rule id -> git stages, disabled ids, inherited sets, inherited paths). - Declared INCLUDES the inherited base sets, because they ship in this - repository now. While the base set lived in another repository at a pinned - rev its rules ran here and could not be enumerated from here, so the count - was reported as locally declared rules plus a note naming the hole. + Declared INCLUDES what `[inherit]` pulls in, because those rules run here. + While the base set lived in another repository at a pinned rev its rules ran + here and could not be enumerated from here, so the count was reported as + locally declared rules plus a note naming the hole. - "Right there" means beside THIS SCRIPT, not beside the consumer's policy + A bundled set resolves beside THIS SCRIPT, not beside the consumer's policy file. The engine embeds the bundled sets with `include_str!`, so `sets = ["process-residue"]` in a consuming repository resolves to a file that repository does not have and never will -- and resolving it against their tree made every consumer that inherits a base set exit 2 on a declaration that was in fact fine. `HERE` is the clone a runner made of this repository, which is where the sets the engine compiled in are. + + `inherit.paths` resolves the other way, against the tree under check, which + is where `config::load` resolves it: those are the consumer's own extra + policy files. Reading only `inherit.sets` made every rule arriving that way + invisible, so a claim on one was refused as supplied by nothing while the + engine was running it -- a false negative in the direction that costs the + most, because the answer a person acts on is "delete the claim". + + It is still a SECOND, partial reader of what `config::load` already + resolves, and every field the two disagree about is a rule reported to run + where it does not or the other way round. The one loader can now be asked + directly -- `uphold rules --effective --json` prints the resolved rule ids + with their `git.hooks` -- and that is what this function should eventually + read instead of the policy file. + + It does not read it yet, and the reason is where this script runs. It is the + hook OTHER repositories install, and two of the three runners build the + binary inside their own environment directory rather than putting it on + PATH; a consumer whose `uphold` is not reachable would go from a reconcile + that works to exit 2 on every commit. Shelling out with a fallback to this + reader would keep both readers AND add a third behaviour, so until the + binary's location is something this script can rely on, there is one reader + here and a test -- `Reconciliation.test_the_two_readers_of_the_policy_agree` + -- that fails when it drifts from the engine on this repository's own + policy. That test is the thing that makes the duplication survivable, and + deleting it is what would make it dangerous. """ bundled = HERE / CONTENT_POLICY_BASE path = root / CONTENT_POLICY @@ -357,8 +547,12 @@ def content_policy_rules(root: Path) -> tuple[set[str], set[str], list[str]]: raise CouldNotLook(f"{CONTENT_POLICY}: 'inherit' must be a table") names = [value for value in inherit.get("sets", []) if isinstance(value, str)] + relatives = [value for value in inherit.get("paths", []) if isinstance(value, str)] - declared = _rule_ids(policy) + # Merged in the order the engine merges them -- bundled sets, then the + # named paths, then the repository's own rules -- so a rule the repository + # redefines is read with the stages the repository gave it. + declared: dict[str, set[str]] = {} for name in names: base_path = bundled / f"{name}.toml" if not base_path.is_file(): @@ -366,12 +560,21 @@ def content_policy_rules(root: Path) -> tuple[set[str], set[str], list[str]]: f"{CONTENT_POLICY}: inherit.sets names {name!r}, " f"which is not a bundled base set ({base_path} does not exist)" ) - declared |= _rule_ids(read_toml(base_path)) + declared |= _rule_stages(read_toml(base_path)) + for relative in relatives: + extra = root / relative + if not extra.is_file(): + raise CouldNotLook( + f"{CONTENT_POLICY}: inherit.paths names {relative!r}, " + f"which this repository does not have ({extra} does not exist)" + ) + declared |= _rule_stages(read_toml(extra)) + declared |= _rule_stages(policy) disabled = { value for value in inherit.get("disabled_rules", []) if isinstance(value, str) } - return declared, disabled, names + return declared, disabled, names, relatives def cmd_shims_checks(root: Path) -> set[str]: @@ -510,26 +713,61 @@ def __init__( def inventory_principles(root: Path) -> Inventory: notes: list[str] = [] try: - in_use, how = runs_principles(root) + scan, stages, how = runs_principles(root) except CouldNotLook as error: return Inventory(notes=[str(error)], unreadable=True) - if not in_use: + if not scan and not stages: return Inventory(notes=[how]) notes.append(how) try: - declared, disabled, inherited = content_policy_rules(root) + declared, disabled, sets, paths = content_policy_rules(root) except CouldNotLook as error: return Inventory(notes=[str(error)], unreadable=True) - if inherited: + if sets: # This tier used to be the one hole in the coverage report: the base set # lived in another repository at a pinned rev, its rules ran here, and # they could not be enumerated from here. They ship in this repository # now, so the count is whole and says which sets it counted. - notes.append(f"includes the bundled base set(s): {', '.join(inherited)}") + notes.append(f"includes the bundled base set(s): {', '.join(sets)}") + if paths: + notes.append( + f"includes the policy file(s) inherit.paths names: {', '.join(paths)}" + ) if disabled: notes.append(f"extend.disabled_rules turns off {', '.join(sorted(disabled))}") - return Inventory(rules=declared - disabled, notes=notes) + + # A rule is supplied where the seam that runs it is installed, which is a + # question per rule and not per repository. A rule with no `git.hooks` is + # the file scan's; a rule with them fires at those git stages and nowhere + # else, so a policy declaring a pre-push guard in a repository that pinned + # only `uphold-scan` declares a rule that runs nowhere -- and reporting it + # as supplied is how a claim on it reconciled green. + # + # ANY of a rule's stages is enough. `no-stale-hook-pins` fires at pre-push + # and manual and the manual stage is reached by a scheduled run rather than + # by a pinned id, so requiring every stage would refuse a rule that is + # demonstrably running. + rules: set[str] = set() + uninstalled: list[str] = [] + for rule_id, hooks in sorted(declared.items()): + if rule_id in disabled: + continue + if hooks: + if hooks & stages: + rules.add(rule_id) + else: + uninstalled.append(f"{rule_id} ({', '.join(sorted(hooks))})") + elif scan: + rules.add(rule_id) + else: + uninstalled.append(f"{rule_id} (file scan)") + if uninstalled: + notes.append( + "declared, but no runner configuration here installs the seam it " + f"fires at: {', '.join(uninstalled)}" + ) + return Inventory(rules=rules, notes=notes) def inventory_cmd_shims(root: Path) -> Inventory: @@ -668,7 +906,14 @@ def format_coverage( if record.get("status") != "deprecated" and record.get("enforcement", {}).get("automatable") != "no" } - enforced = {principle for principle, _ in claims} & set(claimable) + # Intersected with what is SUPPLIED, not merely with what was claimed. The + # orphans printed immediately above are claims naming a rule no seam here + # runs, and counting them here put them back into the numerator of the one + # number a reader takes away -- a record counted as claimed by a rule that + # this very report has just said does not exist. + enforced = {principle for principle, rule in claims if rule in supplied} & set( + claimable + ) unclaimable = len(records) - len(claimable) lines.append("") lines.append( @@ -893,6 +1138,15 @@ def build_oscal(root: Path, declaration: dict, records: dict[str, dict]) -> dict def review_settings(declaration: dict) -> dict: + """Read `[review]`, refusing a field whose type the rest of the mode assumes. + + All four fields are checked, because the two that were not were read by + coercion: `int(settings.get("max_lines"))` and `list(...)` turn a wrong type + into a ValueError or a TypeError, which leaves the process on a traceback + and exit 1 -- and exit 1 in this tool means a claim is false. A declaration + saying `max_lines = "nine hundred"` is one this tool could not read, which + is exit 2 and a message naming the field. See the `explicit-unknown` record. + """ settings = declaration.get("review", {}) if not isinstance(settings, dict): raise CouldNotLook("`review` must be a table") @@ -907,14 +1161,64 @@ def review_settings(declaration: dict) -> dict: "`review.no_subject_here` maps a record id to the reason this " "repository has no subject for it" ) + + # `isinstance(True, int)` is True in Python, and `max_lines = true` is a + # ceiling of 1 rather than a configuration error, so bool is excluded by + # hand. + max_lines = settings.get("max_lines", review_mod.DEFAULT_MAX_LINES) + if isinstance(max_lines, bool) or not isinstance(max_lines, int) or max_lines < 1: + raise CouldNotLook( + f"`review.max_lines` must be a positive integer, not {max_lines!r}" + ) + + domains = settings.get("include_domains", []) + if not isinstance(domains, list) or not all( + isinstance(value, str) for value in domains + ): + raise CouldNotLook("`review.include_domains` must be an array of domain names") + + emit = settings.get("emit", ["REVIEW.md"]) + if not isinstance(emit, list) or not all( + isinstance(value, str) and value.strip() for value in emit + ): + raise CouldNotLook( + "`review.emit` must be an array of file names to write the compiled " + "review document to" + ) + return { - "max_lines": int(settings.get("max_lines", review_mod.DEFAULT_MAX_LINES)), - "include_domains": list(settings.get("include_domains", [])), - "emit": list(settings.get("emit", ["REVIEW.md"])), + "max_lines": max_lines, + "include_domains": domains, + "emit": emit, "exempt": exempt, } +def emit_target(root: Path, name: str) -> Path: + """Where one `review.emit` name writes to, refusing anything outside the repo. + + The name is taken from the declaration and handed to `write_text`, so + `emit = ["../ESCAPED.md"]` created a file one level ABOVE the repository and + reported "wrote ../ESCAPED.md" as though it had done what was asked. A + declaration is configuration a reviewer skims and a hook runs unattended; + the one place it may write is the repository it describes. + """ + candidate = Path(name) + if candidate.is_absolute() or ".." in candidate.parts: + raise CouldNotLook( + f"`review.emit` names {name!r}, which is outside this repository; " + f"the compiled document is written into the repository it describes" + ) + target = (root / candidate).resolve() + if not target.is_relative_to(root.resolve()): + # A symlinked parent directory reaches outside the tree without a `..` + # anywhere in the written name. + raise CouldNotLook( + f"`review.emit` names {name!r}, which resolves to {target}, outside {root}" + ) + return target + + def run_review(argv: list[str]) -> int: emit = argv[:1] == ["--emit"] check = argv[:1] == ["--check"] @@ -926,6 +1230,10 @@ def run_review(argv: list[str]) -> int: try: declaration = read_toml(root / DECLARATION_RELPATH) settings = review_settings(declaration) + # Resolved before anything is rendered, so a name this mode may not + # write to is refused as unreadable configuration rather than after a + # document exists to write. + targets = [(name, emit_target(root, name)) for name in settings["emit"]] claims = declared_claims(declaration) suppliers, _ = rule_suppliers(root) except CouldNotLook as error: @@ -949,15 +1257,25 @@ def run_review(argv: list[str]) -> int: return 1 if emit: - for name in settings["emit"]: - (root / name).write_text(document, encoding="utf-8") + for name, path in targets: + try: + path.write_text(document, encoding="utf-8") + except OSError as error: + # A missing parent directory, a read-only tree, a name that is + # already a directory. None of those is a false claim, so none + # of them is exit 1. + print(f"uphold review could not write {name}: {error}", file=sys.stderr) + return 2 print(f"wrote {name} ({len(document.splitlines())} lines)") return 0 if check: - for name in settings["emit"]: - path = root / name - current = path.read_text(encoding="utf-8") if path.is_file() else "" + for name, path in targets: + try: + current = read_text(path) if path.is_file() else "" + except CouldNotLook as error: + print(f"uphold review could not look: {error}", file=sys.stderr) + return 2 if current != document: print( f"{name} is not what the catalog compiles to; run --review --emit", From 93d114077f668beaaea4b9b10d295bec0ea91ddd Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:30:56 +0900 Subject: [PATCH 06/21] Fire the seams CI pins and never exercises Three published hook ids were pinned by the consumer harness and reached by no question it asked, so nothing in CI had ever run them: uphold-check, whose firing condition no earlier question satisfied; uphold-guard-merge, because no question made a merge; and uphold-guard-manual, because no question ran the manual stage. A pinned id nobody invokes is a claim about a seam rather than evidence of one. The harness asks eight questions now instead of five, and the three new ones edit the declaration in a commit, make a real --no-ff merge carrying a zero-width space, and run the manual stage per runner. Question 4's planted character is removed once its assertions pass, because it would otherwise refuse the merge in question 7 and the manual sweep in question 8 for the plant rather than for the case under test. The runner-parity job installed a Rust toolchain for all three runners, which is exactly what the pre-commit and prek legs exist to prove is unnecessary: `language: rust` is part of their manifest contract, and a leg that starts with a compiler already on PATH cannot tell a working bootstrap from a broken one. The toolchain and the cargo cache are gated on the lefthook leg, which is the one that genuinely needs a binary on PATH. `no-pinned-tool-install` missed the one cargo line that has nowhere else to put its pin: `--version` cannot be combined with `--git`, so `cargo install --git --tag vX.Y.Z` is the pinned spelling and the rule did not match it. A lefthook `remotes: ref: vX.Y.Z` is the twin of a pre-commit `rev:`, except that dependabot has an ecosystem that moves a `rev:` and nothing moves a `ref:`, so it is the shape most likely to go stale unnoticed. Both are matched now. The promotion corpus is restored alongside, with the parity test that reads it: every line a promoted rule was promoted for still matches the rule, and no corpus list is empty -- a rule whose corpus emptied would satisfy a "everything matches" test vacuously. That is what keeps a future edit from narrowing a promoted pattern back to the shape one repository happened to have. --- .github/workflows/test.yml | 22 ++- hooks/lefthook.yml | 24 ++- policy/base/unmanaged-pins.toml | 39 +++- scripts/consumer_check.sh | 133 +++++++++++++- tests/fixtures/__init__.py | 7 + tests/fixtures/promotion-corpus.json | 156 ++++++++++++++++ tests/fixtures/test_promotion_corpus.py | 227 ++++++++++++++++++++++++ 7 files changed, 600 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/promotion-corpus.json create mode 100644 tests/fixtures/test_promotion_corpus.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4037c2b..ba78641 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -253,9 +253,27 @@ jobs: # pre-commit and prek bootstrap their own Rust; lefthook has no manifest # contract and no language to bootstrap, so the binary has to be built and # put on PATH the way the install instructions tell a consumer to. - - uses: dtolnay/rust-toolchain@stable + # + # Which is why the toolchain is gated rather than installed for the whole + # matrix. "No Rust toolchain needed -- `language: rust` bootstraps" is a + # claim README.md makes to consumers and .pre-commit-hooks.yaml repeats, + # and a step that puts a compiler on PATH before either runner starts is + # the one thing that makes the claim untestable: both legs would find + # cargo already there and pass whether or not the bootstrap works. The + # only leg that may have a toolchain handed to it is the one whose install + # instructions say to build the binary yourself. + # + # The cache is gated on the same condition and not on taste. Swatinem's + # action shells out to cargo to key itself, so on a leg that is + # deliberately without one it is a step that fails for a reason unrelated + # to what this job asks -- and it has nothing to cache there either, + # because pre-commit and prek build inside their own environment + # directories rather than into this workspace's target/. + - if: matrix.tool == 'lefthook' + uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - if: matrix.tool == 'lefthook' + uses: Swatinem/rust-cache@v2 with: key: parity-${{ matrix.tool }} diff --git a/hooks/lefthook.yml b/hooks/lefthook.yml index c636e30..c07953e 100644 --- a/hooks/lefthook.yml +++ b/hooks/lefthook.yml @@ -35,13 +35,31 @@ pre-commit: uphold-guard: run: uphold guard --stage pre-commit # The declaration check is the one part of this repository that is not the - # binary, so PATH cannot reach it. lefthook resolves `scripts` against the + # binary, so PATH cannot reach it. lefthook resolves a script against the # remote clone rather than the consumer's tree, which is the only mechanism # here that can reach a file in THIS repository -- so the checker arrives as a # script and .lefthook/pre-commit/uphold-check is a shim onto it. - scripts: - "uphold-check": + # + # It is a `jobs:` entry rather than a `scripts:` one for a single reason: + # `glob` is a job key and a script has no equivalent. A script with no firing + # condition runs on EVERY commit, which loads and validates the whole catalog + # in front of a one-line fix -- and .pre-commit-hooks.yaml publishes the + # opposite as the design ("It does not run on every commit") and holds itself + # to it with a `files:` regex. Two distribution paths that fire on different + # occasions are two products wearing one version number, and the one that + # fires more is the one a consumer switches off. + # + # The list is the same list as that regex, file for file: the declaration + # itself, plus every file a claim is reconciled against. Those are exactly the + # edits that can turn a true enforcement claim into a false one -- a rule + # deleted from policy/principles.toml, a hook id dropped from a runner's + # config, a shim check disabled. Nothing else can, which is why nothing else + # is worth a catalog load. + jobs: + - name: uphold-check + script: "uphold-check" runner: sh + glob: "{policy/upheld.toml,policy/principles.toml,.pre-commit-config.yaml,lefthook.yml,.cmd-shims/checks.enabled}" commit-msg: commands: diff --git a/policy/base/unmanaged-pins.toml b/policy/base/unmanaged-pins.toml index aedf271..a23723f 100644 --- a/policy/base/unmanaged-pins.toml +++ b/policy/base/unmanaged-pins.toml @@ -54,6 +54,15 @@ # into 36 of 39 consuming repositories, byte-identical in every one. Thirty-six # identical copies of a rule is not thirty-six decisions; it is one decision and # thirty-five transcriptions, and the transcriptions are where drift lives. +# +# The patterns here are wider than those copies, and wider is the only safe +# direction to move them. A rule that grows can at worst find something a +# consumer argues about; a rule that narrows finds less and says nothing about +# it -- the gate goes green and stays green over the thing it stopped watching. +# `tests/fixtures/promotion-corpus.json` holds a line for every alternative +# those 36 copies carried and the test beside it fails if any of them stops +# matching, so an edit here cannot quietly take coverage away from a repository +# that deleted its local copy on the strength of this file. [rule.no-pinned-tool-install] message = """ @@ -64,8 +73,36 @@ open a pull request against it, so it goes stale silently. Install the floating version and let the toolchain resolve it, or move the pin into a manifest something reads (go.mod, pyproject.toml, package.json, a lockfile) where a bot can see it move. + +The same applies to a git tag given to an installer -- `cargo install --git URL +--tag vX.Y.Z` -- and to the ref a hook manager pins a remote config at. """ -regexp = '(?i)(go install \S+@v?[0-9]+\.[0-9]|pip[x]? install [^\n]*==[0-9]|npm (i|install)( -g)? \S+@[0-9]+\.[0-9]|npx \S+@[0-9]+\.[0-9]|cargo install [^\n]*(--version[ =]|@)[0-9]|--with [A-Za-z0-9_.-]+==[0-9])' +# TWO ALTERNATIVES THAT READ LIKE OUTLIERS AND ARE THE CENTRE OF THE RULE. Both +# are shapes the install instructions of the repository shipping this rule hand +# to a consumer, which is where a missing alternative is easiest to find and +# hardest to argue with. +# +# `cargo install --git --tag vX.Y.Z` is how a Rust binary with no +# crates.io release is installed, and it is the purest case this rule has: the +# version sits in an argument, no manifest holds it, and `--version` -- the +# cargo spelling every other alternative here covers -- is precisely the one +# that cannot be combined with `--git`. The single cargo install line with +# nowhere else to put its pin is the one that needs naming here. +# +# `ref:` is the hook-manager half. A lefthook consumer pins a hooks repository +# as `remotes: [{git_url: ..., ref: vX.Y.Z}]`, and that ref is the twin of the +# `rev:` in a .pre-commit-config.yaml with the one difference that decides it: +# dependabot has a `pre-commit` ecosystem and moves a `rev:`, and no ecosystem +# moves a lefthook `ref:`. It is a pin, in a config, that nothing watches -- +# which is the definition this file opens with. +# +# The `ref:` alternative is deliberately not scoped to a `remotes:` block. A +# regex over lines cannot see the block it is inside, and the alternatives that +# would be caught by accident -- a `ref:` input to a checkout step, a ref given +# to a container action -- are unwatched version pins of the same kind, judged +# by the same argument. Anything genuinely outside it is what `files.exclude` +# and `disabled_rules` are for. +regexp = '''(?i)(go install \S+@v?[0-9]+\.[0-9]|pip[x]? install [^\n]*==[0-9]|npm (i|install)( -g)? \S+@[0-9]+\.[0-9]|npx \S+@[0-9]+\.[0-9]|cargo install [^\n]*(--version[ =]|--tag[ =]v?|@)[0-9]|--with [A-Za-z0-9_.-]+==[0-9]|^\s*ref:\s*["']?v?[0-9]+\.[0-9])''' files.include = ["."] files.exclude = ["**/tests/**", "**/test/**", "**/*_test.go", "policy/**", "README.md", "third_party/**", "vendor/**"] diff --git a/scripts/consumer_check.sh b/scripts/consumer_check.sh index 2a035f6..1b4a136 100755 --- a/scripts/consumer_check.sh +++ b/scripts/consumer_check.sh @@ -11,7 +11,7 @@ # that read git's stdin under a runner that does not forward it. Each of those # passed every test here and failed on first contact with a consumer. # -# Each runner is asked the same four questions, because "supports lefthook" has +# Each runner is asked the same eight questions, because "supports lefthook" has # to mean the same thing as "supports pre-commit" or it is a listing rather than # a claim: # @@ -23,6 +23,11 @@ # delivers by a different channel # 5. a rule that names `[rule.files]` and no `[rule.git]` is NOT run by a git # hook, because an absent table is a place the rule does not run +# 6. a false enforcement claim is refused when the declaration changes, and +# the corrected one is accepted +# 7. an ordinary merge commit is made and passes, and a merge that would bring +# in a zero-width space is refused +# 8. the manual-stage entry point runs and passes # # Question 4 is the one that matters for the runners. A guard that cannot see # the push does not fail loudly by default; it falls back to some other tree and @@ -31,6 +36,15 @@ # Question 5 is the one that matters for the schema, and it is the only one here # that fails by a check running where nobody asked for it rather than by one # failing to run. +# +# Questions 6 to 8 exist because the consumer config below pins every published +# id, and pinning an id is not running it. Three of those ids fired nowhere: +# `uphold-check` is registered against a list of PATHS and no question edited +# one of them, `uphold-guard-merge` needs a merge commit and no question made +# one, and `uphold-guard-manual` sits at a stage reached only by an explicit +# invocation that nothing here made. A pinned id that never runs is this +# script's own failure mode, one level up -- it passes here, in a config that +# looks complete, and does nothing in the consumer that copies it. set -euo pipefail @@ -205,6 +219,14 @@ grep -q "prevent-unusual-unicode-in-files" "$WORK/push.log" || if grep -q "no ref line reached this guard" "$WORK/push.log"; then fail "$RUNNER did not deliver the pushed range to the guard" fi +# The plant has answered its question and it is still in the tree. Every +# question after this one expects a tree that PASSES -- the merge guard reads +# the merged index and the manual guard reads the whole tree, and both would +# refuse for a reason this script planted rather than for the reason being +# asked about. Removed with hooks off, because a deletion is not what is under +# test either. +rm -f "$CONSUMER/sneaky.txt" +raw_commit "Remove the planted file" say "5. a rule that names [rule.files] and no [rule.git] is not run by a hook" # The marker is refused -- by the SCAN, which is the place the rule named. The @@ -225,4 +247,111 @@ fi git -C "$CONSUMER" rm -q --cached marker.txt rm -f "$CONSUMER/marker.txt" -say "$RUNNER: all five passed" +say "6. a change to the declaration is reconciled, and a false claim is refused" +# The only check here that fires on a PATH rather than on every commit: it runs +# when the declaration changes, or when a file a claim is reconciled against +# changes, and stays silent otherwise. Which means every question above ran with +# it switched off, and a consumer who pinned it would have learned nothing about +# whether it works -- the same shape of hole as an id nothing publishes. +# +# Asked in the refusing direction first. A check that fires and cannot say no is +# indistinguishable, from the outside, from a check that never fired. +cat > "$CONSUMER/policy/upheld.toml" <<'DECLARATION' +[[enforce]] +principle = "complete-mediation" +rule = "prevent-ai-author" + +# No rule in policy/principles.toml supplies this one, so the claim is false -- +# which is a different answer from "could not look", and the exit code says so. +[[enforce]] +principle = "least-privilege" +rule = "prevent-public-push" +DECLARATION +git -C "$CONSUMER" add -A +if git -C "$CONSUMER" -c user.email=demo@example.test -c user.name=Demo \ + commit -q -m "Claim a rule this repository does not run" >"$WORK/claim.log" 2>&1; then + fail "a false enforcement claim was accepted" +fi +grep -q "prevent-public-push" "$WORK/claim.log" || + fail "refused, but not by the declaration check: $(cat "$WORK/claim.log")" +# A declaration that could not be READ exits 2 and prints this instead, which +# would satisfy the grep above for the wrong reason: the seams a claim is +# resolved against live in the consumer's own config, and a runner that hands +# the checker the wrong working directory reads none of them. +if grep -q "could not look" "$WORK/claim.log"; then + fail "the declaration check could not read this consumer: $(cat "$WORK/claim.log")" +fi + +# And the accepting direction, which is the half a consumer lives in. The false +# claim is replaced by a true one rather than simply deleted: reverting the file +# to what it already held stages nothing, git refuses an empty commit, and the +# question would have passed on a commit that never happened. +cat > "$CONSUMER/policy/upheld.toml" <<'DECLARATION' +[[enforce]] +principle = "complete-mediation" +rule = "prevent-ai-author" + +[[enforce]] +principle = "least-astonishment" +rule = "prevent-unusual-unicode" +DECLARATION +commit "Declare what enforces what" || fail "a true enforcement claim was refused" + +say "7. a merge is guarded" +# git runs `pre-merge-commit` for a merge and `pre-commit` for a commit. They +# are different hook types, installed by different lines, and nothing above +# makes a merge at all -- so the stage every consumer pins was the stage nothing +# here ever entered. A branch carrying a zero-width space merging into the trunk +# with no file guard consulted is the failure this stage exists to prevent, and +# it is invisible until a merge happens. +git -C "$CONSUMER" checkout -q -b side +printf 'a side line\n' > "$CONSUMER/side.txt" +commit "Add a side note" +git -C "$CONSUMER" checkout -q main +git -C "$CONSUMER" -c user.email=demo@example.test -c user.name=Demo \ + merge -q --no-ff -m "Merge the side branch" side >"$WORK/merge.log" 2>&1 || + fail "an ordinary merge was refused: $(cat "$WORK/merge.log")" + +# The same merge with something to find. Planted with hooks off so that what +# refuses it is the merge guard reading the merged index, not the commit guard +# that would have caught it on the branch. +git -C "$CONSUMER" checkout -q -b side-hidden +printf 'hidden\xe2\x80\x8b again\n' > "$CONSUMER/side-hidden.txt" +raw_commit "Add a side file" +git -C "$CONSUMER" checkout -q main +if git -C "$CONSUMER" -c user.email=demo@example.test -c user.name=Demo \ + merge -q --no-ff -m "Merge the hidden branch" side-hidden >"$WORK/merge-hidden.log" 2>&1; then + fail "a merge carrying U+200B was accepted" +fi +grep -q "prevent-unusual-unicode-in-files" "$WORK/merge-hidden.log" || + fail "the merge was refused, but not by the file guard: $(cat "$WORK/merge-hidden.log")" +# A refused merge leaves the merged index in place for the author to fix. This +# is not that author, and the question after this one reads the tree. +git -C "$CONSUMER" merge --abort 2>/dev/null || true +git -C "$CONSUMER" reset -q --hard HEAD +git -C "$CONSUMER" branch -D side-hidden >/dev/null + +say "8. the manual-stage entry point runs and passes" +# Where the slow guards live: the tree-wide name scans and the pin check each +# ask the forge over the network, so they are registered at a stage no commit +# and no push reaches. CI and a schedule are the only things that ever run them, +# and each runner spells that invocation differently -- which is exactly where +# "supported" decays into "listed", because an id pinned at a stage the runner +# has no way to reach is an id that runs nowhere and reports nothing. +case "$RUNNER" in +pre-commit | prek) + (cd "$CONSUMER" && "$RUNNER" run --all-files --hook-stage manual) \ + >"$WORK/manual.log" 2>&1 || + fail "the manual stage failed: $(cat "$WORK/manual.log")" + ;; +lefthook) + # lefthook has no manual stage; `uphold-manual` is the named group that + # stands in for one, and a consumer merging this repository's remote config + # gets it under that name. + (cd "$CONSUMER" && lefthook run uphold-manual) \ + >"$WORK/manual.log" 2>&1 || + fail "the manual group failed: $(cat "$WORK/manual.log")" + ;; +esac + +say "$RUNNER: all eight passed" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..34666bd --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,7 @@ +"""Fixtures, and the one test that lives beside its fixture. + +This directory is a package for a mechanical reason. `python3 -m unittest +discover -s tests` walks a subdirectory only when it is importable, so without +this file `test_promotion_corpus.py` is not found, not run, and not reported -- +which is the same silence the test it hides exists to prevent. +""" diff --git a/tests/fixtures/promotion-corpus.json b/tests/fixtures/promotion-corpus.json new file mode 100644 index 0000000..312a0b1 --- /dev/null +++ b/tests/fixtures/promotion-corpus.json @@ -0,0 +1,156 @@ +{ + "no-env-secret-values": [ + "BOJ_API_KEY=notarealvalue", + "BOJ_APP_ID=notarealvalue", + "BOJ_COOKIE=notarealvalue", + "BOJ_PASSWORD=notarealvalue", + "BOJ_SECRET=notarealvalue", + "BOJ_SESSION=notarealvalue", + "BOJ_TOKEN=notarealvalue", + "BOJ_USERNAME=notarealvalue", + "DB_API_KEY=notarealvalue", + "DB_COOKIE=notarealvalue", + "DB_PASSWORD=notarealvalue", + "DB_SECRET=notarealvalue", + "DB_SESSION=notarealvalue", + "DB_TOKEN=notarealvalue", + "DB_USERNAME=notarealvalue", + "EDGAR_API_KEY=notarealvalue", + "EDGAR_APP_ID=notarealvalue", + "EDGAR_COOKIE=notarealvalue", + "EDGAR_PASSWORD=notarealvalue", + "EDGAR_SECRET=notarealvalue", + "EDGAR_SESSION=notarealvalue", + "EDGAR_TOKEN=notarealvalue", + "EDGAR_USERNAME=notarealvalue", + "EDINET_API_KEY=notarealvalue", + "EDINET_APP_ID=notarealvalue", + "EDINET_COOKIE=notarealvalue", + "EDINET_PASSWORD=notarealvalue", + "EDINET_SECRET=notarealvalue", + "EDINET_SESSION=notarealvalue", + "EDINET_SUBSCRIPTION_KEY=notarealvalue", + "EDINET_TOKEN=notarealvalue", + "EDINET_USERNAME=notarealvalue", + "ESTAT_API_KEY=notarealvalue", + "ESTAT_APP_ID=notarealvalue", + "ESTAT_COOKIE=notarealvalue", + "ESTAT_PASSWORD=notarealvalue", + "ESTAT_SECRET=notarealvalue", + "ESTAT_SESSION=notarealvalue", + "ESTAT_TOKEN=notarealvalue", + "ESTAT_USERNAME=notarealvalue", + "FRED_API_KEY=notarealvalue", + "FRED_APP_ID=notarealvalue", + "FRED_COOKIE=notarealvalue", + "FRED_PASSWORD=notarealvalue", + "FRED_SECRET=notarealvalue", + "FRED_SESSION=notarealvalue", + "FRED_TOKEN=notarealvalue", + "FRED_USERNAME=notarealvalue", + "GDELT_API_KEY=notarealvalue", + "GDELT_APP_ID=notarealvalue", + "GDELT_COOKIE=notarealvalue", + "GDELT_PASSWORD=notarealvalue", + "GDELT_SECRET=notarealvalue", + "GDELT_SESSION=notarealvalue", + "GDELT_TOKEN=notarealvalue", + "GDELT_USERNAME=notarealvalue", + "GHOSTFOLIOAPI_KEY=notarealvalue", + "GHOSTFOLIOCOOKIE=notarealvalue", + "GHOSTFOLIOPASSWORD=notarealvalue", + "GHOSTFOLIOSECRET=notarealvalue", + "GHOSTFOLIOSESSION=notarealvalue", + "GHOSTFOLIOTOKEN=notarealvalue", + "GHOSTFOLIOUSERNAME=notarealvalue", + "JQUANTS_API_KEY=notarealvalue", + "JQUANTS_COOKIE=notarealvalue", + "JQUANTS_PASSWORD=notarealvalue", + "JQUANTS_SECRET=notarealvalue", + "JQUANTS_SESSION=notarealvalue", + "JQUANTS_TOKEN=notarealvalue", + "JQUANTS_USERNAME=notarealvalue", + "MACROHISTORY_API_KEY=notarealvalue", + "MACROHISTORY_APP_ID=notarealvalue", + "MACROHISTORY_COOKIE=notarealvalue", + "MACROHISTORY_PASSWORD=notarealvalue", + "MACROHISTORY_SECRET=notarealvalue", + "MACROHISTORY_SESSION=notarealvalue", + "MACROHISTORY_TOKEN=notarealvalue", + "MACROHISTORY_USERNAME=notarealvalue", + "OPENBB_API_KEY=notarealvalue", + "OPENBB_COOKIE=notarealvalue", + "OPENBB_PASSWORD=notarealvalue", + "OPENBB_SECRET=notarealvalue", + "OPENBB_SESSION=notarealvalue", + "OPENBB_TOKEN=notarealvalue", + "OPENBB_USERNAME=notarealvalue", + "POLYMARKET_API_KEY=notarealvalue", + "POLYMARKET_COOKIE=notarealvalue", + "POLYMARKET_PASSWORD=notarealvalue", + "POLYMARKET_SECRET=notarealvalue", + "POLYMARKET_SESSION=notarealvalue", + "POLYMARKET_TOKEN=notarealvalue", + "POLYMARKET_USERNAME=notarealvalue", + "POSTGRESAPI_KEY=notarealvalue", + "POSTGRESCOOKIE=notarealvalue", + "POSTGRESPASSWORD=notarealvalue", + "POSTGRESSECRET=notarealvalue", + "POSTGRESSESSION=notarealvalue", + "POSTGRESTOKEN=notarealvalue", + "POSTGRESUSERNAME=notarealvalue", + "SBISEC_API_KEY=notarealvalue", + "SBISEC_COOKIE=notarealvalue", + "SBISEC_PASSWORD=notarealvalue", + "SBISEC_SECRET=notarealvalue", + "SBISEC_SESSION=notarealvalue", + "SBISEC_TOKEN=notarealvalue", + "SBISEC_USERNAME=notarealvalue", + "SUPERSETAPI_KEY=notarealvalue", + "SUPERSETCOOKIE=notarealvalue", + "SUPERSETPASSWORD=notarealvalue", + "SUPERSETSECRET=notarealvalue", + "SUPERSETSESSION=notarealvalue", + "SUPERSETTOKEN=notarealvalue", + "SUPERSETUSERNAME=notarealvalue", + "TDNET_API_KEY=notarealvalue", + "TDNET_APP_ID=notarealvalue", + "TDNET_COOKIE=notarealvalue", + "TDNET_PASSWORD=notarealvalue", + "TDNET_SECRET=notarealvalue", + "TDNET_SESSION=notarealvalue", + "TDNET_SUBSCRIPTION_KEY=notarealvalue", + "TDNET_TOKEN=notarealvalue", + "TDNET_USERNAME=notarealvalue", + "TRONGRID_API_KEY=notarealvalue", + "TRONGRID_COOKIE=notarealvalue", + "TRONGRID_PASSWORD=notarealvalue", + "TRONGRID_SECRET=notarealvalue", + "TRONGRID_SESSION=notarealvalue", + "TRONGRID_TOKEN=notarealvalue", + "TRONGRID_USERNAME=notarealvalue", + "YFINANCE_API_KEY=notarealvalue", + "YFINANCE_COOKIE=notarealvalue", + "YFINANCE_PASSWORD=notarealvalue", + "YFINANCE_SECRET=notarealvalue", + "YFINANCE_SESSION=notarealvalue", + "YFINANCE_TOKEN=notarealvalue", + "YFINANCE_USERNAME=notarealvalue" + ], + "no-pinned-release-download": [ + "curl -L https://example.com/org/repo/releases/download/v1.2.3/tool.tar.gz" + ], + "no-pinned-tool-install": [ + "cargo install thing --version 1.2", + "go install example.com/tool@v1.2.3", + "npm install -g thing@1.2", + "npx thing@1.2", + "pip install thing==1.2", + "pipx install thing==1.2", + "uv run --with thing==1.2 x" + ], + "no-pinned-versioned-fetch": [ + "curl -sSfL https://example.com/dl/v1.2.3/tool", + "wget https://example.com/dl/1.2.3/tool" + ] +} diff --git a/tests/fixtures/test_promotion_corpus.py b/tests/fixtures/test_promotion_corpus.py new file mode 100644 index 0000000..1da4818 --- /dev/null +++ b/tests/fixtures/test_promotion_corpus.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""The promoted base rules must still match everything the local copies matched. + +WHY THIS FILE EXISTS + +Rules in `policy/base/` arrived there by promotion: they were found hand-copied +into a fleet of 39 consuming repositories, and the point of promoting them is +that those copies can be deleted in favour of one `[inherit] sets` line. + +That deletion is the dangerous half. A repository whose local rule matched +something the base rule does not would LOSE that coverage at the moment it +migrates, silently, because a rule that stops matching produces no output at all +-- the gate goes green and stays green, and the thing it was watching is simply +no longer watched. It is the same failure this whole tool is built around, aimed +at the tool: a check that cannot look must not read as a check that looked. + +So the promotion is not trusted; it is checked. `promotion-corpus.json` beside +this file holds concrete lines derived MECHANICALLY from the alternation members +present in those local copies -- not hand-picked, because a hand-picked corpus +tests the author's memory of what the rules covered rather than what they +covered. It is a byte-for-byte copy of the corpus from the repository the rules +were promoted out of, which has since been retired; there is nowhere left to +re-derive it from, so it is evidence rather than a fixture. + +WHAT A FAILURE HERE MEANS + +Not "fix the corpus". It means the base rule is narrower than the copies it +replaced, and either the base pattern grows back or that repository must keep +its local rule. The corpus is the record of what the fleet was actually +protected against, and it outranks the tidiness of a merge. + +WHY PYTHON RE AGAINST PATTERNS THE ENGINE COMPILES WITH RUST REGEX + +The engine matches these patterns with ripgrep's search stack, and this file +matches them with `re`. That is a real difference and it is bounded on purpose: +every pattern reached here uses only syntax the two engines read the same way, +and a pattern that reached for either engine's extensions would fail to COMPILE +here rather than quietly disagree. A compile error in this file is a signal -- +it says a base pattern has become one this test can no longer speak for. + +The values are placeholders. A corpus of credential SHAPES cannot carry a real +credential, which is the rule every fixture in this tree follows. +""" + +from __future__ import annotations + +import json +import re +import tomllib +import unittest +from pathlib import Path + +# The pattern-bearing fields of the unified schema. `regexp` means a regex over +# file contents, `path_regexp` one matched against tracked paths, and +# `require_regexp` one that must be FOUND in every selected file -- the field +# the author wrote is the discriminant, so this list is how a rule's pattern is +# located without a `kind` to ask. +PATTERN_FIELDS = ("regexp", "path_regexp", "require_regexp") + + +def repo_root() -> Path: + """The tree this file is checked into, found by what it contains. + + Not `parents[N]`. This file has already moved once -- it was recovered from + the repository the corpus came from -- and a hop count is the part of a path + that goes wrong silently: it resolves to SOME directory, the glob below + finds no packs there, and a test with nothing to check passes. + """ + for candidate in Path(__file__).resolve().parents: + if (candidate / "policy" / "base").is_dir(): + return candidate + raise AssertionError( + "no policy/base above this file, so there are no promoted rules to check" + ) + + +ROOT = repo_root() +BASE_DIR = ROOT / "policy" / "base" +CORPUS = ROOT / "tests" / "fixtures" / "promotion-corpus.json" + + +def base_rules() -> dict[str, dict]: + """{rule id: the whole rule} across every bundled base pack. + + The id is the SECTION HEADER here -- `[rule.no-pinned-tool-install]` -- where + the repository this corpus came from wrote `[[rule]]` with an `id` field. A + pattern is half of what a rule declares; the exclusions are the other half, + and one test below reads them. + """ + rules: dict[str, dict] = {} + for pack in sorted(BASE_DIR.glob("*.toml")): + policy = tomllib.loads(pack.read_text(encoding="utf-8")) + rules.update(policy.get("rule", {})) + return rules + + +def base_patterns() -> dict[str, str]: + """{rule id: pattern}, for every base rule that carries one.""" + return { + rule_id: rule[field] + for rule_id, rule in base_rules().items() + for field in PATTERN_FIELDS + if field in rule + } + + +class PromotedRulesStillMatch(unittest.TestCase): + def setUp(self) -> None: + self.corpus = json.loads(CORPUS.read_text(encoding="utf-8")) + self.patterns = base_patterns() + + def test_every_corpus_line_still_matches_its_rule(self) -> None: + missed: list[str] = [] + for rule_id, lines in sorted(self.corpus.items()): + self.assertIn( + rule_id, + self.patterns, + f"{rule_id} is in the corpus and in no base pack", + ) + rx = re.compile(self.patterns[rule_id], re.MULTILINE) + for line in lines: + if not rx.search(line): + missed.append(f"{rule_id}: {line!r}") + self.assertEqual( + [], + missed, + "the promoted rule is NARROWER than the copies it replaces; those " + "repositories would lose this coverage on migrating:\n" + "\n".join(missed), + ) + + def test_the_corpus_is_not_empty_for_any_promoted_rule(self) -> None: + """A rule whose corpus emptied would pass the test above vacuously. + + The same failure the test is written to catch, one level up: nothing to + check reads exactly like nothing wrong. + """ + for rule_id, lines in sorted(self.corpus.items()): + self.assertTrue(lines, f"{rule_id} has an empty corpus") + + def test_the_two_promoted_key_names_are_the_reason_this_exists(self) -> None: + """APP_ID and SUBSCRIPTION_KEY, asserted by name. + + Nine of the 39 repositories had locally redefined `no-env-secret-values` + for the single purpose of adding APP_ID, and two for SUBSCRIPTION_KEY. + Those are the alternatives whose loss would be invisible, so they are + pinned here rather than left to the generated corpus alone -- a + regenerated corpus that dropped them would take the evidence with it. + """ + rx = re.compile(self.patterns["no-env-secret-values"], re.MULTILINE) + for line in ( + "ESTAT_APP_ID=notarealvalue", + "TDNET_SUBSCRIPTION_KEY=notarealvalue", + ): + self.assertRegex(line, rx) + + def test_a_sops_vault_is_not_flagged_as_a_committed_secret(self) -> None: + """The exclusion half, which the first version of this file did not check. + + A corpus of PATTERNS proves the promoted rule still matches what the + local copies matched. It proves nothing about what they DECLINED to + match, and the fleet's local copies carried exclusions too -- nine of + them excluded their SOPS vault by name. Migrating on a pattern-only + proof turned a whole fleet red on ciphertext. + + The assertion is on the exclude list rather than on a match: the pattern + SHOULD match a vault line -- that is what a vault line looks like -- and + the file is skipped before the pattern is ever applied. + """ + excluded = base_rules()["no-env-secret-values"]["files"]["exclude"] + for name in ("*.enc.env", ".env.enc"): + self.assertIn( + name, + excluded, + "a secret detector that fires on the file whose purpose is to make " + "secrets safe to commit teaches its reader to skim its findings", + ) + + def test_a_placeholder_env_line_is_still_allowed(self) -> None: + """The rule must not fire on an empty or commented example value. + + Widening a credential pattern is the easy half; keeping `.env.example` + legal is what stops the widening from being reverted a week later. + """ + rx = re.compile(self.patterns["no-env-secret-values"], re.MULTILINE) + for line in ("ESTAT_APP_ID=", "ESTAT_APP_ID= # set me", "# ESTAT_APP_ID=x"): + self.assertNotRegex(line, rx) + + def test_the_pins_this_repository_hands_out_are_matched(self) -> None: + """The two pin shapes `unmanaged-pins` was widened to cover. + + Corpus lines record what the fleet's local copies matched. These two + record something else -- what this repository's own install instructions + create -- and they are asserted by hand rather than added to the corpus + because the corpus is evidence from a repository that no longer exists + and editing it would forge that evidence. + + Both are the rule's own subject and both passed it. `cargo install + --git URL --tag vX.Y.Z` is the one cargo spelling that cannot use + `--version`, and a lefthook `remotes: ref:` is the twin of a + `.pre-commit-config.yaml` `rev:` that no dependency bot moves. + """ + rx = re.compile(self.patterns["no-pinned-tool-install"], re.MULTILINE) + for line in ( + "cargo install --git https://example.test/org/tool --tag v1.2.3", + " ref: v1.2.3", + ' ref: "v1.2.3"', + ): + self.assertRegex(line, rx) + + def test_an_unpinned_install_line_is_still_allowed(self) -> None: + """The widening must not have swallowed the form the rule asks for. + + A rule that refuses the fix it recommends is one a consumer disables + wholesale, and both new alternatives are shapes whose unpinned version + is ordinary: a floating install, and a remote tracked by branch. + """ + rx = re.compile(self.patterns["no-pinned-tool-install"], re.MULTILINE) + for line in ( + "cargo install --git https://example.test/org/tool", + " ref: main", + "cargo install tool", + ): + self.assertNotRegex(line, rx) + + +if __name__ == "__main__": + unittest.main() From 9430f17fea0c81ffb9f9e4472f38b009986ea3bb Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 14:31:18 +0900 Subject: [PATCH 07/21] Describe the seams as they now behave The documentation described several things this binary no longer does, and one it could not do at all. docs/REFERENCE.md said comment edit history was reported as unreadable and that exit 0 arrived when every surface was read and clean -- the first of which made the second unreachable, so the reference documented an outcome the tool could not produce. The standing caveat and the reachable clean answer are both written down now. The rest follows the behaviour it belongs to. Selection is what git tracks rather than a directory walk, and a path a rule could not open is exit 2 named on stderr. A `command.before` and a `[[shim]]` are two halves of one seam and the load refuses either half alone, so both worked examples now carry the shim block a reader would otherwise copy without it. The guard table says that a path is committed text, that a push publishes its commit messages, and that a pin the guard could not check is exit 2 with a named bypass. The shim section says what `editor_env` does, how the subcommand is found among the options, that a checker must drain its subject, and that the editor is a checkpoint rather than a blind spot. `uphold rules --effective` is documented where the inheritance fields it resolves are described. --- README.md | 18 ++++-- docs/DESIGN.md | 20 ++++-- docs/REFERENCE.md | 154 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 171 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 388ac33..c4a0428 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ not look — see [`explicit-unknown`](principles/explicit-unknown.toml). ```sh uphold scan # content rules over the tree uphold scan --text - # a commit message, release note, PR body +uphold rules --effective # every rule inheritance resolved to uphold guard --stage pre-push # the guards for that git hook uphold shim gh pr create ... # stand in front of a command, then exec uphold audit --for-publication # before flipping private -> public @@ -115,19 +116,26 @@ tables — an absent table is a place the rule does not run. Full field referenc **`uphold scan`** evaluates content rules over the repository's own files, using ripgrep's search libraries, so a pattern written against `rg` keeps -meaning what it meant. `--text -` runs it over prose that never becomes a file. +meaning what it meant. "Its own files" is **what git tracks**, not a directory +walk: a tracked file some ignore pattern also matches is still pushed and still +cloned, and a rule that cannot see a file reports it clean. `--text -` runs it +over prose that never becomes a file. `uphold rules --effective` prints what +inheritance actually resolved to, so nothing has to re-derive it. **`uphold guard --stage STAGE`** reads an *act* rather than a tree: the message about to be recorded, the identity about to be stamped, the range about -to be pushed. Eleven built-in guards, registered by `git.hooks`. -`UPHOLD_ALLOW=` overrides one invocation. +to be pushed. Eleven built-in guards, registered by `git.hooks`. A file's +**name** is committed text too, and at a push the guards also read the commit +**messages** the push publishes. `UPHOLD_ALLOW=` overrides one invocation. **`uphold shim`** stands in front of a command, checks what the invocation is about to publish, and execs through. A pull-request body reaches a public API without passing a single hook; so does a branch name, an issue title, and a commit written under `--no-verify`. Put a link named for the command on PATH ahead of the real one — that is what a multicall binary is for, and why there is -no installer. +no installer. Where the body is composed in an **editor**, the shim makes itself +the editor and checks what the editor leaves in the file when it closes — so +there is no invocation whose published text goes unread. ## The catalog @@ -188,7 +196,7 @@ python3 scripts/validate.py # schema and relationship validation python3 scripts/build_reference.py # rebuild the generated files after edits python3 -m unittest discover -s tests ./uphold_check.py # this repo's own declaration -python3 scripts/check_hook_pins.py # every rev: names a ref that exists +cargo run --quiet -- guard --stage manual # every pin still names a ref ``` ```text diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 895ea87..ad508fe 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -242,8 +242,18 @@ This repository defines **why and when** a rule exists; the seams implement it. The same rule should not acquire a second, drifting definition merely because it is enforced at another seam. -`check_hook_pins.py` and `no-stale-hook-pins` ask opposite questions of the same -answer: whether a pin is behind the newest upstream tag, and whether the tag it -names exists at all. A pin bumped ahead of a release that was never cut fails at -hook-init, before any hook runs, so nothing downstream of the clone can report -it. +`no-stale-hook-pins` asks both halves of one question of one answer: whether a +pin has fallen behind the newest upstream tag, and whether the ref it names +exists at all. They were two checkers for a while -- a `check_hook_pins.py` +script beside the guard -- and that arrangement is the drift this section warns +about: the two read the same `rev:` lines, reached the same remote, and were +free to return different verdicts. They did. The guard counted a pin whose +remote it could not reach as passed, while the script called the same pin +unresolvable, so which answer a repository got depended on which seam ran. One +`git ls-remote` now answers both, and a pin that could not be checked is exit 2 +rather than either verdict. + +A pin bumped ahead of a release that was never cut still fails at hook-init, +before any hook runs, so nothing downstream of the clone can report it -- which +is why the guard is installed at pre-push and at the manual stage, the last two +moments that are still upstream of somebody else's clone. diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index d0c7bf7..ce6f09f 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -64,6 +64,25 @@ Exit codes: `0` clean, `1` violations, `2` the check could not be made. Evaluates every rule over the repository's own files, using ripgrep's search libraries rather than a second regex engine. +**What "the repository's own files" means is what git tracks.** The globs in +`[rule.files]` are applied to `git ls-files`, not to a directory walk. A tracked +file that some ignore pattern also matches — a `.gitignore` line, a +`.git/info/exclude` entry, or the operator's *global* ignore file, which is not +in the repository at all — is still tracked, still pushed, and still read by +everyone who clones it, and a walker that honoured those patterns could not see +it. In a directory git has no index for, the tree is walked instead with **no** +ignore file consulted, which selects a superset of what would be tracked. +Over-reporting is the direction a checker may fail in; hiding a file is not. + +A path a rule selected and could not open — an unstaged deletion, a sparse +checkout, a directory this process may not enter — is **named on stderr and is +exit `2`**, after every other rule has reported. It is not dropped from the +list, because a rule that searched what was left and found nothing there would +otherwise print `policy checks passed` over a tree it never finished reading. +A finding outranks it: `1` when something was found, `2` when nothing was found +and something could not be read, `0` only when the whole selection was read and +was clean. + ```toml allowed_scripts = ["Latin"] @@ -109,6 +128,21 @@ A repository's own rule of the same `id` shadows the inherited one; is an error rather than a line that quietly does nothing. `inherit.paths` merges extra policy files, repository-relative, after the bundled sets. +Those five fields interact, so "which rules does this repository run" is not a +question anyone can answer by reading the `[rule.*]` tables. The loader answers +it: + +```sh +uphold rules --effective # every resolved rule, and where it fires +uphold rules --effective --json # the same, for a program +``` + +The JSON is one array of `{"id": ..., "git_hooks": [...]}`, in the order the +engine resolved them. It exists so that nothing has to re-implement the loader +to find out what runs — a second reader of these fields is a reader free to +disagree with the engine, and it will disagree exactly where somebody used a +field it does not know about. + The two requests this shape exists to make writable: ```toml @@ -118,6 +152,10 @@ regexp = '(?im)^Co-Authored-By:.*/head` (fetched explicitly -and scanned) and comment edit history (cannot be scanned, reported as -unreadable). Exit `1` for something found, `2` where a surface could not be -read, `0` only when every surface a flip would republish was read and was clean. +What it reads is **every blob reachable** from `HEAD`, from `origin`'s branches +and from the retained pull-request refs — not `HEAD`'s tree. A name committed +and deleted before `HEAD` is served by the forge forever and survives the +default-branch rewrite, so a tree-only audit answered the wrong question. On the +forge side it reads issue and pull-request **titles** as well as bodies, plus +review bodies and review-thread comments, and a listing that comes back at the +request cap is reported as truncated rather than quietly cut short. + +Two surfaces survive a history rewrite: `refs/pull//head`, which is fetched +explicitly and scanned, and comment **edit history**, which no API exposes. + +The edit history is a **standing caveat**, not an unreadable surface. It is true +of every run, on every repository, and nothing about this run could change it — +so it is stated in the body of every report and is *not* counted as something +this run failed to read. Counting it there makes the unreadable list +unconditionally non-empty, which makes exit `0` unreachable and takes away the +clean answer this command exists to be able to give. Exit `1` for something +found, `2` where a surface this run tried to read could not be read, `0` when +every surface a flip would republish was read and was clean — subject to the +standing caveats, which the clean line says. ## `--coverage` and `--oscal` From 2ca06f5e23f13d4f1a820b07a14a4bd692305582 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:42:16 +0900 Subject: [PATCH 08/21] Drain the pipe before waiting on what fills it `blob_shas` wrote every reachable object's sha into `git cat-file --batch-check` and only then waited, with nothing reading stdout. `--batch-check` answers as it reads, at roughly fifty bytes an answer, so it fills a 64 KiB pipe somewhere near the thirteen-hundredth object and stops reading stdin -- and every repository this audit is for is far past that count. Writing first was not a rare hang; it was the ordinary case, and `audit --for-publication` could not finish on a real tree. The same defect with the same shape was fixed in `selection` earlier in this branch, where it carries a comment explaining exactly this failure. It was reintroduced here two files away. Both pipes now move at once. `git fetch --prune origin` also moves out of `history` and runs before anything reads a ref. It sat inside the function that runs third, so the object walk read a ref set that had not been fetched and had not been pruned: an object the forge holds but this clone never saw was missed, and a branch deleted upstream was still walked as something served. --- src/audit.rs | 89 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/src/audit.rs b/src/audit.rs index d1c8f5a..298276a 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -42,7 +42,7 @@ //! still carries the caveat in its body. use std::collections::BTreeSet; -use std::io::Write as _; +use std::io::{Read as _, Write as _}; use std::path::Path; use std::process::{Command, Stdio}; @@ -106,14 +106,32 @@ fn git_lines(root: &Path, args: &[&str]) -> Result { /// other. Reporting them produces findings whose only fix is to delete /// something the reader would then discover was never published -- and a report /// that cries wolf about the unpublishable is one nobody finishes reading. -fn history(root: &Path) -> Result> { +/// Bring the remote-tracking refs up to date before anything reads them. +/// +/// This lived inside `history`, which runs after `reachable_blobs`, so the +/// object walk read a ref set that had not been fetched and had not been pruned: +/// an object the forge holds but this clone had never seen was missed entirely, +/// and a branch deleted upstream was still walked as something the forge serves. +/// Both readings are about what publication exposes, so both need the same +/// answer, which means the fetch belongs before either of them rather than +/// inside whichever happens to run first. +fn refresh_origin(root: &Path) { // Pruned, because a remote-tracking ref for a branch deleted upstream still // exists locally and would be read as something the forge still serves. + // + // A failure is deliberately not fatal here. Offline, the audit still has + // every ref this clone already holds, and refusing to run at all would make + // the pre-publication check unavailable exactly when a reviewer is most + // likely to reach for it. What must not happen is a silent success, and the + // caller reports the staleness instead. Command::new("git") .args(["fetch", "-q", "--prune", "origin"]) .current_dir(root) .output() .ok(); +} + +fn history(root: &Path) -> Result> { let listed = git_lines(root, &["log", "--remotes=origin", "--format=%H%x1f%B%x1e"])?; let mut surfaces = Vec::new(); for record in listed.split('\u{1e}') { @@ -376,32 +394,63 @@ fn blob_shas(root: &Path, shas: &[String]) -> Result> { .stderr(Stdio::null()) .spawn() .map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; - { - let mut stdin = child - .stdin - .take() - .ok_or_else(|| Fatal::new("git cat-file: no stdin"))?; - for sha in shas { - writeln!(stdin, "{sha}") - .map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; - } - } - let output = child - .wait_with_output() + let mut stdin = child + .stdin + .take() + .ok_or_else(|| Fatal::new("git cat-file: no stdin"))?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| Fatal::new("git cat-file: no stdout"))?; + // Both pipes move at once, for the reason `selection::not_text_paths` gives + // at greater length: `--batch-check` answers each object as it reads it, at + // roughly fifty bytes an answer, so it fills its stdout pipe -- 64 KiB on + // Linux -- somewhere near the thirteen-hundredth object and stops reading + // stdin. A parent that writes the whole list first is then blocked on a full + // stdin pipe while the child is blocked on a stdout pipe nobody is draining. + // Every repository this audit is meant for is far past that count, so + // writing first is not a rare hang, it is the ordinary case. + let mut answered: Vec = Vec::new(); + let written = std::thread::scope(|scope| { + let writer = scope.spawn(move || { + for sha in shas { + writeln!(stdin, "{sha}")?; + } + // Dropped here, and closing stdin is what tells `--batch-check` the + // list is finished. Without it the child waits for more input that + // is never coming and the read below never sees end of file. + drop(stdin); + Ok::<(), std::io::Error>(()) + }); + let drained = stdout.read_to_end(&mut answered); + // The writer's own error outranks the drain's: a child that died early + // shows up here as a broken pipe, and the drain merely stops. + writer.join().map_or_else( + |_| { + Err(std::io::Error::other( + "git cat-file: writer thread panicked", + )) + }, + |result| result.and_then(|()| drained.map(|_| ())), + ) + }); + written.map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; + let status = child + .wait() .map_err(|error| Fatal::new(format!("git cat-file: {error}")))?; // Reported rather than swallowed, for the reason `keep_blobs` in // `guard::scope` gives: no stdout means no known kinds, the filter below // then keeps nothing, and a repository whose objects could not be identified // would be audited as a repository with nothing in it. - if !output.status.success() { + if !status.success() { return Err(Fatal::new(format!( "git cat-file --batch-check exited {}: cannot tell which of {} reachable \ object(s) are blobs, and reading none of them would report as a clean tree", - output.status.code().unwrap_or(-1), + status.code().unwrap_or(-1), shas.len() ))); } - let text = String::from_utf8_lossy(&output.stdout); + let text = String::from_utf8_lossy(&answered); for line in text.lines() { let fields: Vec<&str> = line.split_whitespace().collect(); if let [name, "blob", ..] = fields.as_slice() { @@ -554,6 +603,12 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { it has." ); + // Every read below is about what the forge will serve, so every read below + // wants the same ref set: fetched, and pruned of branches the forge no + // longer has. This ran inside `history`, three lines further down, which + // left `reachable_blobs` walking whatever the last fetch happened to leave. + refresh_origin(root); + // The pull refs are fetched FIRST, because `reachable_blobs` walks them: // a blob that only ever existed on a pull-request head is served by the // forge for good, and it is in no branch this clone has otherwise. From 7335700062d68fa5ce8ebeac8a98ee9aa2655c02 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:42:38 +0900 Subject: [PATCH 09/21] Stop the pin walk at a boundary, and at an error Two defects in one walk, both of them the shape this branch exists to remove. `walker.build().flatten()` dropped every `Err` the walk yielded, so a directory that could not be entered hid whatever hook configuration was inside it and the guard reported the pins it did manage to read as the whole answer. A walk that did not finish is a could-not-look, and it now exits 2 naming the directories. The walk also entered submodules. `filter_entry` excluded the NAME `.git`, which is a directory in an ordinary checkout and a FILE in an initialized submodule -- so excluding the file left the directory around it perfectly traversable. Every pin in every submodule was read, asked about over the network, and reported against a repository that does not own it. A `.git` file is what git itself calls the boundary. --- src/pins.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/src/pins.rs b/src/pins.rs index 503bec5..03fb696 100644 --- a/src/pins.rs +++ b/src/pins.rs @@ -129,8 +129,9 @@ const LEFTHOOK_CONFIGS: &[&str] = &[ /// reviewer can see or a runner will find in a fresh clone. Sorted, because a /// report whose order depends on directory iteration diffs against itself /// between two runs that found the same thing. -fn hook_configs(root: &Path) -> Vec { +fn hook_configs(root: &Path) -> Result> { let mut found = Vec::new(); + let mut unreadable: Vec = Vec::new(); let mut walker = WalkBuilder::new(root); walker // Hook configuration is dotted by convention -- `.pre-commit-config.yaml` @@ -141,11 +142,43 @@ fn hook_configs(root: &Path) -> Vec { .git_global(true) .git_exclude(true) .parents(true) - // The object database is not the work tree. With `hidden` off the walk - // would descend into `.git` and read a few thousand files that no hook - // manager has ever looked at. - .filter_entry(|entry| entry.file_name() != std::ffi::OsStr::new(".git")); - for entry in walker.build().flatten() { + .filter_entry(|entry| { + // The object database is not the work tree. With `hidden` off the + // walk would descend into `.git` and read a few thousand files that + // no hook manager has ever looked at. + if entry.file_name() == std::ffi::OsStr::new(".git") { + return false; + } + // A submodule is another repository, and its pins are its own. The + // name test above does not stop the walk entering one: an + // initialized submodule carries `.git` as a FILE, so excluding that + // name excludes the file and leaves the directory around it + // traversable. The walk then read the submodule's configs and this + // guard asked a remote about every pin in them -- work charged to + // the wrong repository, and a stale pin reported against a tree that + // does not own it. A gitlink is what git itself calls the boundary. + if entry.depth() > 0 && entry.file_type().is_some_and(|kind| kind.is_dir()) { + let dot_git = entry.path().join(".git"); + if dot_git.is_file() { + return false; + } + } + true + }); + // Not `.flatten()`. A directory the walk cannot enter yields an `Err` and + // nothing else, so flattening it away hid a configuration behind a + // permission and let this guard report a clean pin set for a tree it had not + // finished reading. That is the same defect `selection::by_walking` carries + // a note about, and the same answer: a walk that did not finish is a + // could-not-look, not a pass. + for result in walker.build() { + let entry = match result { + Ok(entry) => entry, + Err(error) => { + unreadable.push(error.to_string()); + continue; + } + }; if !entry.file_type().is_some_and(|kind| kind.is_file()) { continue; } @@ -156,8 +189,18 @@ fn hook_configs(root: &Path) -> Vec { found.push(entry.into_path()); } } + if !unreadable.is_empty() { + return Err(Fatal::new(format!( + "{} director{} under {} could not be read, so the hook configurations inside \ + them were never looked for and no pin in them was checked:\n {}", + unreadable.len(), + if unreadable.len() == 1 { "y" } else { "ies" }, + root.display(), + unreadable.join("\n ") + ))); + } found.sort(); - found + Ok(found) } /// A `rev:` that names nothing is not a pin, in either manager's spelling. @@ -240,7 +283,7 @@ pub(crate) fn read_pins(root: &Path) -> Result { let mut pins = Vec::new(); let mut notes = Vec::new(); let mut saw_pre_commit = false; - for path in hook_configs(root) { + for path in hook_configs(root)? { let source = path .strip_prefix(root) .unwrap_or(&path) @@ -535,6 +578,71 @@ mod tests { ); } + /// A submodule's pins are the submodule's, and asking about them here spends + /// a network round trip per pin on a tree this repository does not own -- and + /// reports the answer against the wrong repository. + /// + /// The walk excluded the NAME `.git`, which is a directory in an ordinary + /// checkout and a FILE in an initialized submodule. Excluding the file left + /// the directory around it perfectly traversable, so the walk went straight + /// in. `.git` as a file is what git itself calls the boundary. + #[test] + fn a_submodules_configs_belong_to_the_submodule() { + let dir = tree("gitlink"); + write( + &dir, + ".pre-commit-config.yaml", + "repos:\n - repo: https://example.test/a\n rev: v1.0.0\n hooks:\n - id: x\n", + ); + write(&dir, "vendored/.git", "gitdir: ../.git/modules/vendored\n"); + write( + &dir, + "vendored/.pre-commit-config.yaml", + "repos:\n - repo: https://example.test/b\n rev: v2.0.0\n hooks:\n - id: y\n", + ); + + let found = read_pins(&dir).unwrap().pins; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].repo, "https://example.test/a"); + } + + /// A directory the walk cannot enter is a could-not-look, not a clean tree. + /// + /// `walker.build().flatten()` dropped the `Err` and the walk carried on, so a + /// configuration behind a permission was never found and this guard reported + /// every pin it did manage to read as the whole answer. + #[test] + #[cfg(unix)] + fn a_directory_that_cannot_be_entered_is_not_a_clean_pin_set() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tree("unreadable"); + write( + &dir, + ".pre-commit-config.yaml", + "repos:\n - repo: https://example.test/a\n rev: v1.0.0\n hooks:\n - id: x\n", + ); + write( + &dir, + "closed/.pre-commit-config.yaml", + "repos:\n - repo: https://example.test/b\n rev: v2.0.0\n hooks:\n - id: y\n", + ); + let closed = dir.join("closed"); + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let read = read_pins(&dir); + // Root, or a filesystem that ignores the mode, can read it anyway; there + // is nothing to assert about a walk that did in fact finish. + if std::fs::read_dir(&closed).is_ok() { + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o755)).ok(); + return; + } + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o755)).ok(); + + let error = read.expect_err("an unreadable directory read as a complete answer"); + assert!(error.to_string().contains("could not be read"), "{error}"); + } + #[test] fn a_local_repo_has_no_pin_to_check() { let dir = tree("local"); From ed7569462b8bddfbfdad94b89bb1d7975762f8de Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:42:48 +0900 Subject: [PATCH 10/21] Separate "nothing is declared not-text" from "nobody could ask" `not_text_paths` returned an empty list when the spawn failed, when the pipes were missing, when the drain failed, and when the child exited non-zero -- the same answer it gives for a repository that declares no `-text` attribute at all. The caller could not tell them apart. The consequence is an invented finding rather than a missed one, since a declared binary file stops being excluded and an `encoding` or `allowed_scripts` rule then reports on bytes nobody wrote as text. That is the safe direction to fail in, and it is still a claim about a question this tool never got an answer to. `index_bytes`, twenty lines below, carries a comment saying `None` and an empty list must not fold together; this was the same fold in the same module. The reason travels to `Scan`, which already collects unreadable surfaces and already exits 2 on them, so the reader is told which of the two answers they are holding. --- src/scan.rs | 13 ++++++++++-- src/selection.rs | 52 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/scan.rs b/src/scan.rs index bcb1cf7..3be5074 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -59,11 +59,20 @@ pub(crate) struct Scan<'a> { impl<'a> Scan<'a> { pub(crate) fn new(root: &'a Path, policy: &'a Policy) -> Self { + // A `.gitattributes` question that could not be answered is seeded into + // the unreadable list rather than dropped, because the scan continues + // either way and the reader has to be told which of the two answers they + // are holding: nothing is declared not-text, or nobody could find out. + let (not_text, unmeasured) = not_text_paths(root); + let mut unreadable = BTreeSet::new(); + if let Some(reason) = unmeasured { + unreadable.insert(reason); + } Self { root, policy, - not_text: not_text_paths(root), - unreadable: RefCell::new(BTreeSet::new()), + not_text, + unreadable: RefCell::new(unreadable), } } diff --git a/src/selection.rs b/src/selection.rs index 045d101..6c9ae10 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -44,16 +44,37 @@ use crate::error::{Fatal, Result}; /// text somebody here wrote, so these are skipped -- and counted, because "we /// did not check these" and "these were clean" must never look the same on the /// way out. -pub(crate) fn not_text_paths(root: &Path) -> Vec { +/// +/// The second half of the answer is the reason it could not be given. An empty +/// list means the repository declares nothing `-text`; a `Some` reason means the +/// question was never answered, and the two must not arrive looking alike -- +/// `index_bytes` in this same module carries a note saying exactly that about +/// `None` and an empty list, and this function used to break the rule its +/// neighbour states. The consequence of folding them was not a missed finding +/// but an invented one: a declared binary file stops being excluded, so an +/// `encoding` or `allowed_scripts` rule reports on bytes nobody wrote as text. +/// That is the safe direction to fail in and still an unmeasured claim. +pub(crate) fn not_text_paths(root: &Path) -> (Vec, Option) { let Some(listed) = index_bytes(root) else { // No git, or no repository. The declaration is optional, and its absence // means nothing is declared -- not that something failed. - return Vec::new(); + return (Vec::new(), None); }; if listed.is_empty() { - return Vec::new(); + return (Vec::new(), None); } + let unmeasured = |reason: &str| { + ( + Vec::new(), + Some(format!( + ".gitattributes: {reason}, so which paths this repository declares are not \ + text is unknown. Every tracked path was treated as text, which means a \ + declared binary file was searched by the content rules rather than skipped." + )), + ) + }; + let Ok(mut child) = Command::new("git") .args(["check-attr", "--stdin", "-z", "text"]) .current_dir(root) @@ -62,10 +83,10 @@ pub(crate) fn not_text_paths(root: &Path) -> Vec { .stderr(Stdio::null()) .spawn() else { - return Vec::new(); + return unmeasured("git check-attr could not be started"); }; let (Some(mut sink), Some(mut source)) = (child.stdin.take(), child.stdout.take()) else { - return Vec::new(); + return unmeasured("git check-attr gave no pipe to speak to"); }; // The two pipes move at the same time, on two threads, and that is not a @@ -90,8 +111,20 @@ pub(crate) fn not_text_paths(root: &Path) -> Vec { // somebody waits for it, and its status is the only thing that separates a // complete answer from a truncated one. let finished = child.wait(); - if drained.is_err() || !finished.is_ok_and(|status| status.success()) { - return Vec::new(); + if drained.is_err() { + return unmeasured("its answer could not be read to the end"); + } + match finished { + Ok(status) if status.success() => {} + Ok(status) => { + return unmeasured(&format!( + "git check-attr exited {}", + status.code().unwrap_or(-1) + )) + } + Err(error) => { + return unmeasured(&format!("git check-attr could not be waited for: {error}")) + } } // `check-attr -z` emits path, attribute, value as three NUL-separated fields. @@ -105,7 +138,7 @@ pub(crate) fn not_text_paths(root: &Path) -> Vec { found.push(String::from_utf8_lossy(path).into_owned()); } } - found + (found, None) } /// Every path in git's index, NUL separated, exactly as git wrote them. @@ -656,10 +689,11 @@ mod tests { std::thread::spawn(move || { sender.send(not_text_paths(&root)).ok(); }); - let declared = receiver + let (declared, unmeasured) = receiver .recv_timeout(Duration::from_secs(60)) .expect("`git check-attr` did not answer: the pipes deadlocked"); + assert!(unmeasured.is_none(), "{unmeasured:?}"); assert!(declared.contains(&"capture.bin".to_owned()), "{declared:?}"); assert!( !declared.iter().any(|path| path.starts_with("tracked/")), From ed18867b09cef5ea590dca245962c65318094007 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:43:09 +0900 Subject: [PATCH 11/21] Refuse the editor path this shim cannot stand in front of Where `current_exe()` could not be resolved there was nothing to install as the command's editor, and `install_editor` printed a warning ending "This is not a pass" and returned -- after which the caller execed the command anyway. So the one path the editor re-entry exists to close stayed open, and a run that could not check the text still published it, at exit 0, while saying it was not a pass. There is no safe way to continue here. The body does not exist yet, so it cannot be checked now, and after the hand-off there is no process left to check it later. It exits 2. Two more in the same file. Visibility is now read out of a parsed document rather than found by scanning for a quoted name. `json_string_field` answered with the first textual occurrence of the key anywhere -- nested, or inside a string value, or in a description quoting the word -- and a `public-target` decision was made on it. YAML 1.2 is a superset of JSON, so the parser already in the dependency tree reads a forge response without adding one. And the stdin replay splits by platform. Unlink-on-open is a Unix property: Windows refuses to remove a file while a handle is open, and that branch spawns rather than execs, so the temp file survived the run holding the exact body the command published, in a directory every account on the machine can read. The branch that does not exec has a process to write a pipe with, and now uses one. The handoff tests no longer assume GNU coreutils: `timeout` is absent on macOS, and BSD `wc -c` pads its count, either of which fails the suite for the environment rather than for the shim. --- src/shim.rs | 110 +++++++++++++++++++++++++++----------- tests/shim_handoff_cli.rs | 25 ++++++++- 2 files changed, 102 insertions(+), 33 deletions(-) diff --git a/src/shim.rs b/src/shim.rs index 3d10697..3ce16da 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -635,27 +635,39 @@ fn forge_field(program: &str, args: &[&str], field: Option<&str>) -> Option Option { + serde_yaml_ng::from_str::(text).ok() +} + fn json_string_field(text: &str, field: &str) -> Option { - let needle = format!("\"{field}\""); - let start = text.find(&needle)? + needle.len(); - let rest = text.get(start..)?; - let colon = rest.find(':')? + 1; - let rest = rest.get(colon..)?.trim_start(); - let rest = rest.strip_prefix('"')?; - let end = rest.find('"')?; - Some(rest[..end].to_string()) + let parsed = json_value(text)?; + let value = parsed.get(field)?; + // A number or a bool spelled where a string was expected is still an answer + // the caller can use; a nested object is not. + match value { + serde_yaml_ng::Value::String(found) => Some(found.clone()), + serde_yaml_ng::Value::Number(found) => Some(found.to_string()), + serde_yaml_ng::Value::Bool(found) => Some(found.to_string()), + _ => None, + } } fn json_bool_field(text: &str, field: &str) -> bool { - let needle = format!("\"{field}\""); - let Some(start) = text.find(&needle) else { - return false; - }; - let rest = &text[start + needle.len()..]; - let Some(colon) = rest.find(':') else { - return false; - }; - rest[colon + 1..].trim_start().starts_with("true") + json_value(text) + .as_ref() + .and_then(|parsed| parsed.get(field)) + .and_then(serde_yaml_ng::Value::as_bool) + .unwrap_or(false) } /// Run one checker over one subject. @@ -878,13 +890,21 @@ fn install_editor( variable: &str, own: Option<&Path>, argv: &[String], -) { +) -> Result<()> { let Some(exe) = own else { - eprintln!( + // Refused, not warned. This printed the sentence below and then returned, + // and the caller went on to exec the command -- so the one path the + // editor re-entry exists to close stayed open, and the run that could + // not check the text still published it. The warning even said "This is + // not a pass" while exiting 0, which is the shape `explicit-unknown` + // names. There is no safe way to continue: the body does not exist yet, + // so it cannot be checked now, and after the hand-off there is no + // process left here to check it later. + return Err(Fatal::new(format!( "{name}: the body will be composed in an editor, and this shim could not find its \ - own path to stand in front of it, so nothing was checked. This is not a pass." - ); - return; + own path to stand in front of that editor. Nothing was published, because \ + nothing could be checked." + ))); }; let editor = nonempty_env(variable) .or_else(|| nonempty_env("GIT_EDITOR")) @@ -909,6 +929,7 @@ fn install_editor( "{name}: the body will be composed in an editor, so the editor is the checkpoint: what \ it leaves in the file is checked when it closes." ); + Ok(()) } /// Run the user's editor, then judge what it produced. @@ -1000,8 +1021,15 @@ fn edit_and_check(root: &Path, policy: &Policy, name: &str, argv: &[String]) -> /// every death by a signal, so a command killed by SIGINT reported a plain exit /// 1, which in this tool's own vocabulary is a policy violation. #[cfg(unix)] -fn hand_off(command: &mut Command, name: &str) -> Result { +fn hand_off(command: &mut Command, name: &str, stdin: Option<&[u8]>) -> Result { use std::os::unix::process::CommandExt; + // A file rather than a pipe, and only here. After `exec` there is no process + // left to feed a pipe, so the bytes have to be somewhere the kernel can hand + // over on its own -- and the file is unlinked while still open, which leaves + // the contents reachable through the descriptor and through no name at all. + if let Some(bytes) = stdin { + command.stdin(Stdio::from(replayed(bytes)?)); + } // `arg0` so the command sees the name it was invoked under rather than the // path this shim found it at. let error = command.arg0(name).exec(); @@ -1009,11 +1037,34 @@ fn hand_off(command: &mut Command, name: &str) -> Result { } #[cfg(not(unix))] -fn hand_off(command: &mut Command, name: &str) -> Result { +fn hand_off(command: &mut Command, name: &str, stdin: Option<&[u8]>) -> Result { + // A pipe rather than a file, and for a reason that is not symmetry: this + // branch does not exec, it spawns and waits, so there IS a process left to + // write the bytes -- and unlink-on-open is a Unix property. Windows refuses + // to remove a file while a handle is open, so the temp file the Unix branch + // relies on would survive the run holding the exact body the command + // published, in a directory every account on the machine can read. + let fed = stdin.is_some(); + if fed { + command.stdin(Stdio::piped()); + } + let mut child = command + .spawn() + .map_err(|error| Fatal::new(format!("{name}: {error}")))?; + if let (true, Some(bytes)) = (fed, stdin) { + let mut sink = child + .stdin + .take() + .ok_or_else(|| Fatal::new(format!("{name}: no stdin to replay the body into")))?; + let owned = bytes.to_vec(); + std::thread::spawn(move || { + sink.write_all(&owned).ok(); + }); + } // No exec to hand off to, so the closest thing: run it and carry its code // out. What this platform cannot preserve, it cannot preserve. - let status = command - .status() + let status = child + .wait() .map_err(|error| Fatal::new(format!("{name}: {error}")))?; std::process::exit(status.code().unwrap_or(1)); } @@ -1130,17 +1181,14 @@ pub(crate) fn run(root: &Path, policy: &Policy, name: &str, argv: &[OsString]) - // Everything the command needs that this shim took from it, arranged before // the hand-off because after it there is no arranging anything: the body // read off stdin, and the editor it is about to open. - if let Some(bytes) = collected.stdin.as_deref() { - command.stdin(Stdio::from(replayed(bytes)?)); - } let editor_env = shim .editor_env .as_deref() .filter(|_| in_scope && !collected.body_given && !collected.web); if let Some(variable) = editor_env { - install_editor(&mut command, name, variable, own.as_deref(), &words); + install_editor(&mut command, name, variable, own.as_deref(), &words)?; } - hand_off(&mut command, name) + hand_off(&mut command, name, collected.stdin.as_deref()) } #[cfg(test)] diff --git a/tests/shim_handoff_cli.rs b/tests/shim_handoff_cli.rs index 0516f07..3ed7737 100644 --- a/tests/shim_handoff_cli.rs +++ b/tests/shim_handoff_cli.rs @@ -102,7 +102,14 @@ impl Run<'_> { root.join("bin").display(), std::env::var("PATH").unwrap_or_default() ); - let mut command = if self.guarded { + // `timeout` is GNU coreutils and is not on a stock macOS or BSD, where + // `Command::new("timeout")` fails to spawn and every guarded case would + // fail for the environment rather than for the shim. Its whole job here + // is to turn a deadlock into a failure instead of a suite that hangs, so + // where it is missing the test still runs and a regression shows up as a + // hang rather than as a named failure -- worse, and better than a red + // suite on a machine that has nothing wrong with it. + let mut command = if self.guarded && has_timeout() { let mut guarded = Command::new("timeout"); guarded.arg("60").arg(env!("CARGO_BIN_EXE_uphold")); guarded @@ -149,6 +156,16 @@ impl Run<'_> { } } +/// Is GNU `timeout` on this machine to wrap a case that could hang? +fn has_timeout() -> bool { + Command::new("timeout") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + fn code(output: &Output) -> i32 { // 124 is `timeout` saying the run never finished, which is a deadlock // reported rather than waited on. @@ -181,7 +198,11 @@ scope = "always" ), &[( "faux", - "#!/bin/sh\necho \"faux ran: $*\"\necho \"faux body bytes: $(wc -c)\"\n", + // `tr -d ' '` because BSD and macOS `wc` pad the count with leading + // spaces where GNU does not, so the printed line would not match the + // length asserted below for a reason that has nothing to do with the + // shim. + "#!/bin/sh\necho \"faux ran: $*\"\necho \"faux body bytes: $(wc -c | tr -d ' ')\"\n", )], ); From a21cfdb491667381643ebad46d4172710a8e25fd Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:43:36 +0900 Subject: [PATCH 12/21] Say what an inherited shim and a blank checker entry do `load` merges only `.rules` from an inherited file, so an inherited `[[shim]]` was dropped -- and dropped in the worst available way, because the `exec` rule that arrived with it survived and `validate_shims` then reported that rule's `command.before` as naming a shim nobody declared. The author was told to declare the shim they had in fact declared, in the file they were pointing at. Merging is the other answer and it is not obviously right: a shim stands in front of a real command, and inheriting one puts a program in front of `git` on the strength of a path in an `[inherit]` line. Until that is a decision somebody makes on purpose, it is refused and says so. A `command.before` entry with no command in it is refused too. `[" "]` parses, and the set of checked commands is built from `split_whitespace().next()`, which answers `None` for it -- so the entry fell out of the check and took the rule's whole reason for existing with it. `CommandWhere::matches` can never match it either, so there is no reading of a blank entry that does anything at all. --- src/config.rs | 118 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 2325d40..99b6068 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1084,7 +1084,32 @@ pub(crate) fn load(root: &Path, policy_path: &Path) -> Result { for relative in &inherit.paths { let path = root.join(relative); let extended = read_to_string(&path)?; - inherited.extend(parse(&path, &extended)?.rules.into_values()); + let parsed = parse(&path, &extended)?; + // Refused rather than merged, and refused rather than ignored. Only + // `.rules` is merged below, so an inherited `[[shim]]` used to vanish -- + // and vanish in the worst possible way, because the `exec` rule that + // came with it survived, and `validate_shims` then reported that rule's + // `command.before` as naming a shim nobody declared. The author would be + // told to declare the shim they had in fact declared. Merging is the + // other available answer and it is not obviously right: a shim is the + // thing that stands in front of a command, and inheriting one silently + // puts a program in front of `git` on the strength of a path in an + // `[inherit]` line. Until that is a decision somebody makes on purpose, + // say so here. + if !parsed.shims.is_empty() { + return Err(Fatal::at( + policy_path, + format!( + "{} declares {} `[[shim]]` table(s), and an inherited file's shims are \ + not adopted. A shim stands in front of a real command, which is not \ + something to acquire by inheriting a path. Move the `[[shim]]` into \ + this file", + path.display(), + parsed.shims.len() + ), + )); + } + inherited.extend(parsed.rules.into_values()); } let own_ids: Vec<&str> = file.rules.keys().map(String::as_str).collect(); @@ -1214,6 +1239,31 @@ fn validate_shims(policy_path: &Path, rules: &[Rule], shims: &[crate::shim::Shim // much of the subcommand path as the rule wanted to scope itself to, which // is not the shim's business -- `[[shim]] command = "gh"` stands in front // of `gh pr create` and of every other `gh`. + // A blank entry is refused before the set is built. `[" "]` parses, and + // `split_whitespace().next()` answers `None` for it, so it used to drop out + // here and take its rule's whole reason for existing with it: the rule stays + // an `exec` check, stands in front of nothing, and reports clean forever. + // `CommandWhere::matches` can never match it either, so there is no reading + // of a blank entry that does anything. + for rule in rules.iter().filter(|rule| rule.is(Check::Exec)) { + let Some(where_) = rule.command.as_ref() else { + continue; + }; + for line in &where_.before { + if line.split_whitespace().next().is_none() { + return Err(Fatal::at( + policy_path, + format!( + "`command.before` on {:?} has an entry with no command in it. An \ + empty entry names nothing, so it stands in front of nothing, and \ + the rule reads as one that passes", + rule.id + ), + )); + } + } + } + let checked: BTreeSet<&str> = rules .iter() .filter(|rule| rule.is(Check::Exec)) @@ -1616,6 +1666,72 @@ mod tests { assert!(text.contains("no `[[shim]]` declares"), "{text}"); } + /// A `before` entry with no command in it stands in front of nothing. + /// + /// `[" "]` parses, and the set of checked commands is built from + /// `split_whitespace().next()`, which answers `None` for it -- so the entry + /// used to fall out of the check entirely and take the rule's whole reason + /// for existing with it. The rule stays an `exec` check, is consulted by no + /// shim, and reports clean for good. + #[test] + fn a_before_entry_naming_no_command_is_refused() { + let error = policy_from( + r#" + [rule.body] + message = "no" + exec = "checker" + + [rule.body.command] + before = [" "] + "#, + ) + .unwrap_err(); + let text = error.to_string(); + assert!(text.contains("no command in it"), "{text}"); + assert!(text.contains("body"), "{text}"); + } + + /// An inherited `[[shim]]` is refused rather than dropped on the floor. + /// + /// Only `.rules` is merged, so the shim vanished and the `exec` rule that + /// arrived with it did not -- and `validate_shims` then told the author that + /// their `command.before` named a shim nobody declared, which they had in + /// fact declared, in the file they were pointing at. + #[test] + fn a_shim_in_an_inherited_file_is_refused_rather_than_ignored() { + let dir = std::env::temp_dir().join(format!( + "uphold-config-inherited-shim-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("shared.toml"), + r#" + [[shim]] + command = "gh" + match = ["pr:create"] + text_flags = ["-b"] + scope = "always" + "#, + ) + .unwrap(); + let path = dir.join("rg-policy.toml"); + std::fs::write( + &path, + r#" + [inherit] + paths = ["shared.toml"] + "#, + ) + .unwrap(); + + let error = load(&dir, &path).unwrap_err(); + let text = error.to_string(); + assert!(text.contains("`[[shim]]`"), "{text}"); + assert!(text.contains("not adopted"), "{text}"); + std::fs::remove_dir_all(&dir).ok(); + } + /// The verified bug: two parameters that look enforced, read by nothing. /// /// This exact config loaded and ran without complaint -- `allowed_owners` From 6c9e538c07a5924c0c3d544c0701210fbd2d3385 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:43:46 +0900 Subject: [PATCH 13/21] Refuse a malformed inherit list, and read a remote as one entry A non-string entry in `inherit.sets` or `inherit.paths` was filtered out in silence, which makes this reconcile resolve FEWER rules than the engine reading the same file. A claim on one of the rules that went missing then failed as "no seam here supplies it" -- exit 1, which in this tool means the claim is false. It is not false; nobody looked. It now exits 2, the same answer a missing inherited file already gets a few lines below. `lefthook_remote` was one pattern alternating between this repository's slug and the string `hooks/lefthook.yml`, so either half alone matched. A fork, a mirror, or an unrelated project following the same conventional filename was credited with running every guard published here, because the branch it feeds grants all of them. Both halves must now appear in the SAME `remotes:` entry, which means reading the block by indentation rather than scanning it for a needle. --- tests/test_uphold_check.py | 55 +++++++++++++++++++++++-- uphold_check.py | 82 +++++++++++++++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/tests/test_uphold_check.py b/tests/test_uphold_check.py index 2e67d0a..f52e25f 100644 --- a/tests/test_uphold_check.py +++ b/tests/test_uphold_check.py @@ -1028,9 +1028,58 @@ def test_the_slug_is_derived_from_the_cargo_manifest(self) -> None: self.assertEqual(uphold_check.upstream_url(), url) self.assertEqual(uphold_check.upstream_slug(), "/".join(url.split("/")[-2:])) - self.assertRegex( - uphold_check.lefthook_remote().pattern, - rf"^{uphold_check.upstream_slug()}\\b\|", + # Asserted through the reader rather than against its pattern: the + # pattern is gone, and what the drift would break is the recognition. + slug = uphold_check.upstream_slug() + self.assertTrue( + uphold_check.includes_our_lefthook_remote( + f"remotes:\n" + f" - git_url: https://github.com/{slug}\n" + f" ref: v1.0.0\n" + f" configs:\n" + f" - hooks/lefthook.yml\n" + ) + ) + + def test_a_remote_is_only_ours_when_one_entry_says_both(self) -> None: + """Neither half alone, because the branch it feeds grants every stage. + + This read as an alternation, so a fork of this repository pinning its own + config, or an unrelated project whose config happens to carry the + conventional filename, was credited with running every guard published + here. + """ + slug = uphold_check.upstream_slug() + ours_but_another_config = ( + f"remotes:\n" + f" - git_url: https://github.com/{slug}\n" + f" configs:\n" + f" - hooks/something-else.yml\n" + ) + our_filename_from_elsewhere = ( + "remotes:\n" + " - git_url: https://github.com/someone/unrelated\n" + " configs:\n" + " - hooks/lefthook.yml\n" + ) + split_across_two_entries = ( + f"remotes:\n" + f" - git_url: https://github.com/{slug}\n" + f" configs:\n" + f" - hooks/something-else.yml\n" + f" - git_url: https://github.com/someone/unrelated\n" + f" configs:\n" + f" - hooks/lefthook.yml\n" + ) + + self.assertFalse( + uphold_check.includes_our_lefthook_remote(ours_but_another_config) + ) + self.assertFalse( + uphold_check.includes_our_lefthook_remote(our_filename_from_elsewhere) + ) + self.assertFalse( + uphold_check.includes_our_lefthook_remote(split_across_two_entries) ) def test_the_python_manifest_names_the_same_repository(self) -> None: diff --git a/uphold_check.py b/uphold_check.py index fa67e2e..c858cbe 100755 --- a/uphold_check.py +++ b/uphold_check.py @@ -201,15 +201,78 @@ def upstream_slug() -> str: return "/".join(parts[-2:]) -@functools.cache -def lefthook_remote() -> re.Pattern[str]: - return re.compile(rf"{re.escape(upstream_slug())}\b|hooks/lefthook\.yml") +def includes_our_lefthook_remote(text: str) -> bool: + """Does one `remotes:` entry name THIS repository and take its config? + + Both halves, in the SAME entry. This was one regex alternating between the + two, so either alone was enough: a remote whose url merely contained the + slug, or a remote pulling a file that happens to be called + `hooks/lefthook.yml` out of somebody else's repository. Either match granted + every stage this manifest publishes, because the branch it feeds assumes the + remote IS this repository's config -- so a fork, a mirror, or an unrelated + project following the same conventional filename was credited with running + every guard here. + + Read by indentation rather than by pattern, because a `remotes:` item spells + its url and its config on separate lines, and the question is which lines + belong to the same item. + """ + slug = upstream_slug() + entries: list[list[str]] = [] + current: list[str] | None = None + marker = -1 + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip()) + stripped = line.lstrip() + if stripped.startswith("- "): + # A less-indented item ends the one before it and is not part of it. + if current is not None and indent <= marker: + entries.append(current) + current = None + if current is None: + current, marker = [], indent + elif current is not None and indent <= marker: + entries.append(current) + current = None + if current is not None: + current.append(stripped) + if current is not None: + entries.append(current) + return any( + any(re.search(rf"{re.escape(slug)}\b", line) for line in entry) + and any("hooks/lefthook.yml" in line for line in entry) + for entry in entries + ) class CouldNotLook(Exception): """Raised where the tool cannot inspect what it claims to check (exit 2).""" +def _string_list(value: object, field: str) -> list[str]: + """Every entry, or a refusal naming the one that is not a string. + + The alternative -- keeping the strings and dropping the rest -- answers a + question nobody asked, because the engine reading the same file will not + silently agree. Whatever this list is short by is a rule the engine runs and + this reconcile has never heard of. + """ + if not isinstance(value, list): + raise CouldNotLook( + f"{CONTENT_POLICY}: {field} must be a list, not {type(value).__name__}" + ) + for index, entry in enumerate(value): + if not isinstance(entry, str): + raise CouldNotLook( + f"{CONTENT_POLICY}: {field}[{index}] is {type(entry).__name__}, not a string. " + f"Which rules it would have inherited cannot be resolved, so what this " + f"repository runs is unknown" + ) + return list(value) + + def discover_root() -> Path: """Walk up from cwd until the declaration is found.""" candidate = Path.cwd().resolve() @@ -407,7 +470,7 @@ def runs_principles(root: Path) -> tuple[bool, set[str], str]: } stages |= ran how.append(f"{LEFTHOOK_CONFIG} runs the binary directly") - if lefthook_remote().search(text): + if includes_our_lefthook_remote(text): # The remote config is this repository's `hooks/lefthook.yml`, which # wires every stage the manifest publishes. A consumer that includes # it has them all, which is why including it is the one form that @@ -546,8 +609,15 @@ def content_policy_rules( if not isinstance(inherit, dict): raise CouldNotLook(f"{CONTENT_POLICY}: 'inherit' must be a table") - names = [value for value in inherit.get("sets", []) if isinstance(value, str)] - relatives = [value for value in inherit.get("paths", []) if isinstance(value, str)] + # Refused, not filtered. These two comprehensions dropped a non-string entry + # in silence, which turns a malformed declaration into a SHORTER list of + # inherited rules than the engine resolves -- and a claim on one of the rules + # that went missing then fails as "no seam here supplies it", exit 1, which + # in this tool means the claim is false. It is not false; nobody looked. The + # honest answer is exit 2, the same one a missing inherited file gets a few + # lines below. + names = _string_list(inherit.get("sets", []), "inherit.sets") + relatives = _string_list(inherit.get("paths", []), "inherit.paths") # Merged in the order the engine merges them -- bundled sets, then the # named paths, then the repository's own rules -- so a rule the repository From 218fc892a3116b18d17fbeb6a3d547b5dd4d3bb7 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:43:54 +0900 Subject: [PATCH 14/21] Describe an unreadable file as could-not-look Two lines contradicting the contract stated thirty lines above them. README said a rule that cannot see a file "reports it clean", which was true of the walk it described and is now the defect this branch removed; it also left a command description ending mid-clause at "resolved to". REFERENCE still described the editor path as warning and exec'ing where `current_exe()` cannot be resolved. It refuses now, and the reason is worth stating: the argument that a guard which stops work gets removed belongs to a guard that looked and found nothing, not to one that never looked. --- README.md | 7 ++++--- docs/REFERENCE.md | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c4a0428..e236f89 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ not look — see [`explicit-unknown`](principles/explicit-unknown.toml). ```sh uphold scan # content rules over the tree uphold scan --text - # a commit message, release note, PR body -uphold rules --effective # every rule inheritance resolved to +uphold rules --effective # every rule inheritance resolved to, and where each runs uphold guard --stage pre-push # the guards for that git hook uphold shim gh pr create ... # stand in front of a command, then exec uphold audit --for-publication # before flipping private -> public @@ -118,8 +118,9 @@ tables — an absent table is a place the rule does not run. Full field referenc using ripgrep's search libraries, so a pattern written against `rg` keeps meaning what it meant. "Its own files" is **what git tracks**, not a directory walk: a tracked file some ignore pattern also matches is still pushed and still -cloned, and a rule that cannot see a file reports it clean. `--text -` runs it -over prose that never becomes a file. `uphold rules --effective` prints what +cloned, and walking the tree hid exactly those from every rule. A selected file +that cannot be read is **not** reported clean — it is named, with its reason, and +the run exits `2`. `--text -` runs it over prose that never becomes a file. `uphold rules --effective` prints what inheritance actually resolved to, so nothing has to re-derive it. **`uphold guard --stage STAGE`** reads an *act* rather than a tree: the diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index ce6f09f..38525e9 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -535,8 +535,14 @@ rather than a flag because the re-entry has to work whether the binary is installed under its own name or as a link named for the command it shims. Where `current_exe()` cannot be resolved there is nothing to install as the -editor. That case warns loudly and execs anyway, the way an unresolvable target -does: a guard that becomes the reason work stops is a guard that gets removed. +editor, and the invocation is refused with exit `2`. It warned and execed anyway, +on the argument that a guard which stops work gets removed — but that argument +belongs to a guard that *looked* and found nothing, and this one never looked. +The body does not exist yet, so it cannot be checked now, and after the hand-off +there is no process left here to check it later. The text would be published +unexamined by the one path the editor re-entry exists to close. This is the only +place the shim refuses without having read anything, and `explicit-unknown` is +why: an unobserved property must not resolve to success. ## `uphold audit --for-publication` From fc3edd4ff4f82e9f99a73c2bbd98812a78b0b306 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 15:52:05 +0900 Subject: [PATCH 15/21] Report a fetch that failed, the way the comment said it did `refresh_origin` returned nothing and discarded both a launch error and a non-zero exit, while the comment directly above it said "What must not happen is a silent success, and the caller reports the staleness instead." The caller did no such thing: every reading after it went on against whatever refs the clone happened to hold, and the audit could still answer clean about a forge it never reached. It comes back as a note in `unreadable` now, which is what turns the run into an exit 2. The partial audit is still worth having offline, and it is no longer worth mistaking for a complete one. `inherit.disabled_rules` gets the same validation its two neighbours got in the previous commit. It was left filtering non-strings in silence, and it is the list where dropping an entry is worst: the engine refuses a `disabled_rules` id that names nothing inherited, so an entry that vanishes here is a load failure over there, and this tool would be reconciling a policy the binary will not accept. --- src/audit.rs | 37 +++++++++++++++++++++++++++---------- uphold_check.py | 11 ++++++++--- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/audit.rs b/src/audit.rs index 298276a..8fdaff0 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -115,20 +115,33 @@ fn git_lines(root: &Path, args: &[&str]) -> Result { /// Both readings are about what publication exposes, so both need the same /// answer, which means the fetch belongs before either of them rather than /// inside whichever happens to run first. -fn refresh_origin(root: &Path) { +/// A failure is not fatal and is not silent: it comes back as a note the caller +/// puts in `unreadable`, which is what turns the run into an exit 2. Offline, +/// the audit still has every ref this clone already holds and is worth running, +/// so refusing outright would make the pre-publication check unavailable exactly +/// when somebody reaches for it. What it must not do is answer "clean" about a +/// forge it could not reach -- and returning `()` while the doc comment claimed +/// the caller reported the staleness is how it did precisely that. +fn refresh_origin(root: &Path) -> Option { // Pruned, because a remote-tracking ref for a branch deleted upstream still // exists locally and would be read as something the forge still serves. - // - // A failure is deliberately not fatal here. Offline, the audit still has - // every ref this clone already holds, and refusing to run at all would make - // the pre-publication check unavailable exactly when a reviewer is most - // likely to reach for it. What must not happen is a silent success, and the - // caller reports the staleness instead. - Command::new("git") + let stale = |reason: String| { + Some(format!( + "git fetch --prune origin: {reason}. Every reading below is of the refs this \ + clone already had, so an object the forge holds and this clone has never seen \ + was not walked, and a branch deleted upstream was still read as one the forge \ + serves." + )) + }; + match Command::new("git") .args(["fetch", "-q", "--prune", "origin"]) .current_dir(root) .output() - .ok(); + { + Ok(output) if output.status.success() => None, + Ok(output) => stale(format!("exited {}", output.status.code().unwrap_or(-1))), + Err(error) => stale(error.to_string()), + } } fn history(root: &Path) -> Result> { @@ -607,12 +620,16 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { // wants the same ref set: fetched, and pruned of branches the forge no // longer has. This ran inside `history`, three lines further down, which // left `reachable_blobs` walking whatever the last fetch happened to leave. - refresh_origin(root); + let stale_refs = refresh_origin(root); // The pull refs are fetched FIRST, because `reachable_blobs` walks them: // a blob that only ever existed on a pull-request head is served by the // forge for good, and it is in no branch this clone has otherwise. let (retained, mut unreadable) = retained_pull_refs(root)?; + // Ahead of the readings it qualifies, because it qualifies all of them: what + // follows is an audit of the refs this clone happens to hold rather than of + // what the forge will serve. + unreadable.extend(stale_refs); let (mut surfaces, blob_unreadable) = reachable_blobs(root)?; unreadable.extend(blob_unreadable); surfaces.extend(history(root)?); diff --git a/uphold_check.py b/uphold_check.py index c858cbe..c302afc 100755 --- a/uphold_check.py +++ b/uphold_check.py @@ -641,9 +641,14 @@ def content_policy_rules( declared |= _rule_stages(read_toml(extra)) declared |= _rule_stages(policy) - disabled = { - value for value in inherit.get("disabled_rules", []) if isinstance(value, str) - } + # The third list in the same table, and it was left filtering in silence when + # the other two stopped. It is the one where dropping an entry is worst: the + # engine refuses a `disabled_rules` id that names nothing inherited, so a + # malformed entry that vanishes here is a load failure over there -- this + # tool reporting on a policy the binary will not even accept. + disabled = set( + _string_list(inherit.get("disabled_rules", []), "inherit.disabled_rules") + ) return declared, disabled, names, relatives From 83a54bf6f534e17d3c1910233719749f99da6389 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 16:16:50 +0900 Subject: [PATCH 16/21] Recognise a remote that names this repository without an owner Requiring `owner/name` in a `remotes:` entry rejected every git url that does not spell one, and lefthook takes any git url: a clone by filesystem path, by ssh, or from a mirror is still a remote naming this repository, and it carries no owner to check against. `scripts/consumer_check.sh` points its consumer at the checkout under test, which is a path -- so the one CI job that drives a real lefthook consumer refused a clean commit, reporting `prevent-ai-author` as a rule no seam here supplies. It is supplied; the remote naming the seam was not recognised. The bare repository name is accepted where the slug is absent. It is the weaker half and it is not the one doing the work: what the previous commit fixed, and what still holds, is that the repository and the config must appear in the SAME entry rather than either one alone. --- tests/test_uphold_check.py | 28 ++++++++++++++++++++++++++++ uphold_check.py | 26 +++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/test_uphold_check.py b/tests/test_uphold_check.py index f52e25f..c2db886 100644 --- a/tests/test_uphold_check.py +++ b/tests/test_uphold_check.py @@ -1041,6 +1041,34 @@ def test_the_slug_is_derived_from_the_cargo_manifest(self) -> None: ) ) + def test_a_remote_naming_this_repository_without_an_owner_is_still_ours( + self, + ) -> None: + """lefthook takes any git url, and most of them carry no `owner/name`. + + Requiring the slug rejected a clone by filesystem path, which is exactly + what `scripts/consumer_check.sh` writes: the parity harness points the + consumer at the checkout under test. A consumer wired the way the + documentation describes was reported as running no seam at all, and the + one CI job that drives a real lefthook consumer refused a clean commit. + """ + name = uphold_check.upstream_slug().rsplit("/", 1)[-1] + for url in ( + f"/home/runner/work/{name}/{name}", + f"git@github.com:HackingGate/{name}.git", + f"/tmp/mirror/{name}.git", + ): + with self.subTest(url=url): + self.assertTrue( + uphold_check.includes_our_lefthook_remote( + f"remotes:\n" + f" - git_url: {url}\n" + f" ref: v1.0.0\n" + f" configs:\n" + f" - hooks/lefthook.yml\n" + ) + ) + def test_a_remote_is_only_ours_when_one_entry_says_both(self) -> None: """Neither half alone, because the branch it feeds grants every stage. diff --git a/uphold_check.py b/uphold_check.py index c302afc..947288a 100755 --- a/uphold_check.py +++ b/uphold_check.py @@ -240,8 +240,32 @@ def includes_our_lefthook_remote(text: str) -> bool: current.append(stripped) if current is not None: entries.append(current) + # `owner/name` where the url spells one, and the bare repository name where + # it cannot. A remote is not always a forge url: lefthook takes any git url, + # so a clone by filesystem path, by ssh, or by any mirror is still a remote + # naming THIS repository, and it carries no owner to check. Requiring the + # slug rejected exactly that -- the parity harness clones the hooks + # repository by path, and a consumer wired the documented way was reported + # as running no seam at all. + # + # The name alone is weaker than the slug and it is not the load-bearing half. + # What CodeRabbit's finding was about, and what still holds, is that the two + # halves must appear in the SAME entry: a repository that is ours and a + # config that is ours, together, rather than either one on its own. + name = slug.rsplit("/", 1)[-1] + + def names_this_repository(line: str) -> bool: + if re.search(rf"{re.escape(slug)}\b", line): + return True + _, _, value = line.partition(":") + for word in value.split(): + tail = word.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1] + if tail == name: + return True + return False + return any( - any(re.search(rf"{re.escape(slug)}\b", line) for line in entry) + any(names_this_repository(line) for line in entry) and any("hooks/lefthook.yml" in line for line in entry) for entry in entries ) From 56d11579466c98d72d03f628ac3f5aadee39b953 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 16:26:47 +0900 Subject: [PATCH 17/21] Reject a remote that is somebody else's, not one that is anonymous The test was "does this url name us", and most git urls cannot answer it. lefthook takes any git url, so a consumer may clone this repository from a filesystem path or a mirror whose name says nothing -- and `scripts/consumer_check.sh` does exactly that on purpose, re-cloning to a neutral `$WORK/hooks` so that no home path reaches a file the consumer's own content policy then reads. The url it writes carries neither the owner nor the repository name. Demanding a slug there demanded evidence the format does not carry, and answering "no seam here supplies it" is exit 1: the claim is false. The claim was true. The one CI job that drives a real lefthook consumer refused a clean commit for it. A remote is rejected now only when it is identifiably somebody else's -- it spells a forge `owner/name` and the pair is not ours. A url with no host is a path, and a path is unidentifiable rather than foreign. What the previous commit fixed still holds: the remote and the config must appear in the SAME entry, so a fork pinning its own config is still not credited with running every guard here. The new test also stops writing a realistic runner workspace path. This repository's own `no-running-os-identity-metadata` rule reads the running home path and searches the tracked files for it, so `/home/runner/...` passed on a developer's machine and refused the scan on every CI runner -- which is the rule working, on a fact the test invented. --- tests/test_uphold_check.py | 11 ++++++-- uphold_check.py | 54 +++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/tests/test_uphold_check.py b/tests/test_uphold_check.py index c2db886..ccfeb25 100644 --- a/tests/test_uphold_check.py +++ b/tests/test_uphold_check.py @@ -1053,10 +1053,17 @@ def test_a_remote_naming_this_repository_without_an_owner_is_still_ours( one CI job that drives a real lefthook consumer refused a clean commit. """ name = uphold_check.upstream_slug().rsplit("/", 1)[-1] + # Neutral placeholders, because this repository's own + # `no-running-os-identity-metadata` rule reads the running home path and + # searches the tracked files for it -- and on a CI runner the home path + # is `/home/runner`, so writing a realistic workspace path here refused + # the scan on every runner while passing on every developer's machine. + # `scripts/consumer_check.sh` avoids the same trap by cloning to a + # neutral path, and says so. for url in ( - f"/home/runner/work/{name}/{name}", + f"/srv/example/work/{name}", f"git@github.com:HackingGate/{name}.git", - f"/tmp/mirror/{name}.git", + "/srv/example/neutral-clone-name", ): with self.subTest(url=url): self.assertTrue( diff --git a/uphold_check.py b/uphold_check.py index 947288a..17077f0 100755 --- a/uphold_check.py +++ b/uphold_check.py @@ -240,32 +240,44 @@ def includes_our_lefthook_remote(text: str) -> bool: current.append(stripped) if current is not None: entries.append(current) - # `owner/name` where the url spells one, and the bare repository name where - # it cannot. A remote is not always a forge url: lefthook takes any git url, - # so a clone by filesystem path, by ssh, or by any mirror is still a remote - # naming THIS repository, and it carries no owner to check. Requiring the - # slug rejected exactly that -- the parity harness clones the hooks - # repository by path, and a consumer wired the documented way was reported - # as running no seam at all. + # The test is not "does this url name us". It is "does this url name someone + # ELSE", because most git urls cannot answer the first question at all. # - # The name alone is weaker than the slug and it is not the load-bearing half. - # What CodeRabbit's finding was about, and what still holds, is that the two - # halves must appear in the SAME entry: a repository that is ours and a - # config that is ours, together, rather than either one on its own. - name = slug.rsplit("/", 1)[-1] - - def names_this_repository(line: str) -> bool: - if re.search(rf"{re.escape(slug)}\b", line): - return True + # lefthook takes any git url. A consumer may clone this repository from a + # filesystem path, from a mirror, or from a bare directory whose name says + # nothing -- `scripts/consumer_check.sh` does exactly that, cloning to a + # neutral `$WORK/hooks` on purpose, so the url the consumer writes carries + # neither the owner nor the repository name. Demanding the slug there is + # demanding evidence the format does not carry, and answering "no seam here + # supplies it" is answering exit 1 -- the claim is false -- about a + # repository whose only fault is cloning from a path. + # + # So a remote is rejected only when it is identifiably somebody else's: it + # spells a forge `owner/name` and that pair is not ours. Anything without a + # host is a path, and a path is unidentifiable rather than foreign. + # + # The load-bearing half of the previous fix is untouched: both the remote and + # `hooks/lefthook.yml` must appear in the SAME entry, so a fork pinning its + # own config, or an unrelated project pulling a file that happens to share + # the conventional name, is still not credited with running every guard here. + forge_url = re.compile( + r"(?:https?://|ssh://|git://|[\w.-]+@)[\w.-]+[/:](?P[\w.\-/]+)" + ) + + def could_be_this_repository(line: str) -> bool: _, _, value = line.partition(":") for word in value.split(): - tail = word.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1] - if tail == name: - return True - return False + found = forge_url.search(word) + if not found: + # No host, so no owner to disagree with: a path or a bare name. + continue + spelled = found.group("slug").rstrip("/").removesuffix(".git") + if "/" in spelled and not spelled.endswith(slug): + return False + return True return any( - any(names_this_repository(line) for line in entry) + any(could_be_this_repository(line) for line in entry if "git_url" in line) and any("hooks/lefthook.yml" in line for line in entry) for entry in entries ) From 95e94aa0be245bbaa7e316de7689656bb0be5757 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 16:34:34 +0900 Subject: [PATCH 18/21] Stop naming a runner's home directory while explaining not to The comment added with the previous fix quoted the very path it was warning about. `no-running-os-identity-metadata` reads the running home directory and searches the tracked files for what it read, and it does not care whether the string it finds is test data or prose about test data -- so the explanation refused the scan on every CI runner exactly as the data had, and passed everywhere else exactly as the data had. Verified this time against the path CI actually runs under rather than against the one this machine has, which is the check the first fix skipped. --- tests/test_uphold_check.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_uphold_check.py b/tests/test_uphold_check.py index ccfeb25..a8f8de2 100644 --- a/tests/test_uphold_check.py +++ b/tests/test_uphold_check.py @@ -1055,11 +1055,12 @@ def test_a_remote_naming_this_repository_without_an_owner_is_still_ours( name = uphold_check.upstream_slug().rsplit("/", 1)[-1] # Neutral placeholders, because this repository's own # `no-running-os-identity-metadata` rule reads the running home path and - # searches the tracked files for it -- and on a CI runner the home path - # is `/home/runner`, so writing a realistic workspace path here refused - # the scan on every runner while passing on every developer's machine. - # `scripts/consumer_check.sh` avoids the same trap by cloning to a - # neutral path, and says so. + # searches the tracked files for it. A realistic workspace path written + # here passes on a developer's machine and refuses the scan on a CI + # runner, whose home directory it happens to name -- and a comment + # quoting that path to explain the trap falls into it exactly as the + # test data did. `scripts/consumer_check.sh` avoids the same edge by + # cloning to a neutral path, and says so. for url in ( f"/srv/example/work/{name}", f"git@github.com:HackingGate/{name}.git", From 54ef0421c97948a70c09ca857ffa6181d32ca678 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 16:42:01 +0900 Subject: [PATCH 19/21] Stop reading a shared build account as somebody's identity `KNOWN_PUBLIC_IDENTITY` has held `runner` since it was written, and the username needle has consulted it since then. The home path needle consulted nothing, so one account name was a leak spelled as a path and not as a name -- and since both needles are read from the environment the scan runs in, the practical effect was a tree that passed on every developer's machine and refused on every CI runner, reported as identity metadata about a string that identifies nobody. The one place the gate is authoritative was the one place it was wrong. The account is now taken off the home path and asked the same question the username is asked. The list it is asked against grows to the shared accounts other providers and images use, which are the same fact under other names. This does not weaken the neighbouring rule and is not meant to. A home path that will not exist on the next machine is `no-hardcoded-home-paths`, whose subject is reproducibility rather than identity -- it still refuses every literal home path, including a runner's, including in the test added here, which assembles its fixtures for exactly that reason. --- src/sources.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src/sources.rs b/src/sources.rs index fd32b85..34017e1 100644 --- a/src/sources.rs +++ b/src/sources.rs @@ -19,7 +19,29 @@ pub(crate) struct Needle { } /// Names that are never a personal identity leak. -const KNOWN_PUBLIC_IDENTITY: &[&str] = &["runner"]; +/// +/// A shared build account is not a person. `runner` is the account every GitHub +/// hosted runner runs as, so it is the same string on every such machine and +/// says nothing about whose machine it is -- which is the property this whole +/// module searches for. The others are the same fact under other providers and +/// in other images. +const KNOWN_PUBLIC_IDENTITY: &[&str] = &[ + "runner", + "ubuntu", + "ec2-user", + "admin", + "azureuser", + "vsts", + "buildkite-agent", + "circleci", + "travis", + "jenkins", + "vagrant", + "docker", + "root", + "ci", + "build", +]; /// Hostname segments that describe a machine's KIND rather than its owner. /// @@ -184,7 +206,31 @@ fn running_os_identity() -> Vec { let user = env("USER").or_else(|| env("LOGNAME")); let home = env("HOME"); - push(&mut needles, "home-path", home, false); + // The home path is asked the same question the username is asked, which it + // was not asking before: whose machine is this. A hosted runner's home + // directory answers nobody -- it is the account every such machine runs as, + // identical on all of them, and `KNOWN_PUBLIC_IDENTITY` has said so about + // the username since it was written. Only this needle skipped the check, so + // the same account name was a leak as a path and not as a name. + // + // A home path that will not exist on the next machine is a real defect and + // it is `no-hardcoded-home-paths`, a separate rule with a separate subject. + // This one is about identity, and suppressing a shared build account here + // takes nothing away from that one. + // + // The effect was worse than an inconsistency. The needle is read from the + // environment the scan runs in, so a tree that mentions a CI path passed on + // every developer's machine and refused on every runner -- the one place the + // gate is authoritative -- and the failure arrived as "identity metadata" + // about a string that identifies nobody. + let home_account = home + .as_deref() + .and_then(|path| path.trim_end_matches('/').rsplit('/').next()) + .unwrap_or_default() + .to_owned(); + if !is_public_identity(&home_account) { + push(&mut needles, "home-path", home, false); + } if let Some(user) = user.as_deref() { if !is_public_identity(user) { push( @@ -397,6 +443,57 @@ pub(crate) fn resolve( mod tests { use super::*; + /// A shared build account is not a person, and a path is not exempt from + /// that just because it is a path. + /// + /// `KNOWN_PUBLIC_IDENTITY` has held `runner` since it was written, and the + /// username needle has consulted it since then -- but the home path needle + /// consulted nothing, so the same account name was a leak spelled one way + /// and not the other. Since the needle is read from the environment the scan + /// runs in, the practical effect was a tree that passed on every developer's + /// machine and refused on every CI runner, reported as identity metadata + /// about a string identifying nobody. + #[test] + fn a_shared_build_account_is_not_an_identity_in_either_spelling() { + assert!(is_public_identity("runner")); + assert!(is_public_identity("ec2-user")); + assert!(is_public_identity("ROOT"), "the check is case-insensitive"); + assert!(!is_public_identity("hg")); + assert!(!is_public_identity("alice")); + + // Assembled rather than written out, because `no-hardcoded-home-paths` + // is a SEPARATE rule from the one under test and it refuses a literal + // home path in any file including this one -- correctly, since its + // subject is a path that will not exist on the next machine rather than + // a path that says who owns this one. The two rules were easy to confuse + // from the outside and this is the line between them. + let root = "/"; + for (parent, account, searched) in [ + ("home", "runner", false), + ("Users", "runner", false), + ("home", "ec2-user", false), + ("home", "alice", true), + ("home", "hg", true), + ] { + let home = format!("{root}{parent}/{account}"); + let read_back = home.trim_end_matches('/').rsplit('/').next().unwrap(); + assert_eq!( + !is_public_identity(read_back), + searched, + "{home} is searched for: {searched}" + ); + } + // A trailing slash must not turn the account into an empty string, which + // is not a public identity and would put the needle back. + assert!(is_public_identity( + format!("{root}home/runner/") + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap() + )); + } + #[test] fn generic_hostname_parts_are_not_searched_for() { let segments = hostname_segments("debian-x8664-arc"); From caa9240d28139bdad4fcbeddce2abca13bda2e94 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 16:51:05 +0900 Subject: [PATCH 20/21] Ask the identity corpus a question that does not depend on who runs it The promotion corpus planted the ambient HOME and asserted the rule refused it, which made the assertion depend on whose machine ran the suite. A hosted runner's home belongs to a shared build account, and `KNOWN_PUBLIC_IDENTITY` deliberately does not read that as anybody's identity -- so the rule correctly did not fire, and the corpus read the correct answer as the rule having stopped working. It sets HOME for the scan now rather than inheriting it, and plants a personal home. That is the question the set was promoted to answer, and it is now the same question on every machine: the test passes under a runner's home and under a developer's, where before it could only pass under one of them at a time. --- tests/scan_cli.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/scan_cli.rs b/tests/scan_cli.rs index 99b6753..045b643 100644 --- a/tests/scan_cli.rs +++ b/tests/scan_cli.rs @@ -948,10 +948,14 @@ fn the_promoted_sets_refuse_what_they_were_promoted_for() { ); // host-identity reads the running machine, so the fixture has to be built - // from it. HOME is a harness precondition: without it there is no literal - // to plant, and a test that quietly asserted less would be the silence this - // set exists to end. - let home = std::env::var("HOME").unwrap(); + // from it -- but from a HOME this test SETS rather than the one it happens + // to inherit. Reading the ambient one made the assertion depend on who ran + // it: a hosted runner's home belongs to a shared build account, which + // `KNOWN_PUBLIC_IDENTITY` deliberately does not treat as anybody's identity, + // so the rule correctly did not fire and the test read that as the rule + // having stopped working. Planting a personal home asks the question the set + // was promoted to answer, and asks it the same way on every machine. + let home = format!("/{}/fixture-person", "home"); write(&root, "docs/setup.md", &format!("run it from {home}\n")); // broken-links: one target that resolves and one that does not, so the // failure is the missing path rather than the rule firing on everything. @@ -968,7 +972,12 @@ fn the_promoted_sets_refuse_what_they_were_promoted_for() { "{\"holder\": \"\u{30c8}\u{30e8}\u{30bf}\"}\n", ); - let output = scan(&root); + let output = Command::new(env!("CARGO_BIN_EXE_uphold")) + .arg("scan") + .current_dir(&root) + .env("HOME", &home) + .output() + .unwrap(); assert_eq!(code(&output), 1, "{}", stderr(&output)); let text = stderr(&output); assert!(text.contains("no-running-os-identity-metadata"), "{text}"); From 8b81ed38990501d6f630dbd01037486af8a92e73 Mon Sep 17 00:00:00 2001 From: Tong Date: Wed, 12 Aug 2026 17:04:39 +0900 Subject: [PATCH 21/21] Refuse a literal owner in every variant, not only the first The owner LIST was taken off all three `no-private-repo-names` variants forty lines earlier, for the reason written there: the variants carry different fields, and which one a policy file lists first is not a decision anybody makes. The disclosure refusal went on reading `rules.first()`. So a name written literally into the second or third variant was handed to the scan as something to look for, in a file the audit then declined to object to -- the audit hunting for a name it had just been given, in the place it was given it. The one surface the flip publishes first is the policy file itself. The test appends the literal-carrying variant deliberately after the one that has none, and fails against the previous code. --- src/audit.rs | 18 +++++++++++--- tests/audit_publication_cli.rs | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/audit.rs b/src/audit.rs index 8fdaff0..4eab4cd 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -647,16 +647,28 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { // Reported here rather than found by the scan below, because the scan would // report it as an ordinary mention in an ordinary file and the reader would // fix it by deleting the declaration -- which switches the rule off. - if !rule.private_owners().is_empty() { + // + // Every variant, not `rules.first()`. The owner LIST was taken off all of + // them forty lines above, for the reason written there -- the three variants + // carry different fields and which one a file lists first is not a decision + // anybody made. This check kept reading only the first, so a literal owner + // written into the second or the third was scanned FOR and never refused: + // the audit went looking for names it had just been handed, in a file it + // declined to object to. + for candidate in &rules { + let literal = candidate.private_owners(); + if literal.is_empty() { + continue; + } refusals.push(Refusal { - id: rule.id.clone(), + id: candidate.id.clone(), report: format!( "the rule declares {} private owner(s) literally, in a file this flip \ would publish. A public repository cannot hold the list of what must not \ be published. Move them out with `private_owners_from = \"...\"`, a \ command whose stdout is one owner per line, and keep the rule committed \ without the names.", - rule.private_owners().len() + literal.len() ), }); } diff --git a/tests/audit_publication_cli.rs b/tests/audit_publication_cli.rs index 09d3397..1751982 100644 --- a/tests/audit_publication_cli.rs +++ b/tests/audit_publication_cli.rs @@ -196,3 +196,48 @@ fn a_forge_that_cannot_be_listed_is_named_with_its_reason() { assert!(report.contains("could not be listed"), "{report}"); assert!(report.contains("not the same as clean"), "{report}"); } + +/// A literal owner in the SECOND variant is refused, not merely searched for. +/// +/// The three `no-private-repo-names` variants carry different fields, and which +/// one a policy file lists first is not a decision anybody makes. The owner list +/// is taken off all of them; the disclosure refusal read `rules.first()` only. +/// So a name written literally into the second or third variant was handed to +/// the scan as something to look for, in a file the audit then declined to +/// object to -- the audit hunting for a name it had just been given, in the +/// place it was given it. +#[test] +fn a_literal_owner_in_a_later_variant_is_still_refused() { + let root = repository(); + let policy = root.join("policy/principles.toml"); + let existing = std::fs::read_to_string(&policy).unwrap(); + // Appended, so the variant carrying the literal is deliberately NOT first. + std::fs::write( + &policy, + format!( + r#"{existing} +[rule.no-private-repo-names-staged] +builtin = "no-private-repo-names-staged" +visibility = "private" +private_owners = ["PrivateOrg"] + +[rule.no-private-repo-names-staged.git] +hooks = ["pre-commit"] +"# + ), + ) + .unwrap(); + std::fs::write(root.join("a.txt"), "nothing to see\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "one", "--no-verify"]); + + let output = audit(&root); + let report = text(&output); + // The refusal can only have come from the appended variant: the first one + // carries `private_owners_from` and no literal list at all, so there is + // nothing there to object to. + assert!( + report.contains("private owner(s) literally"), + "a literal owner outside the first variant was never objected to:\n{report}" + ); +}