Skip to main content

scrollcase_consumer/contract/
payload_digest.rs

1//! Mirror of the rule deciding what a box commits to about its own extracted tree.
2//!
3//! A signed release commits to the archive's SHA-256, which proves every payload byte — but only
4//! while the archive still exists. An application that installs a box once and runs it for months
5//! has thrown that archive away. So a box also carries a *list*: one record per payload entry,
6//! naming it and hashing its content. The release signs the SHA-256 of that list, and the list
7//! travels inside the payload.
8//!
9//! The list is what makes verification a closed question. A verifier walks the *list*, never the
10//! directory, so anything the list does not name is never visited: the `__pycache__` Python writes on
11//! first import, the model cache a caller fills after extraction, the file an application writes into
12//! its own working directory. Those are invisible by construction rather than by an exclusion list.
13//!
14//! Records are sorted by their own bytes rather than by their paths compared as strings. The two are
15//! the same ordering — a path cannot contain NUL, and NUL sorts below every byte a path can hold —
16//! but only one of them is unambiguous across languages. Comparing strings would ask each
17//! implementation to agree on what a string is, and above the Basic Multilingual Plane JavaScript
18//! orders by UTF-16 code unit while Python orders by code point. Rust would order by UTF-8 bytes and
19//! quietly agree with neither, which is precisely why the format does not ask.
20//!
21//! `tests/contract.rs` proves this mirror against `fixtures/payload-digest-contract.json`.
22
23use crate::error::{fail, Result};
24
25use super::documents::sha256_hex;
26
27/// The `format` a release names, and the first line of the stream it names it for.
28pub const PAYLOAD_DIGEST_FORMAT: &str = "sha256-path-list-v1";
29
30/// Where the list lives inside the payload.
31///
32/// It cannot appear in its own records — a file cannot contain its own hash — so the release commits
33/// to it directly and it commits to everything else.
34pub const PAYLOAD_DIGEST_FILE: &str = "payload-digest.v1";
35
36/// The largest list a verifier will read before refusing.
37///
38/// At roughly a hundred bytes per record this is some two million entries, an order of magnitude past
39/// the densest real prefix. The bound exists because the list arrives from the same untrusted tree it
40/// describes, and reading it must not be the thing that exhausts memory.
41pub const MAX_PAYLOAD_DIGEST_BYTES: u64 = 256 * 1024 * 1024;
42
43const NUL: u8 = 0x00;
44const LF: u8 = 0x0a;
45const FILE_BYTE: u8 = b'f';
46const LINK_BYTE: u8 = b'l';
47const SHA256_HEX_LENGTH: usize = 64;
48
49/// What a payload entry is, as the digest sees it. Directories are not represented: neither the
50/// entry collector nor the archive writer produces one, so an empty directory is already lost
51/// between build and install.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum PayloadDigestKind {
54    /// A regular file, hashed over its bytes.
55    File,
56    /// A link, hashed over the UTF-8 bytes of its target.
57    Link,
58}
59
60impl PayloadDigestKind {
61    fn as_byte(self) -> u8 {
62        match self {
63            Self::File => FILE_BYTE,
64            Self::Link => LINK_BYTE,
65        }
66    }
67
68    fn from_byte(byte: u8) -> Option<Self> {
69        match byte {
70            FILE_BYTE => Some(Self::File),
71            LINK_BYTE => Some(Self::Link),
72            _ => None,
73        }
74    }
75}
76
77/// One payload entry as the digest sees it.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct PayloadDigestEntry {
80    /// Payload-relative path, forward slashes.
81    pub path: String,
82    /// Whether the record describes a file or a link.
83    pub kind: PayloadDigestKind,
84    /// Lowercase hex SHA-256 of the file's bytes, or of the link body.
85    pub content_sha256: String,
86}
87
88/// What a release carries to commit to its extracted tree.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct PayloadDigest {
91    /// Always [`PAYLOAD_DIGEST_FORMAT`].
92    pub format: &'static str,
93    /// SHA-256 of the canonical stream.
94    pub sha256: String,
95}
96
97/// Whether a value is lowercase hex SHA-256.
98fn is_sha256_hex(value: &str) -> bool {
99    value.len() == SHA256_HEX_LENGTH
100        && value
101            .bytes()
102            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
103}
104
105/// Serialises payload entries into the canonical bytes a release commits to.
106///
107/// The format name is inside the stream rather than only beside it in the manifest, so a later
108/// revision cannot produce the same bytes for different rules, and the `format` field cannot be
109/// swapped without the hash noticing.
110///
111/// # Errors
112///
113/// When an entry carries an empty path, a NUL in its path, a duplicate path, or a digest that is not
114/// lowercase hex SHA-256.
115pub fn payload_digest_stream(entries: &[PayloadDigestEntry]) -> Result<Vec<u8>> {
116    let mut records: Vec<Vec<u8>> = Vec::with_capacity(entries.len());
117    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
118    for entry in entries {
119        // Asserted rather than assumed: a NUL would end the path field early and let two different
120        // trees produce one stream. `safe_relative_path` already refuses it, and POSIX cannot
121        // express it.
122        if entry.path.is_empty() || entry.path.contains('\0') {
123            fail!("Unsupported payload entry path: {:?}", entry.path);
124        }
125        if !seen.insert(entry.path.as_str()) {
126            fail!("Duplicate payload entry: {}", entry.path);
127        }
128        if !is_sha256_hex(&entry.content_sha256) {
129            fail!(
130                "Invalid payload entry digest for {}: {}",
131                entry.path,
132                entry.content_sha256
133            );
134        }
135
136        let path_bytes = entry.path.as_bytes();
137        let mut record = Vec::with_capacity(path_bytes.len() + SHA256_HEX_LENGTH + 4);
138        record.extend_from_slice(path_bytes);
139        record.push(NUL);
140        record.push(entry.kind.as_byte());
141        record.push(NUL);
142        record.extend_from_slice(entry.content_sha256.as_bytes());
143        record.push(LF);
144        records.push(record);
145    }
146    records.sort_unstable();
147
148    let mut stream = Vec::new();
149    stream.extend_from_slice(PAYLOAD_DIGEST_FORMAT.as_bytes());
150    stream.push(LF);
151    for record in records {
152        stream.extend_from_slice(&record);
153    }
154    Ok(stream)
155}
156
157/// Serialises the entries and returns what a release carries about them.
158///
159/// # Errors
160///
161/// When the entries cannot be serialised — see [`payload_digest_stream`].
162pub fn payload_digest(entries: &[PayloadDigestEntry]) -> Result<PayloadDigest> {
163    let stream = payload_digest_stream(entries)?;
164    Ok(PayloadDigest {
165        format: PAYLOAD_DIGEST_FORMAT,
166        sha256: sha256_hex(&stream),
167    })
168}
169
170/// Reads a list back into entries, refusing anything a serialiser could not have produced.
171///
172/// This parses bytes that arrived with the tree they describe, so it is written as a scanner over a
173/// fixed frame rather than a split on separators: a newline is legal inside a filename, and only the
174/// NUL delimiter and the fixed-width hash field make the framing unambiguous. A caller must have
175/// already compared the stream's hash against the signed release — parsing is not a trust decision,
176/// and nothing here makes untrusted bytes safe.
177///
178/// # Errors
179///
180/// When the stream is not exactly what [`payload_digest_stream`] emits.
181pub fn parse_payload_digest_stream(bytes: &[u8]) -> Result<Vec<PayloadDigestEntry>> {
182    let mut header = Vec::from(PAYLOAD_DIGEST_FORMAT.as_bytes());
183    header.push(LF);
184    if bytes.len() < header.len() || &bytes[..header.len()] != header.as_slice() {
185        fail!("Payload digest list does not carry the expected format header.");
186    }
187
188    let mut entries = Vec::new();
189    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
190    let mut cursor = header.len();
191    let mut previous: Option<&[u8]> = None;
192    while cursor < bytes.len() {
193        let start = cursor;
194        let Some(offset) = bytes[cursor..].iter().position(|byte| *byte == NUL) else {
195            fail!("Payload digest list ends inside a record.");
196        };
197        let path_end = cursor + offset;
198        // The frame after the path is fixed: kind, NUL, sixty-four hex digits, newline.
199        let end = path_end + SHA256_HEX_LENGTH + 4;
200        if end > bytes.len() {
201            fail!("Payload digest list ends inside a record.");
202        }
203        let Some(kind) = PayloadDigestKind::from_byte(bytes[path_end + 1]) else {
204            fail!("Payload digest list holds a malformed record.");
205        };
206        if bytes[path_end + 2] != NUL || bytes[end - 1] != LF {
207            fail!("Payload digest list holds a malformed record.");
208        }
209
210        let (Ok(path), Ok(content_sha256)) = (
211            std::str::from_utf8(&bytes[start..path_end]),
212            std::str::from_utf8(&bytes[path_end + 3..end - 1]),
213        ) else {
214            fail!("Payload digest list holds bytes that are not valid UTF-8.");
215        };
216        if !is_sha256_hex(content_sha256) {
217            fail!("Payload digest list holds an invalid digest for {path}.");
218        }
219        if !seen.insert(path.to_string()) {
220            fail!("Payload digest list names {path} twice.");
221        }
222
223        // Order is part of the format, not a convenience: a reader that accepted any order would
224        // accept streams the builder cannot emit, and two trees could then share one hash.
225        let record = &bytes[start..end];
226        if previous.is_some_and(|earlier| earlier >= record) {
227            fail!("Payload digest list is not in canonical order.");
228        }
229        previous = Some(record);
230
231        entries.push(PayloadDigestEntry {
232            path: path.to_string(),
233            kind,
234            content_sha256: content_sha256.to_string(),
235        });
236        cursor = end;
237    }
238    Ok(entries)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{
244        parse_payload_digest_stream, payload_digest_stream, PayloadDigestEntry, PayloadDigestKind,
245        PAYLOAD_DIGEST_FORMAT,
246    };
247
248    fn entry(path: &str, kind: PayloadDigestKind, digest_byte: u8) -> PayloadDigestEntry {
249        PayloadDigestEntry {
250            path: path.to_string(),
251            kind,
252            content_sha256: format!("{digest_byte:02x}").repeat(32),
253        }
254    }
255
256    fn file(path: &str) -> PayloadDigestEntry {
257        entry(path, PayloadDigestKind::File, 0xab)
258    }
259
260    #[test]
261    fn an_empty_payload_still_commits_to_its_format() {
262        let stream = payload_digest_stream(&[]).unwrap();
263        assert_eq!(stream, format!("{PAYLOAD_DIGEST_FORMAT}\n").into_bytes());
264        assert!(parse_payload_digest_stream(&stream).unwrap().is_empty());
265    }
266
267    #[test]
268    fn a_round_trip_preserves_every_record() {
269        let entries = vec![
270            file("venv/bin/python3.11"),
271            entry("venv/bin/python", PayloadDigestKind::Link, 0x01),
272            file("box.json"),
273        ];
274        let stream = payload_digest_stream(&entries).unwrap();
275        let parsed = parse_payload_digest_stream(&stream).unwrap();
276        // Sorted by record bytes, so the parse comes back in canonical order rather than input order.
277        let paths: Vec<&str> = parsed.iter().map(|entry| entry.path.as_str()).collect();
278        assert_eq!(paths, ["box.json", "venv/bin/python", "venv/bin/python3.11"]);
279        assert_eq!(parsed[1].kind, PayloadDigestKind::Link);
280    }
281
282    #[test]
283    fn a_newline_inside_a_filename_does_not_break_the_framing() {
284        let entries = vec![file("we\nird"), file("weird")];
285        let stream = payload_digest_stream(&entries).unwrap();
286        let parsed = parse_payload_digest_stream(&stream).unwrap();
287        assert_eq!(parsed.len(), 2);
288        assert_eq!(parsed[0].path, "we\nird");
289    }
290
291    #[test]
292    fn a_serialiser_refuses_what_it_could_not_frame() {
293        assert!(payload_digest_stream(&[file("")]).is_err());
294        assert!(payload_digest_stream(&[file("a\0b")]).is_err());
295        assert!(payload_digest_stream(&[file("a"), file("a")]).is_err());
296
297        let mut bad_digest = file("a");
298        bad_digest.content_sha256 = "NOTHEX".to_string();
299        assert!(payload_digest_stream(&[bad_digest]).is_err());
300
301        let mut uppercase = file("a");
302        uppercase.content_sha256 = "AB".repeat(32);
303        assert!(payload_digest_stream(&[uppercase]).is_err());
304    }
305
306    #[test]
307    fn a_reader_refuses_streams_the_builder_cannot_emit() {
308        let good = payload_digest_stream(&[file("a"), file("b")]).unwrap();
309
310        // Wrong header.
311        assert!(parse_payload_digest_stream(b"sha256-path-list-v2\n").is_err());
312        assert!(parse_payload_digest_stream(b"").is_err());
313
314        // Truncated inside a record.
315        assert!(parse_payload_digest_stream(&good[..good.len() - 1]).is_err());
316
317        // Reordered: same records, order the serialiser would never produce.
318        let header_length = PAYLOAD_DIGEST_FORMAT.len() + 1;
319        let record_length = 1 + 64 + 4;
320        let mut reordered = good[..header_length].to_vec();
321        reordered.extend_from_slice(&good[header_length + record_length..]);
322        reordered.extend_from_slice(&good[header_length..header_length + record_length]);
323        let error = parse_payload_digest_stream(&reordered).unwrap_err();
324        assert!(error.message().contains("canonical order"), "{error}");
325
326        // A kind byte the format does not define.
327        let mut wrong_kind = good.clone();
328        wrong_kind[header_length + 2] = b'd';
329        assert!(parse_payload_digest_stream(&wrong_kind).is_err());
330    }
331}