OXYGENHUB

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

169 of 179 lines run, 37 of 45 functions · run #299 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
3078×
fn refs(v: &[ObjectRef]) -> Value {
3178×
    Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())
3278×
}
33
3430×
fn refs_from(v: &Value, what: &str) -> Result<Vec<ObjectRef>, ObjectError> {
3530×
    array(v, what)?.iter().map(|h| oref(h, what)).collect()
3630×
}
37
38
impl Receipt {
392×
    pub fn passed(&self) -> bool {
402×
        self.exit_code == 0
412×
    }
42
4339×
    pub fn to_value(&self) -> Value {
4439×
        Value::map([
4539×
            (0, Value::Uint(1)),
4639×
            (1, Value::Text(self.job_name.clone())),
4739×
            (2, Value::Bytes(self.pipeline_hash.0.to_vec())),
4839×
            (3, Value::Bytes(self.scope_tree_hash.0.to_vec())),
4939×
            (4, refs(&self.inputs)),
5039×
            (5, Value::Bytes(self.environment.0.to_vec())),
5139×
            (6, refs(&self.outputs)),
5239×
            (7, Value::Uint(self.exit_code as u64)),
5339×
            (8, Value::Uint(self.duration_ms)),
5439×
        ])
5539×
    }
56
5739×
    pub fn encode(&self) -> Vec<u8> {
5839×
        cbor::encode(&self.to_value())
5939×
    }
60
6127×
    pub fn hash(&self) -> ObjectRef {
6227×
        hash_object(ObjectType::Receipt, &self.encode())
6327×
    }
64
6515×
    pub fn decode(b: &[u8]) -> Result<Receipt, ObjectError> {
6615×
        let v = cbor::decode(b)?;
6715×
        let m = map(&v, "receipt")?;
6815×
        check_version(m)?;
6915×
        let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?;
70
        Ok(Receipt {
7115×
            job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(),
7215×
            pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?,
7315×
            scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?,
7415×
            inputs: refs_from(field(m, 4, "inputs")?, "inputs")?,
7515×
            environment: oref(field(m, 5, "environment")?, "environment")?,
7615×
            outputs: refs_from(field(m, 6, "outputs")?, "outputs")?,
7715×
            exit_code: u32::try_from(exit_code)
7815×
                .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?,
7915×
            duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?,
80
        })
8115×
    }
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.
1459×
    pub fn state(&self) -> ObjectRef {
1469×
        match &self.attest.body {
1479×
            AttestBody::Receipt { state, .. } => *state,
1480×
            _ => ObjectRef([0; 32]),
149
        }
1509×
    }
151
1527×
    pub fn run(&self) -> &str {
1537×
        match &self.attest.body {
1547×
            AttestBody::Receipt { run, .. } => run,
1550×
            _ => "",
156
        }
1577×
    }
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.
16118×
    pub fn verify(&self) -> Result<(), ReceiptError> {
16218×
        let AttestBody::Receipt { receipt, .. } = &self.attest.body else {
1630×
            return Err(ReceiptError::NotReceipt);
164
        };
16518×
        if *receipt != self.receipt.hash() {
1664×
            return Err(ReceiptError::WrongReceipt);
16714×
        }
16814×
        let key = self.runner.hash();
16914×
        let target = self.attest.hash();
17014×
        let sig = self
17114×
            .sigs
17214×
            .iter()
17314×
            .find(|s| s.key == key)
17414×
            .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?;
17513×
        verify(&self.runner, sig, Domain::Attest, &target)
17613×
            .map_err(|e| ReceiptError::BadSignature(e.to_string()))
17718×
    }
178
179
    /// `[receipt, attest, [Signature], identity]`, each object as its bytes.
1807×
    pub fn encode(&self) -> Vec<u8> {
1817×
        cbor::encode(&Value::Array(vec![
1827×
            Value::Bytes(self.receipt.encode()),
1837×
            Value::Bytes(self.attest.encode()),
1847×
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
1857×
            Value::Bytes(self.runner.encode()),
186
        ]))
1877×
    }
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
}