Execution receipts: the receipt object and attestation fc1a466a1e66
OxygenFlow's receipt becomes an Oxygen object type. An attestation of kind receipt binds one to the state it ran on and is signed by the runner. A signed receipt carries the runner's identity, so it verifies on its own. Repositories keep receipts by state, and gc keeps them with their changes. oxy receipt list, show, export, verify and import. Spec §14.6, Draft 0.8o. closes #23
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 07:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
fc1a466a1e66c2759e326cc3662ab9648f2e962dabe288a29e32b1483dd7691a- Depends on
- 132b884b9a8d 135212de5370 1d27d0a2b3c1 28fc23d7706a 28fee1c9eb6c 387335ff7f4d 4dcb8c0916b7 4dee277c327a 5b9c6ad45f6e 75036530b621 75167672ebcd 83d61e803ccd 83e0677b07fe 86e682c242c6 8f6b03b28e40 982864068a59 ba160f60219e c6aa5bd9d1c8 d82e44097323 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 284fe47d98b9 642da2636de2 ef7d4ea6f33b e59512f2f075 a2387d547373 f53cbe7a204e c63fed2e0465 02e691881a4e 5f661562b3a4 a39754d02c58 3a4b3e018326 b582948f1912 b689779e9e74 648189388f1a 47ce8b8e2d4b 4fe7d6045860 6f8aeb996558 dfb23a3a8cfa 9df6217f5f47 7dc9f65b6546 155313e68e15
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 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
- 50 atoms, 3 tree operations, 16 files
Browse the tree at this change
added crates/oxy-core/src/receipt.rs +275 −0
| 1 | + | //! Execution receipts (spec §14.6): what a runner ran, in what, on which |
|
| 2 | + | //! inputs and with what result. A receipt names no state, so the same |
|
| 3 | + | //! receipt serves every state whose scope is unchanged; an attestation of |
|
| 4 | + | //! kind `receipt`, signed by the runner, binds it to the state it ran on. |
|
| 5 | + | ||
| 6 | + | use crate::attest::{Attest, AttestBody}; |
|
| 7 | + | use crate::cbor::{self, Value}; |
|
| 8 | + | use crate::hash::{hash_object, ObjectRef, ObjectType}; |
|
| 9 | + | use crate::identity::{verify, Domain, Identity, SecretKey, Signature}; |
|
| 10 | + | use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError}; |
|
| 11 | + | ||
| 12 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 13 | + | pub struct Receipt { |
|
| 14 | + | pub job_name: String, |
|
| 15 | + | /// The pipeline definition the job came from. |
|
| 16 | + | pub pipeline_hash: ObjectRef, |
|
| 17 | + | /// BLAKE3 over the sorted (path, content) of the files the job's scope |
|
| 18 | + | /// matches; the whole tree when it has no scope. |
|
| 19 | + | pub scope_tree_hash: ObjectRef, |
|
| 20 | + | /// Artifacts and other inputs consumed beyond the tree. |
|
| 21 | + | pub inputs: Vec<ObjectRef>, |
|
| 22 | + | /// What the job ran in: isolation tier, rootfs, argv and environment. |
|
| 23 | + | pub environment: ObjectRef, |
|
| 24 | + | /// Blobs the job produced. |
|
| 25 | + | pub outputs: Vec<ObjectRef>, |
|
| 26 | + | pub exit_code: u32, |
|
| 27 | + | pub duration_ms: u64, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | fn refs(v: &[ObjectRef]) -> Value { |
|
| 31 | + | Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()) |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | fn refs_from(v: &Value, what: &str) -> Result<Vec<ObjectRef>, ObjectError> { |
|
| 35 | + | array(v, what)?.iter().map(|h| oref(h, what)).collect() |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | impl Receipt { |
|
| 39 | + | pub fn passed(&self) -> bool { |
|
| 40 | + | self.exit_code == 0 |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | pub fn to_value(&self) -> Value { |
|
| 44 | + | Value::map([ |
|
| 45 | + | (0, Value::Uint(1)), |
|
| 46 | + | (1, Value::Text(self.job_name.clone())), |
|
| 47 | + | (2, Value::Bytes(self.pipeline_hash.0.to_vec())), |
|
| 48 | + | (3, Value::Bytes(self.scope_tree_hash.0.to_vec())), |
|
| 49 | + | (4, refs(&self.inputs)), |
|
| 50 | + | (5, Value::Bytes(self.environment.0.to_vec())), |
|
| 51 | + | (6, refs(&self.outputs)), |
|
| 52 | + | (7, Value::Uint(self.exit_code as u64)), |
|
| 53 | + | (8, Value::Uint(self.duration_ms)), |
|
| 54 | + | ]) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 58 | + | cbor::encode(&self.to_value()) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | pub fn hash(&self) -> ObjectRef { |
|
| 62 | + | hash_object(ObjectType::Receipt, &self.encode()) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | pub fn decode(b: &[u8]) -> Result<Receipt, ObjectError> { |
|
| 66 | + | let v = cbor::decode(b)?; |
|
| 67 | + | let m = map(&v, "receipt")?; |
|
| 68 | + | check_version(m)?; |
|
| 69 | + | let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?; |
|
| 70 | + | Ok(Receipt { |
|
| 71 | + | job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(), |
|
| 72 | + | pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?, |
|
| 73 | + | scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?, |
|
| 74 | + | inputs: refs_from(field(m, 4, "inputs")?, "inputs")?, |
|
| 75 | + | environment: oref(field(m, 5, "environment")?, "environment")?, |
|
| 76 | + | outputs: refs_from(field(m, 6, "outputs")?, "outputs")?, |
|
| 77 | + | exit_code: u32::try_from(exit_code) |
|
| 78 | + | .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?, |
|
| 79 | + | duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?, |
|
| 80 | + | }) |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 85 | + | pub enum ReceiptError { |
|
| 86 | + | #[error("the attestation is not of kind receipt")] |
|
| 87 | + | NotReceipt, |
|
| 88 | + | #[error("the attestation names a different receipt")] |
|
| 89 | + | WrongReceipt, |
|
| 90 | + | #[error("no signature by the runner {0}")] |
|
| 91 | + | Unsigned(String), |
|
| 92 | + | #[error("the runner's signature does not verify: {0}")] |
|
| 93 | + | BadSignature(String), |
|
| 94 | + | #[error("{0}")] |
|
| 95 | + | Schema(String), |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | impl From<ObjectError> for ReceiptError { |
|
| 99 | + | fn from(e: ObjectError) -> Self { |
|
| 100 | + | ReceiptError::Schema(e.to_string()) |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// A receipt as a runner hands it over: the receipt, the attestation |
|
| 105 | + | /// binding it to a state, the runner's signature over that attestation, and |
|
| 106 | + | /// the runner's identity, so it verifies with nothing else. |
|
| 107 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 108 | + | pub struct SignedReceipt { |
|
| 109 | + | pub receipt: Receipt, |
|
| 110 | + | pub attest: Attest, |
|
| 111 | + | pub sigs: Vec<Signature>, |
|
| 112 | + | pub runner: Identity, |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | impl SignedReceipt { |
|
| 116 | + | /// Bind `receipt` to `state` of repository `repo` for run `run` (a URL |
|
| 117 | + | /// or other name of the run), signed by `runner` with `sk`. |
|
| 118 | + | pub fn sign( |
|
| 119 | + | receipt: Receipt, |
|
| 120 | + | repo: [u8; 16], |
|
| 121 | + | state: ObjectRef, |
|
| 122 | + | run: &str, |
|
| 123 | + | runner: &Identity, |
|
| 124 | + | sk: &SecretKey, |
|
| 125 | + | ) -> SignedReceipt { |
|
| 126 | + | let attest = Attest { |
|
| 127 | + | repo, |
|
| 128 | + | hlc: Hlc::now(), |
|
| 129 | + | body: AttestBody::Receipt { |
|
| 130 | + | receipt: receipt.hash(), |
|
| 131 | + | state, |
|
| 132 | + | run: run.into(), |
|
| 133 | + | }, |
|
| 134 | + | }; |
|
| 135 | + | let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash()); |
|
| 136 | + | SignedReceipt { |
|
| 137 | + | receipt, |
|
| 138 | + | attest, |
|
| 139 | + | sigs: vec![sig], |
|
| 140 | + | runner: runner.clone(), |
|
| 141 | + | } |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | /// The state the receipt was made on. |
|
| 145 | + | pub fn state(&self) -> ObjectRef { |
|
| 146 | + | match &self.attest.body { |
|
| 147 | + | AttestBody::Receipt { state, .. } => *state, |
|
| 148 | + | _ => ObjectRef([0; 32]), |
|
| 149 | + | } |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | pub fn run(&self) -> &str { |
|
| 153 | + | match &self.attest.body { |
|
| 154 | + | AttestBody::Receipt { run, .. } => run, |
|
| 155 | + | _ => "", |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | /// The attestation is a receipt attestation for this receipt, and the |
|
| 160 | + | /// runner signed it. Whether to trust the runner is the reader's call. |
|
| 161 | + | pub fn verify(&self) -> Result<(), ReceiptError> { |
|
| 162 | + | let AttestBody::Receipt { receipt, .. } = &self.attest.body else { |
|
| 163 | + | return Err(ReceiptError::NotReceipt); |
|
| 164 | + | }; |
|
| 165 | + | if *receipt != self.receipt.hash() { |
|
| 166 | + | return Err(ReceiptError::WrongReceipt); |
|
| 167 | + | } |
|
| 168 | + | let key = self.runner.hash(); |
|
| 169 | + | let target = self.attest.hash(); |
|
| 170 | + | let sig = self |
|
| 171 | + | .sigs |
|
| 172 | + | .iter() |
|
| 173 | + | .find(|s| s.key == key) |
|
| 174 | + | .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?; |
|
| 175 | + | verify(&self.runner, sig, Domain::Attest, &target) |
|
| 176 | + | .map_err(|e| ReceiptError::BadSignature(e.to_string())) |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | /// `[receipt, attest, [Signature], identity]`, each object as its bytes. |
|
| 180 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 181 | + | cbor::encode(&Value::Array(vec![ |
|
| 182 | + | Value::Bytes(self.receipt.encode()), |
|
| 183 | + | Value::Bytes(self.attest.encode()), |
|
| 184 | + | Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), |
|
| 185 | + | Value::Bytes(self.runner.encode()), |
|
| 186 | + | ])) |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> { |
|
| 190 | + | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 191 | + | let a = array(&v, "signed receipt")?; |
|
| 192 | + | if a.len() != 4 { |
|
| 193 | + | return Err(ReceiptError::Schema( |
|
| 194 | + | "signed receipt: expected [receipt, attest, sigs, identity]".into(), |
|
| 195 | + | )); |
|
| 196 | + | } |
|
| 197 | + | Ok(SignedReceipt { |
|
| 198 | + | receipt: Receipt::decode(bytes(&a[0], "receipt")?)?, |
|
| 199 | + | attest: Attest::decode(bytes(&a[1], "attest")?)?, |
|
| 200 | + | sigs: array(&a[2], "sigs")? |
|
| 201 | + | .iter() |
|
| 202 | + | .map(Signature::from_value) |
|
| 203 | + | .collect::<Result<_, _>>()?, |
|
| 204 | + | runner: Identity::decode(bytes(&a[3], "identity")?)?, |
|
| 205 | + | }) |
|
| 206 | + | } |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | #[cfg(test)] |
|
| 210 | + | mod tests { |
|
| 211 | + | use super::*; |
|
| 212 | + | ||
| 213 | + | pub(crate) fn sample() -> Receipt { |
|
| 214 | + | Receipt { |
|
| 215 | + | job_name: "test".into(), |
|
| 216 | + | pipeline_hash: ObjectRef([1; 32]), |
|
| 217 | + | scope_tree_hash: ObjectRef([2; 32]), |
|
| 218 | + | inputs: vec![], |
|
| 219 | + | environment: ObjectRef([3; 32]), |
|
| 220 | + | outputs: vec![ObjectRef([4; 32])], |
|
| 221 | + | exit_code: 0, |
|
| 222 | + | duration_ms: 1500, |
|
| 223 | + | } |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | #[test] |
|
| 227 | + | fn receipts_round_trip_and_hash_as_receipt_objects() { |
|
| 228 | + | let r = sample(); |
|
| 229 | + | assert_eq!(Receipt::decode(&r.encode()).unwrap(), r); |
|
| 230 | + | let mut h = blake3::Hasher::new(); |
|
| 231 | + | h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes()); |
|
| 232 | + | h.update(&r.encode()); |
|
| 233 | + | assert_eq!( |
|
| 234 | + | r.hash().0, |
|
| 235 | + | *h.finalize().as_bytes(), |
|
| 236 | + | "OxygenFlow's receipt hash" |
|
| 237 | + | ); |
|
| 238 | + | assert!(r.passed()); |
|
| 239 | + | } |
|
| 240 | + | ||
| 241 | + | #[test] |
|
| 242 | + | fn a_signed_receipt_verifies_and_tampering_does_not() { |
|
| 243 | + | let sk = SecretKey::generate(); |
|
| 244 | + | let runner = sk.identity("earth-1", "runner@earth", 1); |
|
| 245 | + | let sr = SignedReceipt::sign( |
|
| 246 | + | sample(), |
|
| 247 | + | [7; 16], |
|
| 248 | + | ObjectRef([9; 32]), |
|
| 249 | + | "andy/demo#3", |
|
| 250 | + | &runner, |
|
| 251 | + | &sk, |
|
| 252 | + | ); |
|
| 253 | + | sr.verify().unwrap(); |
|
| 254 | + | let back = SignedReceipt::decode(&sr.encode()).unwrap(); |
|
| 255 | + | assert_eq!(back, sr); |
|
| 256 | + | assert_eq!(back.state(), ObjectRef([9; 32])); |
|
| 257 | + | assert_eq!(back.run(), "andy/demo#3"); |
|
| 258 | + | ||
| 259 | + | let mut forged = sr.clone(); |
|
| 260 | + | forged.receipt.exit_code = 0; |
|
| 261 | + | forged.receipt.duration_ms = 1; |
|
| 262 | + | assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt)); |
|
| 263 | + | ||
| 264 | + | let mut moved = sr.clone(); |
|
| 265 | + | if let AttestBody::Receipt { state, .. } = &mut moved.attest.body { |
|
| 266 | + | *state = ObjectRef([8; 32]); |
|
| 267 | + | } |
|
| 268 | + | assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_)))); |
|
| 269 | + | ||
| 270 | + | let other = SecretKey::generate().identity("mallory", "m@x", 1); |
|
| 271 | + | let mut swapped = sr.clone(); |
|
| 272 | + | swapped.runner = other; |
|
| 273 | + | assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_)))); |
|
| 274 | + | } |
|
| 275 | + | } |
added crates/oxy-core/src/repo/receipts.rs +123 −0
| 1 | + | //! Execution receipts in a repository (spec §14.6): kept beside the state |
|
| 2 | + | //! they were made on, with the runner's identity and signature. |
|
| 3 | + | ||
| 4 | + | use super::*; |
|
| 5 | + | use crate::receipt::SignedReceipt; |
|
| 6 | + | ||
| 7 | + | /// A stored receipt and what verifying it found. |
|
| 8 | + | #[derive(Clone, Debug)] |
|
| 9 | + | pub struct ReceiptInfo { |
|
| 10 | + | /// The receipt attestation's ObjectRef, which names it. |
|
| 11 | + | pub attest: ObjectRef, |
|
| 12 | + | pub signed: SignedReceipt, |
|
| 13 | + | /// Why it does not verify, if it does not. |
|
| 14 | + | pub problem: Option<String>, |
|
| 15 | + | /// The runner's key was revoked (spec §14.2). |
|
| 16 | + | pub revoked: bool, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | impl Repo { |
|
| 20 | + | /// Verify `sr` and keep it: the receipt, the attestation with its |
|
| 21 | + | /// signatures, and the runner's identity. Returns the attestation's |
|
| 22 | + | /// ObjectRef. Receipts for another repository are refused. |
|
| 23 | + | pub fn add_receipt(&self, sr: &SignedReceipt) -> Result<ObjectRef> { |
|
| 24 | + | sr.verify() |
|
| 25 | + | .map_err(|e| RepoError::Msg(format!("receipt does not verify: {e}")))?; |
|
| 26 | + | if sr.attest.repo != self.id()? { |
|
| 27 | + | return fail("that receipt is for another repository"); |
|
| 28 | + | } |
|
| 29 | + | self.store.put_identity(&sr.runner)?; |
|
| 30 | + | self.store.put_receipt(&sr.receipt)?; |
|
| 31 | + | let r = self.store.put_attest(&sr.attest)?; |
|
| 32 | + | for sig in &sr.sigs { |
|
| 33 | + | self.store.put_signature(&r, sig)?; |
|
| 34 | + | } |
|
| 35 | + | self.store.index_receipt(&sr.state(), &r)?; |
|
| 36 | + | Ok(r) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | /// The receipt named by attestation `r`, verified again from the store. |
|
| 40 | + | pub fn receipt(&self, r: &ObjectRef) -> Result<ReceiptInfo> { |
|
| 41 | + | let attest = self.store.get_attest(r)?; |
|
| 42 | + | let AttestBody::Receipt { receipt, .. } = &attest.body else { |
|
| 43 | + | return fail(format!("#{} is not a receipt", r.short(12))); |
|
| 44 | + | }; |
|
| 45 | + | let receipt = self.store.get_receipt(receipt)?; |
|
| 46 | + | let sigs = self.store.signatures(r)?; |
|
| 47 | + | let Some(key) = sigs.first().map(|s| s.key) else { |
|
| 48 | + | return fail(format!("receipt #{} has no signature", r.short(12))); |
|
| 49 | + | }; |
|
| 50 | + | let runner = self.store.get_identity(&key)?; |
|
| 51 | + | let signed = SignedReceipt { |
|
| 52 | + | receipt, |
|
| 53 | + | attest, |
|
| 54 | + | sigs, |
|
| 55 | + | runner, |
|
| 56 | + | }; |
|
| 57 | + | let problem = signed.verify().err().map(|e| e.to_string()); |
|
| 58 | + | Ok(ReceiptInfo { |
|
| 59 | + | attest: *r, |
|
| 60 | + | revoked: self.is_revoked(&key)?, |
|
| 61 | + | signed, |
|
| 62 | + | problem, |
|
| 63 | + | }) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /// Every receipt kept for the state of `heads`, oldest first. |
|
| 67 | + | pub fn receipts(&self, heads: &[ChangeHash]) -> Result<Vec<ReceiptInfo>> { |
|
| 68 | + | let state = hash_object( |
|
| 69 | + | ObjectType::State, |
|
| 70 | + | &StateObject::new(heads.to_vec()).encode(), |
|
| 71 | + | ); |
|
| 72 | + | let mut out = self |
|
| 73 | + | .store |
|
| 74 | + | .receipts_for(&state)? |
|
| 75 | + | .iter() |
|
| 76 | + | .map(|r| self.receipt(r)) |
|
| 77 | + | .collect::<Result<Vec<_>>>()?; |
|
| 78 | + | out.sort_by_key(|i| i.signed.attest.hlc); |
|
| 79 | + | Ok(out) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// Receipts to keep through garbage collection: those whose state's |
|
| 83 | + | /// heads `keep` holds, with their receipts, runners and outputs. |
|
| 84 | + | pub(crate) fn reachable_receipts(&self, keep: &mut HashSet<ObjectRef>) -> Result<()> { |
|
| 85 | + | for state in self.store.receipt_states()? { |
|
| 86 | + | let heads = match self.store.get_state(&state) { |
|
| 87 | + | Ok(s) => s.heads, |
|
| 88 | + | // A receipt may arrive before its state object; its heads |
|
| 89 | + | // are unknown, so keep it. |
|
| 90 | + | Err(StoreError::NotFound(_)) => vec![], |
|
| 91 | + | Err(e) => return Err(e.into()), |
|
| 92 | + | }; |
|
| 93 | + | if !heads.iter().all(|h| keep.contains(h)) { |
|
| 94 | + | continue; |
|
| 95 | + | } |
|
| 96 | + | keep.insert(state); |
|
| 97 | + | for a in self.store.receipts_for(&state)? { |
|
| 98 | + | keep.insert(a); |
|
| 99 | + | for sig in self.store.signatures(&a)? { |
|
| 100 | + | keep.insert(sig.key); |
|
| 101 | + | } |
|
| 102 | + | let Ok(attest) = self.store.get_attest(&a) else { |
|
| 103 | + | continue; |
|
| 104 | + | }; |
|
| 105 | + | let AttestBody::Receipt { receipt, .. } = attest.body else { |
|
| 106 | + | continue; |
|
| 107 | + | }; |
|
| 108 | + | keep.insert(receipt); |
|
| 109 | + | let Ok(r) = self.store.get_receipt(&receipt) else { |
|
| 110 | + | continue; |
|
| 111 | + | }; |
|
| 112 | + | for b in r.outputs.iter().chain(&r.inputs) { |
|
| 113 | + | if keep.insert(*b) { |
|
| 114 | + | if let Ok(bytes) = self.store.get_typed(b, ObjectType::Blob) { |
|
| 115 | + | keep.extend(Blob::decode(&bytes)?.chunks); |
|
| 116 | + | } |
|
| 117 | + | } |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | Ok(()) |
|
| 122 | + | } |
|
| 123 | + | } |
added src/cli/receipt.rs +221 −0
| 1 | + | //! Execution receipts (spec §14.6): list, show, verify and import the |
|
| 2 | + | //! signed receipts runners make. |
|
| 3 | + | ||
| 4 | + | use super::json; |
|
| 5 | + | use super::open; |
|
| 6 | + | use super::ui; |
|
| 7 | + | use anyhow::{bail, Result}; |
|
| 8 | + | use oxy_core::receipt::SignedReceipt; |
|
| 9 | + | use oxy_core::repo::ReceiptInfo; |
|
| 10 | + | ||
| 11 | + | fn status(problem: Option<&str>, revoked: bool) -> String { |
|
| 12 | + | match (problem, revoked) { |
|
| 13 | + | (Some(p), _) => ui::err(format!("{} {p}", ui::glyph("✘", "x"))), |
|
| 14 | + | (None, true) => ui::warn(format!("{} runner key revoked", ui::glyph("!", "!"))), |
|
| 15 | + | (None, false) => ui::ok(format!("{} signed", ui::glyph("✔", "v"))), |
|
| 16 | + | } |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | fn line(sr: &SignedReceipt, problem: Option<&str>, revoked: bool) -> String { |
|
| 20 | + | let r = &sr.receipt; |
|
| 21 | + | let result = if r.passed() { |
|
| 22 | + | ui::ok(format!("{} passed", ui::glyph("●", "*"))) |
|
| 23 | + | } else { |
|
| 24 | + | ui::err(format!("{} exit {}", ui::glyph("●", "*"), r.exit_code)) |
|
| 25 | + | }; |
|
| 26 | + | format!( |
|
| 27 | + | "{} {}{}{}{}{}", |
|
| 28 | + | result, |
|
| 29 | + | ui::subject(&r.job_name), |
|
| 30 | + | ui::sep() + &ui::dim(format!("{:.1} s", r.duration_ms as f64 / 1000.0)), |
|
| 31 | + | ui::sep() + &ui::author(&sr.runner.name), |
|
| 32 | + | ui::sep() + &status(problem, revoked), |
|
| 33 | + | ui::sep() + &ui::dim(ui::ago(sr.attest.hlc.wall_ms)), |
|
| 34 | + | ) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | fn to_json( |
|
| 38 | + | attest: Option<String>, |
|
| 39 | + | sr: &SignedReceipt, |
|
| 40 | + | problem: Option<&str>, |
|
| 41 | + | revoked: bool, |
|
| 42 | + | ) -> String { |
|
| 43 | + | let r = &sr.receipt; |
|
| 44 | + | let refs = |v: &[oxy_core::hash::ObjectRef]| json::arr(v.iter().map(|h| json::s(&h.to_hex()))); |
|
| 45 | + | json::obj([ |
|
| 46 | + | ("attest", json::opt(attest.as_ref(), |a| json::s(a))), |
|
| 47 | + | ("receipt", json::s(&r.hash().to_hex())), |
|
| 48 | + | ("state", json::s(&sr.state().to_hex())), |
|
| 49 | + | ("run", json::s(sr.run())), |
|
| 50 | + | ("job", json::s(&r.job_name)), |
|
| 51 | + | ("exit_code", json::n(r.exit_code)), |
|
| 52 | + | ("duration_ms", json::n(r.duration_ms)), |
|
| 53 | + | ("pipeline", json::s(&r.pipeline_hash.to_hex())), |
|
| 54 | + | ("scope", json::s(&r.scope_tree_hash.to_hex())), |
|
| 55 | + | ("environment", json::s(&r.environment.to_hex())), |
|
| 56 | + | ("inputs", refs(&r.inputs)), |
|
| 57 | + | ("outputs", refs(&r.outputs)), |
|
| 58 | + | ("runner", json::s(&sr.runner.hash().to_hex())), |
|
| 59 | + | ("runner_name", json::s(&sr.runner.name)), |
|
| 60 | + | ("made_ms", json::n(sr.attest.hlc.wall_ms)), |
|
| 61 | + | ("valid", json::b(problem.is_none())), |
|
| 62 | + | ("problem", json::opt(problem, json::s)), |
|
| 63 | + | ("revoked", json::b(revoked)), |
|
| 64 | + | ]) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | fn detail(sr: &SignedReceipt, problem: Option<&str>, revoked: bool) { |
|
| 68 | + | let r = &sr.receipt; |
|
| 69 | + | println!("{}", line(sr, problem, revoked)); |
|
| 70 | + | println!( |
|
| 71 | + | "{}", |
|
| 72 | + | ui::kv("run", if sr.run().is_empty() { "-" } else { sr.run() }) |
|
| 73 | + | ); |
|
| 74 | + | println!( |
|
| 75 | + | "{}", |
|
| 76 | + | ui::kv("state", ui::hash(format!("#{}", sr.state().short(12)))) |
|
| 77 | + | ); |
|
| 78 | + | println!( |
|
| 79 | + | "{}", |
|
| 80 | + | ui::kv("receipt", ui::hash(format!("#{}", r.hash().short(12)))) |
|
| 81 | + | ); |
|
| 82 | + | println!( |
|
| 83 | + | "{}", |
|
| 84 | + | ui::kv("pipeline", ui::hash(r.pipeline_hash.short(12))) |
|
| 85 | + | ); |
|
| 86 | + | println!("{}", ui::kv("scope", ui::hash(r.scope_tree_hash.short(12)))); |
|
| 87 | + | println!( |
|
| 88 | + | "{}", |
|
| 89 | + | ui::kv("environment", ui::hash(r.environment.short(12))) |
|
| 90 | + | ); |
|
| 91 | + | println!( |
|
| 92 | + | "{}", |
|
| 93 | + | ui::kv( |
|
| 94 | + | "runner", |
|
| 95 | + | format!("{} {}", sr.runner.name, ui::dim(sr.runner.hash().short(12))) |
|
| 96 | + | ) |
|
| 97 | + | ); |
|
| 98 | + | for (what, v) in [("input", &r.inputs), ("output", &r.outputs)] { |
|
| 99 | + | for h in v.iter() { |
|
| 100 | + | println!("{}", ui::kv(what, ui::hash(h.short(12)))); |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// `oxy receipt list [REV]`: the receipts kept for a state. |
|
| 106 | + | pub fn list(rev: Option<&str>, as_json: bool) -> Result<i32> { |
|
| 107 | + | let repo = open()?; |
|
| 108 | + | let heads = match rev { |
|
| 109 | + | Some(r) => repo.resolve_heads(r)?, |
|
| 110 | + | None => repo.bookmark_heads()?, |
|
| 111 | + | }; |
|
| 112 | + | let found = repo.receipts(&heads)?; |
|
| 113 | + | if as_json { |
|
| 114 | + | println!( |
|
| 115 | + | "{}", |
|
| 116 | + | json::arr(found.iter().map(|i| to_json( |
|
| 117 | + | Some(i.attest.to_hex()), |
|
| 118 | + | &i.signed, |
|
| 119 | + | i.problem.as_deref(), |
|
| 120 | + | i.revoked |
|
| 121 | + | ))) |
|
| 122 | + | ); |
|
| 123 | + | return Ok(0); |
|
| 124 | + | } |
|
| 125 | + | if found.is_empty() { |
|
| 126 | + | println!("{}", ui::dim("No receipts for this state.")); |
|
| 127 | + | } |
|
| 128 | + | for i in &found { |
|
| 129 | + | println!( |
|
| 130 | + | "{} {}", |
|
| 131 | + | ui::hash(format!("#{}", i.attest.short(12))), |
|
| 132 | + | line(&i.signed, i.problem.as_deref(), i.revoked) |
|
| 133 | + | ); |
|
| 134 | + | } |
|
| 135 | + | Ok(0) |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | fn find(repo: &oxy_core::repo::Repo, prefix: &str, rev: Option<&str>) -> Result<ReceiptInfo> { |
|
| 139 | + | let heads = match rev { |
|
| 140 | + | Some(r) => repo.resolve_heads(r)?, |
|
| 141 | + | None => repo.bookmark_heads()?, |
|
| 142 | + | }; |
|
| 143 | + | let p = prefix.trim_start_matches('#').to_ascii_lowercase(); |
|
| 144 | + | let mut hits: Vec<ReceiptInfo> = repo |
|
| 145 | + | .receipts(&heads)? |
|
| 146 | + | .into_iter() |
|
| 147 | + | .filter(|i| i.attest.to_hex().starts_with(&p)) |
|
| 148 | + | .collect(); |
|
| 149 | + | match hits.len() { |
|
| 150 | + | 1 => Ok(hits.remove(0)), |
|
| 151 | + | 0 => bail!("no receipt #{p} for this state; `oxy receipt list` shows them"), |
|
| 152 | + | n => bail!("#{p} matches {n} receipts; give more of the hash"), |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | /// `oxy receipt show HASH [--rev REV]`. |
|
| 157 | + | pub fn show(prefix: &str, rev: Option<&str>, as_json: bool) -> Result<i32> { |
|
| 158 | + | let repo = open()?; |
|
| 159 | + | let i = find(&repo, prefix, rev)?; |
|
| 160 | + | if as_json { |
|
| 161 | + | println!( |
|
| 162 | + | "{}", |
|
| 163 | + | to_json( |
|
| 164 | + | Some(i.attest.to_hex()), |
|
| 165 | + | &i.signed, |
|
| 166 | + | i.problem.as_deref(), |
|
| 167 | + | i.revoked |
|
| 168 | + | ) |
|
| 169 | + | ); |
|
| 170 | + | } else { |
|
| 171 | + | detail(&i.signed, i.problem.as_deref(), i.revoked); |
|
| 172 | + | } |
|
| 173 | + | Ok(if i.problem.is_some() { 1 } else { 0 }) |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | /// `oxy receipt export HASH FILE`: the signed receipt, to hand to anyone. |
|
| 177 | + | pub fn export(prefix: &str, file: &str, rev: Option<&str>) -> Result<i32> { |
|
| 178 | + | let repo = open()?; |
|
| 179 | + | let i = find(&repo, prefix, rev)?; |
|
| 180 | + | std::fs::write(file, i.signed.encode())?; |
|
| 181 | + | println!( |
|
| 182 | + | "{} {} {}", |
|
| 183 | + | ui::done("Exported receipt"), |
|
| 184 | + | ui::hash(format!("#{}", i.attest.short(12))), |
|
| 185 | + | ui::path(file) |
|
| 186 | + | ); |
|
| 187 | + | Ok(0) |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | fn read(file: &str) -> Result<SignedReceipt> { |
|
| 191 | + | let bytes = std::fs::read(file).map_err(|e| anyhow::anyhow!("{file}: {e}"))?; |
|
| 192 | + | SignedReceipt::decode(&bytes).map_err(|e| anyhow::anyhow!("{file}: not a signed receipt: {e}")) |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | /// `oxy receipt verify FILE`: check a signed receipt without keeping it. |
|
| 196 | + | pub fn verify(file: &str, as_json: bool) -> Result<i32> { |
|
| 197 | + | let sr = read(file)?; |
|
| 198 | + | let problem = sr.verify().err().map(|e| e.to_string()); |
|
| 199 | + | if as_json { |
|
| 200 | + | println!("{}", to_json(None, &sr, problem.as_deref(), false)); |
|
| 201 | + | } else { |
|
| 202 | + | detail(&sr, problem.as_deref(), false); |
|
| 203 | + | } |
|
| 204 | + | Ok(if problem.is_some() { 1 } else { 0 }) |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | /// `oxy receipt import FILE`: verify a signed receipt and keep it. |
|
| 208 | + | pub fn import(file: &str) -> Result<i32> { |
|
| 209 | + | let repo = open()?; |
|
| 210 | + | let sr = read(file)?; |
|
| 211 | + | let r = repo.add_receipt(&sr)?; |
|
| 212 | + | println!( |
|
| 213 | + | "{} {} {} {} {}", |
|
| 214 | + | ui::done("Kept receipt"), |
|
| 215 | + | ui::hash(format!("#{}", r.short(12))), |
|
| 216 | + | ui::subject(&sr.receipt.job_name), |
|
| 217 | + | ui::dim("by"), |
|
| 218 | + | ui::author(&sr.runner.name) |
|
| 219 | + | ); |
|
| 220 | + | Ok(0) |
|
| 221 | + | } |
modified crates/oxy-core/src/attest.rs +23 −1
| 1 | 1 | //! Attestations (spec §14.4): signed statements about references and batches |
|
| 2 | - | //! of changes. Only the `ref` and `seal` kinds exist so far. |
|
| 2 | + | //! of changes, and runs (receipts, §14.6). |
|
| 3 | 3 | ||
| 4 | 4 | use crate::cbor::{self, Value}; |
|
| 5 | 5 | use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; |
|
| … | 24 unchanged lines |
||
| 30 | 30 | Rotate { new: KeyId }, |
|
| 31 | 31 | /// `key` must no longer be trusted (spec §14.2). |
|
| 32 | 32 | Revoke { key: KeyId, reason: String }, |
|
| 33 | + | /// A runner ran the job of `receipt` on `state` (spec §14.6); `run` |
|
| 34 | + | /// names the run, usually by URL. |
|
| 35 | + | Receipt { |
|
| 36 | + | receipt: ObjectRef, |
|
| 37 | + | state: ObjectRef, |
|
| 38 | + | run: String, |
|
| 39 | + | }, |
|
| 33 | 40 | } |
|
| 34 | 41 | ||
| 35 | 42 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| … | 12 unchanged lines |
||
| 48 | 55 | AttestBody::Tag { .. } => "tag", |
|
| 49 | 56 | AttestBody::Rotate { .. } => "rotate", |
|
| 50 | 57 | AttestBody::Revoke { .. } => "revoke", |
|
| 58 | + | AttestBody::Receipt { .. } => "receipt", |
|
| 51 | 59 | } |
|
| 52 | 60 | } |
|
| 53 | 61 | ||
| … | 37 unchanged lines |
||
| 91 | 99 | AttestBody::Revoke { key, reason } => Value::map([ |
|
| 92 | 100 | (0, Value::Bytes(key.0.to_vec())), |
|
| 93 | 101 | (1, Value::Text(reason.clone())), |
|
| 102 | + | ]), |
|
| 103 | + | AttestBody::Receipt { |
|
| 104 | + | receipt, |
|
| 105 | + | state, |
|
| 106 | + | run, |
|
| 107 | + | } => Value::map([ |
|
| 108 | + | (0, Value::Bytes(receipt.0.to_vec())), |
|
| 109 | + | (1, Value::Bytes(state.0.to_vec())), |
|
| 110 | + | (2, Value::Text(run.clone())), |
|
| 94 | 111 | ]), |
|
| 95 | 112 | }; |
|
| 96 | 113 | Value::map([ |
|
| … | 56 unchanged lines |
||
| 153 | 170 | "revoke" => AttestBody::Revoke { |
|
| 154 | 171 | key: oref(field(b, 0, "key")?, "key")?, |
|
| 155 | 172 | reason: text(field(b, 1, "reason")?, "reason")?.to_owned(), |
|
| 173 | + | }, |
|
| 174 | + | "receipt" => AttestBody::Receipt { |
|
| 175 | + | receipt: oref(field(b, 0, "receipt")?, "receipt")?, |
|
| 176 | + | state: oref(field(b, 1, "state")?, "state")?, |
|
| 177 | + | run: text(field(b, 2, "run")?, "run")?.to_owned(), |
|
| 156 | 178 | }, |
|
| 157 | 179 | other => { |
|
| 158 | 180 | return Err(ObjectError::Schema(format!( |
|
| … | 69 unchanged lines |
modified crates/oxy-core/src/hash.rs +4 −1
| … | 62 unchanged lines |
||
| 63 | 63 | Identity, |
|
| 64 | 64 | Policy, |
|
| 65 | 65 | State, |
|
| 66 | + | Receipt, |
|
| 66 | 67 | } |
|
| 67 | 68 | ||
| 68 | 69 | impl ObjectType { |
|
| 69 | - | pub const ALL: [ObjectType; 9] = [ |
|
| 70 | + | pub const ALL: [ObjectType; 10] = [ |
|
| 70 | 71 | ObjectType::Chunk, |
|
| 71 | 72 | ObjectType::Blob, |
|
| 72 | 73 | ObjectType::Change, |
|
| … | 3 unchanged lines |
||
| 76 | 77 | ObjectType::Identity, |
|
| 77 | 78 | ObjectType::Policy, |
|
| 78 | 79 | ObjectType::State, |
|
| 80 | + | ObjectType::Receipt, |
|
| 79 | 81 | ]; |
|
| 80 | 82 | ||
| 81 | 83 | pub fn name(self) -> &'static str { |
|
| … | 7 unchanged lines |
||
| 89 | 91 | ObjectType::Identity => "identity", |
|
| 90 | 92 | ObjectType::Policy => "policy", |
|
| 91 | 93 | ObjectType::State => "state", |
|
| 94 | + | ObjectType::Receipt => "receipt", |
|
| 92 | 95 | } |
|
| 93 | 96 | } |
|
| 94 | 97 | ||
| … | 59 unchanged lines |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub mod policy; |
|
| 20 | 20 | pub mod portable; |
|
| 21 | 21 | pub mod protocol; |
|
| 22 | + | pub mod receipt; |
|
| 22 | 23 | pub mod repo; |
|
| 23 | 24 | pub mod rewrite; |
|
| 24 | 25 | pub mod state; |
|
| … | 4 unchanged lines |
modified crates/oxy-core/src/repo/fsck.rs +1 −0
| … | 96 unchanged lines |
||
| 97 | 97 | ObjectType::Attest => Attest::decode(body).map(|_| ()), |
|
| 98 | 98 | ObjectType::Identity => Identity::decode(body).map(|_| ()), |
|
| 99 | 99 | ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()), |
|
| 100 | + | ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()), |
|
| 100 | 101 | ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()), |
|
| 101 | 102 | }; |
|
| 102 | 103 | r.map_err(|e| e.to_string()) |
|
| … | 364 unchanged lines |
modified crates/oxy-core/src/repo/gc.rs +3 −1
| … | 1 unchanged lines |
||
| 2 | 2 | //! nothing reachable refers to. |
|
| 3 | 3 | //! |
|
| 4 | 4 | //! Roots: the current view, the views of every operation newer than |
|
| 5 | - | //! `core.op-retention`, reference attestations, and the policy. Operation |
|
| 5 | + | //! `core.op-retention`, reference attestations, and the policy; receipts |
|
| 6 | + | //! live as long as the changes of their state. Operation |
|
| 6 | 7 | //! objects are always kept so the log stays walkable, but an operation older |
|
| 7 | 8 | //! than the retention window no longer keeps its states alive, so undoing |
|
| 8 | 9 | //! that far back may fail once its objects are gone. |
|
| … | 133 unchanged lines |
||
| 142 | 143 | } |
|
| 143 | 144 | attests.extend(self.store.seals_for(&h)?); |
|
| 144 | 145 | } |
|
| 146 | + | self.reachable_receipts(&mut keep)?; |
|
| 145 | 147 | for a in attests { |
|
| 146 | 148 | if keep.insert(a) { |
|
| 147 | 149 | for sig in self.store.signatures(&a)? { |
|
| … | 7 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +2 −0
| … | 72 unchanged lines |
||
| 73 | 73 | mod inspect; |
|
| 74 | 74 | mod policy; |
|
| 75 | 75 | mod prompt; |
|
| 76 | + | mod receipts; |
|
| 76 | 77 | mod snapshot; |
|
| 77 | 78 | mod subrepos; |
|
| 78 | 79 | mod sync; |
|
| … | 15 unchanged lines |
||
| 94 | 95 | pub use inspect::{BisectStep, BlameLine, Description}; |
|
| 95 | 96 | pub use policy::PolicyUpdate; |
|
| 96 | 97 | pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT}; |
|
| 98 | + | pub use receipts::ReceiptInfo; |
|
| 97 | 99 | pub use snapshot::{ |
|
| 98 | 100 | added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes, |
|
| 99 | 101 | render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus, |
|
| … | 1540 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +66 −0
| … | 5386 unchanged lines |
||
| 5387 | 5387 | assert!(repo.line_history(&heads, "f.rs", 0, 1).is_err()); |
|
| 5388 | 5388 | assert!(repo.line_history(&heads, "nope.rs", 1, 1).is_err()); |
|
| 5389 | 5389 | } |
|
| 5390 | + | ||
| 5391 | + | #[test] |
|
| 5392 | + | fn receipts_are_kept_with_their_state_and_verified() { |
|
| 5393 | + | use crate::receipt::{Receipt, SignedReceipt}; |
|
| 5394 | + | hermetic(); |
|
| 5395 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5396 | + | let root = dir.path(); |
|
| 5397 | + | let repo = Repo::init(root).unwrap().with_home(root.join("home")); |
|
| 5398 | + | write(root, "a.txt", b"1\n"); |
|
| 5399 | + | repo.commit("A", &[]).unwrap(); |
|
| 5400 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 5401 | + | let state = repo |
|
| 5402 | + | .store() |
|
| 5403 | + | .put_state(&StateObject::new(heads.clone())) |
|
| 5404 | + | .unwrap(); |
|
| 5405 | + | let out = repo.store_blob(b"built\n").unwrap(); |
|
| 5406 | + | let receipt = Receipt { |
|
| 5407 | + | job_name: "build".into(), |
|
| 5408 | + | pipeline_hash: ObjectRef([1; 32]), |
|
| 5409 | + | scope_tree_hash: ObjectRef([2; 32]), |
|
| 5410 | + | inputs: vec![], |
|
| 5411 | + | environment: ObjectRef([3; 32]), |
|
| 5412 | + | outputs: vec![out], |
|
| 5413 | + | exit_code: 0, |
|
| 5414 | + | duration_ms: 42, |
|
| 5415 | + | }; |
|
| 5416 | + | let sk = SecretKey::generate(); |
|
| 5417 | + | let runner = sk.identity("earth-1", "runner@earth", 1); |
|
| 5418 | + | let sr = SignedReceipt::sign( |
|
| 5419 | + | receipt.clone(), |
|
| 5420 | + | repo.id().unwrap(), |
|
| 5421 | + | state, |
|
| 5422 | + | "demo#1", |
|
| 5423 | + | &runner, |
|
| 5424 | + | &sk, |
|
| 5425 | + | ); |
|
| 5426 | + | ||
| 5427 | + | let r = repo.add_receipt(&sr).unwrap(); |
|
| 5428 | + | let found = repo.receipts(&heads).unwrap(); |
|
| 5429 | + | assert_eq!(found.len(), 1); |
|
| 5430 | + | assert_eq!(found[0].attest, r); |
|
| 5431 | + | assert_eq!(found[0].signed, sr); |
|
| 5432 | + | assert!(found[0].problem.is_none() && !found[0].revoked); |
|
| 5433 | + | assert!(repo.receipts(&[ObjectRef([5; 32])]).unwrap().is_empty()); |
|
| 5434 | + | ||
| 5435 | + | // Another repository's receipt, or a forged one, is refused. |
|
| 5436 | + | let elsewhere = SignedReceipt::sign(receipt.clone(), [0; 16], state, "x", &runner, &sk); |
|
| 5437 | + | assert!(repo |
|
| 5438 | + | .add_receipt(&elsewhere) |
|
| 5439 | + | .unwrap_err() |
|
| 5440 | + | .to_string() |
|
| 5441 | + | .contains("another repository")); |
|
| 5442 | + | let mut forged = sr.clone(); |
|
| 5443 | + | forged.receipt.exit_code = 1; |
|
| 5444 | + | assert!(repo |
|
| 5445 | + | .add_receipt(&forged) |
|
| 5446 | + | .unwrap_err() |
|
| 5447 | + | .to_string() |
|
| 5448 | + | .contains("does not verify")); |
|
| 5449 | + | ||
| 5450 | + | // gc keeps the receipt, its runner and its output while the state lives. |
|
| 5451 | + | repo.gc(true).unwrap(); |
|
| 5452 | + | assert_eq!(repo.receipts(&heads).unwrap().len(), 1); |
|
| 5453 | + | assert_eq!(repo.read_blob(&out).unwrap(), b"built\n"); |
|
| 5454 | + | assert!(repo.fsck().unwrap().problems.is_empty()); |
|
| 5455 | + | } |
modified crates/oxy-core/src/store.rs +71 −15
| … | 11 unchanged lines |
||
| 12 | 12 | use crate::hash::{hash_object, header, ObjectRef, ObjectType}; |
|
| 13 | 13 | use crate::identity::{Identity, Signature}; |
|
| 14 | 14 | use crate::objects::{Change, ObjectError, StateObject}; |
|
| 15 | + | use crate::receipt::Receipt; |
|
| 15 | 16 | use rand::RngCore; |
|
| 16 | 17 | use std::cell::RefCell; |
|
| 17 | 18 | use std::fs; |
|
| … | 385 unchanged lines |
||
| 403 | 404 | } |
|
| 404 | 405 | ||
| 405 | 406 | fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> { |
|
| 406 | - | for path in [self.sig_path(r), self.seal_index_path(r)] { |
|
| 407 | + | let sidecars = [ |
|
| 408 | + | self.sig_path(r), |
|
| 409 | + | self.index_path("seals", r), |
|
| 410 | + | self.index_path("receipts", r), |
|
| 411 | + | ]; |
|
| 412 | + | for path in sidecars { |
|
| 407 | 413 | match fs::remove_file(&path) { |
|
| 408 | 414 | Ok(()) => { |
|
| 409 | 415 | let _ = fs::remove_dir(path.parent().unwrap()); |
|
| … | 472 unchanged lines |
||
| 882 | 888 | ||
| 883 | 889 | pub fn get_attest(&self, r: &ObjectRef) -> Result<Attest> { |
|
| 884 | 890 | Ok(Attest::decode(&self.get_typed(r, ObjectType::Attest)?)?) |
|
| 891 | + | } |
|
| 892 | + | ||
| 893 | + | pub fn put_receipt(&self, r: &Receipt) -> Result<ObjectRef> { |
|
| 894 | + | self.put(ObjectType::Receipt, &r.encode()) |
|
| 885 | 895 | } |
|
| 886 | 896 | ||
| 887 | - | fn seal_index_path(&self, change: &ObjectRef) -> PathBuf { |
|
| 888 | - | let h = change.to_hex(); |
|
| 889 | - | self.dir.join("seals").join(&h[..2]).join(&h[2..]) |
|
| 897 | + | pub fn get_receipt(&self, r: &ObjectRef) -> Result<Receipt> { |
|
| 898 | + | Ok(Receipt::decode(&self.get_typed(r, ObjectType::Receipt)?)?) |
|
| 890 | 899 | } |
|
| 891 | 900 | ||
| 892 | - | /// Record that seal attestation `attest` covers `change`. |
|
| 893 | - | pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 894 | - | let mut seals = self.seals_for(change)?; |
|
| 895 | - | if seals.contains(attest) { |
|
| 901 | + | fn index_path(&self, index: &str, key: &ObjectRef) -> PathBuf { |
|
| 902 | + | let h = key.to_hex(); |
|
| 903 | + | self.dir.join(index).join(&h[..2]).join(&h[2..]) |
|
| 904 | + | } |
|
| 905 | + | ||
| 906 | + | /// Add `value` to the sorted list kept for `key` in `index`. |
|
| 907 | + | fn index_add(&self, index: &str, key: &ObjectRef, value: &ObjectRef) -> Result<()> { |
|
| 908 | + | let mut values = self.index_get(index, key)?; |
|
| 909 | + | if values.contains(value) { |
|
| 896 | 910 | return Ok(()); |
|
| 897 | 911 | } |
|
| 898 | - | seals.push(*attest); |
|
| 899 | - | seals.sort(); |
|
| 900 | - | let path = self.seal_index_path(change); |
|
| 912 | + | values.push(*value); |
|
| 913 | + | values.sort(); |
|
| 914 | + | let path = self.index_path(index, key); |
|
| 901 | 915 | fs::create_dir_all(path.parent().unwrap())?; |
|
| 902 | - | let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect(); |
|
| 916 | + | let text: String = values.iter().map(|s| format!("{}\n", s.to_hex())).collect(); |
|
| 903 | 917 | crate::durable::write_atomic(&path, text.as_bytes())?; |
|
| 904 | 918 | Ok(()) |
|
| 905 | 919 | } |
|
| 906 | 920 | ||
| 907 | - | /// Seal attestations known to cover `change`. |
|
| 908 | - | pub fn seals_for(&self, change: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 909 | - | match fs::read_to_string(self.seal_index_path(change)) { |
|
| 921 | + | fn index_get(&self, index: &str, key: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 922 | + | match fs::read_to_string(self.index_path(index, key)) { |
|
| 910 | 923 | Ok(t) => Ok(t.lines().filter_map(ObjectRef::from_hex).collect()), |
|
| 911 | 924 | Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]), |
|
| 912 | 925 | Err(e) => Err(e.into()), |
|
| 913 | 926 | } |
|
| 927 | + | } |
|
| 928 | + | ||
| 929 | + | /// Record that seal attestation `attest` covers `change`. |
|
| 930 | + | pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 931 | + | self.index_add("seals", change, attest) |
|
| 932 | + | } |
|
| 933 | + | ||
| 934 | + | /// Seal attestations known to cover `change`. |
|
| 935 | + | pub fn seals_for(&self, change: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 936 | + | self.index_get("seals", change) |
|
| 937 | + | } |
|
| 938 | + | ||
| 939 | + | /// Record receipt attestation `attest` for `state` (spec §14.6). |
|
| 940 | + | pub fn index_receipt(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 941 | + | self.index_add("receipts", state, attest) |
|
| 942 | + | } |
|
| 943 | + | ||
| 944 | + | /// Receipt attestations recorded for `state`. |
|
| 945 | + | pub fn receipts_for(&self, state: &ObjectRef) -> Result<Vec<ObjectRef>> { |
|
| 946 | + | self.index_get("receipts", state) |
|
| 947 | + | } |
|
| 948 | + | ||
| 949 | + | /// Every state with a receipt, for garbage collection. |
|
| 950 | + | pub fn receipt_states(&self) -> Result<Vec<ObjectRef>> { |
|
| 951 | + | let mut out = Vec::new(); |
|
| 952 | + | let dir = self.dir.join("receipts"); |
|
| 953 | + | let top = match fs::read_dir(&dir) { |
|
| 954 | + | Ok(d) => d, |
|
| 955 | + | Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out), |
|
| 956 | + | Err(e) => return Err(e.into()), |
|
| 957 | + | }; |
|
| 958 | + | for d in top { |
|
| 959 | + | let d = d?; |
|
| 960 | + | let prefix = d.file_name().to_string_lossy().into_owned(); |
|
| 961 | + | for f in fs::read_dir(d.path())? { |
|
| 962 | + | let name = f?.file_name().to_string_lossy().into_owned(); |
|
| 963 | + | if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) { |
|
| 964 | + | out.push(r); |
|
| 965 | + | } |
|
| 966 | + | } |
|
| 967 | + | } |
|
| 968 | + | out.sort(); |
|
| 969 | + | Ok(out) |
|
| 914 | 970 | } |
|
| 915 | 971 | ||
| 916 | 972 | pub fn put_identity(&self, id: &Identity) -> Result<ObjectRef> { |
|
| … | 519 unchanged lines |
modified docs/backlog-overnight-2.md +5 −2
| … | 86 unchanged lines |
||
| 87 | 87 | - [x] P1c Name conflicts on the resolver: keep one candidate, which takes the name as a move. |
|
| 88 | 88 | - [x] P1d "Resolve in VS Code": the resolver hands a conflict to oxygen-vscode (a vscode:// URI the extension handles: clone or open, switch to the bookmark, open the file). |
|
| 89 | 89 | - [x] P1e Replace conflicts of binary files: oxy-core cannot yet resolve one by keeping an existing candidate (writing a candidate's bytes counts as no change); make that a resolution, then offer it on the resolver. |
|
| 90 | - | - [ ] P1 **Conflict resolution on OxygenHub** (Andy's list, item 1). A resolver page for a conflicted state: |
|
| 90 | + | - [x] P1 **Conflict resolution on OxygenHub** (Andy's list, item 1). A resolver page for a conflicted state: |
|
| 91 | 91 | - Per conflict region: pick a side, several sides, or none; edit freely; tree conflicts too (keep a candidate). |
|
| 92 | 92 | - Commit the result as a `kind = resolution` change on the bookmark, through the web-edit path. Signing: sealed when the author next pushes, or "resolve in VS Code" hands off to oxygen-vscode. |
|
| 93 | 93 | - Shareable link to a conflict. |
|
| … | 8 unchanged lines |
||
| 102 | 102 | - The cross-file semantic graph stays out; noted as future work. |
|
| 103 | 103 | - [x] P3a The swimlane board on the hub: lanes of independent changes, dependency edges, no merge nodes (first part of P3, below). |
|
| 104 | 104 | - [x] P3b Drag and drop on the board, proposal bookmarks only: dropping a change onto another proposes a squash, dragging a dependent chain proposes a rebase; the hub writes the plan, the author's oxy or oxygen-vscode applies and signs it. |
|
| 105 | - | - [ ] P3 **Swimlanes** (item 3): |
|
| 105 | + | - [x] P3 **Swimlanes** (item 3): |
|
| 106 | 106 | - Hub: a bookmark's changes drawn as lanes of independent changes (oxy-core `lanes`), with dependencies as edges and no merge nodes. |
|
| 107 | 107 | - P3b: drag and drop on proposal bookmarks only. Dropping a change onto another proposes a squash, dragging a dependent chain proposes a rebase. The hub produces a plan and the author's oxy or oxygen-vscode applies and signs it (V6 has split, squash and rebase). Reordering independent changes means nothing and is not offered. |
|
| 108 | + | - [ ] P4a The `receipt` object and attestation in the Oxygen spec and oxy-core: a signed receipt verifies on its own; the repository keeps receipts by state; `oxy receipt list|show|export|verify|import` (first part of P4, below). |
|
| 109 | + | - [ ] P4c The runner signs each receipt with its own identity; the hub verifies it against its known runner keys, keeps it in the repository, and shows it on the run, the check and the change. |
|
| 110 | + | - [ ] P4d Flow `output` and artifacts: a job's declared outputs become blobs named by its receipt, and a release asset links to the receipt that built it. |
|
| 108 | 111 | - [ ] P4 **Signed Flow receipts**: |
|
| 109 | 112 | - A `receipt` object type in the Oxygen spec. |
|
| 110 | 113 | - A runner identity whose key (composite Ed25519 + ML-DSA-65) signs each receipt. |
|
| … | 587 unchanged lines |
modified docs/guide.md +14 −0
| … | 288 unchanged lines |
||
| 289 | 289 | push and fetch. Workspaces have their own working copy and bookmark |
|
| 290 | 290 | (`workspace.bookmark`) but share objects and history. |
|
| 291 | 291 | ||
| 292 | + | ### Receipts from CI |
|
| 293 | + | ||
| 294 | + | A runner such as OxygenFlow signs a receipt for every job it runs: what ran, |
|
| 295 | + | in what environment, on which files, and the result. Receipts are kept with |
|
| 296 | + | the state they ran on. |
|
| 297 | + | ||
| 298 | + | ``` |
|
| 299 | + | oxy receipt list # receipts for the bookmark's state |
|
| 300 | + | oxy receipt show 3f2a9c # one in full, with its signature checked |
|
| 301 | + | oxy receipt export 3f2a9c build.receipt # hand it to someone |
|
| 302 | + | oxy receipt verify build.receipt # check one without keeping it |
|
| 303 | + | oxy receipt import build.receipt # verify and keep it |
|
| 304 | + | ``` |
|
| 305 | + | ||
| 292 | 306 | ## 11. Big repositories: partial, sparse, shallow |
|
| 293 | 307 | ||
| 294 | 308 | ``` |
|
| … | 148 unchanged lines |
modified docs/spec.md +29 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8n | |
|
| 5 | + | | **Status** | Draft 0.8o | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 152 unchanged lines |
||
| 161 | 161 | | `identity` | A contributor's keys. | §14.2 | |
|
| 162 | 162 | | `policy` | The repository's trust policy. | §14.5 | |
|
| 163 | 163 | | `state` | A sorted list of heads naming a state. | §10.1 | |
|
| 164 | + | | `receipt` | What a runner ran and its result. | §14.6 | |
|
| 164 | 165 | ||
| 165 | 166 | ### 5.4 Signatures are outside the hash |
|
| 166 | 167 | ||
| … | 453 unchanged lines |
||
| 620 | 621 | ``` |
|
| 621 | 622 | Attest { |
|
| 622 | 623 | version [0]: 1 |
|
| 623 | - | kind [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" |
|
| 624 | + | kind [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" |
|
| 624 | 625 | repo [2]: bytes(16) // repository ID, chosen at init |
|
| 625 | 626 | hlc [3]: HLC |
|
| 626 | 627 | body [4]: map // per kind |
|
| … | 4 unchanged lines |
||
| 631 | 632 | rotate: { new: KeyId } |
|
| 632 | 633 | revoke: { key: KeyId, reason } |
|
| 633 | 634 | lock: { node: NodeId, path, acquire: bool, expires } |
|
| 635 | + | receipt: { receipt: ObjectRef, state: ObjectRef, run: text } // §14.6 |
|
| 634 | 636 | ``` |
|
| 635 | 637 | ||
| 636 | 638 | A **seal** is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push. |
|
| … | 31 unchanged lines |
||
| 668 | 670 | - **Propagation.** The `refs` response carries the current signed policy together with the earlier policies the server still holds, and a push MAY carry a newer policy with the intermediate ones in its bundle, applied before any bookmark update. A receiver behind by several serials verifies each step against the one before it. A receiver accepts a policy only if it is for the same repository, its serial exceeds the current one, and it is signed by the current policy's root threshold; the first policy a repository ever sees vouches for itself, which means at least one valid signature by one of its own roots (trust on first use). |
|
| 669 | 671 | - **Downgrade protection.** A client records the highest policy serial each remote has presented. A remote that later presents none, or a lower serial, is refused. A remote that has never presented a policy is unverified: the client fetches with a warning and cannot verify what it received. Policy signatures use the domain `policy` (§14.3). |
|
| 670 | 672 | - **Existing history.** A policy governs changes added after it. The receiver of a push checks the changes the new state adds beyond the state it already had, so history that predates the policy is not re-verified. A fetching client verifies the bookmark's attestation, which binds the whole state through its head hashes, and does not re-verify individual changes; it therefore trusts what an authorized attester vouched for, which is the point of the policy. |
|
| 673 | + | ||
| 674 | + | ### 14.6 Execution receipts |
|
| 675 | + | ||
| 676 | + | A runner (OxygenFlow, or any CI) records each job it ran as a `receipt` object: |
|
| 677 | + | ||
| 678 | + | ``` |
|
| 679 | + | Receipt { |
|
| 680 | + | version [0]: 1 |
|
| 681 | + | job_name [1]: text |
|
| 682 | + | pipeline_hash [2]: ObjectRef // the pipeline definition the job came from |
|
| 683 | + | scope_tree_hash [3]: ObjectRef // BLAKE3 over the sorted (path, content) of the files the job's scope matches; the whole tree without a scope |
|
| 684 | + | inputs [4]: [ObjectRef] // artifacts and other inputs beyond the tree |
|
| 685 | + | environment [5]: ObjectRef // what it ran in: isolation tier, rootfs, argv, environment |
|
| 686 | + | outputs [6]: [ObjectRef] // blobs the job produced |
|
| 687 | + | exit_code [7]: u32 // 0 passed |
|
| 688 | + | duration_ms [8]: u64 |
|
| 689 | + | } |
|
| 690 | + | ``` |
|
| 671 | 691 | ||
| 692 | + | A receipt names no state: the same job on the same scope in the same environment gives the same receipt, which is what lets a runner reuse it for a later state whose scope is unchanged. An attestation of kind `receipt` (§14.4) binds a receipt to the `state` it ran on and names the `run` (usually its URL). The runner signs that attestation with its own identity (§14.2), an ordinary composite key, in domain `attest`. |
|
| 693 | + | ||
| 694 | + | **Signed receipt.** A runner hands a receipt over as `[receipt bytes, attest bytes, [Signature], identity bytes]`, so it verifies with nothing else: the attestation's `receipt` is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported. |
|
| 695 | + | ||
| 696 | + | **Storage.** A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). `oxy receipt list|show|export|verify|import` reads and exchanges them; a receipt for another repository is refused. |
|
| 697 | + | ||
| 672 | 698 | ## 15. Transport |
|
| 673 | 699 | ||
| 674 | 700 | Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel. |
|
| … | 271 unchanged lines |
||
| 946 | 972 | ||
| 947 | 973 | --- |
|
| 948 | 974 | ||
| 949 | - | *Changelog.* Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 975 | + | *Changelog.* Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 950 | 976 | ||
| 951 | 977 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 952 | 978 | ||
| … | 61 unchanged lines |
modified src/cli/mod.rs +67 −0
| … | 3 unchanged lines |
||
| 4 | 4 | mod identity; |
|
| 5 | 5 | mod json; |
|
| 6 | 6 | mod proposal; |
|
| 7 | + | mod receipt; |
|
| 7 | 8 | mod sync; |
|
| 8 | 9 | pub mod ui; |
|
| 9 | 10 | mod workspace; |
|
| … | 318 unchanged lines |
||
| 328 | 329 | #[command(subcommand)] |
|
| 329 | 330 | cmd: SubmoduleCmd, |
|
| 330 | 331 | }, |
|
| 332 | + | /// Execution receipts: signed records of what a runner ran on a state |
|
| 333 | + | Receipt { |
|
| 334 | + | #[command(subcommand)] |
|
| 335 | + | cmd: ReceiptCmd, |
|
| 336 | + | }, |
|
| 331 | 337 | /// Tags: immutable named states |
|
| 332 | 338 | Tag { |
|
| 333 | 339 | #[command(subcommand)] |
|
| … | 246 unchanged lines |
||
| 580 | 586 | } |
|
| 581 | 587 | ||
| 582 | 588 | #[derive(Subcommand)] |
|
| 589 | + | enum ReceiptCmd { |
|
| 590 | + | /// The receipts kept for a state (default: the bookmark heads) |
|
| 591 | + | List { |
|
| 592 | + | /// Change, bookmark or tag whose state to look at |
|
| 593 | + | rev: Option<String>, |
|
| 594 | + | /// Machine-readable output: one JSON document on stdout |
|
| 595 | + | #[arg(long)] |
|
| 596 | + | json: bool, |
|
| 597 | + | }, |
|
| 598 | + | /// One receipt in full, and whether its signature verifies |
|
| 599 | + | Show { |
|
| 600 | + | /// The receipt (a prefix of its hash, as `list` shows it) |
|
| 601 | + | receipt: String, |
|
| 602 | + | /// Change, bookmark or tag whose state holds it |
|
| 603 | + | #[arg(long)] |
|
| 604 | + | rev: Option<String>, |
|
| 605 | + | /// Machine-readable output: one JSON document on stdout |
|
| 606 | + | #[arg(long)] |
|
| 607 | + | json: bool, |
|
| 608 | + | }, |
|
| 609 | + | /// Write a signed receipt to FILE |
|
| 610 | + | Export { |
|
| 611 | + | /// The receipt (a prefix of its hash, as `list` shows it) |
|
| 612 | + | receipt: String, |
|
| 613 | + | /// Where to write it |
|
| 614 | + | file: String, |
|
| 615 | + | /// Change, bookmark or tag whose state holds it |
|
| 616 | + | #[arg(long)] |
|
| 617 | + | rev: Option<String>, |
|
| 618 | + | }, |
|
| 619 | + | /// Check a signed receipt in FILE without keeping it |
|
| 620 | + | Verify { |
|
| 621 | + | /// A signed receipt, as a runner or `export` writes it |
|
| 622 | + | file: String, |
|
| 623 | + | /// Machine-readable output: one JSON document on stdout |
|
| 624 | + | #[arg(long)] |
|
| 625 | + | json: bool, |
|
| 626 | + | }, |
|
| 627 | + | /// Verify a signed receipt in FILE and keep it with its state |
|
| 628 | + | Import { |
|
| 629 | + | /// A signed receipt, as a runner or `export` writes it |
|
| 630 | + | file: String, |
|
| 631 | + | }, |
|
| 632 | + | } |
|
| 633 | + | ||
| 634 | + | #[derive(Subcommand)] |
|
| 583 | 635 | enum TagCmd { |
|
| 584 | 636 | /// Tag the state of CHANGE... (default: the bookmark heads) |
|
| 585 | 637 | Create { |
|
| … | 406 unchanged lines |
||
| 992 | 1044 | SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths), |
|
| 993 | 1045 | SubmoduleCmd::Status { json } => sync::submodule_status(json), |
|
| 994 | 1046 | SubmoduleCmd::Remove { path } => sync::submodule_remove(&path), |
|
| 1047 | + | }, |
|
| 1048 | + | Cmd::Receipt { cmd } => match cmd { |
|
| 1049 | + | ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json), |
|
| 1050 | + | ReceiptCmd::Show { |
|
| 1051 | + | receipt: r, |
|
| 1052 | + | rev, |
|
| 1053 | + | json, |
|
| 1054 | + | } => receipt::show(&r, rev.as_deref(), json), |
|
| 1055 | + | ReceiptCmd::Export { |
|
| 1056 | + | receipt: r, |
|
| 1057 | + | file, |
|
| 1058 | + | rev, |
|
| 1059 | + | } => receipt::export(&r, &file, rev.as_deref()), |
|
| 1060 | + | ReceiptCmd::Verify { file, json } => receipt::verify(&file, json), |
|
| 1061 | + | ReceiptCmd::Import { file } => receipt::import(&file), |
|
| 995 | 1062 | }, |
|
| 996 | 1063 | Cmd::Tag { cmd } => match cmd { |
|
| 997 | 1064 | TagCmd::Create { |
|
| … | 141 unchanged lines |
modified tests/cli_json.rs +79 −0
| … | 297 unchanged lines |
||
| 298 | 298 | assert_ne!(code, 0); |
|
| 299 | 299 | assert!(err.contains("START,END:PATH"), "{err}"); |
|
| 300 | 300 | } |
|
| 301 | + | ||
| 302 | + | #[test] |
|
| 303 | + | fn receipts_verify_import_list_show_and_export() { |
|
| 304 | + | use oxy_core::hash::{ObjectRef, ObjectType}; |
|
| 305 | + | use oxy_core::identity::SecretKey; |
|
| 306 | + | use oxy_core::objects::StateObject; |
|
| 307 | + | use oxy_core::receipt::{Receipt, SignedReceipt}; |
|
| 308 | + | let tmp = tempfile::tempdir().unwrap(); |
|
| 309 | + | let home = tmp.path().join("home"); |
|
| 310 | + | std::fs::create_dir_all(&home).unwrap(); |
|
| 311 | + | let r = tmp.path().join("repo"); |
|
| 312 | + | assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0); |
|
| 313 | + | write(&r, "a.txt", "a\n"); |
|
| 314 | + | assert_eq!(oxy(&r, &home, &["commit", "-m", "A"]).0, 0); |
|
| 315 | + | ||
| 316 | + | let repo = oxy_core::repo::Repo::open(&r).unwrap(); |
|
| 317 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 318 | + | let state = oxy_core::hash::hash_object(ObjectType::State, &StateObject::new(heads).encode()); |
|
| 319 | + | let receipt = Receipt { |
|
| 320 | + | job_name: "test".into(), |
|
| 321 | + | pipeline_hash: ObjectRef([1; 32]), |
|
| 322 | + | scope_tree_hash: ObjectRef([2; 32]), |
|
| 323 | + | inputs: vec![], |
|
| 324 | + | environment: ObjectRef([3; 32]), |
|
| 325 | + | outputs: vec![], |
|
| 326 | + | exit_code: 0, |
|
| 327 | + | duration_ms: 2500, |
|
| 328 | + | }; |
|
| 329 | + | let sk = SecretKey::generate(); |
|
| 330 | + | let runner = sk.identity("earth-1", "runner@earth", 1); |
|
| 331 | + | let sr = SignedReceipt::sign(receipt, repo.id().unwrap(), state, "demo#7", &runner, &sk); |
|
| 332 | + | let file = tmp.path().join("r.receipt"); |
|
| 333 | + | std::fs::write(&file, sr.encode()).unwrap(); |
|
| 334 | + | let f = file.to_str().unwrap(); |
|
| 335 | + | ||
| 336 | + | let v = json(&r, &home, &["receipt", "verify", f, "--json"]); |
|
| 337 | + | assert_eq!( |
|
| 338 | + | (v["valid"].as_bool(), v["job"].as_str(), v["run"].as_str()), |
|
| 339 | + | (Some(true), Some("test"), Some("demo#7")) |
|
| 340 | + | ); |
|
| 341 | + | assert!(v["attest"].is_null()); |
|
| 342 | + | ||
| 343 | + | assert!(json(&r, &home, &["receipt", "list", "--json"]) |
|
| 344 | + | .as_array() |
|
| 345 | + | .unwrap() |
|
| 346 | + | .is_empty()); |
|
| 347 | + | assert_eq!(oxy(&r, &home, &["receipt", "import", f]).0, 0); |
|
| 348 | + | let list = json(&r, &home, &["receipt", "list", "--json"]); |
|
| 349 | + | let list = list.as_array().unwrap(); |
|
| 350 | + | assert_eq!(list.len(), 1); |
|
| 351 | + | assert_eq!(list[0]["runner_name"], "earth-1"); |
|
| 352 | + | assert_eq!(list[0]["state"], state.to_hex()); |
|
| 353 | + | let attest = list[0]["attest"].as_str().unwrap().to_string(); |
|
| 354 | + | let shown = json(&r, &home, &["receipt", "show", &attest[..10], "--json"]); |
|
| 355 | + | assert_eq!(shown["duration_ms"], 2500); |
|
| 356 | + | ||
| 357 | + | let out = tmp.path().join("out.receipt"); |
|
| 358 | + | assert_eq!( |
|
| 359 | + | oxy( |
|
| 360 | + | &r, |
|
| 361 | + | &home, |
|
| 362 | + | &["receipt", "export", &attest[..10], out.to_str().unwrap()] |
|
| 363 | + | ) |
|
| 364 | + | .0, |
|
| 365 | + | 0 |
|
| 366 | + | ); |
|
| 367 | + | assert_eq!(std::fs::read(&out).unwrap(), sr.encode()); |
|
| 368 | + | ||
| 369 | + | // A receipt whose result was changed after signing does not verify. |
|
| 370 | + | let mut forged = sr.clone(); |
|
| 371 | + | forged.receipt.exit_code = 3; |
|
| 372 | + | std::fs::write(&file, forged.encode()).unwrap(); |
|
| 373 | + | let (code, out, _) = oxy(&r, &home, &["receipt", "verify", f]); |
|
| 374 | + | assert_eq!(code, 1); |
|
| 375 | + | assert!(String::from_utf8_lossy(&out).contains("different receipt")); |
|
| 376 | + | let (code, _, err) = oxy(&r, &home, &["receipt", "import", f]); |
|
| 377 | + | assert_ne!(code, 0); |
|
| 378 | + | assert!(err.contains("does not verify"), "{err}"); |
|
| 379 | + | } |