scrollcase_consumer/contract/
payload_digest.rs1use crate::error::{fail, Result};
24
25use super::documents::sha256_hex;
26
27pub const PAYLOAD_DIGEST_FORMAT: &str = "sha256-path-list-v1";
29
30pub const PAYLOAD_DIGEST_FILE: &str = "payload-digest.v1";
35
36pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum PayloadDigestKind {
54 File,
56 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#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct PayloadDigestEntry {
80 pub path: String,
82 pub kind: PayloadDigestKind,
84 pub content_sha256: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct PayloadDigest {
91 pub format: &'static str,
93 pub sha256: String,
95}
96
97fn 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
105pub 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 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
157pub 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
170pub 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 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 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 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 assert!(parse_payload_digest_stream(b"sha256-path-list-v2\n").is_err());
312 assert!(parse_payload_digest_stream(b"").is_err());
313
314 assert!(parse_payload_digest_stream(&good[..good.len() - 1]).is_err());
316
317 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 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}