Approvals are kept in the repository: indexed by state, kept by gc 3a4b3e018326
Repo::add_approval verifies a signed approval, refuses another repository's, stores the signer, attestation and signatures and indexes it by the state it approves (index approval/approvals); gc keeps the approvals of states it keeps, with their signatures and signers. Test: stored, refused when foreign or changed, survives gc, fsck clean. Refs andymccall/oxygenhub#235
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 09:53 UTC
- Signature
- ✔ signed by Claude Code
- Hash
3a4b3e01832633d68dc6f1bcfbff0abfedecf2dad03daa1d8b49513c316d3d74- Depends on
- 648189388f1a 982864068a59 ba160f60219e dfb23a3a8cfa fc1a466a1e66 ff0af1ee299d
- Depended on by
- ef7d4ea6f33b
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 8 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/oxy-core/src/repo/gc.rs +1 −0
| … | 168 unchanged lines |
||
| 169 | 169 | attests.extend(self.store.seals_for(&h)?); |
|
| 170 | 170 | } |
|
| 171 | 171 | self.reachable_receipts(&mut keep)?; |
|
| 172 | + | self.reachable_approvals(&mut keep)?; |
|
| 172 | 173 | for a in attests { |
|
| 173 | 174 | if keep.insert(a) { |
|
| 174 | 175 | for sig in self.store.signatures(&a)? { |
|
| … | 7 unchanged lines |
modified crates/oxy-core/src/repo/receipts.rs +48 −0
| … | 80 unchanged lines |
||
| 81 | 81 | ||
| 82 | 82 | /// Receipts to keep through garbage collection: those whose state's |
|
| 83 | 83 | /// heads `keep` holds, with their receipts, runners and outputs. |
|
| 84 | + | /// Keep a signed approval (oxygenhub#235): verified, for this repository, |
|
| 85 | + | /// with the signer's identity and indexed by the state it approves, so |
|
| 86 | + | /// garbage collection keeps it while that state lives. Who may approve is |
|
| 87 | + | /// the caller's rule. |
|
| 88 | + | pub fn add_approval(&self, sa: &crate::approval::SignedApproval) -> Result<ObjectRef> { |
|
| 89 | + | sa.verify() |
|
| 90 | + | .map_err(|e| RepoError::Msg(format!("approval does not verify: {e}")))?; |
|
| 91 | + | if sa.attest.repo != self.id()? { |
|
| 92 | + | return fail("that approval is for another repository"); |
|
| 93 | + | } |
|
| 94 | + | let state = sa |
|
| 95 | + | .statement() |
|
| 96 | + | .map_err(|e| RepoError::Msg(e.to_string()))? |
|
| 97 | + | .state; |
|
| 98 | + | self.store.put_identity(&sa.signer)?; |
|
| 99 | + | let r = self.store.put_attest(&sa.attest)?; |
|
| 100 | + | for sig in &sa.sigs { |
|
| 101 | + | self.store.put_signature(&r, sig)?; |
|
| 102 | + | } |
|
| 103 | + | self.store.index_approval(&state, &r)?; |
|
| 104 | + | Ok(r) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | /// Approvals of states garbage collection keeps are kept, with their |
|
| 108 | + | /// signatures and signers. |
|
| 109 | + | pub(crate) fn reachable_approvals(&self, keep: &mut HashSet<ObjectRef>) -> Result<()> { |
|
| 110 | + | for state in self.store.approval_states()? { |
|
| 111 | + | if !keep.contains(&state) { |
|
| 112 | + | let heads = match self.store.get_state(&state) { |
|
| 113 | + | Ok(s) => s.heads, |
|
| 114 | + | Err(StoreError::NotFound(_)) => vec![], |
|
| 115 | + | Err(e) => return Err(e.into()), |
|
| 116 | + | }; |
|
| 117 | + | if !heads.iter().all(|h| keep.contains(h)) { |
|
| 118 | + | continue; |
|
| 119 | + | } |
|
| 120 | + | keep.insert(state); |
|
| 121 | + | } |
|
| 122 | + | for a in self.store.approvals_for(&state)? { |
|
| 123 | + | keep.insert(a); |
|
| 124 | + | for sig in self.store.signatures(&a)? { |
|
| 125 | + | keep.insert(sig.key); |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | Ok(()) |
|
| 130 | + | } |
|
| 131 | + | ||
| 84 | 132 | pub(crate) fn reachable_receipts(&self, keep: &mut HashSet<ObjectRef>) -> Result<()> { |
|
| 85 | 133 | for state in self.store.receipt_states()? { |
|
| 86 | 134 | let heads = match self.store.get_state(&state) { |
|
| … | 37 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +57 −0
| … | 5545 unchanged lines |
||
| 5546 | 5546 | } |
|
| 5547 | 5547 | ||
| 5548 | 5548 | #[test] |
|
| 5549 | + | fn approvals_are_kept_with_their_state_and_survive_gc() { |
|
| 5550 | + | hermetic(); |
|
| 5551 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5552 | + | let root = dir.path(); |
|
| 5553 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 5554 | + | write(root, "a.txt", b"1\n"); |
|
| 5555 | + | repo.commit("A", &[]).unwrap(); |
|
| 5556 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 5557 | + | let state = repo.store().put_state(&StateObject::new(heads)).unwrap(); |
|
| 5558 | + | let plan = repo.store_blob(b"the reviewed plan\n").unwrap(); |
|
| 5559 | + | let sk = SecretKey::generate(); |
|
| 5560 | + | let andy = sk.identity("Andy", "[email protected]", 1); |
|
| 5561 | + | let statement = crate::approval::Statement { |
|
| 5562 | + | repo: repo.id().unwrap(), |
|
| 5563 | + | run: "https://hub.example/andy/infra/flow/8".into(), |
|
| 5564 | + | job: "apply".into(), |
|
| 5565 | + | job_hash: ObjectRef([1; 32]), |
|
| 5566 | + | state, |
|
| 5567 | + | inputs: vec![plan], |
|
| 5568 | + | environment: "prod".into(), |
|
| 5569 | + | approve: true, |
|
| 5570 | + | }; |
|
| 5571 | + | let sa = crate::approval::SignedApproval::sign(&statement, &andy, &sk); |
|
| 5572 | + | let r = repo.add_approval(&sa).unwrap(); |
|
| 5573 | + | assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]); |
|
| 5574 | + | // Another repository's approval, or a changed one, is refused. |
|
| 5575 | + | let elsewhere = crate::approval::SignedApproval::sign( |
|
| 5576 | + | &crate::approval::Statement { |
|
| 5577 | + | repo: [0; 16], |
|
| 5578 | + | ..statement.clone() |
|
| 5579 | + | }, |
|
| 5580 | + | &andy, |
|
| 5581 | + | &sk, |
|
| 5582 | + | ); |
|
| 5583 | + | assert!(repo |
|
| 5584 | + | .add_approval(&elsewhere) |
|
| 5585 | + | .unwrap_err() |
|
| 5586 | + | .to_string() |
|
| 5587 | + | .contains("another repository")); |
|
| 5588 | + | let mut forged = sa.clone(); |
|
| 5589 | + | if let AttestBody::Approval { environment, .. } = &mut forged.attest.body { |
|
| 5590 | + | *environment = "dev".into(); |
|
| 5591 | + | } |
|
| 5592 | + | assert!(repo |
|
| 5593 | + | .add_approval(&forged) |
|
| 5594 | + | .unwrap_err() |
|
| 5595 | + | .to_string() |
|
| 5596 | + | .contains("does not verify")); |
|
| 5597 | + | // gc keeps it, its signature and its signer while the state lives. |
|
| 5598 | + | repo.gc(true).unwrap(); |
|
| 5599 | + | assert_eq!(repo.store().approvals_for(&state).unwrap(), vec![r]); |
|
| 5600 | + | assert_eq!(repo.store().get_attest(&r).unwrap(), sa.attest); |
|
| 5601 | + | assert_eq!(repo.store().signatures(&r).unwrap(), sa.sigs); |
|
| 5602 | + | assert!(repo.fsck().unwrap().problems.is_empty()); |
|
| 5603 | + | } |
|
| 5604 | + | ||
| 5605 | + | #[test] |
|
| 5549 | 5606 | fn status_leaves_an_unchanged_manifest_alone() { |
|
| 5550 | 5607 | hermetic(); |
|
| 5551 | 5608 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 1440 unchanged lines |
modified crates/oxy-core/src/store.rs +27 −2
| … | 1615 unchanged lines |
||
| 1616 | 1616 | self.index_get("receipt", "receipts", state) |
|
| 1617 | 1617 | } |
|
| 1618 | 1618 | ||
| 1619 | + | pub fn index_approval(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 1620 | + | self.index_add("approval", state, attest) |
|
| 1621 | + | } |
|
| 1622 | + | ||
| 1623 | + | /// Approval attestations recorded for `state` (OxygenFlow approval |
|
| 1624 | + | /// gates, oxygenhub#235). |
|
| 1625 | + | pub fn approvals_for(&self, state: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 1626 | + | self.index_get("approval", "approvals", state) |
|
| 1627 | + | } |
|
| 1628 | + | ||
| 1629 | + | /// Every state with an approval, for garbage collection. |
|
| 1630 | + | pub fn approval_states(&self) -> Result<Vec<ObjectRef>> { |
|
| 1631 | + | self.indexed_states("approval", "approvals") |
|
| 1632 | + | } |
|
| 1633 | + | ||
| 1619 | 1634 | /// Every state with a receipt, for garbage collection. |
|
| 1620 | 1635 | pub fn receipt_states(&self) -> Result<Vec<ObjectRef>> { |
|
| 1636 | + | self.indexed_states("receipt", "receipts") |
|
| 1637 | + | } |
|
| 1638 | + | ||
| 1639 | + | /// The states an index (`one/<hh>/<rest>/<attest>` entries, and the |
|
| 1640 | + | /// packed `many/<hh>/<rest>` lists) holds attestations for. |
|
| 1641 | + | fn indexed_states(&self, one: &str, many: &str) -> Result<Vec<ObjectRef>> { |
|
| 1621 | 1642 | let mut out: Vec<ObjectRef> = Vec::new(); |
|
| 1622 | - | for e in self.backend.list("receipt/").map_err(backend_err)? { |
|
| 1643 | + | for e in self.backend.list(&format!("{one}/")).map_err(backend_err)? { |
|
| 1623 | 1644 | // receipt/<hh>/<rest>/<attest> |
|
| 1624 | 1645 | let mut parts = e.key.splitn(4, '/'); |
|
| 1625 | 1646 | let (Some(_), Some(hh), Some(rest), Some(_)) = |
|
| … | 3 unchanged lines |
||
| 1629 | 1650 | }; |
|
| 1630 | 1651 | out.extend(ObjectRef::from_hex(&format!("{hh}{rest}"))); |
|
| 1631 | 1652 | } |
|
| 1632 | - | for e in self.backend.list("receipts/").map_err(backend_err)? { |
|
| 1653 | + | for e in self |
|
| 1654 | + | .backend |
|
| 1655 | + | .list(&format!("{many}/")) |
|
| 1656 | + | .map_err(backend_err)? |
|
| 1657 | + | { |
|
| 1633 | 1658 | let mut parts = e.key.splitn(3, '/'); |
|
| 1634 | 1659 | let (Some(_), Some(hh), Some(rest)) = (parts.next(), parts.next(), parts.next()) else { |
|
| 1635 | 1660 | continue; |
|
| … | 848 unchanged lines |