crates/kernel/ents-gate/tests/gate.rs
gate.rshistorycomment on this file
| 1 | //! Integration tests for the gate: the verdict table (rstest — the spec |
| 2 | //! enumerates the cases), identity binding and owner mutation, the epoch |
| 3 | //! and bootstrap windows, and the one-parameterized-test proof that all |
| 4 | //! three call sites see identical verdicts. |
| 5 | |
| 6 | #![expect( |
| 7 | clippy::expect_used, |
| 8 | clippy::panic, |
| 9 | clippy::unreachable, |
| 10 | reason = "integration test: fixtures panic on setup failure" |
| 11 | )] |
| 12 | |
| 13 | use ents_anchor::Binding; |
| 14 | use ents_gate::{AdmissionKind, Config, Requirement, Update, Verdict, verify}; |
| 15 | use ents_model::{ |
| 16 | Claim, Effect, Member, MemberId, Provenance, ResultRecord, Status, |
| 17 | claim::Verdict as ClaimVerdict, namespace, |
| 18 | }; |
| 19 | use ents_testutil::{ |
| 20 | CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member, write_commit, |
| 21 | write_member, write_meta_entity, |
| 22 | }; |
| 23 | use gix::refs::FullName; |
| 24 | use gix_hash::ObjectId; |
| 25 | use gix_ref_store::{Expected, RefStoreRead as _}; |
| 26 | use rstest::rstest; |
| 27 | |
| 28 | const ADMIN_SEED: u8 = 1; |
| 29 | const SELF_ATTESTED_SEED: u8 = 2; |
| 30 | const OUTSIDER_SEED: u8 = 9; |
| 31 | |
| 32 | /// A forge fixture with verification in force: an admin-registered |
| 33 | /// member `admin` (enrolled pre-epoch), a self-attested member `guest`, |
| 34 | /// and an epoch recorded in `refs/meta/config`. |
| 35 | struct Forge { |
| 36 | refs: MemRefStore, |
| 37 | objects: ObjectStore, |
| 38 | admin: Keypair, |
| 39 | guest: Keypair, |
| 40 | } |
| 41 | |
| 42 | fn forge() -> Forge { |
| 43 | let refs = MemRefStore::default(); |
| 44 | let objects = ObjectStore::default(); |
| 45 | let admin = Keypair::from_seed(ADMIN_SEED); |
| 46 | let guest = Keypair::from_seed(SELF_ATTESTED_SEED); |
| 47 | enroll_member( |
| 48 | &refs, |
| 49 | &objects, |
| 50 | "admin", |
| 51 | &admin, |
| 52 | Provenance::AdminRegistered, |
| 53 | 100, |
| 54 | ); |
| 55 | enroll_member( |
| 56 | &refs, |
| 57 | &objects, |
| 58 | "guest", |
| 59 | &guest, |
| 60 | Provenance::SelfAttested, |
| 61 | 110, |
| 62 | ); |
| 63 | let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid"); |
| 64 | write_meta_entity( |
| 65 | &refs, |
| 66 | &objects, |
| 67 | config_ref, |
| 68 | &Config { epoch: Some(200) }, |
| 69 | Some(&admin), |
| 70 | 200, |
| 71 | ); |
| 72 | Forge { |
| 73 | refs, |
| 74 | objects, |
| 75 | admin, |
| 76 | guest, |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | fn name(s: &str) -> FullName { |
| 81 | s.try_into().expect("valid refname in test") |
| 82 | } |
| 83 | |
| 84 | /// The oid-keyed refname of a hash-identified issue whose genesis is |
| 85 | /// `genesis` (`meta-ref.identity-binding`). |
| 86 | fn issue_ref(genesis: ObjectId) -> FullName { |
| 87 | name(&format!("refs/meta/issues/{genesis}")) |
| 88 | } |
| 89 | |
| 90 | /// A signed empty-tree commit — a generic meta-mutation body. A |
| 91 | /// hash-identified entity binds by its genesis oid and the all-roots |
| 92 | /// walk, not by tree content, so an empty tree exercises the tip |
| 93 | /// invariant on `refs/meta/issues/*` and `refs/meta/comments/*` fully. |
| 94 | fn commit(forge: &Forge, parents: Vec<ObjectId>, key: Option<&Keypair>, seconds: i64) -> ObjectId { |
| 95 | let tree = empty_tree(&forge.objects); |
| 96 | write_commit( |
| 97 | &forge.objects, |
| 98 | &CommitSpec { |
| 99 | tree, |
| 100 | parents, |
| 101 | message: "mutate".into(), |
| 102 | seconds, |
| 103 | }, |
| 104 | key, |
| 105 | ) |
| 106 | } |
| 107 | |
| 108 | /// A signed commit whose tree is `tree` — for the namespaces whose binding |
| 109 | /// reads a tree field (natural-key, composite). |
| 110 | fn tree_commit( |
| 111 | forge: &Forge, |
| 112 | tree: ObjectId, |
| 113 | parents: Vec<ObjectId>, |
| 114 | key: Option<&Keypair>, |
| 115 | seconds: i64, |
| 116 | ) -> ObjectId { |
| 117 | write_commit( |
| 118 | &forge.objects, |
| 119 | &CommitSpec { |
| 120 | tree, |
| 121 | parents, |
| 122 | message: "mutate".into(), |
| 123 | seconds, |
| 124 | }, |
| 125 | key, |
| 126 | ) |
| 127 | } |
| 128 | |
| 129 | fn run(forge: &Forge, refname: &FullName, new: Option<ObjectId>) -> Verdict { |
| 130 | verify( |
| 131 | &forge.refs, |
| 132 | &forge.objects, |
| 133 | &Update { |
| 134 | name: refname.clone(), |
| 135 | new, |
| 136 | }, |
| 137 | ) |
| 138 | .expect("the gate must reach a verdict on a complete fixture") |
| 139 | } |
| 140 | |
| 141 | fn expect_fail(verdict: &Verdict, requirement: Requirement) { |
| 142 | let Verdict::Fail(refusal) = verdict else { |
| 143 | panic!("expected a refusal against {requirement:?}, got {verdict:?}"); |
| 144 | }; |
| 145 | assert_eq!(refusal.requirement, requirement, "refusal: {refusal}"); |
| 146 | } |
| 147 | |
| 148 | fn expect_pass(verdict: &Verdict, kind: AdmissionKind) { |
| 149 | let Verdict::Pass(admission) = verdict else { |
| 150 | panic!("expected admission {kind:?}, got {verdict:?}"); |
| 151 | }; |
| 152 | assert_eq!(admission.kind, kind); |
| 153 | } |
| 154 | |
| 155 | // --------------------------------------------------------------------- |
| 156 | // The verdict table: member × signature × FF × namespace cases. |
| 157 | // --------------------------------------------------------------------- |
| 158 | |
| 159 | #[rstest] |
| 160 | // @relation(gate.tip-signed, gate.verdict-reason, scope=function, role=Verifies) |
| 161 | fn authorized_signed_mutation_passes_the_tip_invariant() { |
| 162 | let f = forge(); |
| 163 | let new = commit(&f, vec![], Some(&f.admin), 300); |
| 164 | expect_pass( |
| 165 | &run(&f, &issue_ref(new), Some(new)), |
| 166 | AdmissionKind::TipInvariant, |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | #[rstest] |
| 171 | // @relation(gate.tip-signed, scope=function, role=Verifies) |
| 172 | fn unsigned_tip_is_refused() { |
| 173 | let f = forge(); |
| 174 | let new = commit(&f, vec![], None, 300); |
| 175 | expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned); |
| 176 | } |
| 177 | |
| 178 | #[rstest] |
| 179 | // @relation(gate.tip-signed, scope=function, role=Verifies) |
| 180 | fn non_member_signature_is_refused() { |
| 181 | let f = forge(); |
| 182 | let outsider = Keypair::from_seed(OUTSIDER_SEED); |
| 183 | let new = commit(&f, vec![], Some(&outsider), 300); |
| 184 | expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned); |
| 185 | } |
| 186 | |
| 187 | /// Revoke `id`'s key in the fixture, as an admin-signed mutation of the |
| 188 | /// member's ref. |
| 189 | fn revoke(f: &Forge, id: &str, key: &Keypair, provenance: Provenance, seconds: i64) { |
| 190 | let mut revoked = Member::new(id, key.public_openssh(), provenance); |
| 191 | revoked.revoke(); |
| 192 | write_member(&f.refs, &f.objects, id, &revoked, Some(&f.admin), seconds); |
| 193 | } |
| 194 | |
| 195 | #[rstest] |
| 196 | // @relation(gate.tip-signed, model.member-revocation, scope=function, role=Verifies) |
| 197 | fn a_revoked_members_new_push_is_refused() { |
| 198 | let f = forge(); |
| 199 | revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); |
| 200 | |
| 201 | let new = commit(&f, vec![], Some(&f.admin), 500); |
| 202 | let verdict = run(&f, &issue_ref(new), Some(new)); |
| 203 | expect_fail(&verdict, Requirement::TipSigned); |
| 204 | let Verdict::Fail(refusal) = &verdict else { |
| 205 | unreachable!() |
| 206 | }; |
| 207 | assert!(refusal.detail.contains("revoked"), "detail: {refusal}"); |
| 208 | } |
| 209 | |
| 210 | #[rstest] |
| 211 | // @relation(model.member-revocation, gate.tip-signed, scope=function, role=Verifies) |
| 212 | fn a_backdated_commit_cannot_reach_past_a_revocation() { |
| 213 | // The security-review regression: admission consults the member |
| 214 | // entity currently in force, so a revoked key authoring a NEW commit |
| 215 | // with a committer timestamp claimed from before the revocation — |
| 216 | // descending cleanly from the live tip — is still refused. |
| 217 | let f = forge(); |
| 218 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 219 | let refname = issue_ref(genesis); |
| 220 | f.refs.set(refname.as_ref(), genesis); |
| 221 | revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); |
| 222 | |
| 223 | // Authored "at 300", pushed after the revocation at 400. |
| 224 | let backdated = commit(&f, vec![genesis], Some(&f.admin), 300); |
| 225 | expect_fail(&run(&f, &refname, Some(backdated)), Requirement::TipSigned); |
| 226 | } |
| 227 | |
| 228 | #[rstest] |
| 229 | // @relation(model.member-revocation, gate.fast-forward, scope=function, role=Verifies) |
| 230 | fn refs_accepted_before_a_revocation_are_never_rejudged() { |
| 231 | // A second admin keeps working on a ref whose current tip was placed |
| 232 | // by a member revoked afterwards: the accepted tip stays valid |
| 233 | // history, and fast-forwarding over it is ordinary descent. |
| 234 | let f = forge(); |
| 235 | let second = Keypair::from_seed(OUTSIDER_SEED); |
| 236 | enroll_member( |
| 237 | &f.refs, |
| 238 | &f.objects, |
| 239 | "second", |
| 240 | &second, |
| 241 | Provenance::AdminRegistered, |
| 242 | 310, |
| 243 | ); |
| 244 | |
| 245 | let genesis = commit(&f, vec![], Some(&f.admin), 320); |
| 246 | let refname = issue_ref(genesis); |
| 247 | f.refs.set(refname.as_ref(), genesis); |
| 248 | revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); |
| 249 | |
| 250 | let continued = commit(&f, vec![genesis], Some(&second), 500); |
| 251 | expect_pass( |
| 252 | &run(&f, &refname, Some(continued)), |
| 253 | AdmissionKind::TipInvariant, |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | #[rstest] |
| 258 | // @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) |
| 259 | fn a_refname_not_naming_the_genesis_oid_is_refused() { |
| 260 | // The identity binding replaces the retired Advance-ref trailer: a |
| 261 | // signed commit proposed under a refname whose final segment is not |
| 262 | // its genesis oid is refused, so it cannot be replayed as the tip of a |
| 263 | // different meta-ref than the one its content names. |
| 264 | let f = forge(); |
| 265 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 266 | // A different, valid oid that is not this commit's genesis. |
| 267 | let wrong = |
| 268 | issue_ref(ObjectId::from_hex(b"00000000000000000000000000000000deadbeef").expect("hex")); |
| 269 | expect_fail( |
| 270 | &run(&f, &wrong, Some(genesis)), |
| 271 | Requirement::IdentityBinding, |
| 272 | ); |
| 273 | } |
| 274 | |
| 275 | #[rstest] |
| 276 | // @relation(gate.fast-forward, scope=function, role=Verifies) |
| 277 | fn non_fast_forward_is_refused() { |
| 278 | let f = forge(); |
| 279 | // A genesis and two children of it, all correctly bound to the same |
| 280 | // oid-keyed ref; the current tip is one child, the proposal the |
| 281 | // sibling — a genuine fast-forward refusal, not an identity mismatch. |
| 282 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 283 | let refname = issue_ref(genesis); |
| 284 | let current = commit(&f, vec![genesis], Some(&f.admin), 310); |
| 285 | let sibling = commit(&f, vec![genesis], Some(&f.admin), 311); |
| 286 | f.refs.set(refname.as_ref(), current); |
| 287 | assert_ne!(sibling, current); |
| 288 | expect_fail(&run(&f, &refname, Some(sibling)), Requirement::FastForward); |
| 289 | } |
| 290 | |
| 291 | #[rstest] |
| 292 | // @relation(gate.fast-forward, scope=function, role=Verifies) |
| 293 | fn meta_ref_deletion_is_refused() { |
| 294 | let f = forge(); |
| 295 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 296 | let refname = issue_ref(genesis); |
| 297 | f.refs.set(refname.as_ref(), genesis); |
| 298 | expect_fail(&run(&f, &refname, None), Requirement::FastForward); |
| 299 | } |
| 300 | |
| 301 | #[rstest] |
| 302 | // @relation(gate.principled-split, scope=function, role=Verifies) |
| 303 | fn code_refs_are_not_subject_to_the_tip_invariant() { |
| 304 | let f = forge(); |
| 305 | // Unsigned, non-FF — none of it matters outside refs/meta/*. |
| 306 | let new = commit(&f, vec![], None, 300); |
| 307 | expect_pass( |
| 308 | &run(&f, &name("refs/heads/main"), Some(new)), |
| 309 | AdmissionKind::CodeRef, |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | // --------------------------------------------------------------------- |
| 314 | // Identity binding: the refname is a function of signed content. |
| 315 | // --------------------------------------------------------------------- |
| 316 | |
| 317 | #[rstest] |
| 318 | // @relation(gate.identity-binding, meta-ref.identity-binding, model.member-identity, scope=function, role=Verifies) |
| 319 | fn a_natural_key_member_binds_by_its_id_field() { |
| 320 | let f = forge(); |
| 321 | // A member entity whose id field disagrees with the refname's final |
| 322 | // segment is refused; agreeing, it passes. |
| 323 | let mismatched = Member::new( |
| 324 | "someone-else", |
| 325 | f.admin.public_openssh(), |
| 326 | Provenance::AdminRegistered, |
| 327 | ); |
| 328 | let tree = facet_git_tree::serialize_into(&mismatched, &f.objects).expect("ser"); |
| 329 | let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); |
| 330 | expect_fail( |
| 331 | &run(&f, &name("refs/meta/member/newcomer"), Some(tip)), |
| 332 | Requirement::IdentityBinding, |
| 333 | ); |
| 334 | |
| 335 | let matched = Member::new( |
| 336 | "newcomer", |
| 337 | f.admin.public_openssh(), |
| 338 | Provenance::AdminRegistered, |
| 339 | ); |
| 340 | let tree = facet_git_tree::serialize_into(&matched, &f.objects).expect("ser"); |
| 341 | let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); |
| 342 | expect_pass( |
| 343 | &run(&f, &name("refs/meta/member/newcomer"), Some(tip)), |
| 344 | AdmissionKind::TipInvariant, |
| 345 | ); |
| 346 | } |
| 347 | |
| 348 | #[rstest] |
| 349 | // @relation(gate.identity-binding, meta-ref.identity-binding, model.effect-definition, scope=function, role=Verifies) |
| 350 | fn a_natural_key_effect_binds_by_its_name_field() { |
| 351 | let f = forge(); |
| 352 | let effect = Effect { |
| 353 | name: "unit".into(), |
| 354 | trigger: "rev(refs/heads/main)".into(), |
| 355 | toolchains: vec![], |
| 356 | run: "true".into(), |
| 357 | }; |
| 358 | let tree = facet_git_tree::serialize_into(&effect, &f.objects).expect("ser"); |
| 359 | // Named `unit` in the tree — refused under a ref that names `lint`. |
| 360 | let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); |
| 361 | expect_fail( |
| 362 | &run(&f, &name("refs/meta/effects/lint"), Some(tip)), |
| 363 | Requirement::IdentityBinding, |
| 364 | ); |
| 365 | // Under the ref its name field recomputes, it passes. |
| 366 | expect_pass( |
| 367 | &run(&f, &name("refs/meta/effects/unit"), Some(tip)), |
| 368 | AdmissionKind::TipInvariant, |
| 369 | ); |
| 370 | } |
| 371 | |
| 372 | /// A commit recording `result` at `results/<effect>/<short>`. |
| 373 | fn result_commit( |
| 374 | f: &Forge, |
| 375 | effect: &str, |
| 376 | target: ObjectId, |
| 377 | parents: Vec<ObjectId>, |
| 378 | key: Option<&Keypair>, |
| 379 | seconds: i64, |
| 380 | ) -> ObjectId { |
| 381 | let record = ResultRecord::new(effect, target, Status::Pass); |
| 382 | let tree = facet_git_tree::serialize_into(&record, &f.objects).expect("ser"); |
| 383 | tree_commit(f, tree, parents, key, seconds) |
| 384 | } |
| 385 | |
| 386 | #[rstest] |
| 387 | // @relation(gate.identity-binding, model.result-identity, scope=function, role=Verifies) |
| 388 | fn a_composite_result_binds_by_its_effect_and_target_fields() { |
| 389 | let f = forge(); |
| 390 | let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex"); |
| 391 | let tip = result_commit(&f, "unit", target, vec![], Some(&f.admin), 300); |
| 392 | |
| 393 | // Correct effect and short-oid prefix: passes. |
| 394 | expect_pass( |
| 395 | &run( |
| 396 | &f, |
| 397 | &namespace::result_ref("unit", "abc123").expect("valid"), |
| 398 | Some(tip), |
| 399 | ), |
| 400 | AdmissionKind::TipInvariant, |
| 401 | ); |
| 402 | // Wrong effect segment: refused. |
| 403 | expect_fail( |
| 404 | &run( |
| 405 | &f, |
| 406 | &namespace::result_ref("lint", "abc123").expect("valid"), |
| 407 | Some(tip), |
| 408 | ), |
| 409 | Requirement::IdentityBinding, |
| 410 | ); |
| 411 | // Short oid that is not a prefix of the target field: refused. |
| 412 | expect_fail( |
| 413 | &run( |
| 414 | &f, |
| 415 | &namespace::result_ref("unit", "ffffff").expect("valid"), |
| 416 | Some(tip), |
| 417 | ), |
| 418 | Requirement::IdentityBinding, |
| 419 | ); |
| 420 | } |
| 421 | |
| 422 | /// A stand-in Review tree: the gate reads only the `target` field |
| 423 | /// generically, so any struct carrying it exercises the composite key. |
| 424 | #[derive(facet::Facet)] |
| 425 | struct Review { |
| 426 | target: [u8; 20], |
| 427 | verdict: String, |
| 428 | } |
| 429 | |
| 430 | fn review_commit(f: &Forge, target: ObjectId, key: Option<&Keypair>, seconds: i64) -> ObjectId { |
| 431 | let mut bytes = [0u8; 20]; |
| 432 | bytes.copy_from_slice(target.as_slice()); |
| 433 | let review = Review { |
| 434 | target: bytes, |
| 435 | verdict: "approve".into(), |
| 436 | }; |
| 437 | let tree = facet_git_tree::serialize_into(&review, &f.objects).expect("ser"); |
| 438 | tree_commit(f, tree, vec![], key, seconds) |
| 439 | } |
| 440 | |
| 441 | #[rstest] |
| 442 | // @relation(gate.identity-binding, model.review, scope=function, role=Verifies) |
| 443 | fn a_composite_review_binds_by_its_target_field_and_signer() { |
| 444 | let f = forge(); |
| 445 | let target = ObjectId::from_hex(b"deadbeef00000000000000000000000000000000").expect("hex"); |
| 446 | let tip = review_commit(&f, target, Some(&f.admin), 300); |
| 447 | let good = namespace::review_ref(&target.to_string(), &MemberId::new("admin")).expect("valid"); |
| 448 | expect_pass(&run(&f, &good, Some(tip)), AdmissionKind::TipInvariant); |
| 449 | |
| 450 | // Right target, wrong reviewer segment: the signer is admin, not guest. |
| 451 | let wrong_member = |
| 452 | namespace::review_ref(&target.to_string(), &MemberId::new("guest")).expect("valid"); |
| 453 | expect_fail( |
| 454 | &run(&f, &wrong_member, Some(tip)), |
| 455 | Requirement::IdentityBinding, |
| 456 | ); |
| 457 | |
| 458 | // Right reviewer, wrong target segment. |
| 459 | let other = ObjectId::from_hex(b"0000000000000000000000000000000000000001").expect("hex"); |
| 460 | let wrong_target = |
| 461 | namespace::review_ref(&other.to_string(), &MemberId::new("admin")).expect("valid"); |
| 462 | expect_fail( |
| 463 | &run(&f, &wrong_target, Some(tip)), |
| 464 | Requirement::IdentityBinding, |
| 465 | ); |
| 466 | } |
| 467 | |
| 468 | /// A result tree carrying an entry that is not a `ResultRecord` field — |
| 469 | /// for the strict-decode disjointness check. |
| 470 | #[derive(facet::Facet)] |
| 471 | struct ResultPlus { |
| 472 | effect: String, |
| 473 | target: [u8; 20], |
| 474 | status: Status, |
| 475 | surprise: String, |
| 476 | } |
| 477 | |
| 478 | #[rstest] |
| 479 | // @relation(gate.identity-binding, meta-ref.typed-tree, scope=function, role=Verifies) |
| 480 | fn strict_genesis_decode_refuses_an_unknown_tree_entry() { |
| 481 | let f = forge(); |
| 482 | let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex"); |
| 483 | let mut bytes = [0u8; 20]; |
| 484 | bytes.copy_from_slice(target.as_slice()); |
| 485 | let bogus = ResultPlus { |
| 486 | effect: "unit".into(), |
| 487 | target: bytes, |
| 488 | status: Status::Pass, |
| 489 | surprise: "not a result field".into(), |
| 490 | }; |
| 491 | let tree = facet_git_tree::serialize_into(&bogus, &f.objects).expect("ser"); |
| 492 | let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); |
| 493 | // Even though effect and short-oid recompute, the extra tree entry |
| 494 | // makes strict decode refuse the genesis. |
| 495 | expect_fail( |
| 496 | &run( |
| 497 | &f, |
| 498 | &namespace::result_ref("unit", "abc123").expect("valid"), |
| 499 | Some(tip), |
| 500 | ), |
| 501 | Requirement::IdentityBinding, |
| 502 | ); |
| 503 | } |
| 504 | |
| 505 | #[rstest] |
| 506 | // @relation(gate.identity-binding, gate.same-actor-divergence, scope=function, role=Verifies) |
| 507 | fn the_all_roots_walk_holds_across_a_sync_created_merge() { |
| 508 | // Two children of the same genesis, merged: the merge has two parents |
| 509 | // but a single parentless root (the genesis), so the hash-identified |
| 510 | // binding still recomputes the genesis oid across the merge commit. |
| 511 | let f = forge(); |
| 512 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 513 | let refname = issue_ref(genesis); |
| 514 | let a = commit(&f, vec![genesis], Some(&f.admin), 310); |
| 515 | let b = commit(&f, vec![genesis], Some(&f.admin), 311); |
| 516 | f.refs.set(refname.as_ref(), a); |
| 517 | let merge = commit(&f, vec![a, b], Some(&f.admin), 320); |
| 518 | expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant); |
| 519 | |
| 520 | // The same merge proposed under a doppelgänger genesis id (the merge's |
| 521 | // own oid) is refused: its parentless root is still the genesis. |
| 522 | expect_fail( |
| 523 | &run(&f, &issue_ref(merge), Some(merge)), |
| 524 | Requirement::IdentityBinding, |
| 525 | ); |
| 526 | } |
| 527 | |
| 528 | #[rstest] |
| 529 | // @relation(gate.identity-binding, model.review-pin, scope=function, role=Verifies) |
| 530 | fn a_pin_is_never_subjected_to_the_all_roots_walk() { |
| 531 | // A pin's ancestry reaches into code history (its parents include the |
| 532 | // reviewed commit), so the parentless-roots walk must not apply. A pin |
| 533 | // commit whose parent is an arbitrary reviewed commit — not a genesis |
| 534 | // whose oid is the pin's id — still binds. |
| 535 | let f = forge(); |
| 536 | let reviewed = commit(&f, vec![], Some(&f.admin), 250); |
| 537 | let target = reviewed.to_string(); |
| 538 | let pin_ref = namespace::review_pin_ref(&target, &MemberId::new("admin")).expect("valid"); |
| 539 | // The pin's tip is a signed commit whose parents include the reviewed |
| 540 | // commit; its tree is empty. The all-roots walk would reject it (the |
| 541 | // root is `reviewed`, not the pin's segments), so this passing verdict |
| 542 | // proves the walk is skipped for pins. |
| 543 | let pin_tip = commit(&f, vec![reviewed], Some(&f.admin), 300); |
| 544 | expect_pass( |
| 545 | &run(&f, &pin_ref, Some(pin_tip)), |
| 546 | AdmissionKind::TipInvariant, |
| 547 | ); |
| 548 | } |
| 549 | |
| 550 | // --------------------------------------------------------------------- |
| 551 | // Claims: append-once, witness-retaining, signer-bound genesis refs. |
| 552 | // --------------------------------------------------------------------- |
| 553 | |
| 554 | /// A real serialized [`Claim`] tree over a `Binding::Commit { commit: witness |
| 555 | /// }`, asserted by `signer_id` — built through `Claim::new` so these tests |
| 556 | /// exercise the entity as it is actually stored, not a hand-wired tree. |
| 557 | fn claim_tree(f: &Forge, signer_id: &str, verdict: ClaimVerdict, witness: ObjectId) -> ObjectId { |
| 558 | let binding = Binding::Commit { commit: witness }; |
| 559 | let claim = Claim::new( |
| 560 | MemberId::new(signer_id), |
| 561 | &binding, |
| 562 | verdict, |
| 563 | "review", |
| 564 | &f.objects, |
| 565 | ) |
| 566 | .expect("claim serializes"); |
| 567 | facet_git_tree::serialize_into(&claim, &f.objects).expect("ser") |
| 568 | } |
| 569 | |
| 570 | #[rstest] |
| 571 | // @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) |
| 572 | fn a_claim_genesis_with_a_witness_parent_passes_the_tip_invariant() { |
| 573 | let f = forge(); |
| 574 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 575 | let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness); |
| 576 | let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300); |
| 577 | let refname = namespace::claim_ref(&tip.to_string()).expect("valid"); |
| 578 | expect_pass(&run(&f, &refname, Some(tip)), AdmissionKind::TipInvariant); |
| 579 | } |
| 580 | |
| 581 | #[rstest] |
| 582 | // @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) |
| 583 | fn a_claim_refname_not_naming_the_tips_own_oid_is_refused() { |
| 584 | let f = forge(); |
| 585 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 586 | let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness); |
| 587 | let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300); |
| 588 | // Named for the witness rather than the claim's own genesis oid. |
| 589 | let wrong = namespace::claim_ref(&witness.to_string()).expect("valid"); |
| 590 | expect_fail(&run(&f, &wrong, Some(tip)), Requirement::IdentityBinding); |
| 591 | } |
| 592 | |
| 593 | #[rstest] |
| 594 | // @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) |
| 595 | fn a_claim_ref_is_append_once_and_refuses_an_advance() { |
| 596 | let f = forge(); |
| 597 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 598 | let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness); |
| 599 | let genesis = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300); |
| 600 | let refname = namespace::claim_ref(&genesis.to_string()).expect("valid"); |
| 601 | f.refs.set(refname.as_ref(), genesis); |
| 602 | |
| 603 | // A changed assertion is a new claim, never an advance of this one: |
| 604 | // even a well-formed, correctly signed child commit under the same |
| 605 | // ref is refused, because its own oid is not the ref's segment. |
| 606 | let advance_tree = claim_tree(&f, "admin", ClaimVerdict::Deny, witness); |
| 607 | let advance = tree_commit(&f, advance_tree, vec![genesis], Some(&f.admin), 310); |
| 608 | expect_fail( |
| 609 | &run(&f, &refname, Some(advance)), |
| 610 | Requirement::IdentityBinding, |
| 611 | ); |
| 612 | } |
| 613 | |
| 614 | #[rstest] |
| 615 | // @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) |
| 616 | fn a_parentless_claim_tip_is_refused() { |
| 617 | let f = forge(); |
| 618 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 619 | let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness); |
| 620 | let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); |
| 621 | let refname = namespace::claim_ref(&tip.to_string()).expect("valid"); |
| 622 | expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding); |
| 623 | } |
| 624 | |
| 625 | #[rstest] |
| 626 | // @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) |
| 627 | fn a_claim_signer_field_mismatching_the_actual_signer_is_refused() { |
| 628 | let f = forge(); |
| 629 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 630 | // The claim's tree names a signer other than whoever actually signed |
| 631 | // the ledger commit. |
| 632 | let tree = claim_tree(&f, "someone-else", ClaimVerdict::Affirm, witness); |
| 633 | let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300); |
| 634 | let refname = namespace::claim_ref(&tip.to_string()).expect("valid"); |
| 635 | expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding); |
| 636 | } |
| 637 | |
| 638 | /// A claim tree carrying an entry that is not a `Claim` field — for the |
| 639 | /// strict-decode disjointness check. |
| 640 | #[derive(facet::Facet)] |
| 641 | struct ClaimPlus { |
| 642 | signer: MemberId, |
| 643 | binding: facet_git_tree::RawTree, |
| 644 | verdict: ClaimVerdict, |
| 645 | kind: String, |
| 646 | surprise: String, |
| 647 | } |
| 648 | |
| 649 | #[rstest] |
| 650 | // @relation(gate.identity-binding, meta-ref.typed-tree, scope=function, role=Verifies) |
| 651 | fn strict_genesis_decode_refuses_an_unknown_claim_tree_entry() { |
| 652 | let f = forge(); |
| 653 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 654 | let binding = Binding::Commit { commit: witness }; |
| 655 | let binding_tree = binding |
| 656 | .serialize_into(&f.objects) |
| 657 | .expect("binding serializes"); |
| 658 | let bogus = ClaimPlus { |
| 659 | signer: MemberId::new("admin"), |
| 660 | binding: facet_git_tree::RawTree::new(binding_tree), |
| 661 | verdict: ClaimVerdict::Affirm, |
| 662 | kind: "review".into(), |
| 663 | surprise: "not a claim field".into(), |
| 664 | }; |
| 665 | let tree = facet_git_tree::serialize_into(&bogus, &f.objects).expect("ser"); |
| 666 | let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300); |
| 667 | let refname = namespace::claim_ref(&tip.to_string()).expect("valid"); |
| 668 | expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding); |
| 669 | } |
| 670 | |
| 671 | #[rstest] |
| 672 | // @relation(model.member-provenance, meta-ref.inbox, gate.tip-signed, scope=function, role=Verifies) |
| 673 | fn a_self_attested_member_falls_back_to_its_inbox_for_a_claim() { |
| 674 | let f = forge(); |
| 675 | let witness = commit(&f, vec![], Some(&f.guest), 250); |
| 676 | let tree = claim_tree(&f, "guest", ClaimVerdict::Note, witness); |
| 677 | let tip = tree_commit(&f, tree, vec![witness], Some(&f.guest), 300); |
| 678 | |
| 679 | // The canonical claim ref is refused — self-attested provenance is not |
| 680 | // authorized for canonical refs — with the inbox alternative surfaced. |
| 681 | let canonical = namespace::claim_ref(&tip.to_string()).expect("valid"); |
| 682 | let verdict = run(&f, &canonical, Some(tip)); |
| 683 | expect_fail(&verdict, Requirement::TipSigned); |
| 684 | let Verdict::Fail(refusal) = &verdict else { |
| 685 | unreachable!() |
| 686 | }; |
| 687 | assert!(refusal.inbox_alternative, "detail: {refusal}"); |
| 688 | |
| 689 | // The identical claim under the member's own inbox segment passes: the |
| 690 | // inbox arm recurses into the synthesized canonical refname |
| 691 | // (`refs/meta/claims/<id>`) and finds the same binding. |
| 692 | let inbox = |
| 693 | namespace::inbox_ref(&MemberId::new("guest"), &format!("claims/{tip}")).expect("valid"); |
| 694 | expect_pass(&run(&f, &inbox, Some(tip)), AdmissionKind::TipInvariant); |
| 695 | } |
| 696 | |
| 697 | #[rstest] |
| 698 | // @relation(meta-ref.inbox, gate.tip-signed, scope=function, role=Verifies) |
| 699 | fn an_inbox_claim_by_its_owner_with_a_correct_binding_passes() { |
| 700 | let f = forge(); |
| 701 | let witness = commit(&f, vec![], Some(&f.admin), 250); |
| 702 | let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness); |
| 703 | let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300); |
| 704 | let inbox = |
| 705 | namespace::inbox_ref(&MemberId::new("admin"), &format!("claims/{tip}")).expect("valid"); |
| 706 | expect_pass(&run(&f, &inbox, Some(tip)), AdmissionKind::TipInvariant); |
| 707 | } |
| 708 | |
| 709 | // --------------------------------------------------------------------- |
| 710 | // Owner mutation: an advance is keyed to ownership. |
| 711 | // --------------------------------------------------------------------- |
| 712 | |
| 713 | #[rstest] |
| 714 | // @relation(gate.owner-mutation, scope=function, role=Verifies) |
| 715 | fn an_admin_may_advance_another_members_hash_identified_entity() { |
| 716 | // Ownership of a hash-identified entity is intrinsic to its id, but an |
| 717 | // admin-registered member may advance it too (∪ admins). A second |
| 718 | // admin advances the first admin's comment. |
| 719 | let f = forge(); |
| 720 | let second = Keypair::from_seed(OUTSIDER_SEED); |
| 721 | enroll_member( |
| 722 | &f.refs, |
| 723 | &f.objects, |
| 724 | "second", |
| 725 | &second, |
| 726 | Provenance::AdminRegistered, |
| 727 | 210, |
| 728 | ); |
| 729 | |
| 730 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 731 | let refname = name(&format!("refs/meta/comments/{genesis}")); |
| 732 | f.refs.set(refname.as_ref(), genesis); |
| 733 | let advance = commit(&f, vec![genesis], Some(&second), 310); |
| 734 | expect_pass( |
| 735 | &run(&f, &refname, Some(advance)), |
| 736 | AdmissionKind::TipInvariant, |
| 737 | ); |
| 738 | } |
| 739 | |
| 740 | #[rstest] |
| 741 | // @relation(gate.owner-mutation, model.member-provenance, scope=function, role=Verifies) |
| 742 | fn a_self_attested_non_owner_cannot_advance_a_comment() { |
| 743 | // A self-attested member is not authorized for canonical refs at all |
| 744 | // (creation stays provenance-keyed, routed to the inbox); it therefore |
| 745 | // cannot advance someone else's comment either. |
| 746 | let f = forge(); |
| 747 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 748 | let refname = name(&format!("refs/meta/comments/{genesis}")); |
| 749 | f.refs.set(refname.as_ref(), genesis); |
| 750 | let advance = commit(&f, vec![genesis], Some(&f.guest), 310); |
| 751 | let verdict = run(&f, &refname, Some(advance)); |
| 752 | expect_fail(&verdict, Requirement::TipSigned); |
| 753 | } |
| 754 | |
| 755 | #[rstest] |
| 756 | // @relation(gate.owner-mutation, model.review, scope=function, role=Verifies) |
| 757 | fn the_wrong_member_cannot_advance_a_review() { |
| 758 | // A review advances only under the member its refname names. `guest` |
| 759 | // (were it registered) could not advance admin's review; here we prove |
| 760 | // the composite key + owner rule refuse a mismatched signer. |
| 761 | let f = forge(); |
| 762 | let second = Keypair::from_seed(OUTSIDER_SEED); |
| 763 | enroll_member( |
| 764 | &f.refs, |
| 765 | &f.objects, |
| 766 | "second", |
| 767 | &second, |
| 768 | Provenance::AdminRegistered, |
| 769 | 210, |
| 770 | ); |
| 771 | let target = ObjectId::from_hex(b"deadbeef00000000000000000000000000000000").expect("hex"); |
| 772 | // A review ref named for admin, but signed by `second`. |
| 773 | let tip = review_commit(&f, target, Some(&second), 300); |
| 774 | let refname = |
| 775 | namespace::review_ref(&target.to_string(), &MemberId::new("admin")).expect("valid"); |
| 776 | let verdict = run(&f, &refname, Some(tip)); |
| 777 | let Verdict::Fail(_) = verdict else { |
| 778 | panic!( |
| 779 | "a review signed by a member other than the one it names must be refused: {verdict:?}" |
| 780 | ); |
| 781 | }; |
| 782 | } |
| 783 | |
| 784 | // --------------------------------------------------------------------- |
| 785 | // Provenance-keyed authorization. |
| 786 | // --------------------------------------------------------------------- |
| 787 | |
| 788 | #[rstest] |
| 789 | #[case::canonical_issue("refs/meta/issues/1", true)] |
| 790 | #[case::canonical_result("refs/meta/results/unit/abc", true)] |
| 791 | #[case::effects_is_admin_only("refs/meta/effects/unit", true)] |
| 792 | #[case::another_self_run("refs/meta/self/admin/unit/abc", false)] |
| 793 | // @relation(model.member-provenance, effect.admin-only, gate.tip-signed, scope=function, role=Verifies) |
| 794 | fn self_attested_members_are_limited_to_their_own_namespaces( |
| 795 | #[case] refname: &str, |
| 796 | #[case] inbox_alternative: bool, |
| 797 | ) { |
| 798 | // Authorization is judged before identity binding, so an empty-tree |
| 799 | // commit under any of these canonical refs is refused on provenance. |
| 800 | let f = forge(); |
| 801 | let new = commit(&f, vec![], Some(&f.guest), 300); |
| 802 | let verdict = run(&f, &name(refname), Some(new)); |
| 803 | expect_fail(&verdict, Requirement::TipSigned); |
| 804 | let Verdict::Fail(refusal) = &verdict else { |
| 805 | unreachable!() |
| 806 | }; |
| 807 | assert_eq!( |
| 808 | refusal.inbox_alternative, inbox_alternative, |
| 809 | "inbox hint for {refname}: {refusal}" |
| 810 | ); |
| 811 | } |
| 812 | |
| 813 | #[rstest] |
| 814 | // @relation(meta-ref.inbox, effect.self-run, gate.tip-signed, scope=function, role=Verifies) |
| 815 | fn a_member_may_write_its_own_self_run_namespace() { |
| 816 | let f = forge(); |
| 817 | let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex"); |
| 818 | let tip = result_commit(&f, "unit", target, vec![], Some(&f.guest), 300); |
| 819 | let refname = |
| 820 | namespace::self_result_ref(&MemberId::new("guest"), "unit", "abc123").expect("valid"); |
| 821 | expect_pass(&run(&f, &refname, Some(tip)), AdmissionKind::TipInvariant); |
| 822 | } |
| 823 | |
| 824 | #[rstest] |
| 825 | #[case::own_segment_self_attested(false, "refs/meta/inbox/guest/issue-1", true)] |
| 826 | #[case::own_segment_admin(true, "refs/meta/inbox/admin/issue-1", true)] |
| 827 | #[case::foreign_segment_self_attested(false, "refs/meta/inbox/admin/issue-1", false)] |
| 828 | #[case::foreign_segment_even_for_admins(true, "refs/meta/inbox/guest/issue-1", false)] |
| 829 | #[case::unscoped_legacy_shape_owns_nothing(true, "refs/meta/inbox/issue-1", false)] |
| 830 | // @relation(meta-ref.inbox, model.member-provenance, gate.tip-signed, scope=function, role=Verifies) |
| 831 | fn inbox_segments_are_owner_only_for_both_provenances( |
| 832 | #[case] as_admin: bool, |
| 833 | #[case] refname: &str, |
| 834 | #[case] admitted: bool, |
| 835 | ) { |
| 836 | let f = forge(); |
| 837 | let key = if as_admin { &f.admin } else { &f.guest }; |
| 838 | let new = commit(&f, vec![], Some(key), 300); |
| 839 | let verdict = run(&f, &name(refname), Some(new)); |
| 840 | if admitted { |
| 841 | expect_pass(&verdict, AdmissionKind::TipInvariant); |
| 842 | } else { |
| 843 | expect_fail(&verdict, Requirement::TipSigned); |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | #[rstest] |
| 848 | // @relation(effect.admin-only, gate.tip-signed, scope=function, role=Verifies) |
| 849 | fn admin_registered_members_may_write_the_effects_namespace() { |
| 850 | let f = forge(); |
| 851 | let effect = Effect { |
| 852 | name: "unit".into(), |
| 853 | trigger: "rev(refs/heads/main)".into(), |
| 854 | toolchains: vec![], |
| 855 | run: "true".into(), |
| 856 | }; |
| 857 | let tree = facet_git_tree::serialize_into(&effect, &f.objects).expect("ser"); |
| 858 | let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); |
| 859 | expect_pass( |
| 860 | &run(&f, &name("refs/meta/effects/unit"), Some(tip)), |
| 861 | AdmissionKind::TipInvariant, |
| 862 | ); |
| 863 | } |
| 864 | |
| 865 | // --------------------------------------------------------------------- |
| 866 | // Adoption and divergence: consequences of judging only the tip. |
| 867 | // --------------------------------------------------------------------- |
| 868 | |
| 869 | #[rstest] |
| 870 | // @relation(gate.adoption-merge, scope=function, role=Verifies) |
| 871 | fn adoption_is_a_merge_that_keeps_the_contributor_commit_in_ancestry() { |
| 872 | let f = forge(); |
| 873 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 874 | let refname = name(&format!("refs/meta/comments/{genesis}")); |
| 875 | f.refs.set(refname.as_ref(), genesis); |
| 876 | // The contributor's own signed commit, not authorized for this ref. |
| 877 | let contributed = commit(&f, vec![genesis], Some(&f.guest), 310); |
| 878 | // The authorized member merges it: the merge tip satisfies the |
| 879 | // invariant; the contributor's signature survives in ancestry. |
| 880 | let merge = commit(&f, vec![genesis, contributed], Some(&f.admin), 320); |
| 881 | expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant); |
| 882 | } |
| 883 | |
| 884 | #[rstest] |
| 885 | // @relation(gate.adoption-no-fast-forward, scope=function, role=Verifies) |
| 886 | fn fast_forwarding_to_a_contributor_commit_is_not_adoption() { |
| 887 | let f = forge(); |
| 888 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 889 | let refname = name(&format!("refs/meta/comments/{genesis}")); |
| 890 | f.refs.set(refname.as_ref(), genesis); |
| 891 | let contributed = commit(&f, vec![genesis], Some(&f.guest), 310); |
| 892 | // Descends fine — but the tip signature is the contributor's, and the |
| 893 | // contributor is not authorized for this refname. |
| 894 | expect_fail( |
| 895 | &run(&f, &refname, Some(contributed)), |
| 896 | Requirement::TipSigned, |
| 897 | ); |
| 898 | } |
| 899 | |
| 900 | #[rstest] |
| 901 | // @relation(gate.same-actor-divergence, scope=function, role=Verifies) |
| 902 | fn a_members_own_divergent_heads_merge_cleanly() { |
| 903 | let f = forge(); |
| 904 | let genesis = commit(&f, vec![], Some(&f.admin), 300); |
| 905 | let refname = issue_ref(genesis); |
| 906 | f.refs.set(refname.as_ref(), genesis); |
| 907 | // Two of the member's own machines raced the single-writer ref. |
| 908 | let a = commit(&f, vec![genesis], Some(&f.admin), 310); |
| 909 | let b = commit(&f, vec![genesis], Some(&f.admin), 311); |
| 910 | let merge = commit(&f, vec![a, b], Some(&f.admin), 320); |
| 911 | expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant); |
| 912 | } |
| 913 | |
| 914 | // --------------------------------------------------------------------- |
| 915 | // The verification epoch. |
| 916 | // --------------------------------------------------------------------- |
| 917 | |
| 918 | #[rstest] |
| 919 | // @relation(gate.epoch, scope=function, role=Verifies) |
| 920 | fn before_any_epoch_meta_history_is_archival() { |
| 921 | let refs = MemRefStore::default(); |
| 922 | let objects = ObjectStore::default(); |
| 923 | let f = Forge { |
| 924 | refs, |
| 925 | objects, |
| 926 | admin: Keypair::from_seed(ADMIN_SEED), |
| 927 | guest: Keypair::from_seed(SELF_ATTESTED_SEED), |
| 928 | }; |
| 929 | // No config, no members: an unsigned meta write passes as pre-epoch. |
| 930 | let new = commit(&f, vec![], None, 100); |
| 931 | expect_pass( |
| 932 | &run(&f, &issue_ref(new), Some(new)), |
| 933 | AdmissionKind::PreEpoch, |
| 934 | ); |
| 935 | } |
| 936 | |
| 937 | #[rstest] |
| 938 | // @relation(gate.epoch, scope=function, role=Verifies) |
| 939 | fn the_epoch_setting_commit_is_itself_the_first_gated_tip() { |
| 940 | let refs = MemRefStore::default(); |
| 941 | let objects = ObjectStore::default(); |
| 942 | let admin = Keypair::from_seed(ADMIN_SEED); |
| 943 | enroll_member( |
| 944 | &refs, |
| 945 | &objects, |
| 946 | "admin", |
| 947 | &admin, |
| 948 | Provenance::AdminRegistered, |
| 949 | 100, |
| 950 | ); |
| 951 | let f = Forge { |
| 952 | refs, |
| 953 | objects, |
| 954 | admin, |
| 955 | guest: Keypair::from_seed(SELF_ATTESTED_SEED), |
| 956 | }; |
| 957 | |
| 958 | let tree = facet_git_tree::serialize_into( |
| 959 | &Config { epoch: Some(200) }, |
| 960 | &f.objects, |
| 961 | ) |
| 962 | .expect("config serializes"); |
| 963 | let make = |key: Option<&Keypair>| { |
| 964 | write_commit( |
| 965 | &f.objects, |
| 966 | &CommitSpec { |
| 967 | tree, |
| 968 | parents: vec![], |
| 969 | message: "enable verification".into(), |
| 970 | seconds: 200, |
| 971 | }, |
| 972 | key, |
| 973 | ) |
| 974 | }; |
| 975 | |
| 976 | // Unsigned epoch-setting is refused: the circularity resolves by |
| 977 | // gating the very commit that turns gating on. |
| 978 | expect_fail( |
| 979 | &run(&f, &name(namespace::CONFIG_REF), Some(make(None))), |
| 980 | Requirement::TipSigned, |
| 981 | ); |
| 982 | // Signed by an enrolled member, it passes under the tip invariant. |
| 983 | expect_pass( |
| 984 | &run(&f, &name(namespace::CONFIG_REF), Some(make(Some(&f.admin)))), |
| 985 | AdmissionKind::TipInvariant, |
| 986 | ); |
| 987 | } |
| 988 | |
| 989 | // --------------------------------------------------------------------- |
| 990 | // Bootstrap: fail-closed empty-member-list handling. |
| 991 | // --------------------------------------------------------------------- |
| 992 | |
| 993 | /// A store with verification in force but no members at all. |
| 994 | fn bare_forge_with_epoch() -> Forge { |
| 995 | let refs = MemRefStore::default(); |
| 996 | let objects = ObjectStore::default(); |
| 997 | let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid"); |
| 998 | write_meta_entity( |
| 999 | &refs, |
| 1000 | &objects, |
| 1001 | config_ref, |
| 1002 | &Config { epoch: Some(50) }, |
| 1003 | None, |
| 1004 | 50, |
| 1005 | ); |
| 1006 | Forge { |
| 1007 | refs, |
| 1008 | objects, |
| 1009 | admin: Keypair::from_seed(ADMIN_SEED), |
| 1010 | guest: Keypair::from_seed(SELF_ATTESTED_SEED), |
| 1011 | } |
| 1012 | } |
| 1013 | |
| 1014 | fn enrollment_proposal(f: &Forge, id: &str, enrolled: &Keypair, signer: &Keypair) -> ObjectId { |
| 1015 | let member = Member::new(id, enrolled.public_openssh(), Provenance::AdminRegistered); |
| 1016 | let tree = facet_git_tree::serialize_into(&member, &f.objects).expect("member serializes"); |
| 1017 | write_commit( |
| 1018 | &f.objects, |
| 1019 | &CommitSpec { |
| 1020 | tree, |
| 1021 | parents: vec![], |
| 1022 | message: format!("enroll {id}"), |
| 1023 | seconds: 100, |
| 1024 | }, |
| 1025 | Some(signer), |
| 1026 | ) |
| 1027 | } |
| 1028 | |
| 1029 | #[rstest] |
| 1030 | // @relation(gate.bootstrap, scope=function, role=Verifies) |
| 1031 | fn first_enrollment_is_self_admitting() { |
| 1032 | let f = bare_forge_with_epoch(); |
| 1033 | let new = enrollment_proposal(&f, "first", &f.admin, &f.admin); |
| 1034 | expect_pass( |
| 1035 | &run(&f, &name("refs/meta/member/first"), Some(new)), |
| 1036 | AdmissionKind::Bootstrap, |
| 1037 | ); |
| 1038 | } |
| 1039 | |
| 1040 | #[rstest] |
| 1041 | // @relation(gate.bootstrap, gate.identity-binding, scope=function, role=Verifies) |
| 1042 | fn a_bootstrap_enrollment_naming_the_wrong_ref_is_refused() { |
| 1043 | // Even the self-admitting bootstrap write is bound by the member's own |
| 1044 | // id field, not a trailer: an enrollment whose id is `first` cannot |
| 1045 | // land on `refs/meta/member/other`. |
| 1046 | let f = bare_forge_with_epoch(); |
| 1047 | let new = enrollment_proposal(&f, "first", &f.admin, &f.admin); |
| 1048 | expect_fail( |
| 1049 | &run(&f, &name("refs/meta/member/other"), Some(new)), |
| 1050 | Requirement::IdentityBinding, |
| 1051 | ); |
| 1052 | } |
| 1053 | |
| 1054 | #[rstest] |
| 1055 | // @relation(gate.bootstrap, scope=function, role=Verifies) |
| 1056 | fn bootstrap_enrollment_must_be_signed_by_the_key_it_enrolls() { |
| 1057 | let f = bare_forge_with_epoch(); |
| 1058 | let other = Keypair::from_seed(OUTSIDER_SEED); |
| 1059 | let new = enrollment_proposal(&f, "first", &f.admin, &other); |
| 1060 | expect_fail( |
| 1061 | &run(&f, &name("refs/meta/member/first"), Some(new)), |
| 1062 | Requirement::TipSigned, |
| 1063 | ); |
| 1064 | } |
| 1065 | |
| 1066 | #[rstest] |
| 1067 | // @relation(gate.bootstrap, scope=function, role=Verifies) |
| 1068 | fn bootstrap_admits_only_enrollments() { |
| 1069 | let f = bare_forge_with_epoch(); |
| 1070 | let new = commit(&f, vec![], Some(&f.admin), 100); |
| 1071 | expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned); |
| 1072 | } |
| 1073 | |
| 1074 | #[rstest] |
| 1075 | // @relation(gate.bootstrap, model.member-revocation, scope=function, role=Verifies) |
| 1076 | fn revoking_every_key_does_not_reopen_the_bootstrap_window() { |
| 1077 | let f = forge(); |
| 1078 | for id in ["admin", "guest"] { |
| 1079 | let key = if id == "admin" { &f.admin } else { &f.guest }; |
| 1080 | let provenance = if id == "admin" { |
| 1081 | Provenance::AdminRegistered |
| 1082 | } else { |
| 1083 | Provenance::SelfAttested |
| 1084 | }; |
| 1085 | let mut member = Member::new(id, key.public_openssh(), provenance); |
| 1086 | member.revoke(); |
| 1087 | write_member(&f.refs, &f.objects, id, &member, Some(&f.admin), 400); |
| 1088 | } |
| 1089 | // A would-be new member self-enrolling: the member set is non-empty |
| 1090 | // (though fully revoked), so the self-admitting window stays shut. |
| 1091 | let newcomer = Keypair::from_seed(OUTSIDER_SEED); |
| 1092 | let new = enrollment_proposal(&f, "newcomer", &newcomer, &newcomer); |
| 1093 | expect_fail( |
| 1094 | &run(&f, &name("refs/meta/member/newcomer"), Some(new)), |
| 1095 | Requirement::TipSigned, |
| 1096 | ); |
| 1097 | // And the fully-revoked members cannot write anything either. |
| 1098 | let attempt = commit(&f, vec![], Some(&f.admin), 500); |
| 1099 | expect_fail( |
| 1100 | &run(&f, &issue_ref(attempt), Some(attempt)), |
| 1101 | Requirement::TipSigned, |
| 1102 | ); |
| 1103 | } |
| 1104 | |
| 1105 | // --------------------------------------------------------------------- |
| 1106 | // CAS binding, call sites, and offline reproducibility. |
| 1107 | // --------------------------------------------------------------------- |
| 1108 | |
| 1109 | #[rstest] |
| 1110 | // @relation(gate.atomic-cas, scope=function, role=Verifies) |
| 1111 | fn the_admission_carries_the_cas_precondition_from_the_same_read() { |
| 1112 | let f = forge(); |
| 1113 | |
| 1114 | // Creation: the ref must still be absent at write time. |
| 1115 | let created = commit(&f, vec![], Some(&f.admin), 300); |
| 1116 | let refname = issue_ref(created); |
| 1117 | let Verdict::Pass(admission) = run(&f, &refname, Some(created)) else { |
| 1118 | panic!("expected a pass"); |
| 1119 | }; |
| 1120 | assert_eq!(admission.cas, Expected::MustNotExist); |
| 1121 | |
| 1122 | // Update: the precondition is exactly the old tip the FF check used. |
| 1123 | f.refs.set(refname.as_ref(), created); |
| 1124 | let advanced = commit(&f, vec![created], Some(&f.admin), 320); |
| 1125 | let Verdict::Pass(admission) = run(&f, &refname, Some(advanced)) else { |
| 1126 | panic!("expected a pass"); |
| 1127 | }; |
| 1128 | assert_eq!(admission.cas, Expected::MustExistAndMatch(created)); |
| 1129 | } |
| 1130 | |
| 1131 | /// Every scenario the verdict table distinguishes, evaluated the way each |
| 1132 | /// of the three call sites would evaluate it, in one parameterized test. |
| 1133 | #[rstest] |
| 1134 | #[case::authorized_pass(true, true)] |
| 1135 | #[case::unsigned_fail(false, true)] |
| 1136 | #[case::unauthorized_namespace_guest(true, false)] |
| 1137 | // @relation(gate.call-sites, gate.mandatory-hosted, gate.advisory-local, scope=function, role=Verifies) |
| 1138 | fn all_three_call_sites_return_identical_verdicts(#[case] signed: bool, #[case] as_admin: bool) { |
| 1139 | let f = forge(); |
| 1140 | let key = if as_admin { &f.admin } else { &f.guest }; |
| 1141 | let new = commit(&f, vec![], signed.then_some(key), 300); |
| 1142 | let update = Update { |
| 1143 | name: issue_ref(new), |
| 1144 | new: Some(new), |
| 1145 | }; |
| 1146 | |
| 1147 | // Call site 1: hosted CAS time (mandatory — the caller aborts on Fail). |
| 1148 | let hosted = verify(&f.refs, &f.objects, &update).expect("verdict"); |
| 1149 | // Call site 2: the local UI verdict (advisory — annotates the write). |
| 1150 | let local = verify(&f.refs, &f.objects, &update).expect("verdict"); |
| 1151 | // Call site 3: push pre-flight, against a fetched copy of the refs. |
| 1152 | let fetched = f.refs.fetched_copy(); |
| 1153 | let preflight = verify(&fetched, &f.objects, &update).expect("verdict"); |
| 1154 | |
| 1155 | assert_eq!(hosted, local, "hosted vs local"); |
| 1156 | assert_eq!(hosted, preflight, "hosted vs pre-flight"); |
| 1157 | } |
| 1158 | |
| 1159 | #[rstest] |
| 1160 | // @relation(gate.signature-artifact, gate.policy-as-state, scope=function, role=Verifies) |
| 1161 | fn verdicts_reproduce_offline_from_repository_state_alone() { |
| 1162 | let build = || { |
| 1163 | let f = forge(); |
| 1164 | let new = commit(&f, vec![], Some(&f.admin), 300); |
| 1165 | (f, new) |
| 1166 | }; |
| 1167 | let (origin, new_at_origin) = build(); |
| 1168 | let (clone, new_at_clone) = build(); |
| 1169 | assert_eq!(new_at_origin, new_at_clone, "deterministic fixtures"); |
| 1170 | |
| 1171 | let update = Update { |
| 1172 | name: issue_ref(new_at_origin), |
| 1173 | new: Some(new_at_origin), |
| 1174 | }; |
| 1175 | let at_origin = verify(&origin.refs, &origin.objects, &update).expect("verdict"); |
| 1176 | let at_clone = verify(&clone.refs, &clone.objects, &update).expect("verdict"); |
| 1177 | assert_eq!(at_origin, at_clone); |
| 1178 | } |
| 1179 | |
| 1180 | #[rstest] |
| 1181 | // @relation(gate.verdict-reason, gate.advisory-local, scope=function, role=Verifies) |
| 1182 | fn refusals_render_an_actionable_reason_with_the_inbox_alternative() { |
| 1183 | let f = forge(); |
| 1184 | let new = commit(&f, vec![], Some(&f.guest), 300); |
| 1185 | let refname = issue_ref(new); |
| 1186 | let Verdict::Fail(refusal) = run(&f, &refname, Some(new)) else { |
| 1187 | panic!("self-attested member on a canonical ref must be refused"); |
| 1188 | }; |
| 1189 | let rendered = refusal.to_string(); |
| 1190 | assert!( |
| 1191 | rendered.contains("gate.tip-signed"), |
| 1192 | "names the rule: {rendered}" |
| 1193 | ); |
| 1194 | assert!( |
| 1195 | rendered.contains("refs/meta/issues/"), |
| 1196 | "names the subject ref: {rendered}" |
| 1197 | ); |
| 1198 | assert!( |
| 1199 | rendered.contains("refs/meta/inbox"), |
| 1200 | "surfaces the inbox alternative at verdict time: {rendered}" |
| 1201 | ); |
| 1202 | } |
| 1203 | |
| 1204 | #[rstest] |
| 1205 | // @relation(gate.epoch, scope=function, role=Verifies) |
| 1206 | fn config_round_trips_with_and_without_an_epoch() { |
| 1207 | for config in [Config { epoch: None }, Config { epoch: Some(42) }] { |
| 1208 | let (root, store) = facet_git_tree::serialize(&config).expect("serialize"); |
| 1209 | let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize"); |
| 1210 | assert_eq!(back, config); |
| 1211 | } |
| 1212 | } |
| 1213 | |
| 1214 | #[rstest] |
| 1215 | fn fixture_stores_read_back_what_they_seed() { |
| 1216 | let f = forge(); |
| 1217 | let member_ref = name("refs/meta/member/admin"); |
| 1218 | assert!(f.refs.get(member_ref.as_ref()).expect("readable").is_some()); |
| 1219 | } |