git-ents.gitmain
⌘K
foforge
commit 45ecffd
gate: add ents-gate, the pure verify function at all three call sites

One verb, verify(), pure over RefStoreRead + gix_object::Find: tip signature (SSHSIG from the commit’s gpgsig header, verified in-process via ssh-key — never a push certificate), refname binding via the Ents-Ref trailer, DAG fast-forward, the verification epoch (a gate-owned Config typed tree on refs/meta/config; the epoch-setting commit is itself the first gated tip), and the fail-closed empty-member-list bootstrap window.

Authorization is exactly what the spec pins: self-run namespaces are owner-only, refs/meta/effects/* admin-only, self-attested members refused canonical refs. Signatures are judged against the member entity in force at the commit’s own timestamp, recovered by walking the member ref’s history — revocation’s before/after boundary with no validity-window field, and the reason verdicts reproduce offline in any clone.

Known gap, deliberately not invented around: refs/meta/inbox/* encodes no member segment, so model.member-provenance’s "its own inbox" cannot be keyed on the refname; the gate fails closed for self-attested members there and says so in the refusal.

Verdicts carry the failed rule + subject refname (gate.verdict-reason) and, on passes, the CAS precondition bound to the same old-tip read (gate.atomic-cas). The identical-verdicts exit criterion is one parameterized test across hosted/local/pre-flight invocations.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -369,6 +369,23 @@ "cfg-if", ] +[[package]] +name = "ents-gate" +version = "0.0.0" +dependencies = [ + "ents-model", + "ents-testutil", + "facet", + "facet-git-tree", + "gix", + "gix-hash", + "gix-object", + "gix-ref-store", + "rstest", + "ssh-key", + "thiserror", +] + [[package]] name = "ents-model" version = "0.0.0"
Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "crates/ents-gate", "crates/ents-model", "crates/ents-testutil", "crates/gix-ref-store", @@ -16,6 +17,7 @@ missing_docs = "warn" [workspace.dependencies] +ents-gate = { path = "crates/ents-gate" } ents-model = { path = "crates/ents-model" } ents-testutil = { path = "crates/ents-testutil" } gix-ref-store = { path = "crates/gix-ref-store" }
.DS_Store Binary file changed.
crates/ents-gate/Cargo.toml @@ -1,0 +1,24 @@ +[package] +name = "ents-gate" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ents-model = { workspace = true } +facet = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +gix-ref-store = { workspace = true } +ssh-key = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +ents-testutil = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true
crates/ents-gate/src/config.rs @@ -1,0 +1,75 @@ +//! The verification epoch, read from `refs/meta/config` (`gate.epoch`). + +use facet::Facet; +use gix_hash::ObjectId; +use gix_object::Find; +use gix_ref_store::RefStoreRead; + +use crate::error::{Error, Result}; +use crate::object::expect_commit; + +/// The slice of `refs/meta/config`'s typed tree the gate consults: the +/// verification epoch (`gate.epoch`). +/// +/// `model.sdoc` defines no Config entity yet, so this struct is the +/// first (and currently only) definition of the config tree's shape; it +/// lives here rather than in `ents-model` because the epoch is the only +/// field any crate reads today. When configuration grows non-gate fields +/// (description, role rules, ...), the entity moves to `ents-model` and +/// that change is a storage migration like any other struct change +/// (`meta-ref.migration`). +/// +/// `epoch` is `None` on a config written before verification was turned +/// on. Once it is `Some`, the gate applies the tip invariant to every +/// `refs/meta/*` update; the value records *when* (seconds since the +/// Unix epoch) enforcement began, for audit tooling — the live gate only +/// tests presence, because every proposed update is by definition after +/// the epoch that admits it. +/// +/// # Examples +/// +/// ``` +/// use ents_gate::Config; +/// +/// let config = Config { epoch: Some(1_700_000_000) }; +/// let (root, store) = facet_git_tree::serialize(&config).expect("serialize"); +/// let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize"); +/// assert_eq!(back, config); +/// ``` +// @relation(gate.epoch, scope=file) +#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)] +pub struct Config { + /// When the tip invariant came into force, seconds since the Unix + /// epoch; `None` while verification has never been enabled. + pub epoch: Option<u64>, +} + +/// The epoch recorded by the config tree of the commit at `oid`, or an +/// [`Error::Entity`] when the tree does not parse as [`Config`] — an +/// unreadable config fails closed rather than silently disabling the +/// gate. +pub(crate) fn epoch_at_commit(objects: &dyn Find, oid: ObjectId) -> Result<Option<u64>> { + let commit = expect_commit(objects, oid)?; + let config: Config = facet_git_tree::deserialize(&commit.tree, objects) + .map_err(|source| Error::Entity { oid, source })?; + Ok(config.epoch) +} + +/// The epoch currently in force, read from `refs/meta/config`'s tip; +/// `None` when the config ref does not exist or records no epoch. +// @relation(gate.epoch, gate.policy-as-state, scope=function) +pub(crate) fn current_epoch(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<u64>> { + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "CONFIG_REF is a compile-time constant; the doctest below and \ + ents-model's own tests pin its validity" + )] + let name: gix::refs::FullName = ents_model::namespace::CONFIG_REF + .try_into() + .expect("CONFIG_REF is a valid refname"); + match refs.get(name.as_ref())? { + Some(tip) => epoch_at_commit(objects, tip), + None => Ok(None), + } +}
crates/ents-gate/src/error.rs @@ -1,0 +1,64 @@ +//! The gate's infrastructure error type. +//! +//! An [`Error`] is never a verdict: it means the gate could not *reach* a +//! judgment (a store read failed, an object is missing or undecodable), +//! as opposed to [`crate::Refusal`], which is the judgment "no". Callers +//! at the mandatory call site (`gate.mandatory-hosted`) must treat an +//! `Error` exactly like a failing verdict — abort the write — because a +//! gate that cannot read its policy must fail closed; advisory call sites +//! should surface it as "could not evaluate", not as "refused". + +use gix_hash::ObjectId; + +/// Everything that can prevent the gate from reaching a verdict. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The ref store's read half failed. Retry or surface; the proposed + /// update was neither admitted nor refused. + #[error("ref store read failed: {0}")] + Refs(#[from] gix_ref_store::Error), + + /// The object store failed while looking up `oid`. + #[error("object lookup failed for {oid}: {source}")] + Object { + /// The object being looked up. + oid: ObjectId, + /// The underlying object-store error. + #[source] + source: gix_object::find::Error, + }, + + /// `oid` is not present in the object store. At the hosted call site + /// this means the push's objects were not ingested before the gate + /// ran; at pre-flight it usually means an unfetched object. + #[error("object {oid} is missing from the object store")] + Missing { + /// The absent object. + oid: ObjectId, + }, + + /// `oid` exists but could not be decoded as the object kind the gate + /// needed (a commit, or a commit's timestamp field). + #[error("object {oid} could not be decoded: {detail}")] + Decode { + /// The undecodable object. + oid: ObjectId, + /// What failed, human-readable. + detail: String, + }, + + /// A policy entity's typed tree (a member, or `refs/meta/config`) + /// could not be deserialized. The gate fails closed on this rather + /// than treating unreadable policy as absent policy. + #[error("policy entity at {oid} is unreadable: {source}")] + Entity { + /// The tree (or commit) whose entity failed to load. + oid: ObjectId, + /// The typed-tree deserialization error. + #[source] + source: facet_git_tree::Error, + }, +} + +/// The `Result` alias every fallible `ents-gate` operation returns. +pub type Result<T> = std::result::Result<T, Error>;
crates/ents-gate/src/lib.rs @@ -1,0 +1,131 @@ +//! The gate: the one pure admission judgment over ref-store reads +//! (`docs/spec/gate.sdoc`). +//! +//! This crate owns exactly one verb — [`verify`] — evaluated identically +//! at the three call sites the design names (`gate.call-sites`): hosted +//! CAS (mandatory, a failing verdict aborts the transaction), local UI +//! verdict (advisory, a failing verdict annotates), and push pre-flight +//! (advisory, a prediction that can only go stale). It is deliberately a +//! separate crate from `receive` (`arch.gate-receive-split`) so the two +//! advisory call sites link no effect-matching or enqueue logic, and it +//! consumes only the *read* half of the ref store +//! (`arch.refstore-read-cas-split`) plus gitoxide's `Find` seam for +//! objects, so it is statically incapable of writing. +//! +//! # Spec coverage +//! +//! From `docs/spec/gate.sdoc`: +//! +//! - `gate.tip-signed`, `gate.refname-binding`, `gate.fast-forward` — +//! [`verify`]. +//! - `gate.atomic-cas` — [`verify`] reads the old tip once and returns +//! it as [`Admission::cas`], the precondition the writer MUST hand to +//! `RefStore::transaction`; the CAS itself is the store's. +//! - `gate.signature-artifact` — signatures are read from the commit's +//! `gpgsig` header and verified in-process; no push certificate is +//! consulted, and no API here could accept one. +//! - `gate.policy-as-state` — members and the epoch are read only from +//! `refs/meta/*` through `RefStoreRead`, so any clone evaluates the +//! actual policy offline. +//! - `gate.epoch` — [`Config`]; the tip invariant applies once an epoch +//! is recorded, and the epoch-setting commit is itself the first gated +//! tip of the config ref. +//! - `gate.call-sites`, `gate.verdict-reason` — [`Verdict`], [`Refusal`], +//! [`Requirement`]; proven identical across call sites by this crate's +//! parameterized call-site test. +//! - `gate.adoption-merge`, `gate.adoption-no-fast-forward`, +//! `gate.same-actor-divergence` — consequences of judging only the tip +//! plus DAG descent; pinned by the verdict-table tests. +//! - `gate.principled-split` — refs outside `refs/meta/*` pass as +//! [`AdmissionKind::CodeRef`]; the tip invariant never applies to +//! branch refs. +//! - `gate.bootstrap` — the empty-member-list window admits only a +//! self-admitting first enrollment; an all-revoked member set fails +//! closed and never reopens it. +//! +//! Partially here, completed by later phases: `gate.mandatory-hosted` +//! and `gate.advisory-local` are caller policies (`ents-receive`, the +//! composition roots) — this crate contributes the shared verdict and, +//! for the advisory sites, the verdict-time reason rendering including +//! the inbox alternative ([`Refusal`]). `gate.adoption-no-cherry-pick` +//! is a prohibition on adoption *tooling* (`ents-sync`): a cherry-pick +//! produces an ordinary commit by the placer, which no pure function +//! over the result could distinguish, so the gate has nothing to check. +//! +//! # Authorization model +//! +//! "Signed by a member authorized for that refname" uses exactly the +//! rules the spec pins today: self-run namespaces are owner-only, +//! `refs/meta/effects/*` is admin-only (`effect.admin-only`), and +//! self-attested members are refused canonical refs until promoted +//! (`model.member-provenance`). Finer-grained, config-stored refname +//! rules (for example designating worker keys for one effect's results +//! namespace, `effect.official`) are a later, additive narrowing: they +//! arrive with a Config entity in `ents-model`, not a new gate. +//! +//! Signature-time semantics: a signature is judged against the member +//! entity in force *at the commit's own timestamp*, recovered by walking +//! the member ref's history (`model.member-revocation`). This keeps +//! verdicts reproducible after the fact in any clone — re-running the +//! gate years later returns the same answer — at the documented cost +//! that commit timestamps are author-controlled; the fast-forward +//! requirement still forces any replay to descend from the live tip. +//! +//! # Examples +//! +//! A hosted-shaped round trip: enroll a member (pre-epoch, archival), +//! turn verification on by setting the epoch (the first gated tip of the +//! config ref), then verify a signed mutation and use the admission's +//! CAS precondition. +//! +//! ``` +//! use ents_gate::{AdmissionKind, Config, Update, Verdict, verify}; +//! use ents_model::{Provenance, namespace}; +//! use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity}; +//! use gix_ref_store::Expected; +//! +//! let refs = MemRefStore::default(); +//! let objects = ObjectStore::default(); +//! let key = Keypair::from_seed(1); +//! +//! // 1. Enrollment lands pre-epoch: history before the epoch is archival. +//! enroll_member(&refs, &objects, "jdc", &key, Provenance::AdminRegistered, 100); +//! +//! // 2. The epoch-setting commit is the first gated tip of refs/meta/config. +//! let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid"); +//! let epoch_tip = write_meta_entity( +//! &refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&key), 200, +//! ); +//! +//! // 3. From here on, every meta-ref update is judged by the tip invariant. +//! let issue = ents_model::Issue { +//! title: "t".into(), body: "b".into(), state: "open".into(), +//! assignees: vec![], labels: vec![], +//! }; +//! let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid"); +//! let tip = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&key), 300); +//! +//! // The fixture already moved the ref; judge the same tip as a proposal +//! // against a pre-push copy of the store, the way pre-flight would. +//! let before = refs.fetched_copy(); +//! before.remove(name.as_ref()); +//! let verdict = verify(&before, &objects, &Update { name, new: Some(tip) }) +//! .expect("evaluates"); +//! let Verdict::Pass(admission) = verdict else { panic!("authorized update passes") }; +//! assert_eq!(admission.kind, AdmissionKind::TipInvariant); +//! assert_eq!(admission.cas, Expected::MustNotExist); +//! # let _ = epoch_tip; +//! ``` + +mod config; +mod error; +mod object; +mod policy; +mod signature; +mod verdict; +mod verify; + +pub use config::Config; +pub use error::{Error, Result}; +pub use verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict}; +pub use verify::{Update, verify};
crates/ents-gate/src/object.rs @@ -1,0 +1,108 @@ +//! Minimal commit reading over `gix_object::Find` — the only object +//! access the gate performs (`arch.no-object-store-trait`: gitoxide's +//! traits are the object seam; no private store trait). + +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind}; + +use crate::error::{Error, Result}; + +/// The decoded pieces of one commit the gate judges or walks. +#[derive(Debug, Clone)] +pub(crate) struct CommitData { + /// The raw commit bytes as stored — what a signature covers (minus + /// the `gpgsig` header itself). + pub raw: Vec<u8>, + /// The tree the commit records. + pub tree: ObjectId, + /// Parents, in order. + pub parents: Vec<ObjectId>, + /// Committer timestamp, seconds since the Unix epoch — the time a + /// signature is judged against (`model.member-revocation`). + pub committer_seconds: i64, + /// The full commit message, for trailer parsing. + pub message: Vec<u8>, +} + +/// Read `oid` and decode it as a commit; `Ok(None)` when the object +/// exists but is not a commit (the caller turns that into a refusal, not +/// an error). +pub(crate) fn read_commit(objects: &dyn Find, oid: ObjectId) -> Result<Option<CommitData>> { + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .map_err(|source| Error::Object { oid, source })? + .ok_or(Error::Missing { oid })?; + if data.kind != Kind::Commit { + return Ok(None); + } + let raw = data.data.to_vec(); + let (tree, parents, committer_seconds, message) = decode_commit(&raw, oid)?; + Ok(Some(CommitData { + tree, + parents, + committer_seconds, + message, + raw, + })) +} + +fn decode_commit(raw: &[u8], oid: ObjectId) -> Result<(ObjectId, Vec<ObjectId>, i64, Vec<u8>)> { + let commit = CommitRef::from_bytes(raw, oid.kind()).map_err(|e| Error::Decode { + oid, + detail: e.to_string(), + })?; + let committer_seconds = commit + .committer() + .map_err(|e| Error::Decode { + oid, + detail: format!("committer: {e}"), + })? + .time() + .map_err(|e| Error::Decode { + oid, + detail: format!("committer time: {e}"), + })? + .seconds; + Ok(( + commit.tree(), + commit.parents().collect(), + committer_seconds, + commit.message.to_vec(), + )) +} + +/// Like [`read_commit`], but a non-commit is an [`Error::Decode`] — +/// for walks where every node must be a commit. +pub(crate) fn expect_commit(objects: &dyn Find, oid: ObjectId) -> Result<CommitData> { + read_commit(objects, oid)?.ok_or(Error::Decode { + oid, + detail: "expected a commit".into(), + }) +} + +/// Whether `ancestor` is reachable from `descendant` by parent edges +/// (inclusive: a commit descends from itself) — the DAG sense of +/// `gate.fast-forward`. +pub(crate) fn descends_from( + objects: &dyn Find, + descendant: ObjectId, + ancestor: ObjectId, +) -> Result<bool> { + let mut queue = vec![descendant]; + let mut seen = std::collections::HashSet::new(); + while let Some(oid) = queue.pop() { + if oid == ancestor { + return Ok(true); + } + if !seen.insert(oid) { + continue; + } + // A missing or non-commit ancestor object simply ends this path: + // at pre-flight, history below the last fetch may be shallow. + if let Some(commit) = read_commit(objects, oid).ok().flatten() { + queue.extend(commit.parents); + } + } + Ok(false) +}
crates/ents-gate/src/policy.rs @@ -1,0 +1,73 @@ +//! Policy loading: the member set, read from `refs/meta/member/*` +//! through the read half of the ref store (`gate.policy-as-state`). +//! +//! The gate consults no state outside `refs/meta/*`: members, their +//! revocation timelines, and the epoch (`crate::config`) are all +//! repository state, so any frontend with a clone evaluates the actual +//! policy offline, staleness bounded only by the age of its last fetch. + +use ents_model::{Member, MemberId}; +use gix_hash::ObjectId; +use gix_object::Find; +use gix_ref_store::RefStoreRead; + +use crate::error::{Error, Result}; +use crate::object::expect_commit; + +/// One enrolled member: its id (from the refname) and its ref's tip. +#[derive(Debug, Clone)] +pub(crate) struct Enrolled { + /// The member id, i.e. the `<id>` of `refs/meta/member/<id>`. + pub id: MemberId, + /// The member ref's current tip commit. + pub tip: ObjectId, +} + +/// Every ref under `refs/meta/member/`, in store order. +// @relation(gate.policy-as-state, scope=function) +pub(crate) fn members(refs: &dyn RefStoreRead) -> Result<Vec<Enrolled>> { + let mut out = Vec::new(); + for entry in refs.iter_prefix("refs/meta/member/")? { + let (name, tip) = entry?; + let path = name.as_bstr().to_string(); + let id = path + .strip_prefix("refs/meta/member/") + .unwrap_or(&path) + .to_owned(); + out.push(Enrolled { + id: MemberId::new(id), + tip, + }); + } + Ok(out) +} + +/// The member entity in force at `at_seconds`, found by walking the +/// member ref's own commit history (first-parent) from `tip` back to the +/// newest mutation at or before that time, and deserializing *that* +/// commit's tree. +/// +/// This is how revocation gets its before/after boundary with no +/// validity-window field on the entity (`model.member-revocation`): the +/// ref's commit chain is the audit trail (`meta-ref.namespace`), so the +/// state, provenance, and key that judge a signature are the ones the +/// chain records for the signature's own timestamp. `Ok(None)` means the +/// member had not been enrolled yet at `at_seconds`. +// @relation(model.member-revocation, gate.policy-as-state, scope=function) +pub(crate) fn member_at( + objects: &dyn Find, + tip: ObjectId, + at_seconds: i64, +) -> Result<Option<Member>> { + let mut cursor = Some(tip); + while let Some(oid) = cursor { + let commit = expect_commit(objects, oid)?; + if commit.committer_seconds <= at_seconds { + let member: Member = facet_git_tree::deserialize(&commit.tree, objects) + .map_err(|source| Error::Entity { oid, source })?; + return Ok(Some(member)); + } + cursor = commit.parents.first().copied(); + } + Ok(None) +}
crates/ents-gate/src/signature.rs @@ -1,0 +1,119 @@ +//! Commit signature extraction and offline verification +//! (`gate.tip-signed`, `gate.signature-artifact`). +//! +//! The signature is read from the commit object's `gpgsig` header — a +//! data artifact that replicates with the repository — and verified in +//! pure Rust against a member's stored OpenSSH public key. Nothing here +//! reads a push certificate, the environment, or any transport state: +//! give this module the same bytes in any clone and it returns the same +//! answer (`gate.signature-artifact`). + +use ssh_key::{PublicKey, SshSig}; + +/// The SSHSIG namespace git signs commits under. +const GIT_NAMESPACE: &str = "git"; + +/// Split a raw commit object into its signed payload and its detached +/// signature: the payload is the commit serialization with the `gpgsig` +/// header removed, exactly the bytes git signs. +/// +/// Returns `None` when the commit carries no `gpgsig` header — an +/// unsigned commit. +pub(crate) fn split_signed(raw: &[u8]) -> Option<(Vec<u8>, String)> { + // The header section ends at the first blank line; gpgsig is a + // header whose continuation lines start with a single space. + let header_end = raw + .windows(2) + .position(|w| w == b"\n\n") + .map_or(raw.len(), |i| i.saturating_add(1)); + + let mut sig_start = None; + let mut sig_end = None; + let mut line_start = 0usize; + while line_start < header_end { + let rest = raw.get(line_start..header_end)?; + let line_len = rest + .iter() + .position(|&b| b == b'\n') + .map_or(rest.len(), |i| i.saturating_add(1)); + let line = rest.get(..line_len)?; + if sig_start.is_none() { + if line.starts_with(b"gpgsig ") { + sig_start = Some(line_start); + sig_end = Some(line_start.saturating_add(line_len)); + } + } else if sig_end == Some(line_start) && line.starts_with(b" ") { + sig_end = Some(line_start.saturating_add(line_len)); + } + line_start = line_start.saturating_add(line_len); + } + + let (start, end) = (sig_start?, sig_end?); + let mut payload = Vec::with_capacity(raw.len().saturating_sub(end.saturating_sub(start))); + payload.extend_from_slice(raw.get(..start)?); + payload.extend_from_slice(raw.get(end..)?); + + let header = raw.get(start..end)?; + let text = std::str::from_utf8(header).ok()?; + let mut sig = String::new(); + for (i, line) in text.lines().enumerate() { + let value = if i == 0 { + line.strip_prefix("gpgsig ")? + } else { + line.strip_prefix(' ')? + }; + sig.push_str(value); + sig.push('\n'); + } + Some((payload, sig)) +} + +/// Whether `signature` (an armored SSHSIG) over `payload` verifies +/// against `key` (an OpenSSH single-line public key, as stored on a +/// [`ents_model::Member`]). +/// +/// Any malformed key, malformed signature, wrong namespace, or failed +/// cryptographic check is `false` — the gate treats them all as "not +/// signed by this member", and the caller renders which member set was +/// consulted. +pub(crate) fn verifies(key: &str, payload: &[u8], signature: &str) -> bool { + let Ok(key) = PublicKey::from_openssh(key) else { + return false; + }; + let Ok(sig) = SshSig::from_pem(signature) else { + return false; + }; + key.verify(GIT_NAMESPACE, payload, &sig).is_ok() +} + +#[cfg(test)] +mod tests { + #![expect(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + /// A hand-written commit shape: gpgsig between committer and an + /// extra header, with two continuation lines (each continuation + /// line starts with one space, as git writes multi-line headers). + const RAW: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n QUJD\n -----END SSH SIGNATURE-----\nother value\n\nmessage body\n"; + + #[rstest] + // @relation(gate.signature-artifact, scope=function, role=Verifies) + fn split_removes_exactly_the_gpgsig_header() { + let (payload, sig) = split_signed(RAW).expect("signed"); + assert_eq!( + sig, + "-----BEGIN SSH SIGNATURE-----\nQUJD\n-----END SSH SIGNATURE-----\n" + ); + let expected: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\nother value\n\nmessage body\n"; + assert_eq!(payload, expected); + } + + #[rstest] + fn unsigned_commit_splits_to_none() { + let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\n\ngpgsig in the message is not a header\n"; + assert!(split_signed(raw).is_none()); + } +}
crates/ents-gate/src/verdict.rs @@ -1,0 +1,181 @@ +//! The gate's verdict vocabulary: admission, refusal, and the +//! machine-readable reason a refusal carries (`gate.verdict-reason`). + +use gix::refs::FullName; +use gix_ref_store::Expected; + +/// The requirement a refusal names — one of the tip-invariant rules +/// `gate.tip-signed` through `gate.atomic-cas`, exactly the range +/// `gate.verdict-reason` requires a failure to identify. +/// +/// [`Requirement::AtomicCas`] is never produced by [`crate::verify`] +/// itself (the gate reads, it does not write); it exists so the caller +/// that *does* run the compare-and-swap can report a stale-precondition +/// rejection in the same vocabulary. +/// +/// # Examples +/// +/// ``` +/// use ents_gate::Requirement; +/// +/// assert_eq!(Requirement::TipSigned.uid(), "gate.tip-signed"); +/// assert_eq!(Requirement::FastForward.uid(), "gate.fast-forward"); +/// ``` +// @relation(gate.verdict-reason, scope=file) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Requirement { + /// `gate.tip-signed`: the new tip must be signed by a member + /// authorized for the refname. + TipSigned, + /// `gate.refname-binding`: the commit's `Ents-Ref:` trailer must + /// match the refname being updated. + RefnameBinding, + /// `gate.fast-forward`: the new tip must descend from the old tip. + FastForward, + /// `gate.atomic-cas`: the update must commit via compare-and-swap + /// against the old tip the gate read. + AtomicCas, +} + +impl Requirement { + /// The spec requirement id this variant names. + #[must_use] + pub fn uid(&self) -> &'static str { + match self { + Self::TipSigned => "gate.tip-signed", + Self::RefnameBinding => "gate.refname-binding", + Self::FastForward => "gate.fast-forward", + Self::AtomicCas => "gate.atomic-cas", + } + } +} + +/// Why a passing update passed — advisory call sites render this, so a +/// local UI can say "admitted under the bootstrap window" rather than a +/// bare yes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdmissionKind { + /// The full tip invariant held (`gate.tip-signed` through + /// `gate.fast-forward`, with the CAS precondition attached). + TipInvariant, + /// No verification epoch is recorded in `refs/meta/config`, and this + /// update does not set one: the tip invariant is not yet in force + /// (`gate.epoch` — history before the epoch is archival). + PreEpoch, + /// Admitted by the empty-member-list bootstrap window: a first + /// enrollment, self-admitting (`gate.bootstrap`). + Bootstrap, + /// The refname is outside `refs/meta/*`: branch and tag refs keep + /// transport-level authorization instead of the tip invariant + /// (`gate.principled-split`). + CodeRef, +} + +/// A passing verdict: the update may proceed, and `cas` is the +/// compare-and-swap precondition the write MUST use — bound to the same +/// old-tip read the fast-forward check used, which is what makes the +/// eventual ref update atomic against races (`gate.atomic-cas`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Admission { + /// Why the update passed. + pub kind: AdmissionKind, + /// The refname judged. + pub refname: FullName, + /// The CAS precondition for the write: `MustExistAndMatch(old tip)` + /// when the ref existed at verification time, `MustNotExist` when it + /// did not. + pub cas: Expected, +} + +/// A failing verdict: which requirement failed, for which refname, and a +/// rendered reason (`gate.verdict-reason` — never a bare pass/fail). +/// +/// # Examples +/// +/// ``` +/// use ents_gate::{Refusal, Requirement}; +/// +/// let refusal = Refusal { +/// requirement: Requirement::TipSigned, +/// refname: "refs/meta/issues/42".try_into().expect("valid"), +/// detail: "your signing key is not authorized for this ref".into(), +/// inbox_alternative: true, +/// }; +/// let rendered = refusal.to_string(); +/// assert!(rendered.contains("gate.tip-signed")); +/// assert!(rendered.contains("refs/meta/inbox")); +/// ``` +// @relation(gate.verdict-reason, scope=file) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + /// The tip-invariant rule that failed. + pub requirement: Requirement, + /// The refname the update targeted. + pub refname: FullName, + /// A human-readable, actionable reason. + pub detail: String, + /// Whether submitting through the inbox namespace would be accepted + /// instead — set on authorization refusals so advisory call sites can + /// surface the inbox alternative at verdict time, not only once a + /// push is rejected (`gate.advisory-local`, `sync.inbox-routing`). + pub inbox_alternative: bool, +} + +impl std::fmt::Display for Refusal { + // @relation(gate.verdict-reason, gate.advisory-local, scope=function) + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} (rule {}, ref {})", + self.detail, + self.requirement.uid(), + self.refname.as_bstr() + )?; + if self.inbox_alternative { + write!( + f, + "; you can still submit this change under refs/meta/inbox/* for adoption by an authorized member" + )?; + } + Ok(()) + } +} + +/// The gate's verdict on one proposed ref update. +/// +/// The same value is computed at all three call sites +/// (`gate.call-sites`); what differs is only what the caller does with a +/// [`Verdict::Fail`] — abort the transaction (hosted CAS, +/// `gate.mandatory-hosted`) or annotate and proceed (local UI and push +/// pre-flight, `gate.advisory-local`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + /// The update satisfies the gate; write it with + /// [`Admission::cas`] as the precondition. + Pass(Admission), + /// The update violates the tip invariant; the refusal says which + /// rule, for which ref, and why. + Fail(Refusal), +} + +impl Verdict { + /// Whether this verdict admits the update. + /// + /// # Examples + /// + /// ``` + /// use ents_gate::{Admission, AdmissionKind, Verdict}; + /// use gix_ref_store::Expected; + /// + /// let verdict = Verdict::Pass(Admission { + /// kind: AdmissionKind::CodeRef, + /// refname: "refs/heads/main".try_into().expect("valid"), + /// cas: Expected::MustNotExist, + /// }); + /// assert!(verdict.is_pass()); + /// ``` + #[must_use] + pub fn is_pass(&self) -> bool { + matches!(self, Self::Pass(_)) + } +}
crates/ents-gate/src/verify.rs @@ -1,0 +1,401 @@ +//! The pure verify function — the one admission judgment +//! (`gate.tip-signed` through `gate.fast-forward`, `gate.epoch`, +//! `gate.bootstrap`), identical at every call site (`gate.call-sites`). + +use ents_model::namespace::{self, Namespace}; +use ents_model::trailer::Trailers; +use ents_model::{Member, MemberId, MemberState, Provenance}; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_object::Find; +use gix_ref_store::{Expected, RefStoreRead}; + +use crate::config; +use crate::error::Result; +use crate::object::{CommitData, descends_from, read_commit}; +use crate::policy; +use crate::signature; +use crate::verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict}; + +/// One proposed ref update, as every call site sees it: the refname and +/// the tip it should come to point at (`None` proposes deletion). +/// +/// There is deliberately no `old` field: the gate reads the current tip +/// itself, from the same store snapshot its fast-forward check uses, and +/// returns it as the CAS precondition ([`Admission::cas`]) — binding +/// `gate.fast-forward` and `gate.atomic-cas` to one read. +/// +/// # Examples +/// +/// ``` +/// use ents_gate::Update; +/// +/// let update = Update { +/// name: "refs/meta/issues/42".try_into().expect("valid"), +/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)), +/// }; +/// assert!(update.new.is_some()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Update { + /// The ref being updated. + pub name: FullName, + /// The proposed new tip, or `None` to delete the ref. + pub new: Option<ObjectId>, +} + +/// Verify one proposed ref update against current repository state. +/// +/// This is a pure function over the read half of the ref store and +/// gitoxide's object-find seam: same inputs, same verdict, no writes, no +/// clock, no transport state. The three call sites — hosted CAS +/// (`gate.mandatory-hosted`), local UI verdict (`gate.advisory-local`), +/// and push pre-flight (`sync.pre-flight`) — call exactly this function +/// (`gate.call-sites`) and differ only in what they do with a failing +/// verdict. +/// +/// The checks, in spec order, for a `refs/meta/*` ref once the epoch is +/// in force (`gate.epoch`): +/// +/// 1. `gate.tip-signed` — the new tip carries a `gpgsig` SSHSIG that +/// verifies against the key of an enrolled member whose entity, *as +/// recorded at the signature's own timestamp* in the member ref's +/// history, is active (`model.member-revocation`) and whose +/// provenance authorizes this refname (`model.member-provenance`, +/// `effect.admin-only`). +/// 2. `gate.refname-binding` — the commit's `Ents-Ref:` trailer names +/// exactly this ref. +/// 3. `gate.fast-forward` — the new tip descends from the current tip. +/// +/// Refs outside `refs/meta/*` pass as [`AdmissionKind::CodeRef`]: branch +/// refs keep transport-level authorization instead of the tip invariant +/// (`gate.principled-split`). +/// +/// # Errors +/// +/// An [`crate::Error`] means the gate could not evaluate (store or +/// object failure) — distinct from a [`Verdict::Fail`], which is a +/// reached judgment. The mandatory call site must treat both as +/// blocking. +/// +/// # Examples +/// +/// ``` +/// use ents_gate::{AdmissionKind, Update, Verdict, verify}; +/// use ents_testutil::{MemRefStore, ObjectStore}; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// +/// // A code ref is not subject to the tip invariant. +/// let verdict = verify(&refs, &objects, &Update { +/// name: "refs/heads/main".try_into().expect("valid"), +/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)), +/// }).expect("evaluates"); +/// let Verdict::Pass(admission) = verdict else { panic!("code refs pass") }; +/// assert_eq!(admission.kind, AdmissionKind::CodeRef); +/// ``` +// @relation(gate.tip-signed, gate.refname-binding, gate.fast-forward, gate.atomic-cas, gate.epoch, gate.call-sites, gate.principled-split, scope=function) +pub fn verify(refs: &dyn RefStoreRead, objects: &dyn Find, update: &Update) -> Result<Verdict> { + let old = refs.get(update.name.as_ref())?; + let cas = old.map_or(Expected::MustNotExist, Expected::MustExistAndMatch); + let pass = |kind: AdmissionKind| { + Ok(Verdict::Pass(Admission { + kind, + refname: update.name.clone(), + cas: cas.clone(), + })) + }; + + // The principled split: content signatures authorize only + // single-writer appends, which only meta-refs guarantee. + // @relation(gate.principled-split, scope=function) + if !update.name.as_bstr().starts_with(b"refs/meta/") { + return pass(AdmissionKind::CodeRef); + } + + // The verification epoch: the tip invariant applies only once an + // epoch is recorded in refs/meta/config — or for the update that + // records it, which must itself be the first gated tip of the + // config ref (`gate.epoch`). + let epoch = config::current_epoch(refs, objects)?; + let epoch_setting = epoch.is_none() + && update.name.as_bstr() == ents_model::namespace::CONFIG_REF + && match update.new { + // A new config that does not parse (or has no epoch) is not + // epoch-setting; pre-epoch, it passes as archival anyway. + Some(new) => matches!(config::epoch_at_commit(objects, new), Ok(Some(_))), + None => false, + }; + if epoch.is_none() && !epoch_setting { + return pass(AdmissionKind::PreEpoch); + } + + let refuse = |requirement: Requirement, detail: String, inbox_alternative: bool| { + Ok(Verdict::Fail(Refusal { + requirement, + refname: update.name.clone(), + detail, + inbox_alternative, + })) + }; + + // Meta-refs advance fast-forward-only; deletion is not a descent + // and would discard the audit trail. + let Some(new) = update.new else { + return refuse( + Requirement::FastForward, + "meta-refs advance fast-forward-only; deletion is refused".into(), + false, + ); + }; + + let Some(commit) = read_commit(objects, new)? else { + return refuse( + Requirement::TipSigned, + "the proposed tip is not a commit object, so it cannot carry a member signature".into(), + false, + ); + }; + + // gate.tip-signed / gate.signature-artifact: the signature is a data + // artifact inside the commit object; no push certificate is read. + // @relation(gate.signature-artifact, scope=function) + let Some((payload, sig)) = signature::split_signed(&commit.raw) else { + return refuse( + Requirement::TipSigned, + "the proposed tip is unsigned; meta-ref mutations must be author-signed commits".into(), + false, + ); + }; + + let members = policy::members(refs)?; + if members.is_empty() { + return bootstrap(objects, update, new, &commit, &payload, &sig, old, &cas); + } + + // Identify the signer: the member whose entity *at the signature's + // own timestamp* carries the verifying key. Walking the member ref's + // history for that entity is what gives revocation its + // before/after boundary (`model.member-revocation`). + let at = commit.committer_seconds; + let mut signer: Option<(MemberId, Member)> = None; + for enrolled in &members { + if let Some(member) = policy::member_at(objects, enrolled.tip, at)? + && signature::verifies(&member.key, &payload, &sig) + { + signer = Some((enrolled.id.clone(), member)); + break; + } + } + // @relation(gate.tip-signed, gate.bootstrap, scope=function) + let Some((id, member)) = signer else { + return refuse( + Requirement::TipSigned, + "the tip's signature does not verify against any member key enrolled and in force \ + at the commit's timestamp" + .into(), + false, + ); + }; + + // A revoked key is explicitly rejected for signatures made after + // revocation (`model.member-revocation`); because the entity above + // was resolved at the signature's timestamp, a signature made while + // the key was valid stays verifiable even if the member is revoked + // by the time the gate runs. + if member.state == MemberState::Revoked { + return refuse( + Requirement::TipSigned, + format!("member {id}'s key was revoked at the signature's timestamp"), + false, + ); + } + + if let Some(refusal) = authorize(&update.name, &id, &member) { + return Ok(Verdict::Fail(refusal)); + } + + // gate.refname-binding: without this, a signed commit could be + // replayed as the tip of a different meta-ref. + // @relation(gate.refname-binding, scope=function) + let trailers = Trailers::parse(&commit.message); + if trailers.ents_ref.as_ref() != Some(&update.name) { + let found = trailers + .ents_ref + .as_ref() + .map_or_else(|| "no Ents-Ref trailer".to_owned(), |n| n.to_string()); + return refuse( + Requirement::RefnameBinding, + format!( + "the commit was authored for {found}, not for {}", + update.name.as_bstr() + ), + false, + ); + } + + // gate.fast-forward: the parent hash is the anti-replay freshness + // binding; the CAS precondition below pins the same old tip. + // @relation(gate.fast-forward, scope=function) + if let Some(old) = old + && !descends_from(objects, new, old)? + { + return refuse( + Requirement::FastForward, + "the new tip does not descend from the current tip; merge the divergent heads \ + (adoption and same-actor divergence are merges, never rewrites)" + .into(), + false, + ); + } + + pass(AdmissionKind::TipInvariant) +} + +/// Refname-keyed authorization for an identified, active signer +/// (`gate.tip-signed`'s "authorized for that refname"). +/// +/// The rules are exactly the ones the spec itself fixes: +/// +/// - `refs/meta/self/<member>/*` is writable only by `<member>` +/// (`meta-ref.inbox`, `effect.self-run`). +/// - `refs/meta/effects/*` requires an admin-registered member +/// regardless of anything else (`effect.admin-only`). +/// - A self-attested member is not authorized for canonical refs — its +/// writes are limited to its own inbox and self-run namespaces +/// (`model.member-provenance`). +/// - `refs/meta/inbox/*`: the spec's "its own inbox" cannot be keyed on +/// the refname because `refs/meta/inbox/*` encodes no member segment +/// (open question between `meta-ref.inbox` and +/// `model.member-provenance`); until the spec picks a shape, the gate +/// fails closed for self-attested members and admits admin-registered +/// ones, and says so in the refusal. +// @relation(gate.tip-signed, effect.admin-only, model.member-provenance, scope=function) +fn authorize(name: &FullName, id: &MemberId, member: &Member) -> Option<Refusal> { + let namespace = namespace::classify(name.as_ref())?; + let refuse = |detail: String, inbox_alternative: bool| { + Some(Refusal { + requirement: Requirement::TipSigned, + refname: name.clone(), + detail, + inbox_alternative, + }) + }; + match namespace { + Namespace::SelfRun => { + if namespace::self_run_owner(name.as_ref()).as_ref() == Some(id) { + None + } else { + refuse( + format!( + "refs/meta/self/<member>/* is writable only by that member; \ + {id} does not own this ref" + ), + false, + ) + } + } + Namespace::Inbox => match member.provenance { + Provenance::AdminRegistered => None, + Provenance::SelfAttested => refuse( + "inbox authorization for self-attested members is not yet decidable: \ + refs/meta/inbox/* encodes no member segment, so \"its own inbox\" \ + (model.member-provenance) cannot be keyed on the refname; failing closed \ + until the spec picks a shape" + .into(), + false, + ), + }, + Namespace::Effect => match member.provenance { + Provenance::AdminRegistered => None, + Provenance::SelfAttested => refuse( + format!( + "authoring an effect schedules code execution on canonical \ + infrastructure; {id} is not admin-registered" + ), + true, + ), + }, + _ => match member.provenance { + Provenance::AdminRegistered => None, + Provenance::SelfAttested => refuse( + format!( + "{id}'s membership is self-attested and not authorized for canonical \ + refs until promoted by an admin-registered member" + ), + true, + ), + }, + } +} + +/// The empty-member-list bootstrap window (`gate.bootstrap`): with no +/// `refs/meta/member/*` ref present, a first enrollment is +/// self-admitting — and only an enrollment. Self-admitting is taken +/// literally: the enrollment commit must be signed by the key inside the +/// Member tree it pushes, must bind to its refname, and must fast-forward, +/// so even the bootstrap write satisfies every mechanically-checkable +/// part of the tip invariant. Because this path is reachable only while +/// the member set is empty, a member set whose keys are all revoked +/// never reopens it: those updates take the ordinary path and fail +/// closed on the revoked state. +// @relation(gate.bootstrap, scope=function) +#[expect( + clippy::too_many_arguments, + reason = "a private continuation of verify(); grouping these into a struct would only rename the arguments" +)] +fn bootstrap( + objects: &dyn Find, + update: &Update, + new: ObjectId, + commit: &CommitData, + payload: &[u8], + sig: &str, + old: Option<ObjectId>, + cas: &Expected, +) -> Result<Verdict> { + let refuse = |detail: String| { + Ok(Verdict::Fail(Refusal { + requirement: Requirement::TipSigned, + refname: update.name.clone(), + detail, + inbox_alternative: false, + })) + }; + if namespace::classify(update.name.as_ref()) != Some(Namespace::Member) { + return refuse( + "no members are enrolled; only a first member enrollment is self-admitting".into(), + ); + } + let Ok(pushed) = facet_git_tree::deserialize::<Member>(&commit.tree, objects) else { + return refuse("a first enrollment must push a readable Member entity".into()); + }; + if !signature::verifies(&pushed.key, payload, sig) { + return refuse("a first enrollment must be signed by the key it enrolls".into()); + } + let trailers = Trailers::parse(&commit.message); + if trailers.ents_ref.as_ref() != Some(&update.name) { + return Ok(Verdict::Fail(Refusal { + requirement: Requirement::RefnameBinding, + refname: update.name.clone(), + detail: "the enrollment commit's Ents-Ref trailer does not name this ref".into(), + inbox_alternative: false, + })); + } + if let Some(old) = old + && !descends_from(objects, new, old)? + { + return Ok(Verdict::Fail(Refusal { + requirement: Requirement::FastForward, + refname: update.name.clone(), + detail: "the enrollment does not descend from the ref's current tip".into(), + inbox_alternative: false, + })); + } + Ok(Verdict::Pass(Admission { + kind: AdmissionKind::Bootstrap, + refname: update.name.clone(), + cas: cas.clone(), + })) +}
crates/ents-gate/tests/gate.rs @@ -1,0 +1,717 @@ +//! Integration tests for the gate: the verdict table (rstest — the spec +//! enumerates the cases), the epoch and bootstrap windows, and the +//! one-parameterized-test proof that all three call sites see identical +//! verdicts. + +#![expect( + clippy::expect_used, + clippy::panic, + clippy::unreachable, + reason = "integration test: fixtures panic on setup failure" +)] + +use ents_gate::{AdmissionKind, Config, Requirement, Update, Verdict, verify}; +use ents_model::trailer::Trailers; +use ents_model::{Member, MemberId, Provenance, namespace}; +use ents_testutil::{ + CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member, write_commit, + write_member, write_meta_entity, +}; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_ref_store::{Expected, RefStoreRead as _}; +use rstest::rstest; + +const ADMIN_SEED: u8 = 1; +const SELF_ATTESTED_SEED: u8 = 2; +const OUTSIDER_SEED: u8 = 9; + +/// A forge fixture with verification in force: an admin-registered +/// member `admin` (enrolled pre-epoch), a self-attested member `guest`, +/// and an epoch recorded in `refs/meta/config`. +struct Forge { + refs: MemRefStore, + objects: ObjectStore, + admin: Keypair, + guest: Keypair, +} + +fn forge() -> Forge { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let admin = Keypair::from_seed(ADMIN_SEED); + let guest = Keypair::from_seed(SELF_ATTESTED_SEED); + enroll_member( + &refs, + &objects, + "admin", + &admin, + Provenance::AdminRegistered, + 100, + ); + enroll_member( + &refs, + &objects, + "guest", + &guest, + Provenance::SelfAttested, + 110, + ); + let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid"); + write_meta_entity( + &refs, + &objects, + config_ref, + &Config { epoch: Some(200) }, + Some(&admin), + 200, + ); + Forge { + refs, + objects, + admin, + guest, + } +} + +fn name(s: &str) -> FullName { + s.try_into().expect("valid refname in test") +} + +/// A proposal commit: empty tree, explicit parents/trailer/key/time. +fn proposal( + forge: &Forge, + parents: Vec<ObjectId>, + ents_ref: Option<&str>, + key: Option<&Keypair>, + seconds: i64, +) -> ObjectId { + let tree = empty_tree(&forge.objects); + let message = ents_ref.map_or_else( + || "mutate\n\nno trailer here\n".to_owned(), + |r| { + let trailers = Trailers { + ents_ref: Some(name(r)), + schema_version: None, + }; + format!("mutate\n\n{}", trailers.render()) + }, + ); + write_commit( + &forge.objects, + &CommitSpec { + tree, + parents, + message, + seconds, + }, + key, + ) +} + +fn run(forge: &Forge, refname: &str, new: Option<ObjectId>) -> Verdict { + verify( + &forge.refs, + &forge.objects, + &Update { + name: name(refname), + new, + }, + ) + .expect("the gate must reach a verdict on a complete fixture") +} + +fn expect_fail(verdict: &Verdict, requirement: Requirement) { + let Verdict::Fail(refusal) = verdict else { + panic!("expected a refusal against {requirement:?}, got {verdict:?}"); + }; + assert_eq!(refusal.requirement, requirement, "refusal: {refusal}"); +} + +fn expect_pass(verdict: &Verdict, kind: AdmissionKind) { + let Verdict::Pass(admission) = verdict else { + panic!("expected admission {kind:?}, got {verdict:?}"); + }; + assert_eq!(admission.kind, kind); +} + +// --------------------------------------------------------------------- +// The verdict table: member × trailer × FF × namespace cases. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(gate.tip-signed, gate.verdict-reason, scope=function, role=Verifies) +fn authorized_signed_mutation_passes_the_tip_invariant() { + let f = forge(); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300); + expect_pass( + &run(&f, "refs/meta/issues/1", Some(new)), + AdmissionKind::TipInvariant, + ); +} + +#[rstest] +// @relation(gate.tip-signed, scope=function, role=Verifies) +fn unsigned_tip_is_refused() { + let f = forge(); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), None, 300); + expect_fail( + &run(&f, "refs/meta/issues/1", Some(new)), + Requirement::TipSigned, + ); +} + +#[rstest] +// @relation(gate.tip-signed, scope=function, role=Verifies) +fn non_member_signature_is_refused() { + let f = forge(); + let outsider = Keypair::from_seed(OUTSIDER_SEED); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&outsider), 300); + expect_fail( + &run(&f, "refs/meta/issues/1", Some(new)), + Requirement::TipSigned, + ); +} + +#[rstest] +// @relation(gate.tip-signed, model.member-revocation, scope=function, role=Verifies) +fn signature_made_after_revocation_is_refused() { + let f = forge(); + let mut revoked = Member::new(f.admin.public_openssh(), Provenance::AdminRegistered); + revoked.revoke(); + write_member(&f.refs, &f.objects, "admin", &revoked, Some(&f.admin), 400); + + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 500); + let verdict = run(&f, "refs/meta/issues/1", Some(new)); + expect_fail(&verdict, Requirement::TipSigned); + let Verdict::Fail(refusal) = &verdict else { + unreachable!() + }; + assert!(refusal.detail.contains("revoked"), "detail: {refusal}"); +} + +#[rstest] +// @relation(model.member-revocation, gate.tip-signed, scope=function, role=Verifies) +fn signature_made_before_revocation_stays_verifiable() { + // The boundary is found by walking the member ref's history and + // deserializing the tree in force at the signature's timestamp — + // there is no validity-window field, by design. + let f = forge(); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300); + + let mut revoked = Member::new(f.admin.public_openssh(), Provenance::AdminRegistered); + revoked.revoke(); + write_member(&f.refs, &f.objects, "admin", &revoked, Some(&f.admin), 400); + + // The gate runs *after* the revocation, on a commit signed before it. + expect_pass( + &run(&f, "refs/meta/issues/1", Some(new)), + AdmissionKind::TipInvariant, + ); +} + +#[rstest] +#[case::wrong_ref(Some("refs/meta/issues/2"))] +#[case::missing_trailer(None)] +// @relation(gate.refname-binding, scope=function, role=Verifies) +fn refname_binding_mismatch_is_refused(#[case] trailer: Option<&str>) { + let f = forge(); + let new = proposal(&f, vec![], trailer, Some(&f.admin), 300); + expect_fail( + &run(&f, "refs/meta/issues/1", Some(new)), + Requirement::RefnameBinding, + ); +} + +#[rstest] +// @relation(gate.fast-forward, scope=function, role=Verifies) +fn non_fast_forward_is_refused() { + let f = forge(); + let refname = "refs/meta/issues/1"; + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 300, + ); + // A sibling that does not descend from `tip`. + let sibling = proposal(&f, vec![], Some(refname), Some(&f.admin), 310); + assert_ne!(sibling, tip); + expect_fail(&run(&f, refname, Some(sibling)), Requirement::FastForward); +} + +#[rstest] +// @relation(gate.fast-forward, scope=function, role=Verifies) +fn meta_ref_deletion_is_refused() { + let f = forge(); + write_meta_entity( + &f.refs, + &f.objects, + name("refs/meta/issues/1"), + &ents_model::Status::Pass, + Some(&f.admin), + 300, + ); + expect_fail( + &run(&f, "refs/meta/issues/1", None), + Requirement::FastForward, + ); +} + +#[rstest] +// @relation(gate.principled-split, scope=function, role=Verifies) +fn code_refs_are_not_subject_to_the_tip_invariant() { + let f = forge(); + // Unsigned, trailerless, non-FF — none of it matters outside refs/meta/*. + let new = proposal(&f, vec![], None, None, 300); + expect_pass( + &run(&f, "refs/heads/main", Some(new)), + AdmissionKind::CodeRef, + ); +} + +// --------------------------------------------------------------------- +// Provenance-keyed authorization. +// --------------------------------------------------------------------- + +#[rstest] +#[case::canonical_issue("refs/meta/issues/1", true)] +#[case::canonical_result("refs/meta/results/unit/abc", true)] +#[case::effects_is_admin_only("refs/meta/effects/unit", true)] +#[case::inbox_shape_is_an_open_spec_question("refs/meta/inbox/xyz", false)] +#[case::another_self_run("refs/meta/self/admin/unit/abc", false)] +// @relation(model.member-provenance, effect.admin-only, gate.tip-signed, scope=function, role=Verifies) +fn self_attested_members_are_limited_to_their_own_namespaces( + #[case] refname: &str, + #[case] inbox_alternative: bool, +) { + let f = forge(); + let new = proposal(&f, vec![], Some(refname), Some(&f.guest), 300); + let verdict = run(&f, refname, Some(new)); + expect_fail(&verdict, Requirement::TipSigned); + let Verdict::Fail(refusal) = &verdict else { + unreachable!() + }; + assert_eq!( + refusal.inbox_alternative, inbox_alternative, + "inbox hint for {refname}: {refusal}" + ); +} + +#[rstest] +// @relation(meta-ref.inbox, effect.self-run, gate.tip-signed, scope=function, role=Verifies) +fn a_member_may_write_its_own_self_run_namespace() { + let f = forge(); + let refname = "refs/meta/self/guest/unit/abc123"; + let new = proposal(&f, vec![], Some(refname), Some(&f.guest), 300); + expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant); +} + +#[rstest] +#[case::effects("refs/meta/effects/unit")] +#[case::inbox("refs/meta/inbox/xyz")] +#[case::results("refs/meta/results/unit/abc")] +// @relation(effect.admin-only, gate.tip-signed, scope=function, role=Verifies) +fn admin_registered_members_may_write_canonical_namespaces(#[case] refname: &str) { + let f = forge(); + let new = proposal(&f, vec![], Some(refname), Some(&f.admin), 300); + expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant); +} + +// --------------------------------------------------------------------- +// Adoption and divergence: consequences of judging only the tip. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(gate.adoption-merge, scope=function, role=Verifies) +fn adoption_is_a_merge_that_keeps_the_contributor_commit_in_ancestry() { + let f = forge(); + let refname = "refs/meta/comments/c1"; + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 300, + ); + // The contributor's own signed commit, not authorized for this ref. + let contributed = proposal(&f, vec![tip], Some(refname), Some(&f.guest), 310); + // The authorized member merges it: the merge tip satisfies the + // invariant; the contributor's signature survives in ancestry. + let merge = proposal( + &f, + vec![tip, contributed], + Some(refname), + Some(&f.admin), + 320, + ); + expect_pass(&run(&f, refname, Some(merge)), AdmissionKind::TipInvariant); +} + +#[rstest] +// @relation(gate.adoption-no-fast-forward, scope=function, role=Verifies) +fn fast_forwarding_to_a_contributor_commit_is_not_adoption() { + let f = forge(); + let refname = "refs/meta/comments/c1"; + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 300, + ); + let contributed = proposal(&f, vec![tip], Some(refname), Some(&f.guest), 310); + // Descends fine — but the tip signature is the contributor's, and + // the contributor is not authorized for this refname. + expect_fail(&run(&f, refname, Some(contributed)), Requirement::TipSigned); +} + +#[rstest] +// @relation(gate.same-actor-divergence, scope=function, role=Verifies) +fn a_members_own_divergent_heads_merge_cleanly() { + let f = forge(); + let refname = "refs/meta/issues/1"; + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 300, + ); + // Two of the member's own machines raced the single-writer ref. + let a = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 310); + let b = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 311); + // Either head alone is a non-fast-forward once the other landed; + // the resolution is the member merging their own heads. + let merge = proposal(&f, vec![a, b], Some(refname), Some(&f.admin), 320); + expect_pass(&run(&f, refname, Some(merge)), AdmissionKind::TipInvariant); +} + +// --------------------------------------------------------------------- +// The verification epoch. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(gate.epoch, scope=function, role=Verifies) +fn before_any_epoch_meta_history_is_archival() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let f = Forge { + refs, + objects, + admin: Keypair::from_seed(ADMIN_SEED), + guest: Keypair::from_seed(SELF_ATTESTED_SEED), + }; + // No config, no members: an unsigned meta write passes as pre-epoch. + let new = proposal(&f, vec![], None, None, 100); + expect_pass( + &run(&f, "refs/meta/issues/1", Some(new)), + AdmissionKind::PreEpoch, + ); +} + +#[rstest] +// @relation(gate.epoch, scope=function, role=Verifies) +fn the_epoch_setting_commit_is_itself_the_first_gated_tip() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let admin = Keypair::from_seed(ADMIN_SEED); + enroll_member( + &refs, + &objects, + "admin", + &admin, + Provenance::AdminRegistered, + 100, + ); + let f = Forge { + refs, + objects, + admin, + guest: Keypair::from_seed(SELF_ATTESTED_SEED), + }; + + let tree = facet_git_tree::serialize_into(&Config { epoch: Some(200) }, &f.objects) + .expect("config serializes"); + let make = |key: Option<&Keypair>| { + let trailers = Trailers { + ents_ref: Some(name(namespace::CONFIG_REF)), + schema_version: None, + }; + write_commit( + &f.objects, + &CommitSpec { + tree, + parents: vec![], + message: format!("enable verification\n\n{}", trailers.render()), + seconds: 200, + }, + key, + ) + }; + + // Unsigned epoch-setting is refused: the circularity resolves by + // gating the very commit that turns gating on. + expect_fail( + &run(&f, namespace::CONFIG_REF, Some(make(None))), + Requirement::TipSigned, + ); + // Signed by an enrolled member, it passes under the tip invariant. + expect_pass( + &run(&f, namespace::CONFIG_REF, Some(make(Some(&f.admin)))), + AdmissionKind::TipInvariant, + ); +} + +// --------------------------------------------------------------------- +// Bootstrap: fail-closed empty-member-list handling. +// --------------------------------------------------------------------- + +/// A store with verification in force but no members at all — the shape +/// a hosted deployment initializes (`roots.bootstrap` owns hardening). +fn bare_forge_with_epoch() -> Forge { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid"); + write_meta_entity( + &refs, + &objects, + config_ref, + &Config { epoch: Some(50) }, + None, + 50, + ); + Forge { + refs, + objects, + admin: Keypair::from_seed(ADMIN_SEED), + guest: Keypair::from_seed(SELF_ATTESTED_SEED), + } +} + +fn enrollment_proposal(f: &Forge, id: &str, enrolled: &Keypair, signer: &Keypair) -> ObjectId { + let member = Member::new(enrolled.public_openssh(), Provenance::AdminRegistered); + let tree = facet_git_tree::serialize_into(&member, &f.objects).expect("member serializes"); + let refname = namespace::member_ref(&MemberId::new(id)).expect("valid id"); + let trailers = Trailers { + ents_ref: Some(refname), + schema_version: None, + }; + write_commit( + &f.objects, + &CommitSpec { + tree, + parents: vec![], + message: format!("enroll {id}\n\n{}", trailers.render()), + seconds: 100, + }, + Some(signer), + ) +} + +#[rstest] +// @relation(gate.bootstrap, scope=function, role=Verifies) +fn first_enrollment_is_self_admitting() { + let f = bare_forge_with_epoch(); + let new = enrollment_proposal(&f, "first", &f.admin, &f.admin); + expect_pass( + &run(&f, "refs/meta/member/first", Some(new)), + AdmissionKind::Bootstrap, + ); +} + +#[rstest] +// @relation(gate.bootstrap, scope=function, role=Verifies) +fn bootstrap_enrollment_must_be_signed_by_the_key_it_enrolls() { + let f = bare_forge_with_epoch(); + let other = Keypair::from_seed(OUTSIDER_SEED); + let new = enrollment_proposal(&f, "first", &f.admin, &other); + expect_fail( + &run(&f, "refs/meta/member/first", Some(new)), + Requirement::TipSigned, + ); +} + +#[rstest] +// @relation(gate.bootstrap, scope=function, role=Verifies) +fn bootstrap_admits_only_enrollments() { + let f = bare_forge_with_epoch(); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 100); + expect_fail( + &run(&f, "refs/meta/issues/1", Some(new)), + Requirement::TipSigned, + ); +} + +#[rstest] +// @relation(gate.bootstrap, model.member-revocation, scope=function, role=Verifies) +fn revoking_every_key_does_not_reopen_the_bootstrap_window() { + let f = forge(); + for id in ["admin", "guest"] { + let key = if id == "admin" { &f.admin } else { &f.guest }; + let provenance = if id == "admin" { + Provenance::AdminRegistered + } else { + Provenance::SelfAttested + }; + let mut member = Member::new(key.public_openssh(), provenance); + member.revoke(); + write_member(&f.refs, &f.objects, id, &member, Some(&f.admin), 400); + } + // A would-be new member self-enrolling: the member set is non-empty + // (though fully revoked), so the self-admitting window stays shut. + let newcomer = Keypair::from_seed(OUTSIDER_SEED); + let new = enrollment_proposal(&f, "newcomer", &newcomer, &newcomer); + expect_fail( + &run(&f, "refs/meta/member/newcomer", Some(new)), + Requirement::TipSigned, + ); + // And the fully-revoked members cannot write anything either. + let attempt = proposal(&f, vec![], Some("refs/meta/issues/9"), Some(&f.admin), 500); + expect_fail( + &run(&f, "refs/meta/issues/9", Some(attempt)), + Requirement::TipSigned, + ); +} + +// --------------------------------------------------------------------- +// CAS binding, call sites, and offline reproducibility. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(gate.atomic-cas, scope=function, role=Verifies) +fn the_admission_carries_the_cas_precondition_from_the_same_read() { + let f = forge(); + let refname = "refs/meta/issues/1"; + + // Creation: the ref must still be absent at write time. + let created = proposal(&f, vec![], Some(refname), Some(&f.admin), 300); + let Verdict::Pass(admission) = run(&f, refname, Some(created)) else { + panic!("expected a pass"); + }; + assert_eq!(admission.cas, Expected::MustNotExist); + + // Update: the precondition is exactly the old tip the FF check used. + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 310, + ); + let advanced = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 320); + let Verdict::Pass(admission) = run(&f, refname, Some(advanced)) else { + panic!("expected a pass"); + }; + assert_eq!(admission.cas, Expected::MustExistAndMatch(tip)); +} + +/// Every scenario the verdict table distinguishes, evaluated the way +/// each of the three call sites would evaluate it — hosted CAS on the +/// live store, local UI verdict on the same store, and push pre-flight +/// on a fetched copy of the refs — in one parameterized test: the gate +/// is one function, and its verdict is identical at every site. +#[rstest] +#[case::authorized_pass("refs/meta/issues/1", true, true, 300)] +#[case::unsigned_fail("refs/meta/issues/1", false, true, 300)] +#[case::unauthorized_namespace("refs/meta/effects/unit", true, false, 300)] +#[case::self_run_pass("refs/meta/self/guest/unit/abc", true, false, 300)] +// @relation(gate.call-sites, gate.mandatory-hosted, gate.advisory-local, scope=function, role=Verifies) +fn all_three_call_sites_return_identical_verdicts( + #[case] refname: &str, + #[case] signed: bool, + #[case] as_admin: bool, + #[case] seconds: i64, +) { + let f = forge(); + let key = if as_admin { &f.admin } else { &f.guest }; + let new = proposal(&f, vec![], Some(refname), signed.then_some(key), seconds); + let update = Update { + name: name(refname), + new: Some(new), + }; + + // Call site 1: hosted CAS time (mandatory — the caller aborts on Fail). + let hosted = verify(&f.refs, &f.objects, &update).expect("verdict"); + // Call site 2: the local UI verdict (advisory — annotates the write). + let local = verify(&f.refs, &f.objects, &update).expect("verdict"); + // Call site 3: push pre-flight, against a fetched copy of the refs. + let fetched = f.refs.fetched_copy(); + let preflight = verify(&fetched, &f.objects, &update).expect("verdict"); + + assert_eq!(hosted, local, "hosted vs local"); + assert_eq!(hosted, preflight, "hosted vs pre-flight"); +} + +#[rstest] +// @relation(gate.signature-artifact, gate.policy-as-state, scope=function, role=Verifies) +fn verdicts_reproduce_offline_from_repository_state_alone() { + // Build the identical repository twice from deterministic seeds — + // the fixture analogue of verifying in an independent clone. The + // verdict depends only on refs/meta/* state and object bytes, so + // both "clones" agree, with no transport artifact consulted. + let build = || { + let f = forge(); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300); + (f, new) + }; + let (origin, new_at_origin) = build(); + let (clone, new_at_clone) = build(); + assert_eq!(new_at_origin, new_at_clone, "deterministic fixtures"); + + let update = Update { + name: name("refs/meta/issues/1"), + new: Some(new_at_origin), + }; + let at_origin = verify(&origin.refs, &origin.objects, &update).expect("verdict"); + let at_clone = verify(&clone.refs, &clone.objects, &update).expect("verdict"); + assert_eq!(at_origin, at_clone); +} + +#[rstest] +// @relation(gate.verdict-reason, gate.advisory-local, scope=function, role=Verifies) +fn refusals_render_an_actionable_reason_with_the_inbox_alternative() { + let f = forge(); + let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.guest), 300); + let Verdict::Fail(refusal) = run(&f, "refs/meta/issues/1", Some(new)) else { + panic!("self-attested member on a canonical ref must be refused"); + }; + let rendered = refusal.to_string(); + assert!( + rendered.contains("gate.tip-signed"), + "names the rule: {rendered}" + ); + assert!( + rendered.contains("refs/meta/issues/1"), + "names the subject ref: {rendered}" + ); + assert!( + rendered.contains("refs/meta/inbox"), + "surfaces the inbox alternative at verdict time: {rendered}" + ); +} + +#[rstest] +// @relation(gate.epoch, scope=function, role=Verifies) +fn config_round_trips_with_and_without_an_epoch() { + for config in [Config { epoch: None }, Config { epoch: Some(42) }] { + let (root, store) = facet_git_tree::serialize(&config).expect("serialize"); + let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize"); + assert_eq!(back, config); + } +} + +#[rstest] +fn fixture_stores_read_back_what_they_seed() { + // Guards the fixture itself: the enrolled member refs exist and the + // ref store returns them through the production read trait. + let f = forge(); + let member_ref = name("refs/meta/member/admin"); + assert!(f.refs.get(member_ref.as_ref()).expect("readable").is_some()); +}