git-ents.gitmain
⌘K
foforge
lib.rs442 lines · 17.8 KB · rusthistorycomment on this file
1//! The six abstractions (`docs/abstractions.adoc`), the ones expressible as
2//! facts about a proposed ref transaction, restated as compiled Datalog.
3//!
4//! Every load-bearing rule here is a statement over facts the real
5//! extractor would pull from a pack plus current repository state: which
6//! refs move from what to what, which commits exist with which parents,
7//! who signed what, which keys are enrolled members. [`ascent`] embeds
8//! Datalog in Rust via a proc macro, so rustc checks the rules' types,
9//! arities, variable bindings, and stratification; a violation is simply a
10//! non-empty relation, and [`gate`] collects them.
11//!
12//! # Why this is a separate crate from `ents-gate`
13//!
14//! `ents-gate` is the one pure admission judgment actually wired into the
15//! three real call sites (hosted CAS, local UI verdict, push pre-flight;
16//! its own module docs cite `gate.call-sites`) — it reads a live
17//! `RefStoreRead`, decodes typed trees, and renders actionable refusals.
18//! This crate consumes none of that; it takes plain facts and is meant to
19//! be cheap to grow one denial rule at a time while an invariant is still
20//! being worked out, exactly as the source technique note that motivated
21//! this crate puts it: rules here are fixed at compile time, which is a
22//! feature for load-bearing, human-authored invariants, not a runtime
23//! query surface over live entity data.
24//!
25//! It is not itself one of the three enforcement points today. Carrying a
26//! rule proven out here into `ents-gate`'s actual fact extraction (or
27//! `ents-effect`'s trigger/dedup bookkeeping) is future work, one rule at
28//! a time, the same way the technique note describes: "a rule without a
29//! red test is a rule you don't know fires."
30//!
31//! # Coverage and gaps
32//!
33//! Five rules restate abstractions 2, 3, 4, and 5 directly:
34//!
35//! - [`ff_violation`](GateRules::ff_violation) — fast-forward-only advance
36//! is the anti-replay binding a signed commit relies on (abstraction 4).
37//! - [`genesis_violation`](GateRules::genesis_violation) and
38//! [`second_root_violation`](GateRules::second_root_violation) — a
39//! hash-identified entity's ref has exactly one parentless commit
40//! reachable from its tip (abstraction 2's typed tree, `meta-ref.
41//! identity-binding`'s all-roots walk).
42//! - [`unsigned_violation`](GateRules::unsigned_violation) — every commit
43//! a transaction introduces must carry a member signature (abstraction
44//! 5's tip invariant).
45//! - [`dangling_anchor_violation`](GateRules::dangling_anchor_violation)
46//! and [`dangling_context_violation`](GateRules::dangling_context_violation)
47//! — an anchor's embedded retention is two objects, not one: the
48//! anchored blob and a context blob of the surrounding lines
49//! (abstraction 3, `anchor.retention`); both must resolve.
50//!
51//! One rule grows the set past the original five, following the technique
52//! note's own suggested next step ("role-scoped authorization,
53//! `member(Key, Role)` plus per-namespace requirements"):
54//!
55//! - [`effect_admin_violation`](GateRules::effect_admin_violation) — a
56//! commit introduced onto `refs/meta/effects/*` must be signed by an
57//! admin-registered member, never merely any member (abstraction 6,
58//! `effect.admin-only`: authoring an effect schedules code execution on
59//! canonical infrastructure, which needs more trust than an ordinary
60//! append).
61//!
62//! Two invariants are deliberately *not* encoded here, the gap marked
63//! rather than papered over:
64//!
65//! - Abstraction 1's granularity rule ("one ref per independently-authored
66//! entity") is a ref-layout convention checked by which refname a write
67//! targets, not a property of the commits within one transaction's
68//! facts — it has no shape as a per-transaction Datalog fact here.
69//! - Abstraction 6's monotone, exactly-once effect semantics needs the
70//! dedup key `(effect, oid)` checked against the results namespace
71//! *across* transactions and time, which is queue/materialization state
72//! this crate's fact set does not carry.
73//!
74//! # Examples
75//!
76//! ```
77//! use ents_gate_rules::{Facts, Role, gate};
78//!
79//! let mut facts = Facts {
80//! member: vec![("key:joey".into(), Role::Member)],
81//! ..Facts::default()
82//! };
83//! facts.ref_update = vec![("refs/meta/issues/g".into(), Some("g".into()), "c1".into())];
84//! facts.parent = vec![("c1".into(), "g".into())];
85//! facts.signed_by = vec![("g".into(), "key:joey".into()), ("c1".into(), "key:joey".into())];
86//! assert!(gate(facts).is_empty());
87//! ```
88
89use ascent::ascent;
90
91/// An object id, standing in for `gix_hash::ObjectId` — a plain `String`
92/// here so a rule's facts stay readable in tests, the same simplification
93/// the technique note that motivated this crate makes with `&'static
94/// str`; any `Clone + Eq + Hash` type works once this is wired to a real
95/// extractor.
96pub type Oid = String;
97/// A refname, standing in for `gix::refs::FullName`.
98pub type Ref = String;
99/// A signing key's identity, standing in for a member's enrolled public
100/// key material.
101pub type Key = String;
102
103/// A member's provenance, exactly the two cases `ents_model::Provenance`
104/// carries — kept as a local, minimal fact rather than a dependency on
105/// `ents-model` itself, so this crate stays a standalone place to iterate
106/// on invariants rather than a second consumer of the kernel's real
107/// types.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum Role {
110 /// Enrolled by an admin-registered member — the only provenance
111 /// `effect.admin-only` accepts for a write to `refs/meta/effects/*`.
112 Admin,
113 /// Any other enrolled member, admin-registered or self-attested, for
114 /// rules that only need "signed by someone currently enrolled."
115 Member,
116}
117
118ascent! {
119 /// The compiled rule set: an `ascent`-generated struct whose fields
120 /// are `Vec`-backed relations, populated from [`Facts`] and run to a
121 /// fixpoint by [`gate`]. Not part of this crate's public surface —
122 /// [`Facts`] and [`gate`] are the two things a caller needs.
123 struct GateRules;
124
125 // ---- EDB: facts a real extractor would pull from the pack, the
126 // proposed ref transaction, and current repository state ----
127
128 /// Proposed ref transaction: (ref, old tip, new tip). `None` old tip
129 /// means entity creation.
130 relation ref_update(Ref, Option<Oid>, Oid);
131 /// (child, parent) commit edges for the new tips' ancestry, bounded at
132 /// the old tips — the frontier the update can reach beyond what the
133 /// old tip already covers.
134 relation parent(Oid, Oid);
135 /// (commit, signing key), emitted only after signature verification
136 /// succeeds — the crypto lives in the extractor, never in a rule.
137 relation signed_by(Oid, Key);
138 /// Keys currently enrolled, and each one's provenance.
139 relation member(Key, Role);
140 /// (entity commit, anchored blob) — the first of the two objects
141 /// `anchor.retention` requires a comment (or any anchor consumer) to
142 /// embed.
143 relation anchor(Oid, Oid);
144 /// (entity commit, context blob) — the second embedded object
145 /// `anchor.retention` requires: a context blob of the surrounding
146 /// source lines, written fresh alongside the anchored blob.
147 relation context(Oid, Oid);
148 /// Objects the repository already has, or that arrive in this pack.
149 relation object_exists(Oid);
150
151 // ---- IDB: derived relations ----
152
153 /// Transitive ancestry.
154 relation ancestor(Oid, Oid);
155 ancestor(c.clone(), p.clone()) <-- parent(c, p);
156 ancestor(c.clone(), a.clone()) <-- parent(c, p), ancestor(p, a);
157
158 /// Whether a commit has any recorded parent.
159 relation has_parent(Oid);
160 has_parent(c.clone()) <-- parent(c, _p);
161
162 /// Commits already covered by a ref's old tip.
163 relation covered(Ref, Oid);
164 covered(r.clone(), o.clone()) <--
165 ref_update(r, old, _new), if let Some(o) = old;
166 covered(r.clone(), a.clone()) <--
167 ref_update(r, old, _new), if let Some(o) = old, ancestor(o, a);
168
169 /// Commits this transaction introduces to a ref: the new tip and its
170 /// ancestors, minus everything the old tip already reached.
171 relation introduced(Ref, Oid);
172 introduced(r.clone(), n.clone()) <--
173 ref_update(r, _old, n), !covered(r, n);
174 introduced(r.clone(), c.clone()) <--
175 ref_update(r, _old, n), ancestor(n, c), !covered(r, c);
176
177 /// A commit signed by any currently enrolled member, of any
178 /// provenance.
179 relation member_signed(Oid);
180 member_signed(c.clone()) <-- signed_by(c, k), member(k, _role);
181
182 /// A commit signed by an admin-registered member specifically.
183 relation admin_signed(Oid);
184 admin_signed(c.clone()) <-- signed_by(c, k), member(k, Role::Admin);
185
186 // ---- Denial rules: any row here rejects the transaction ----
187
188 /// Fast-forward-only: the new tip must descend from the old tip.
189 relation ff_violation(Ref);
190 ff_violation(r.clone()) <--
191 ref_update(r, old, new), if let Some(o) = old,
192 if o != new, !ancestor(new, o);
193
194 /// Creation must point at a parentless genesis commit.
195 relation genesis_violation(Ref);
196 genesis_violation(r.clone()) <--
197 ref_update(r, old, new), if old.is_none(), has_parent(new);
198
199 /// One entity, one root: past genesis, an update may not introduce a
200 /// second parentless commit — merging in an unrelated chain would
201 /// satisfy fast-forward while smuggling in a doppelgänger identity.
202 relation second_root_violation(Ref, Oid);
203 second_root_violation(r.clone(), c.clone()) <--
204 ref_update(r, old, _new), if old.is_some(),
205 introduced(r, c), !has_parent(c);
206
207 /// Every introduced commit must carry a signature from a currently
208 /// enrolled member.
209 relation unsigned_violation(Ref, Oid);
210 unsigned_violation(r.clone(), c.clone()) <--
211 introduced(r, c), !member_signed(c);
212
213 /// An anchored blob must resolve to an object the repository will
214 /// contain.
215 relation dangling_anchor_violation(Ref, Oid);
216 dangling_anchor_violation(r.clone(), t.clone()) <--
217 introduced(r, c), anchor(c, t), !object_exists(t);
218
219 /// The paired context blob must resolve too — `anchor.retention`
220 /// requires both, not only the anchored blob.
221 relation dangling_context_violation(Ref, Oid);
222 dangling_context_violation(r.clone(), t.clone()) <--
223 introduced(r, c), context(c, t), !object_exists(t);
224
225 /// A write to `refs/meta/effects/*` must be signed by an
226 /// admin-registered member, regardless of any other role rule
227 /// (`effect.admin-only`).
228 relation effect_admin_violation(Ref, Oid);
229 effect_admin_violation(r.clone(), c.clone()) <--
230 introduced(r, c), if r.starts_with("refs/meta/effects/"),
231 !admin_signed(c);
232}
233
234/// Facts for one proposed transaction. In the real system these would be
235/// extracted with gix from the pack and the current ref/member state; here
236/// they are supplied directly so a rule's behavior can be pinned by a
237/// test.
238#[derive(Debug, Clone, Default)]
239pub struct Facts {
240 /// See [`GateRules::ref_update`].
241 pub ref_update: Vec<(Ref, Option<Oid>, Oid)>,
242 /// See [`GateRules::parent`].
243 pub parent: Vec<(Oid, Oid)>,
244 /// See [`GateRules::signed_by`].
245 pub signed_by: Vec<(Oid, Key)>,
246 /// See [`GateRules::member`].
247 pub member: Vec<(Key, Role)>,
248 /// See [`GateRules::anchor`].
249 pub anchor: Vec<(Oid, Oid)>,
250 /// See [`GateRules::context`].
251 pub context: Vec<(Oid, Oid)>,
252 /// See [`GateRules::object_exists`].
253 pub object_exists: Vec<(Oid,)>,
254}
255
256/// Run every denial rule to a fixpoint over `facts`. An empty result means
257/// the transaction is admitted under every invariant this crate currently
258/// states.
259#[must_use]
260pub fn gate(facts: Facts) -> Vec<String> {
261 let mut rules = GateRules {
262 ref_update: facts.ref_update,
263 parent: facts.parent,
264 signed_by: facts.signed_by,
265 member: facts.member,
266 anchor: facts.anchor,
267 context: facts.context,
268 object_exists: facts.object_exists,
269 ..GateRules::default()
270 };
271 rules.run();
272
273 let mut out = Vec::new();
274 for (r,) in &rules.ff_violation {
275 out.push(format!("ff: {r}: new tip does not descend from old tip"));
276 }
277 for (r,) in &rules.genesis_violation {
278 out.push(format!("genesis: {r}: creation tip has parents"));
279 }
280 for (r, c) in &rules.second_root_violation {
281 out.push(format!("root: {r}: introduces second root {c}"));
282 }
283 for (r, c) in &rules.unsigned_violation {
284 out.push(format!(
285 "signature: {r}: {c} not signed by an enrolled member"
286 ));
287 }
288 for (r, t) in &rules.dangling_anchor_violation {
289 out.push(format!("anchor: {r}: anchored object {t} does not exist"));
290 }
291 for (r, t) in &rules.dangling_context_violation {
292 out.push(format!("context: {r}: context object {t} does not exist"));
293 }
294 for (r, c) in &rules.effect_admin_violation {
295 out.push(format!(
296 "effect-admin: {r}: {c} not signed by an admin-registered member"
297 ));
298 }
299 out.sort();
300 out
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 const ISSUE: &str = "refs/meta/issues/g";
308 const COMMENT: &str = "refs/meta/comments/g2";
309 const EFFECT: &str = "refs/meta/effects/ci";
310
311 fn base() -> Facts {
312 Facts {
313 member: vec![("key:joey".into(), Role::Member)],
314 ..Facts::default()
315 }
316 }
317
318 #[test]
319 fn creation_and_ff_update_pass() {
320 // genesis g, then g <- c1 pushed as an update.
321 let mut f = base();
322 f.ref_update = vec![(ISSUE.into(), Some("g".into()), "c1".into())];
323 f.parent = vec![("c1".into(), "g".into())];
324 f.signed_by = vec![
325 ("g".into(), "key:joey".into()),
326 ("c1".into(), "key:joey".into()),
327 ];
328 assert!(gate(f).is_empty());
329
330 let mut f = base();
331 f.ref_update = vec![(COMMENT.into(), None, "g2".into())];
332 f.signed_by = vec![("g2".into(), "key:joey".into())];
333 f.anchor = vec![("g2".into(), "blob:a".into())];
334 f.context = vec![("g2".into(), "blob:ctx".into())];
335 f.object_exists = vec![("blob:a".into(),), ("blob:ctx".into(),)];
336 assert!(gate(f).is_empty());
337 }
338
339 #[test]
340 fn non_ff_is_rejected() {
341 let mut f = base();
342 f.ref_update = vec![(ISSUE.into(), Some("g".into()), "x".into())]; // x unrelated to g
343 f.signed_by = vec![("x".into(), "key:joey".into())];
344 let v = gate(f);
345 assert!(v.iter().any(|m| m.starts_with("ff:")), "{v:?}");
346 }
347
348 #[test]
349 fn parented_genesis_is_rejected() {
350 let mut f = base();
351 f.ref_update = vec![(ISSUE.into(), None, "c1".into())];
352 f.parent = vec![("c1".into(), "elsewhere".into())];
353 f.signed_by = vec![("c1".into(), "key:joey".into())];
354 let v = gate(f);
355 assert!(v.iter().any(|m| m.starts_with("genesis:")), "{v:?}");
356 }
357
358 #[test]
359 fn merged_in_second_root_is_rejected() {
360 // old tip g; new tip m is a merge of c1 (descends from g) and z
361 // (an unrelated parentless chain). FF holds; root rule fires.
362 let mut f = base();
363 f.ref_update = vec![(ISSUE.into(), Some("g".into()), "m".into())];
364 f.parent = vec![
365 ("c1".into(), "g".into()),
366 ("m".into(), "c1".into()),
367 ("m".into(), "z".into()),
368 ];
369 f.signed_by = vec![
370 ("c1".into(), "key:joey".into()),
371 ("m".into(), "key:joey".into()),
372 ("z".into(), "key:joey".into()),
373 ];
374 let v = gate(f);
375 assert!(
376 v.iter().any(|m| m.starts_with("root:") && m.contains('z')),
377 "{v:?}"
378 );
379 assert!(!v.iter().any(|m| m.starts_with("ff:")), "{v:?}");
380 }
381
382 #[test]
383 fn non_member_signature_is_rejected() {
384 let mut f = base();
385 f.ref_update = vec![(ISSUE.into(), Some("g".into()), "c1".into())];
386 f.parent = vec![("c1".into(), "g".into())];
387 f.signed_by = vec![("c1".into(), "key:mallory".into())];
388 let v = gate(f);
389 assert!(
390 v.iter()
391 .any(|m| m.starts_with("signature:") && m.contains("c1")),
392 "{v:?}"
393 );
394 }
395
396 #[test]
397 fn dangling_anchor_is_rejected() {
398 let mut f = base();
399 f.ref_update = vec![(COMMENT.into(), None, "g2".into())];
400 f.signed_by = vec![("g2".into(), "key:joey".into())];
401 f.anchor = vec![("g2".into(), "blob:missing".into())];
402 f.context = vec![("g2".into(), "blob:ctx".into())];
403 f.object_exists = vec![("blob:ctx".into(),)];
404 let v = gate(f);
405 assert!(v.iter().any(|m| m.starts_with("anchor:")), "{v:?}");
406 }
407
408 #[test]
409 fn dangling_context_is_rejected() {
410 let mut f = base();
411 f.ref_update = vec![(COMMENT.into(), None, "g2".into())];
412 f.signed_by = vec![("g2".into(), "key:joey".into())];
413 f.anchor = vec![("g2".into(), "blob:a".into())];
414 f.context = vec![("g2".into(), "blob:missing-ctx".into())];
415 f.object_exists = vec![("blob:a".into(),)];
416 let v = gate(f);
417 assert!(v.iter().any(|m| m.starts_with("context:")), "{v:?}");
418 }
419
420 #[test]
421 fn effect_definition_by_admin_passes() {
422 let mut f = Facts {
423 member: vec![("key:admin".into(), Role::Admin)],
424 ..Facts::default()
425 };
426 f.ref_update = vec![(EFFECT.into(), None, "e1".into())];
427 f.signed_by = vec![("e1".into(), "key:admin".into())];
428 assert!(gate(f).is_empty());
429 }
430
431 #[test]
432 fn effect_definition_by_non_admin_is_rejected() {
433 // Signed by a currently enrolled member, so `unsigned_violation`
434 // does not fire — only the effects-specific admin rule should.
435 let mut f = base();
436 f.ref_update = vec![(EFFECT.into(), None, "e1".into())];
437 f.signed_by = vec![("e1".into(), "key:joey".into())];
438 let v = gate(f);
439 assert!(v.iter().any(|m| m.starts_with("effect-admin:")), "{v:?}");
440 assert!(!v.iter().any(|m| m.starts_with("signature:")), "{v:?}");
441 }
442}