Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2009,6 +2009,45 @@ history and in a CI log. It stays in the environment and is deliberately not a
rule field: a bypass written into the policy file would be committed, reviewed
once, and permanent.

### Recording what a run decided

```sh
UPHOLD_VERDICTS=/path/run.jsonl uphold guard --stage pre-push
```

With the variable set, each rule the stage evaluated appends one JSON line to
the file it names. Unset, nothing is written.

```json
{"schema_version":1,"seam":"guard","stage":"pre-push","rule":"prevent-public-push","set":"unowned-push","outcome":"pass"}
{"schema_version":1,"seam":"guard","stage":"pre-push","rule":"no-local-merge","set":"unreviewed-history","outcome":"bypass","by":"UPHOLD_ALLOW"}
```

| field | holds |
|---|---|
| `schema_version` | `1`. Bumped when a field changes meaning or goes away, never when one is added |
| `seam` | `guard` |
| `stage` | the stage the run was told it is at |
| `rule` | the rule's id |
| `set` | the bundled set the rule came from; absent for a rule the policy declares itself |
| `outcome` | `pass`, `refuse`, `bypass` or `unknown` |
| `by` | `UPHOLD_ALLOW`, on a bypass and only there |

- **No subject.** A line never carries the message, the diff or the refs the
rule read, so the file never holds the text a refusal was protecting.
- **Appended, never replaced.** The file belongs to the caller; two runs
sharing it interleave whole lines.
- **`unknown` is the last line.** A rule that could not look ends the run with
exit `2`, and its line says which rule that was.
- **A file that cannot be written is exit `2`**, checked before any rule runs.
So is a variable set to nothing: a caller who asked for verdicts and got
none must not read the absence as a clean run.

uphold records nothing itself. Signing the lines, storing them beside the
commit and publishing them belong to whatever ran the gate. A commit pushed
with `--no-verify` ran no hook and so has no lines, which a verifier
expecting a record on every pushed commit sees as the record's absence.

## `uphold shim` — the shims

A pull-request body is typed into a CLI and goes straight to a public API
Expand Down
35 changes: 27 additions & 8 deletions src/guard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use std::path::Path;

use crate::config::{CheckKind, Policy, Rule};
use crate::error::{Exit, Fatal, Result};
use crate::verdicts::Outcome;

/// Every built-in check this binary carries, and the only list of them.
///
Expand Down Expand Up @@ -647,8 +648,12 @@ pub(crate) fn parameters(builtin: &str) -> &'static [&'static str] {

/// Run every guard the policy declares that has something to say at `stage`.
pub(crate) fn run(root: &Path, policy: &Policy, request: &RunRequest<'_>) -> Result<Exit> {
// Opened before anything is decided, so a file that cannot be written stops
// the run while it has decided nothing. See `verdicts`.
let mut verdicts = crate::verdicts::Sink::from_env()?;
let stage = request.stage.as_str();
// Every rule the CONFIG puts at this hook, and nothing else decides it.
let at_hook: Vec<&Rule> = policy.at_hook(request.stage.as_str()).collect();
let at_hook: Vec<&Rule> = policy.at_hook(stage).collect();
if policy.at_hook_any().next().is_none() {
println!("no rule declares a git hook");
return Ok(Exit::Clean);
Expand All @@ -658,11 +663,13 @@ pub(crate) fn run(root: &Path, policy: &Policy, request: &RunRequest<'_>) -> Res
let mut ran = 0usize;
let mut bypassed_here = 0usize;
for rule in at_hook {
let set = policy.set_of(&rule.id);
// Counted where the work happens, not where the loop starts. A bypassed
// rule did not run, and folding it into the same number reported a
// deliberately disabled check as one that passed.
if bypassed(rule.id.as_str()) {
eprintln!("uphold guard: {} bypassed by UPHOLD_ALLOW", rule.id);
verdicts.record(stage, &rule.id, set, Outcome::Bypass)?;
bypassed_here += 1;
continue;
}
Expand All @@ -678,8 +685,23 @@ pub(crate) fn run(root: &Path, policy: &Policy, request: &RunRequest<'_>) -> Res
remote_name: request.remote_name,
remote_url: request.remote_url,
};
if let Some(refusal) = evaluate(&one)? {
refusals.push(refusal);
match evaluate(&one) {
Ok(Some(refusal)) => {
verdicts.record(stage, &rule.id, set, Outcome::Refuse)?;
refusals.push(refusal);
}
Ok(None) => verdicts.record(stage, &rule.id, set, Outcome::Pass)?,
// The rule could not look, and the run ends here with exit 2. The
// line says which rule that was; the error the reader acts on is
// still the rule's own, with the file's appended if both failed.
Err(error) => {
return Err(
match verdicts.record(stage, &rule.id, set, Outcome::Unknown) {
Ok(()) => error,
Err(also) => Fatal::new(format!("{error}\n\n{also}")),
},
);
}
}
}

Expand All @@ -701,12 +723,9 @@ pub(crate) fn run(root: &Path, policy: &Policy, request: &RunRequest<'_>) -> Res
}

if bypassed_here > 0 {
println!(
"{ran} guard(s) passed at {}, {bypassed_here} bypassed by UPHOLD_ALLOW",
request.stage.as_str()
);
println!("{ran} guard(s) passed at {stage}, {bypassed_here} bypassed by UPHOLD_ALLOW");
} else {
println!("{ran} guard(s) passed at {}", request.stage.as_str());
println!("{ran} guard(s) passed at {stage}");
}
Ok(Exit::Clean)
}
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ mod shim;
mod sources;
mod supply;
mod text;
mod verdicts;

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -167,6 +168,7 @@ exit codes:
2 the check could not be made (bad policy, missing source, unreadable file)

UPHOLD_ALLOW=<id>,<id> bypasses named guards for one invocation.
UPHOLD_VERDICTS=FILE appends one JSON line per guard a `guard` run evaluated.
";

/// The policy file, newest name first.
Expand Down
177 changes: 177 additions & 0 deletions src/verdicts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! `UPHOLD_VERDICTS` -- what a guard run decided, for whoever ran it.
//!
//! uphold decides and does not record. Signing a pass, storing it beside the
//! commit and publishing it are a recorder's, and a recorder already knows
//! where its record goes; what it cannot know without parsing prose is which
//! rules ran and what each one answered. So the caller names a file, and each
//! rule the run evaluated appends one JSON line to it. Unset, nothing is
//! written and the run is the run it always was.
//!
//! A line carries the decision and never the subject: no message, no body, no
//! argv. A record of a refusal that quoted what was refused would hold exactly
//! the text the rule existed to keep out of somewhere.
//!
//! A file that cannot be written is exit 2. A caller who asked for verdicts and
//! received none must not read the absence as a run that found nothing.

use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;

use serde::Serialize;

use crate::error::{Fatal, Result};

/// The variable naming the file. A path, never a descriptor: a hook runner
/// owns the descriptors a hook inherits, and a path survives every one of them.
pub(crate) const VARIABLE: &str = "UPHOLD_VERDICTS";

/// Bumped when a field changes meaning or goes away, never when one is added.
/// The same contract `uphold check --json` keeps.
const SCHEMA_VERSION: u32 = 1;

/// The four answers a rule can give, and no fifth: they are the exit codes and
/// the bypass, named.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
/// Ran, and found nothing to refuse.
Pass,
/// Ran, and refused.
Refuse,
/// Did not run, because `UPHOLD_ALLOW` named it for this invocation.
Bypass,
/// Could not look. The run stops here with exit 2, so a line with this
/// outcome is the last one the run writes.
Unknown,
}

impl Outcome {
const fn name(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Refuse => "refuse",
Self::Bypass => "bypass",
Self::Unknown => "unknown",
}
}
}

/// One line, in the order a reader scans it.
#[derive(Serialize)]
struct Line<'a> {
schema_version: u32,
seam: &'a str,
stage: &'a str,
rule: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
set: Option<&'a str>,
outcome: &'a str,
/// What switched the rule off, present on a bypass and only there.
#[serde(skip_serializing_if = "Option::is_none")]
by: Option<&'a str>,
}

/// Where this run's verdicts go, if anywhere.
pub(crate) struct Sink {
open: Option<(PathBuf, File)>,
}

impl Sink {
/// The file the caller named, opened for appending before any rule runs,
/// so a path that cannot be written stops the run before it decides
/// anything rather than after it decided everything.
pub(crate) fn from_env() -> Result<Self> {
let Some(path) = std::env::var_os(VARIABLE) else {
return Ok(Self { open: None });
};
// Refused rather than read as unset. A caller who exported the
// variable and blanked it asked for verdicts somewhere; running on as
// if they had not is the silent absence this file exists to rule out.
if path.is_empty() {
return Err(Fatal::new(format!(
"{VARIABLE} is set and names no file. Unset it to write no verdicts"
)));
}
let path = PathBuf::from(path);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| Fatal::at(&path, format!("{VARIABLE}: {error}")))?;
Ok(Self {
open: Some((path, file)),
})
}

/// Append one rule's answer. One `write_all` per line on a file opened for
/// appending, so two runs sharing a file interleave whole lines.
pub(crate) fn record(
&mut self,
stage: &str,
rule: &str,
set: Option<&str>,
outcome: Outcome,
) -> Result<()> {
let Some((path, file)) = &mut self.open else {
return Ok(());
};
let line = Line {
schema_version: SCHEMA_VERSION,
seam: "guard",
stage,
rule,
set,
outcome: outcome.name(),
by: (outcome == Outcome::Bypass).then_some("UPHOLD_ALLOW"),
};
let mut bytes = serde_json::to_vec(&line)
.map_err(|error| Fatal::at(path, format!("{VARIABLE}: {error}")))?;
bytes.push(b'\n');
file.write_all(&bytes)
.map_err(|error| Fatal::at(path, format!("{VARIABLE}: {error}")))
}
}

#[cfg(test)]
mod tests {
use super::*;

fn written(outcome: Outcome, set: Option<&str>) -> serde_json::Value {
let directory = crate::fixture::scratch("verdicts");
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("run.jsonl");
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
let mut sink = Sink {
open: Some((path.clone(), file)),
};
sink.record("pre-push", "a-rule", set, outcome).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.ends_with('\n') && text.lines().count() == 1, "{text}");
serde_json::from_str(&text).unwrap()
}

#[test]
fn a_bypass_names_what_switched_the_rule_off_and_a_pass_does_not() {
let bypass = written(Outcome::Bypass, Some("a-set"));
assert_eq!(bypass["outcome"], "bypass");
assert_eq!(bypass["by"], "UPHOLD_ALLOW");
assert_eq!(bypass["set"], "a-set");
let pass = written(Outcome::Pass, None);
assert_eq!(pass["outcome"], "pass");
assert!(pass.get("by").is_none(), "{pass}");
assert!(pass.get("set").is_none(), "{pass}");
assert_eq!(pass["schema_version"], 1);
assert_eq!(pass["seam"], "guard");
}

#[test]
fn a_sink_nobody_asked_for_writes_nothing() {
let mut sink = Sink { open: None };
sink.record("pre-push", "a-rule", None, Outcome::Refuse)
.unwrap();
}
}
Loading
Loading