OXYGENHUB

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