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/.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/README.md b/README.md index 388ac33..e236f89 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, 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 @@ -115,19 +116,27 @@ 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 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 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 +197,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/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/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..38525e9 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` 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/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/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/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/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/src/audit.rs b/src/audit.rs index 1ce5806..4eab4cd 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::{Read as _, 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, @@ -78,14 +106,45 @@ 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. +/// 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. - 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> { 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}') { @@ -165,101 +224,356 @@ 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"))?; + 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 !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", + status.code().unwrap_or(-1), + shas.len() + ))); + } + 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() { + 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 +616,25 @@ pub(crate) fn for_publication(root: &Path, policy: &Policy) -> Result { it has." ); - let (mut surfaces, tree_unreadable) = tree(root)?; - surfaces.extend(history(root)?); + // 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. + 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)?; - unreadable.extend(tree_unreadable); + // 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)?); surfaces.extend(retained); - let (conversations, more) = forge_conversations(root)?; + let (conversations, more) = forge_conversations(root); surfaces.extend(conversations); unreadable.extend(more); @@ -320,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() ), }); } @@ -346,6 +685,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 +711,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 +751,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/src/config.rs b/src/config.rs index 0b10944..99b6068 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() } @@ -1037,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(); @@ -1081,6 +1153,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 +1219,90 @@ 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`. + // 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)) + .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 +1451,12 @@ mod tests { [rule.body.command] before = ["gh pr create"] + + [[shim]] + command = "gh" + match = ["pr:create"] + text_flags = ["-b"] + scope = "always" "#, ) .unwrap(); @@ -1406,6 +1569,169 @@ 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}"); + } + + /// 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` 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/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/pins.rs b/src/pins.rs index 97480bd..03fb696 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,257 @@ 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) -> 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` + // 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) + .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; + } + let Some(name) = entry.file_name().to_str() else { + continue; + }; + if name == PRE_COMMIT_CONFIG || LEFTHOOK_CONFIGS.contains(&name) { + 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(); + Ok(found) } -pub(crate) fn read_pins(root: &Path) -> Result> { - let path = root.join(".pre-commit-config.yaml"); - let text = read_to_string(&path)?; +/// 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" + ), + ) +} + +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 +408,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 +426,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 +440,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 +453,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 +483,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 +542,211 @@ 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(), }] ); } + /// 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 = 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/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..3be5074 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,14 +46,33 @@ 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> { 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), + not_text, + unreadable: RefCell::new(unreadable), } } @@ -60,6 +80,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 +156,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..6c9ae10 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; @@ -30,43 +44,91 @@ 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 { - let listed = Command::new("git") - .args(["ls-files", "-z"]) - .current_dir(root) - .output(); - let Ok(listed) = listed else { +/// +/// 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.status.success() || listed.stdout.is_empty() { - return Vec::new(); + if listed.is_empty() { + 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) - .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(); + return unmeasured("git check-attr could not be started"); }; - 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 { - return Vec::new(); + let (Some(mut sink), Some(mut source)) = (child.stdin.take(), child.stdout.take()) else { + return unmeasured("git check-attr gave no pipe to speak to"); }; - if !output.status.success() { - return Vec::new(); + + // 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() { + 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. - 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 { @@ -76,70 +138,162 @@ 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. +/// +/// `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, and the globs that chose them. +/// 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 +305,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 +485,219 @@ 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, 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/")), + "{declared:?}" + ); + } +} diff --git a/src/shim.rs b/src/shim.rs index a751261..3ce16da 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,44 +599,75 @@ 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>, +} + +/// The forges whose visibility question this tool knows how to ask. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Forge { + GitHub, + GitLab, } -fn forge_visibility(target: &str) -> Option { - let output = Command::new("gh") - .args(["api", &format!("repos/{target}"), "--jq", ".visibility"]) - .output() - .ok()?; +/// 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) +} + +/// Parse the document and read a TOP-LEVEL key, rather than scan for a needle. +/// +/// These two were `text.find("\"field\"")` and a walk forwards from there, which +/// answers with the first textual occurrence of the name anywhere in the +/// document -- inside a nested object, inside a string value, inside a +/// description that happens to quote the word. On the visibility question that +/// is a `public-target` decision made from somebody else's field, and the shim +/// stands in front of publication on the strength of it. The parser is already a +/// dependency: YAML 1.2 is a superset of JSON, so `serde_yaml_ng` reads a forge +/// response without adding one. +fn json_value(text: &str) -> 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. @@ -520,6 +675,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 +697,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 +815,287 @@ 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], +) -> Result<()> { + let Some(exe) = own else { + // 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 that editor. Nothing was published, because \ + nothing could be checked." + ))); + }; + 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." + ); + Ok(()) +} + +/// 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, 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(); + Err(Fatal::new(format!("{name}: {error}"))) +} + +#[cfg(not(unix))] +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 = child + .wait() + .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 +1118,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 +1176,19 @@ 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. + 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, collected.stdin.as_deref()) } #[cfg(test)] @@ -846,6 +1353,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/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"); 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/audit_publication_cli.rs b/tests/audit_publication_cli.rs new file mode 100644 index 0000000..1751982 --- /dev/null +++ b/tests/audit_publication_cli.rs @@ -0,0 +1,243 @@ +//! 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}"); +} + +/// 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}" + ); +} 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() 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)); +} 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/scan_cli.rs b/tests/scan_cli.rs index 9a0d761..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}"); @@ -981,3 +990,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..3ed7737 --- /dev/null +++ b/tests/shim_handoff_cli.rs @@ -0,0 +1,553 @@ +//! 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() + ); + // `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 + } 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() + } +} + +/// 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. + 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", + // `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", + )], + ); + + // 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)); +} 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() 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..a8f8de2 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) @@ -729,9 +1028,94 @@ 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_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] + # 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. 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", + "/srv/example/neutral-clone-name", + ): + 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. + + 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 1f586fc..17077f0 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 @@ -170,15 +201,114 @@ 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) + # 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. + # + # 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(): + 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(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 + ) 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() @@ -196,17 +326,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 +398,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 +470,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" - 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`", - ) + 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 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 + # 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 +568,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 @@ -356,9 +645,20 @@ def content_policy_rules(root: Path) -> tuple[set[str], set[str], list[str]]: 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)] - - declared = _rule_ids(policy) + # 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 + # 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 +666,26 @@ 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)) - - disabled = { - value for value in inherit.get("disabled_rules", []) if isinstance(value, str) - } - return declared, disabled, names + 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) + + # 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 def cmd_shims_checks(root: Path) -> set[str]: @@ -510,26 +824,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 +1017,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 +1249,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 +1272,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 +1341,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 +1368,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",