use crate::error::{fail, Result};
use super::documents::sha256_hex;
pub const PAYLOAD_DIGEST_FORMAT: &str = "sha256-path-list-v1";
pub const PAYLOAD_DIGEST_FILE: &str = "payload-digest.v1";
pub const MAX_PAYLOAD_DIGEST_BYTES: u64 = 256 * 1024 * 1024;
const NUL: u8 = 0x00;
const LF: u8 = 0x0a;
const FILE_BYTE: u8 = b'f';
const LINK_BYTE: u8 = b'l';
const SHA256_HEX_LENGTH: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadDigestKind {
File,
Link,
}
impl PayloadDigestKind {
fn as_byte(self) -> u8 {
match self {
Self::File => FILE_BYTE,
Self::Link => LINK_BYTE,
}
}
fn from_byte(byte: u8) -> Option<Self> {
match byte {
FILE_BYTE => Some(Self::File),
LINK_BYTE => Some(Self::Link),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayloadDigestEntry {
pub path: String,
pub kind: PayloadDigestKind,
pub content_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayloadDigest {
pub format: &'static str,
pub sha256: String,
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == SHA256_HEX_LENGTH
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
pub fn payload_digest_stream(entries: &[PayloadDigestEntry]) -> Result<Vec<u8>> {
let mut records: Vec<Vec<u8>> = Vec::with_capacity(entries.len());
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for entry in entries {
if entry.path.is_empty() || entry.path.contains('\0') {
fail!("Unsupported payload entry path: {:?}", entry.path);
}
if !seen.insert(entry.path.as_str()) {
fail!("Duplicate payload entry: {}", entry.path);
}
if !is_sha256_hex(&entry.content_sha256) {
fail!(
"Invalid payload entry digest for {}: {}",
entry.path,
entry.content_sha256
);
}
let path_bytes = entry.path.as_bytes();
let mut record = Vec::with_capacity(path_bytes.len() + SHA256_HEX_LENGTH + 4);
record.extend_from_slice(path_bytes);
record.push(NUL);
record.push(entry.kind.as_byte());
record.push(NUL);
record.extend_from_slice(entry.content_sha256.as_bytes());
record.push(LF);
records.push(record);
}
records.sort_unstable();
let mut stream = Vec::new();
stream.extend_from_slice(PAYLOAD_DIGEST_FORMAT.as_bytes());
stream.push(LF);
for record in records {
stream.extend_from_slice(&record);
}
Ok(stream)
}
pub fn payload_digest(entries: &[PayloadDigestEntry]) -> Result<PayloadDigest> {
let stream = payload_digest_stream(entries)?;
Ok(PayloadDigest {
format: PAYLOAD_DIGEST_FORMAT,
sha256: sha256_hex(&stream),
})
}
pub fn parse_payload_digest_stream(bytes: &[u8]) -> Result<Vec<PayloadDigestEntry>> {
let mut header = Vec::from(PAYLOAD_DIGEST_FORMAT.as_bytes());
header.push(LF);
if bytes.len() < header.len() || &bytes[..header.len()] != header.as_slice() {
fail!("Payload digest list does not carry the expected format header.");
}
let mut entries = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut cursor = header.len();
let mut previous: Option<&[u8]> = None;
while cursor < bytes.len() {
let start = cursor;
let Some(offset) = bytes[cursor..].iter().position(|byte| *byte == NUL) else {
fail!("Payload digest list ends inside a record.");
};
let path_end = cursor + offset;
let end = path_end + SHA256_HEX_LENGTH + 4;
if end > bytes.len() {
fail!("Payload digest list ends inside a record.");
}
let Some(kind) = PayloadDigestKind::from_byte(bytes[path_end + 1]) else {
fail!("Payload digest list holds a malformed record.");
};
if bytes[path_end + 2] != NUL || bytes[end - 1] != LF {
fail!("Payload digest list holds a malformed record.");
}
let (Ok(path), Ok(content_sha256)) = (
std::str::from_utf8(&bytes[start..path_end]),
std::str::from_utf8(&bytes[path_end + 3..end - 1]),
) else {
fail!("Payload digest list holds bytes that are not valid UTF-8.");
};
if !is_sha256_hex(content_sha256) {
fail!("Payload digest list holds an invalid digest for {path}.");
}
if !seen.insert(path.to_string()) {
fail!("Payload digest list names {path} twice.");
}
let record = &bytes[start..end];
if previous.is_some_and(|earlier| earlier >= record) {
fail!("Payload digest list is not in canonical order.");
}
previous = Some(record);
entries.push(PayloadDigestEntry {
path: path.to_string(),
kind,
content_sha256: content_sha256.to_string(),
});
cursor = end;
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::{
parse_payload_digest_stream, payload_digest_stream, PayloadDigestEntry, PayloadDigestKind,
PAYLOAD_DIGEST_FORMAT,
};
fn entry(path: &str, kind: PayloadDigestKind, digest_byte: u8) -> PayloadDigestEntry {
PayloadDigestEntry {
path: path.to_string(),
kind,
content_sha256: format!("{digest_byte:02x}").repeat(32),
}
}
fn file(path: &str) -> PayloadDigestEntry {
entry(path, PayloadDigestKind::File, 0xab)
}
#[test]
fn an_empty_payload_still_commits_to_its_format() {
let stream = payload_digest_stream(&[]).unwrap();
assert_eq!(stream, format!("{PAYLOAD_DIGEST_FORMAT}\n").into_bytes());
assert!(parse_payload_digest_stream(&stream).unwrap().is_empty());
}
#[test]
fn a_round_trip_preserves_every_record() {
let entries = vec![
file("venv/bin/python3.11"),
entry("venv/bin/python", PayloadDigestKind::Link, 0x01),
file("box.json"),
];
let stream = payload_digest_stream(&entries).unwrap();
let parsed = parse_payload_digest_stream(&stream).unwrap();
let paths: Vec<&str> = parsed.iter().map(|entry| entry.path.as_str()).collect();
assert_eq!(paths, ["box.json", "venv/bin/python", "venv/bin/python3.11"]);
assert_eq!(parsed[1].kind, PayloadDigestKind::Link);
}
#[test]
fn a_newline_inside_a_filename_does_not_break_the_framing() {
let entries = vec![file("we\nird"), file("weird")];
let stream = payload_digest_stream(&entries).unwrap();
let parsed = parse_payload_digest_stream(&stream).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].path, "we\nird");
}
#[test]
fn a_serialiser_refuses_what_it_could_not_frame() {
assert!(payload_digest_stream(&[file("")]).is_err());
assert!(payload_digest_stream(&[file("a\0b")]).is_err());
assert!(payload_digest_stream(&[file("a"), file("a")]).is_err());
let mut bad_digest = file("a");
bad_digest.content_sha256 = "NOTHEX".to_string();
assert!(payload_digest_stream(&[bad_digest]).is_err());
let mut uppercase = file("a");
uppercase.content_sha256 = "AB".repeat(32);
assert!(payload_digest_stream(&[uppercase]).is_err());
}
#[test]
fn a_reader_refuses_streams_the_builder_cannot_emit() {
let good = payload_digest_stream(&[file("a"), file("b")]).unwrap();
assert!(parse_payload_digest_stream(b"sha256-path-list-v2\n").is_err());
assert!(parse_payload_digest_stream(b"").is_err());
assert!(parse_payload_digest_stream(&good[..good.len() - 1]).is_err());
let header_length = PAYLOAD_DIGEST_FORMAT.len() + 1;
let record_length = 1 + 64 + 4;
let mut reordered = good[..header_length].to_vec();
reordered.extend_from_slice(&good[header_length + record_length..]);
reordered.extend_from_slice(&good[header_length..header_length + record_length]);
let error = parse_payload_digest_stream(&reordered).unwrap_err();
assert!(error.message().contains("canonical order"), "{error}");
let mut wrong_kind = good.clone();
wrong_kind[header_length + 2] = b'd';
assert!(parse_payload_digest_stream(&wrong_kind).is_err());
}
}