OXYGENHUB

Coverage / crates/oxy-core/src/receipt.rs 94.4%

169 of 179 lines run, 37 of 45 functions · run #296 on main · run missed

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
3070×
fn refs(v: &[ObjectRef]) -> Value {
3170×
    Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())
3270×
}
33
3426×
fn refs_from(v: &Value, what: &str) -> Result<Vec<ObjectRef>, ObjectError> {
3526×
    array(v, what)?.iter().map(|h| oref(h, what)).collect()
3626×
}
37
38
impl Receipt {
392×
    pub fn passed(&self) -> bool {
402×
        self.exit_code == 0
412×
    }
42
4335×
    pub fn to_value(&self) -> Value {
4435×
        Value::map([
4535×
            (0, Value::Uint(1)),
4635×
            (1, Value::Text(self.job_name.clone())),
4735×
            (2, Value::Bytes(self.pipeline_hash.0.to_vec())),
4835×
            (3, Value::Bytes(self.scope_tree_hash.0.to_vec())),
4935×
            (4, refs(&self.inputs)),
5035×
            (5, Value::Bytes(self.environment.0.to_vec())),
5135×
            (6, refs(&self.outputs)),
5235×
            (7, Value::Uint(self.exit_code as u64)),
5335×
            (8, Value::Uint(self.duration_ms)),
5435×
        ])
5535×
    }
56
5735×
    pub fn encode(&self) -> Vec<u8> {
5835×
        cbor::encode(&self.to_value())
5935×
    }
60
6125×
    pub fn hash(&self) -> ObjectRef {
6225×
        hash_object(ObjectType::Receipt, &self.encode())
6325×
    }
64
6513×
    pub fn decode(b: &[u8]) -> Result<Receipt, ObjectError> {
6613×
        let v = cbor::decode(b)?;
6713×
        let m = map(&v, "receipt")?;
6813×
        check_version(m)?;
6913×
        let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?;
70
        Ok(Receipt {
7113×
            job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(),
7213×
            pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?,
7313×
            scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?,
7413×
            inputs: refs_from(field(m, 4, "inputs")?, "inputs")?,
7513×
            environment: oref(field(m, 5, "environment")?, "environment")?,
7613×
            outputs: refs_from(field(m, 6, "outputs")?, "outputs")?,
7713×
            exit_code: u32::try_from(exit_code)
7813×
                .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?,
7913×
            duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?,
80
        })
8113×
    }
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 {
990×
    fn from(e: ObjectError) -> Self {
1000×
        ReceiptError::Schema(e.to_string())
1010×
    }
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`.
1184×
    pub fn sign(
1194×
        receipt: Receipt,
1204×
        repo: [u8; 16],
1214×
        state: ObjectRef,
1224×
        run: &str,
1234×
        runner: &Identity,
1244×
        sk: &SecretKey,
1254×
    ) -> SignedReceipt {
1264×
        let attest = Attest {
1274×
            repo,
1284×
            hlc: Hlc::now(),
1294×
            body: AttestBody::Receipt {
1304×
                receipt: receipt.hash(),
1314×
                state,
1324×
                run: run.into(),
1334×
            },
1344×
        };
1354×
        let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash());
1364×
        SignedReceipt {
1374×
            receipt,
1384×
            attest,
1394×
            sigs: vec![sig],
1404×
            runner: runner.clone(),
1414×
        }
1424×
    }
143
144
    /// The state the receipt was made on.
1457×
    pub fn state(&self) -> ObjectRef {
1467×
        match &self.attest.body {
1477×
            AttestBody::Receipt { state, .. } => *state,
1480×
            _ => ObjectRef([0; 32]),
149
        }
1507×
    }
151
1526×
    pub fn run(&self) -> &str {
1536×
        match &self.attest.body {
1546×
            AttestBody::Receipt { run, .. } => run,
1550×
            _ => "",
156
        }
1576×
    }
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.
16116×
    pub fn verify(&self) -> Result<(), ReceiptError> {
16216×
        let AttestBody::Receipt { receipt, .. } = &self.attest.body else {
1630×
            return Err(ReceiptError::NotReceipt);
164
        };
16516×
        if *receipt != self.receipt.hash() {
1664×
            return Err(ReceiptError::WrongReceipt);
16712×
        }
16812×
        let key = self.runner.hash();
16912×
        let target = self.attest.hash();
17012×
        let sig = self
17112×
            .sigs
17212×
            .iter()
17312×
            .find(|s| s.key == key)
17412×
            .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?;
17511×
        verify(&self.runner, sig, Domain::Attest, &target)
17611×
            .map_err(|e| ReceiptError::BadSignature(e.to_string()))
17716×
    }
178
179
    /// `[receipt, attest, [Signature], identity]`, each object as its bytes.
1805×
    pub fn encode(&self) -> Vec<u8> {
1815×
        cbor::encode(&Value::Array(vec![
1825×
            Value::Bytes(self.receipt.encode()),
1835×
            Value::Bytes(self.attest.encode()),
1845×
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
1855×
            Value::Bytes(self.runner.encode()),
186
        ]))
1875×
    }
188
1895×
    pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> {
1905×
        let v = cbor::decode(b).map_err(ObjectError::from)?;
1915×
        let a = array(&v, "signed receipt")?;
1925×
        if a.len() != 4 {
1930×
            return Err(ReceiptError::Schema(
1940×
                "signed receipt: expected [receipt, attest, sigs, identity]".into(),
1950×
            ));
1965×
        }
197
        Ok(SignedReceipt {
1985×
            receipt: Receipt::decode(bytes(&a[0], "receipt")?)?,
1995×
            attest: Attest::decode(bytes(&a[1], "attest")?)?,
2005×
            sigs: array(&a[2], "sigs")?
2015×
                .iter()
2025×
                .map(Signature::from_value)
2035×
                .collect::<Result<_, _>>()?,
2045×
            runner: Identity::decode(bytes(&a[3], "identity")?)?,
205
        })
2065×
    }
207
}
208
209
#[cfg(test)]
210
mod tests {
211
    use super::*;
212
2132×
    pub(crate) fn sample() -> Receipt {
2142×
        Receipt {
2152×
            job_name: "test".into(),
2162×
            pipeline_hash: ObjectRef([1; 32]),
2172×
            scope_tree_hash: ObjectRef([2; 32]),
2182×
            inputs: vec![],
2192×
            environment: ObjectRef([3; 32]),
2202×
            outputs: vec![ObjectRef([4; 32])],
2212×
            exit_code: 0,
2222×
            duration_ms: 1500,
2232×
        }
2242×
    }
225
226
    #[test]
2271×
    fn receipts_round_trip_and_hash_as_receipt_objects() {
2281×
        let r = sample();
2291×
        assert_eq!(Receipt::decode(&r.encode()).unwrap(), r);
2301×
        let mut h = blake3::Hasher::new();
2311×
        h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes());
2321×
        h.update(&r.encode());
2331×
        assert_eq!(
2341×
            r.hash().0,
2351×
            *h.finalize().as_bytes(),
236
            "OxygenFlow's receipt hash"
237
        );
2381×
        assert!(r.passed());
2391×
    }
240
241
    #[test]
2421×
    fn a_signed_receipt_verifies_and_tampering_does_not() {
2431×
        let sk = SecretKey::generate();
2441×
        let runner = sk.identity("earth-1", "runner@earth", 1);
2451×
        let sr = SignedReceipt::sign(
2461×
            sample(),
2471×
            [7; 16],
2481×
            ObjectRef([9; 32]),
2491×
            "andy/demo#3",
2501×
            &runner,
2511×
            &sk,
252
        );
2531×
        sr.verify().unwrap();
2541×
        let back = SignedReceipt::decode(&sr.encode()).unwrap();
2551×
        assert_eq!(back, sr);
2561×
        assert_eq!(back.state(), ObjectRef([9; 32]));
2571×
        assert_eq!(back.run(), "andy/demo#3");
258
2591×
        let mut forged = sr.clone();
2601×
        forged.receipt.exit_code = 0;
2611×
        forged.receipt.duration_ms = 1;
2621×
        assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt));
263
2641×
        let mut moved = sr.clone();
2651×
        if let AttestBody::Receipt { state, .. } = &mut moved.attest.body {
2661×
            *state = ObjectRef([8; 32]);
2671×
        }
2681×
        assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_))));
269
2701×
        let other = SecretKey::generate().identity("mallory", "m@x", 1);
2711×
        let mut swapped = sr.clone();
2721×
        swapped.runner = other;
2731×
        assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_))));
2741×
    }
275
}