crates/gunnar-policy/src/gates/mod.rs
//! The gates.
//!
//! Each one is a [`Gate`](crate::Gate) impl over a slice of
//! [`PolicyConfig`](crate::PolicyConfig). None of them shells out, forks, or
//! reads a file; a gate is a pure function of the push context and its own
//! configuration, which is what makes them testable at all and what makes them
//! runnable on a machine that is PID 1 with no filesystem.
mod ai_trailers;
mod encrypted;
mod limits;
mod protection;
mod push_options;
mod refname;
mod signed;
pub use ai_trailers::AiTrailerGate;
pub use encrypted::{check_pushed, EncryptedGate, PushedObjects};
pub use limits::LimitsGate;
pub use protection::BranchProtectionGate;
pub use push_options::PushOptionsGate;
pub use refname::RefNameGate;
pub use signed::SignedCommitsGate;
use crate::config::SignerScope;
use crate::context::{PushContext, RefUpdate};
use crate::error::Result;
use crate::signature::{self, SignatureCheck};
use crate::Verdict;
/// Verify every commit this update introduces.
///
/// Shared by [`SignedCommitsGate`] and by [`BranchProtectionGate`]'s
/// `require_signature`, because two copies of "which commits count and which
/// keys count" is precisely the pair that drifts (one implementation): the
/// protection rule would keep checking while the standalone gate quietly
/// stopped, and both would be green.
///
/// `checked` carries commits already verified earlier in this push, so a commit
/// reachable from two pushed branches is verified once.
pub(crate) fn verify_update_signatures(
ctx: &PushContext<'_>,
update: &RefUpdate,
scope: SignerScope,
namespace: &str,
budget: usize,
checked: &mut std::collections::HashSet<gix_hash::ObjectId>,
gate: &str,
) -> Result<Verdict> {
let Some(new_tip) = update.new else {
return Ok(Verdict::Allow); // a delete adds no commits
};
let keys = match scope {
SignerScope::AnyRegisteredKey => ctx.keys.all_keys(),
SignerScope::PusherKeysOnly => ctx.keys.keys_for(&ctx.principal.id),
};
let introduced = ctx.new_object_set();
// ── the tag chain, before the commits under it (GUN-003) ───────────────
//
// A ref may point at an annotated tag object, `refs/heads/*` included:
// nothing in git says a branch names a commit, and `verify_connectivity`
// peels one happily. `introduced_commits` used to `continue` on the tag
// and end with an EMPTY commit list, which the loop below then iterated
// zero times and answered `Allow` for. `git tag -a v1 <unsigned-commit> &&
// git push origin refs/tags/v1` — two stock git commands — was the whole
// exploit.
//
// Peeling alone is not the whole answer. A tag object is a signable git
// object in its own right, and a `require_signature` pattern matching
// `refs/tags/*` would otherwise accept an unsigned tag as long as what it
// pointed at was signed. So every tag this push introduced is checked too,
// against the same key set and the same namespace.
let (peeled, tags) = crate::ancestry::peel_to_commit(ctx.objects, &new_tip)?;
for tag in tags {
if !introduced.contains(&tag) || !checked.insert(tag) {
continue;
}
match signature::check_tag(ctx.objects, &tag, &keys, namespace)? {
SignatureCheck::Verified(verified) => {
tracing::debug!(
gate,
tag = %tag,
key = %verified.fingerprint,
"tag signature verified"
);
}
SignatureCheck::Problem(problem) => {
return Ok(Verdict::reject_ref(
update.name.clone(),
format!("tag {tag}: {problem}"),
));
}
}
}
let Some(new_tip) = peeled else {
// A tag of a tree or of a blob. There are no commits under it to
// check, and its own signature has just been checked; nothing is being
// waved through, the loop below simply has nothing to walk.
return Ok(Verdict::Allow);
};
// ── what this update ADDS, which is not the same as what the pack sent ──
//
// GUN-013. `introduced_commits` walks only the pack's own new-object set,
// so a commit that is ALREADY in the repository is never signature-checked
// however the reference comes to point at it. With GUN-003 that made two
// ordinary pushes enough to land unsigned code on a protected branch: push
// the commit under a tag (no check), then move the branch onto it (the
// commit is no longer new, so still no check).
//
// For an UPDATE the honest set is the range `old..new` — every commit the
// move makes reachable — whether or not those commits arrived in this
// pack. `old` is peeled for the same reason `new` is: it may be a tag.
//
// For a CREATE there is no `old` and therefore no range. The set stays the
// pack's, because the alternative is "everything reachable from the new
// tip", which on a first push of a real repository is the whole history
// and on a branch created at an existing commit is the whole history
// again. That is a residual: `refs/heads/x` created at an unsigned commit
// the repository already holds is not checked. Closing it needs the
// repository's other ref tips as stop points, which `PushContext` does not
// carry, and that is a seam change rather than a line.
//
// Cost: on a fast-forward the walk stops at `old`, so it is O(commits
// added) exactly as before. On a force-push over unrelated history it
// walks further, bounded by `budget` — and an exhausted budget is an
// error, which every gate turns into a refusal.
let commits = match update.old {
Some(old) => {
let (old_peeled, _) = crate::ancestry::peel_to_commit(ctx.objects, &old)?;
match old_peeled {
Some(old) => crate::ancestry::commits_between(ctx.objects, &new_tip, &old, budget)?,
// The old tip was a tag of a tree or a blob: no commit to
// start the range at, so fall back to the pack's set rather
// than walking the repository.
None => {
crate::ancestry::introduced_commits(ctx.objects, &new_tip, &introduced, budget)?
}
}
}
None => crate::ancestry::introduced_commits(ctx.objects, &new_tip, &introduced, budget)?,
};
for commit in commits {
if !checked.insert(commit) {
continue;
}
match signature::check_commit(ctx.objects, &commit, &keys, namespace)? {
SignatureCheck::Verified(verified) => {
tracing::debug!(
gate,
commit = %commit,
key = %verified.fingerprint,
"commit signature verified"
);
}
SignatureCheck::Problem(problem) => {
return Ok(Verdict::reject_ref(
update.name.clone(),
format!("commit {commit}: {problem}"),
));
}
}
}
Ok(Verdict::Allow)
}