From 3f47181fb244b5e56c1e6cbaf91ff88644e8e96a Mon Sep 17 00:00:00 2001 From: sehkone Date: Sun, 27 Sep 2026 20:24:54 +0900 Subject: [PATCH 1/3] Expose a read container's raw blocks for hashing bootler binds each recorded install attempt to a fingerprint over the container's version and its raw manifest, archive and envelope blocks, and must take those blocks from this crate's reader rather than grow a second footer parser. UnparsedContainer now hands them out: the footer version, the manifest block exactly as written, and a bounded reader over the still-compressed archive block that never leaves the block and reports a source ending inside it as UnexpectedEof rather than a short stream. The manifest accessor is documented as unauthenticated and points decoding at the crate's own parsers, keeping the concern that kept these bytes private. A test-support writer for version-1 containers lets a dependent test version-1 input through the real reader without hand-building footers. Closes #131 --- CHANGELOG.md | 14 +- README.md | 7 +- src/payload.rs | 184 +++++++++++- src/payload/raw_block_tests.rs | 519 +++++++++++++++++++++++++++++++++ src/verify.rs | 17 +- tests/raw_container_blocks.rs | 98 +++++++ 6 files changed, 817 insertions(+), 22 deletions(-) create mode 100644 src/payload/raw_block_tests.rs create mode 100644 tests/raw_container_blocks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ac07c..9723872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,13 +172,25 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm compact malformed-envelope case for a dependent crate to write sparsely when testing bounded package reads without duplicating deploy-core's private footer layout. +- `payload::append_version_1_trailer`, a `test-support` fixture that writes a + version-1 container around caller-supplied manifest and archive blocks, so a + dependent crate can test version-1 input through the real reader without + hand-building a footer. - `payload::UnparsedContainer::parse_unverified_manifest`, which lets a caller decode manifest metadata from `read_package_container` without reopening an untrusted package. The returned manifest is intentionally unauthenticated; callers with a `TrustSet` continue to use the verifying path. - `payload::read_package_container`, which reports a package's signature and `key_id` metadata under the release format's fixed envelope bounds without - allocating blocks advertised at another length. + allocating blocks advertised at another length. The container it returns + also hands out its raw blocks for hashing, whatever its envelope state and + whether or not its manifest parses: `container_version` reports the footer's + container version, `raw_manifest_block` the manifest block exactly as + written, unparsed and unauthenticated, `archive_block_len` the archive + block's length, and `raw_archive_block` a `payload::RawArchiveBlock` reader + that streams exactly that block, still compressed, and reports a source + ending inside it as `UnexpectedEof`. A file with no trailer is + `PayloadError::NoTrailer`. - `payload::append_trailer_signed`, which gives a caller-supplied signer the exact manifest bytes the writer emits and stamps its detached Ed25519 signature and `key_id` into either a `.pkg` package or an installer payload. diff --git a/README.md b/README.md index ef1376e..0f8e9e9 100644 --- a/README.md +++ b/README.md @@ -167,9 +167,10 @@ cargo test The `test-support` feature exposes test-only account fixtures (`Principal::Fixture` / `ServiceAccount::Fixture`), payload fixtures such as -`payload::widen_envelope_blocks`, and `image::test_support` — a builder for -synthetic image archives the image validator accepts, and a classifier that -holds any image archive against a declaration — and +`payload::widen_envelope_blocks` and `payload::append_version_1_trailer`, and +`image::test_support` — a builder for synthetic image archives the image +validator accepts, and a classifier that holds any image archive against a +declaration — and `executor::test_support::RecordingExecutor`, which records every call it receives and answers each from a script, so a **dependent** crate's tests can construct them across the crate boundary. With diff --git a/src/payload.rs b/src/payload.rs index d51df8b..b94229c 100644 --- a/src/payload.rs +++ b/src/payload.rs @@ -104,6 +104,8 @@ pub(crate) mod counters; mod golden; mod outer; #[cfg(test)] +mod raw_block_tests; +#[cfg(test)] mod tail_tests; #[cfg(test)] @@ -987,6 +989,64 @@ pub fn widen_envelope_blocks(container: &[u8], advertised_len: u64) -> Vec { compact } +/// Streams a version-1 container fixture onto `base`: +/// `base ‖ manifest_block ‖ archive_block ‖ footer`, under the 41-byte +/// version-1 footer that records no envelope pairs. +/// +/// Every writer in this crate stamps the current [`FORMAT_VERSION`], so this is +/// how a dependent crate's tests reach version-1 input through the real reader +/// without hand-building a footer. Both blocks are written verbatim and +/// neither is checked: a caller may pass a manifest that does not parse or an +/// archive that is not one, and the container still frames them correctly. +/// Passing an empty `base` (for example [`std::io::empty`]) writes a `.pkg` +/// whose manifest block starts at offset `0`. +/// +/// This test-support fixture is not compiled into a default-feature build. +/// Enable `test-support` only under a dependent's `[dev-dependencies]`. +/// +/// # Errors +/// +/// Returns [`PayloadError::MalformedFooter`] when an offset, or the +/// container's length, would overflow a `u64`, and [`PayloadError::Io`] when +/// reading `base` or writing to `out` fails. +#[cfg(any(test, feature = "test-support"))] +pub fn append_version_1_trailer( + mut base: B, + mut out: W, + manifest_block: &[u8], + archive_block: &[u8], +) -> Result<(), PayloadError> { + const VERSION: u8 = 1; + const OVERFLOW: PayloadError = PayloadError::MalformedFooter { + reason: "a block offset or the container length overflows a u64", + }; + + let manifest_offset = std::io::copy(&mut base, &mut out)?; + let manifest_len = u64::try_from(manifest_block.len()).map_err(|_| OVERFLOW)?; + let archive_len = u64::try_from(archive_block.len()).map_err(|_| OVERFLOW)?; + let archive_offset = manifest_offset.checked_add(manifest_len).ok_or(OVERFLOW)?; + let footer_start = archive_offset.checked_add(archive_len).ok_or(OVERFLOW)?; + let footer = Footer { + version: VERSION, + manifest_offset, + manifest_len, + archive_offset, + archive_len, + signature_offset: 0, + signature_len: 0, + key_id_offset: 0, + key_id_len: 0, + } + .encode(); + let footer_len = u64::try_from(footer.len()).map_err(|_| OVERFLOW)?; + footer_start.checked_add(footer_len).ok_or(OVERFLOW)?; + + out.write_all(manifest_block)?; + out.write_all(archive_block)?; + out.write_all(&footer)?; + Ok(()) +} + /// Computes the lowercase hex SHA-256 of `bytes`. #[must_use] pub fn sha256_hex(bytes: &[u8]) -> String { @@ -1673,6 +1733,17 @@ where /// It is an added path, not a replacement: [`open`] reads the container /// through the same internal head reader this is built from, so the two cannot /// come to disagree about where a block is. +/// +/// The raw accessors — [`container_version`](Self::container_version), +/// [`raw_manifest_block`](Self::raw_manifest_block), +/// [`archive_block_len`](Self::archive_block_len) and +/// [`raw_archive_block`](Self::raw_archive_block), beside +/// [`signature`](Self::signature) and [`key_id`](Self::key_id) — hand out the +/// blocks exactly as the validated footer locates them, for a caller that +/// hashes a container rather than trusting it. They answer whatever the +/// envelope state and whether or not the manifest would parse: refusing a +/// [`EnvelopeBlock::WrongLength`] block or an unparsable manifest is the +/// caller's decision, not theirs. pub struct UnparsedContainer { src: R, footer_version: u8, @@ -1686,20 +1757,60 @@ pub struct UnparsedContainer { impl UnparsedContainer { /// Returns the container format version the selected footer recorded. /// - /// The manifest parse takes it, because only a reader that knows it can - /// evaluate the pre-versioned baseline conjunction. - pub(crate) fn footer_version(&self) -> u8 { + /// This is the footer's version — currently `1` or [`FORMAT_VERSION`] — + /// and not the manifest's own `format_version`, which versions the + /// manifest schema; no manifest parse is needed to answer it. The manifest + /// parse takes it, because only a reader that knows it can evaluate the + /// pre-versioned baseline conjunction. + #[must_use] + pub fn container_version(&self) -> u8 { self.footer_version } - /// Returns the manifest block exactly as it sits in the container. + /// Returns the manifest block exactly as it sits in the container: + /// unparsed, unauthenticated, and never re-serialized. /// - /// These are the bytes a signature is computed over, so they are handed - /// out unparsed and never re-serialized from a parsed manifest. - pub(crate) fn manifest_bytes(&self) -> &[u8] { + /// These are the bytes a signature is computed over, which is what they + /// are for: hashing them or checking a signature against them. Nothing + /// about them has been checked. Decode them through + /// [`parse_unverified_manifest`](Self::parse_unverified_manifest) or + /// [`crate::verify::verify_package`], never with `serde_json` directly, + /// which would skip every rule this crate's manifest parse enforces. + #[must_use] + pub fn raw_manifest_block(&self) -> &[u8] { &self.manifest_bytes } + /// Returns the length in bytes of the raw, still compressed archive block, + /// as the validated footer records it. + #[must_use] + pub fn archive_block_len(&self) -> u64 { + self.archive_len + } + + /// Returns a reader over exactly the raw archive block. + /// + /// The source is sought to the block's start once, here, and the reader + /// then yields exactly [`archive_block_len`](Self::archive_block_len) + /// bytes followed by end-of-file. It never reads outside the block, never + /// holds the block in memory and never decompresses it: the bytes are the + /// compressed block as the container carries it, for hashing, and nothing + /// in them has been checked. A source that ends before the recorded + /// length surfaces from [`Read::read`] as + /// [`std::io::ErrorKind::UnexpectedEof`], never as a short stream that + /// ends successfully. + /// + /// # Errors + /// + /// Returns [`PayloadError::Io`] when the seek fails. + pub fn raw_archive_block(&mut self) -> Result, PayloadError> { + self.src.seek(SeekFrom::Start(self.archive_offset))?; + Ok(RawArchiveBlock { + src: &mut self.src, + remaining: self.archive_len, + }) + } + /// Returns the archive block's offset and length within the container, /// as the validated footer records them. pub(crate) fn archive_block(&self) -> (u64, u64) { @@ -1720,12 +1831,12 @@ impl UnparsedContainer { /// decodable JSON, or [`PayloadError::InvalidManifest`] for every other /// manifest validation failure. pub fn parse_unverified_manifest(&self) -> Result { - PayloadManifest::parse(self.manifest_bytes(), self.footer_version()).map_err(|error| { - match error { + PayloadManifest::parse(self.raw_manifest_block(), self.container_version()).map_err( + |error| match error { ManifestError::Decode(source) => PayloadError::ManifestParse(source), other => PayloadError::InvalidManifest(other), - } - }) + }, + ) } /// Returns the detached signature block as the bounded read left it: @@ -1799,6 +1910,54 @@ impl EnvelopeBlock { } } +/// Exactly one container's raw archive block, as +/// [`UnparsedContainer::raw_archive_block`] hands it out. +/// +/// It borrows the container's source, already positioned at the block's start, +/// and reads no further than the block's recorded length. It is [`Read`] only: +/// no [`Seek`], so it cannot be steered outside the block it was made for. +pub struct RawArchiveBlock<'a, R> { + src: &'a mut R, + remaining: u64, +} + +impl Read for RawArchiveBlock<'_, R> { + /// Reads from the block, never past its end. + /// + /// Once the whole block has been read this returns `Ok(0)`. A source that + /// reports end-of-file while block bytes remain is a container shorter + /// than its validated footer said, which is an error rather than a short + /// stream: [`std::io::ErrorKind::UnexpectedEof`]. + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.remaining == 0 || buf.is_empty() { + return Ok(0); + } + // A `remaining` no `usize` holds is larger than any buffer, so the + // buffer's own length is the cap. + let cap = + usize::try_from(self.remaining).map_or(buf.len(), |remaining| remaining.min(buf.len())); + let (window, _) = buf.split_at_mut(cap); + let read = self.src.read(window)?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "the source ended inside the archive block", + )); + } + // `read` is at most `cap`, which is at most `remaining`, so neither + // the conversion nor the subtraction can fail for a source keeping the + // `Read` contract; one that breaks it is refused rather than trusted. + let consumed = u64::try_from(read) + .ok() + .filter(|&consumed| read <= cap && consumed <= self.remaining) + .ok_or_else(|| { + std::io::Error::other("the source reported reading more than it was asked for") + })?; + self.remaining -= consumed; + Ok(read) + } +} + /// The exact lengths [`read_package_container`] uses for its two envelope /// blocks. /// @@ -1991,6 +2150,9 @@ fn read_bounded_envelope( /// /// A package that carries no container is broken, not an ordinary file, so /// that condition is [`PayloadError::NoTrailer`] here exactly as it is there. +/// That variant is also how a caller reading an installer rather than a +/// package recognises an empty payload — an executable that carries no +/// trailer at all — since [`open`]'s `Ok(None)` has no counterpart here. /// /// # Errors /// diff --git a/src/payload/raw_block_tests.rs b/src/payload/raw_block_tests.rs new file mode 100644 index 0000000..056493a --- /dev/null +++ b/src/payload/raw_block_tests.rs @@ -0,0 +1,519 @@ +//! The raw-block accessors on [`UnparsedContainer`]: the container version, +//! the manifest block and the archive block exactly as the writers emitted +//! them, and the version-1 fixture writer that reaches them through the real +//! reader. + +use std::cell::{Cell, RefCell}; +use std::collections::BTreeSet; +use std::io::{self, Cursor, Read, Seek, SeekFrom}; +use std::ops::Range; +use std::path::Path; +use std::rc::Rc; + +use super::{ + ArtifactInput, ED25519_SIGNATURE_LEN, EnvelopeBlock, EnvelopeBounds, FOOTER_SIZE, + FORMAT_VERSION, Footer, KEY_ID_HEX_LEN, MemberLength, PayloadError, Signed, UnparsedContainer, + append_trailer, append_trailer_signed, append_version_1_trailer, open, read_package_container, + rewrap_trailer, sha256_hex, write_archive_block, +}; +use crate::manifest::{ArtifactKind, Disposition, TargetArch}; +use crate::verify::ENVELOPE_BOUNDS; + +const BASE: &[u8] = b"#!/bin/false\nnot a real executable, just a base binary\n"; + +/// A base of another length, for the rewrap cases. +const OTHER_BASE: &[u8] = b"a replacement executable base of a distinct, longer length\n"; + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +const MEMBER: &str = "bin/tool"; + +const MEMBER_BYTES: &[u8] = b"the bytes of the one artifact this container carries"; + +/// A manifest block no parse accepts. +const UNPARSABLE: &[u8] = b"{ this is not a manifest"; + +/// An archive block that is not even a compressed stream. +const NOT_AN_ARCHIVE: &[u8] = b"not an archive, not even zstd"; + +fn input(dir: &Path) -> ArtifactInput { + let source = dir.join("tool.src"); + std::fs::write(&source, MEMBER_BYTES).expect("the source file is written"); + ArtifactInput { + component: "example".to_string(), + version: "1.0.0".to_string(), + commit: COMMIT.to_string(), + target_arch: TargetArch::X86_64, + kind: ArtifactKind::NativeBinary, + dispositions: BTreeSet::from([Disposition::Install]), + archive_path: MEMBER.to_string(), + spec: None, + image: None, + source, + } +} + +/// The archive block the writers emit for [`MEMBER`], produced by the one +/// archive writer they all go through rather than sliced out of a container. +fn expected_archive() -> Vec { + let mut out = Vec::new(); + let length = u64::try_from(MEMBER_BYTES.len()).expect("the member length fits a u64"); + write_archive_block( + [Ok((MEMBER, length, MEMBER_BYTES))], + &mut out, + MemberLength::Legacy, + ) + .expect("the archive block is written"); + out +} + +fn unsigned(dir: &Path) -> Vec { + let mut out = Vec::new(); + append_trailer(Cursor::new(BASE), &mut out, None, None, &[input(dir)]) + .expect("the unsigned writer succeeds"); + out +} + +/// A signed container, with the manifest bytes its signer was handed. +fn signed(dir: &Path) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut handed = Vec::new(); + append_trailer_signed( + Cursor::new(BASE), + &mut out, + None, + None, + &[input(dir)], + |manifest| { + handed = manifest.to_vec(); + Ok(Signed { + signature: vec![0x5a; ED25519_SIGNATURE_LEN], + key_id: "a".repeat(KEY_ID_HEX_LEN), + }) + }, + ) + .expect("the signed writer succeeds"); + (out, handed) +} + +fn rewrapped(container: &[u8]) -> Vec { + let mut out = Vec::new(); + rewrap_trailer(Cursor::new(container), Cursor::new(OTHER_BASE), &mut out) + .expect("the container rewraps"); + out +} + +fn version_1(base: &[u8], manifest: &[u8], archive: &[u8]) -> Vec { + let mut out = Vec::new(); + append_version_1_trailer(Cursor::new(base), &mut out, manifest, archive) + .expect("the version-1 writer succeeds"); + out +} + +fn container(bytes: &[u8]) -> UnparsedContainer> { + read_package_container(Cursor::new(bytes), &ENVELOPE_BOUNDS).expect("the container reads") +} + +fn archive_of(container: &mut UnparsedContainer) -> Vec { + let mut bytes = Vec::new(); + container + .raw_archive_block() + .expect("the block is sought") + .read_to_end(&mut bytes) + .expect("the block reads to its end"); + bytes +} + +/// Asserts the three raw accessors report `manifest` and `archive`. +#[track_caller] +fn assert_blocks( + container: &mut UnparsedContainer, + manifest: &[u8], + archive: &[u8], +) { + assert_eq!(container.raw_manifest_block(), manifest); + assert_eq!( + container.archive_block_len(), + u64::try_from(archive.len()).expect("the archive length fits a u64") + ); + assert_eq!(archive_of(container), archive); +} + +/// A current-format container over arbitrary blocks, with envelope blocks of +/// whatever length is given, an empty one recorded absent: `base ‖ manifest ‖ archive ‖ signature ‖ key_id ‖ +/// footer`. +fn version_2(manifest: &[u8], archive: &[u8], signature: &[u8], key_id: &[u8]) -> Vec { + let len = |bytes: &[u8]| u64::try_from(bytes.len()).expect("a fixture length fits a u64"); + let manifest_offset = len(BASE); + let archive_offset = manifest_offset + len(manifest); + // An empty block is recorded absent, as the all-zero pair, and occupies + // no bytes. + let pair = |offset: u64, bytes: &[u8]| { + if bytes.is_empty() { + (0, 0) + } else { + (offset, len(bytes)) + } + }; + let (signature_offset, signature_len) = pair(archive_offset + len(archive), signature); + let (key_id_offset, key_id_len) = pair(archive_offset + len(archive) + len(signature), key_id); + let footer = Footer { + version: FORMAT_VERSION, + manifest_offset, + manifest_len: len(manifest), + archive_offset, + archive_len: len(archive), + signature_offset, + signature_len, + key_id_offset, + key_id_len, + }; + [BASE, manifest, archive, signature, key_id, &footer.encode()].concat() +} + +/// What a [`RecordingSource`] saw, shared so a test can read it while the +/// container still owns the source. +#[derive(Default)] +struct Log { + /// Every position a seek landed on, in order. + seeks: Vec, + /// Every byte range a read delivered, in order. + reads: Vec>, +} + +/// A [`Log`] shared between a [`RecordingSource`] and the test reading it. +type SharedLog = Rc>; + +/// The offset a [`RecordingSource`] ends at, set by the test once the +/// container has been validated. +type Truncation = Rc>>; + +/// A `Read + Seek` source that records every seek and read range, and that +/// can be told, once the container has been validated, to end early. +struct RecordingSource { + inner: Cursor>, + log: SharedLog, + /// When set, the source reports end-of-file at and past this offset. + end: Truncation, +} + +impl RecordingSource { + fn new(bytes: Vec) -> (Self, SharedLog, Truncation) { + let log = Rc::new(RefCell::new(Log::default())); + let end = Rc::new(Cell::new(None)); + let source = Self { + inner: Cursor::new(bytes), + log: Rc::clone(&log), + end: Rc::clone(&end), + }; + (source, log, end) + } +} + +impl Read for RecordingSource { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let start = self.inner.position(); + let allowed = match self.end.get() { + Some(end) => usize::try_from(end.saturating_sub(start)) + .expect("a fixture length fits a usize") + .min(buf.len()), + None => buf.len(), + }; + let read = self.inner.read(&mut buf[..allowed])?; + let delivered = u64::try_from(read).expect("a read count fits a u64"); + self.log.borrow_mut().reads.push(start..start + delivered); + Ok(read) + } +} + +impl Seek for RecordingSource { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let landed = self.inner.seek(pos)?; + self.log.borrow_mut().seeks.push(landed); + Ok(landed) + } +} + +#[test] +fn a_version_1_container_reports_1_and_a_current_one_reports_2() { + let dir = tempfile::tempdir().expect("tempdir"); + let current = unsigned(dir.path()); + assert_eq!(container(¤t).container_version(), 2); + assert_eq!(container(¤t).container_version(), FORMAT_VERSION); + + let legacy = version_1(BASE, UNPARSABLE, NOT_AN_ARCHIVE); + assert_eq!(container(&legacy).container_version(), 1); + // Rewrapping keeps each container at its own version. + assert_eq!(container(&rewrapped(&legacy)).container_version(), 1); + assert_eq!( + container(&rewrapped(¤t)).container_version(), + FORMAT_VERSION + ); +} + +#[test] +fn the_raw_manifest_block_is_the_one_the_unsigned_writer_emitted() { + let dir = tempfile::tempdir().expect("tempdir"); + let bytes = unsigned(dir.path()); + let container = container(&bytes); + let manifest = container.raw_manifest_block(); + + // The writer emits the manifest block straight after the base, so the + // bytes it wrote there are the ones the accessor must return. + assert_eq!( + bytes.get(BASE.len()..BASE.len() + manifest.len()), + Some(manifest) + ); + // And they are the serialized manifest itself, not merely bytes that + // happen to sit there: the archive block follows them directly. + let archive = expected_archive(); + let archive_at = BASE.len() + manifest.len(); + assert_eq!( + bytes.get(archive_at..archive_at + archive.len()), + Some(archive.as_slice()) + ); + assert_eq!(bytes.len(), archive_at + archive.len() + FOOTER_SIZE); +} + +#[test] +fn the_raw_manifest_block_is_the_one_the_signer_was_handed() { + let dir = tempfile::tempdir().expect("tempdir"); + let (bytes, handed) = signed(dir.path()); + assert!(!handed.is_empty()); + assert_eq!(container(&bytes).raw_manifest_block(), handed.as_slice()); + assert_eq!( + bytes.get(BASE.len()..BASE.len() + handed.len()), + Some(handed.as_slice()) + ); + // Signing adds envelope blocks and changes nothing about the manifest. + assert_eq!( + container(&unsigned(dir.path())).raw_manifest_block(), + handed.as_slice() + ); +} + +#[test] +fn the_raw_archive_block_is_the_one_the_writer_emitted_and_survives_a_rewrap() { + let dir = tempfile::tempdir().expect("tempdir"); + let archive = expected_archive(); + let (signed_bytes, handed) = signed(dir.path()); + let unsigned_bytes = unsigned(dir.path()); + assert_ne!(OTHER_BASE.len(), BASE.len()); + + for (label, bytes) in [ + ("unsigned", unsigned_bytes.clone()), + ("signed", signed_bytes.clone()), + ("unsigned, rewrapped", rewrapped(&unsigned_bytes)), + ("signed, rewrapped", rewrapped(&signed_bytes)), + ] { + let mut container = container(&bytes); + assert_eq!(archive_of(&mut container), archive, "{label}"); + assert_eq!( + container.archive_block_len(), + u64::try_from(archive.len()).expect("the archive length fits a u64"), + "{label}" + ); + assert_eq!(container.raw_manifest_block(), handed.as_slice(), "{label}"); + assert_eq!(container.container_version(), FORMAT_VERSION, "{label}"); + // A second reader re-seeks and yields the same block again. + assert_eq!(archive_of(&mut container), archive, "{label}"); + } + + // The same holds for a version-1 container rewrapped at version 1. + let legacy = version_1(BASE, &handed, &archive); + let moved = rewrapped(&legacy); + let mut moved = container(&moved); + assert_blocks(&mut moved, &handed, &archive); +} + +#[test] +fn the_archive_reader_reads_exactly_the_block_and_nothing_around_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let (bytes, handed) = signed(dir.path()); + let archive = expected_archive(); + let offset = u64::try_from(BASE.len() + handed.len()).expect("the offset fits a u64"); + let len = u64::try_from(archive.len()).expect("the archive length fits a u64"); + + // A buffer much smaller than the block and one much larger than it: the + // first proves the reader pieces the block together, the second that it + // never asks the source for a byte past the block's end. + for buffer in [7, archive.len() * 4] { + let (source, log, _) = RecordingSource::new(bytes.clone()); + let mut container = + read_package_container(source, &ENVELOPE_BOUNDS).expect("the container reads"); + *log.borrow_mut() = Log::default(); + + let mut block = container.raw_archive_block().expect("the block is sought"); + let mut delivered = Vec::new(); + let mut chunk = vec![0u8; buffer]; + loop { + let read = block.read(&mut chunk).expect("the block reads"); + if read == 0 { + break; + } + delivered.extend_from_slice(&chunk[..read]); + } + // Past the end it keeps answering end-of-file without touching the + // source. + assert_eq!(block.read(&mut chunk).expect("end-of-file"), 0); + + assert_eq!(delivered, archive, "buffer {buffer}"); + let log = log.borrow(); + assert_eq!(log.seeks, vec![offset], "buffer {buffer}"); + let mut cursor = offset; + for range in &log.reads { + assert_eq!(range.start, cursor, "buffer {buffer}: reads are contiguous"); + assert!(range.end > range.start, "buffer {buffer}: no empty read"); + cursor = range.end; + } + assert_eq!( + cursor, + offset + len, + "buffer {buffer}: the block and no more" + ); + } +} + +#[test] +fn a_source_that_ends_inside_the_block_is_unexpected_eof() { + let dir = tempfile::tempdir().expect("tempdir"); + let (bytes, handed) = signed(dir.path()); + let archive = expected_archive(); + let offset = u64::try_from(BASE.len() + handed.len()).expect("the offset fits a u64"); + let cut = 5; + + for end in [offset, offset + cut] { + let (source, _, truncate) = RecordingSource::new(bytes.clone()); + // The container is validated against the whole file first. + let mut container = + read_package_container(source, &ENVELOPE_BOUNDS).expect("the container reads"); + truncate.set(Some(end)); + + let mut block = container.raw_archive_block().expect("the block is sought"); + let mut delivered = Vec::new(); + let error = block + .read_to_end(&mut delivered) + .expect_err("a short source is not a short stream"); + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); + let prefix = usize::try_from(end - offset).expect("the cut fits a usize"); + assert_eq!(delivered, archive[..prefix]); + } +} + +#[test] +fn the_accessors_answer_whatever_the_envelope_state() { + let archive = expected_archive(); + + // Both blocks present at lengths the release format cannot use: the + // bounded read reports them without reading them, and the raw blocks are + // still there. + let wrong = version_2( + UNPARSABLE, + &archive, + &[0x5a; ED25519_SIGNATURE_LEN + 1], + &[b'a'; KEY_ID_HEX_LEN - 1], + ); + let mut read = container(&wrong); + assert!(matches!(read.signature(), EnvelopeBlock::WrongLength)); + assert!(matches!(read.key_id(), EnvelopeBlock::WrongLength)); + assert_eq!(read.container_version(), FORMAT_VERSION); + assert_blocks(&mut read, UNPARSABLE, &archive); + + // The same verdict reached through bounds a well-formed signed container + // does not meet. + let dir = tempfile::tempdir().expect("tempdir"); + let (bytes, handed) = signed(dir.path()); + let bounds = EnvelopeBounds { + signature_len: 1, + key_id_len: 1, + }; + let mut read = read_package_container(Cursor::new(bytes.as_slice()), &bounds) + .expect("the container reads"); + assert!(matches!(read.signature(), EnvelopeBlock::WrongLength)); + assert!(matches!(read.key_id(), EnvelopeBlock::WrongLength)); + assert_blocks(&mut read, &handed, &archive); + + // Present at the bounded lengths, and absent. + let mut read = container(&bytes); + assert!(matches!(read.signature(), EnvelopeBlock::Present(_))); + assert_blocks(&mut read, &handed, &archive); + let bytes = unsigned(dir.path()); + let mut read = container(&bytes); + assert!(matches!(read.signature(), EnvelopeBlock::Absent)); + assert_blocks(&mut read, &handed, &archive); +} + +#[test] +fn the_accessors_answer_for_a_manifest_that_does_not_parse() { + for (label, bytes) in [ + ("version 1", version_1(BASE, UNPARSABLE, NOT_AN_ARCHIVE)), + ("version 2", version_2(UNPARSABLE, NOT_AN_ARCHIVE, &[], &[])), + ] { + let mut read = container(&bytes); + assert!( + matches!( + read.parse_unverified_manifest(), + Err(PayloadError::ManifestParse(_)) + ), + "{label}" + ); + assert_blocks(&mut read, UNPARSABLE, NOT_AN_ARCHIVE); + } +} + +#[test] +fn the_version_1_writer_frames_a_container_the_real_readers_accept() { + // A genuine pre-versioned baseline: no `format_version`, no bound member + // list, no `commit`, over an archive the writer's own archive block + // produces — the shape every published version-1 payload has. + let archive = expected_archive(); + let manifest = format!( + r#"{{"artifacts":[{{"component":"example","version":"1.0.0","target_arch":"x86_64","kind":"native-binary","dispositions":["install"],"archive_path":"{MEMBER}","sha256":"{}"}}]}}"#, + sha256_hex(MEMBER_BYTES) + ) + .into_bytes(); + + let bytes = version_1(BASE, &manifest, &archive); + let mut read = container(&bytes); + assert_eq!(read.container_version(), 1); + assert!(matches!(read.signature(), EnvelopeBlock::Absent)); + assert!(matches!(read.key_id(), EnvelopeBlock::Absent)); + let parsed = read + .parse_unverified_manifest() + .expect("the baseline parses"); + assert_eq!(parsed.format_version(), None); + assert_blocks(&mut read, &manifest, &archive); + + // The layout is the version-1 one: the blocks follow the base and a + // 41-byte footer closes the file. + assert_eq!( + bytes.len(), + BASE.len() + manifest.len() + archive.len() + 41 + ); + + // `open` accepts it too, down to extracting the artifact. + let mut payload = open(Cursor::new(bytes)) + .expect("the payload opens") + .expect("a payload is present"); + let dest = tempfile::tempdir().expect("tempdir"); + payload + .extract_to(dest.path()) + .expect("the payload extracts"); + assert_eq!( + std::fs::read(dest.path().join(MEMBER)).expect("the artifact is on disk"), + MEMBER_BYTES + ); + + // An empty base writes a `.pkg` whose manifest starts at offset 0. + let pkg = version_1(&[], &manifest, &archive); + assert_eq!(pkg.get(..manifest.len()), Some(manifest.as_slice())); + assert_blocks(&mut container(&pkg), &manifest, &archive); +} + +#[test] +fn a_file_without_a_trailer_is_no_trailer() { + assert!(matches!( + read_package_container(Cursor::new(BASE), &ENVELOPE_BOUNDS), + Err(PayloadError::NoTrailer) + )); +} diff --git a/src/verify.rs b/src/verify.rs index e2bb36c..e57d0c1 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -1507,15 +1507,18 @@ fn authenticate( // for its version rather than as a generic decode error. The injected // floor is checked here; the build's own range is stage one's, inside // the parse below, and reaches this taxonomy mapped. - if let Some(found) = - manifest::parse_format_version(container.manifest_bytes()).map_err(map_manifest_error)? + if let Some(found) = manifest::parse_format_version(container.raw_manifest_block()) + .map_err(map_manifest_error)? { check_format_version(found, trust.min_manifest_format_version)?; } // 4. Parse, mapping the two path faults and the version refusal. - let manifest = PayloadManifest::parse(container.manifest_bytes(), container.footer_version()) - .map_err(map_manifest_error)?; + let manifest = PayloadManifest::parse( + container.raw_manifest_block(), + container.container_version(), + ) + .map_err(map_manifest_error)?; // 5-15. The statements the authenticated manifest makes. check_statements(&manifest, request, Some(trust))?; @@ -1671,7 +1674,7 @@ fn verify_signature( // is `UnknownKeyId` whatever the signature's length was. EnvelopeBlock::WrongLength => None, }; - let message = container.manifest_bytes(); + let message = container.raw_manifest_block(); let hint = usable_hint(container.key_id()); let verifies = |anchor: &TrustAnchor| { signature.is_some_and(|signature| anchor.verifies(message, signature)) @@ -2743,7 +2746,7 @@ mod tests { let container = payload::read_package_container(Cursor::new(package.clone()), &ENVELOPE_BOUNDS) .expect("the fixture container reads"); - assert_eq!(container.footer_version(), 1, "{label}"); + assert_eq!(container.container_version(), 1, "{label}"); assert!( matches!(container.signature(), EnvelopeBlock::Absent), "{label}" @@ -4877,7 +4880,7 @@ mod tests { payload::read_package_container(Cursor::new(SIGNED_V6_PACKAGE), &ENVELOPE_BOUNDS) .expect("the fixture is a container"); assert_eq!( - crate::manifest::parse_format_version(container.manifest_bytes()) + crate::manifest::parse_format_version(container.raw_manifest_block()) .expect("a readable version"), Some(IMAGE_DECLARATION_FORMAT_VERSION) ); diff --git a/tests/raw_container_blocks.rs b/tests/raw_container_blocks.rs new file mode 100644 index 0000000..8f9093a --- /dev/null +++ b/tests/raw_container_blocks.rs @@ -0,0 +1,98 @@ +//! A dependent crate hashing a container's raw blocks through the public API +//! alone: the container version, the manifest block and a stream over the +//! archive block, across a rewrap and at both container versions. + +use std::collections::BTreeSet; +use std::io::{Cursor, Read}; + +use deploy_core::manifest::{ArtifactKind, Disposition, TargetArch}; +use deploy_core::payload::{ + ArtifactInput, FORMAT_VERSION, RawArchiveBlock, append_trailer, read_package_container, + rewrap_trailer, +}; +use deploy_core::verify::ENVELOPE_BOUNDS; +use tempfile::tempdir; + +const BASE: &[u8] = b"a base executable"; + +const OTHER_BASE: &[u8] = b"a different base executable, of another length"; + +/// The container version and the raw manifest and archive blocks of +/// `container`, read through the public accessors. +fn raw_blocks(container: &[u8]) -> (u8, Vec, Vec) { + let mut read = read_package_container(Cursor::new(container), &ENVELOPE_BOUNDS) + .expect("the container reads"); + let mut archive = Vec::new(); + let mut block: RawArchiveBlock<'_, _> = read.raw_archive_block().expect("the block is sought"); + block + .read_to_end(&mut archive) + .expect("the block reads to its end"); + assert_eq!( + read.archive_block_len(), + u64::try_from(archive.len()).expect("the archive length fits a u64") + ); + ( + read.container_version(), + read.raw_manifest_block().to_vec(), + archive, + ) +} + +fn rewrapped(container: &[u8]) -> Vec { + let mut out = Vec::new(); + rewrap_trailer(Cursor::new(container), Cursor::new(OTHER_BASE), &mut out) + .expect("the container rewraps"); + out +} + +#[test] +fn the_raw_blocks_survive_a_rewrap_onto_another_base() { + let tempdir = tempdir().expect("a temporary fixture directory is available"); + let source = tempdir.path().join("fixture"); + std::fs::write(&source, b"fixture artifact").expect("the fixture artifact is written"); + let inputs = [ArtifactInput { + component: "fixture".to_string(), + version: "1.0.0".to_string(), + commit: "a".repeat(40), + target_arch: TargetArch::X86_64, + kind: ArtifactKind::StaticAssets, + dispositions: BTreeSet::from([Disposition::Install]), + archive_path: "fixture".to_string(), + spec: None, + image: None, + source, + }]; + let mut container = Vec::new(); + append_trailer(Cursor::new(BASE), &mut container, None, None, &inputs) + .expect("the container is written"); + + let before = raw_blocks(&container); + assert_eq!(before.0, FORMAT_VERSION); + assert_eq!( + container.get(BASE.len()..BASE.len() + before.1.len()), + Some(before.1.as_slice()) + ); + assert_eq!(raw_blocks(&rewrapped(&container)), before); +} + +#[cfg(feature = "test-support")] +#[test] +fn a_version_1_fixture_reads_through_the_real_reader() { + use deploy_core::payload::{EnvelopeBlock, append_version_1_trailer}; + + let manifest = b"a manifest block nothing parses"; + let archive = b"an archive block nothing decompresses"; + let mut container = Vec::new(); + append_version_1_trailer(Cursor::new(BASE), &mut container, manifest, archive) + .expect("the fixture is written"); + + let read = read_package_container(Cursor::new(container.as_slice()), &ENVELOPE_BOUNDS) + .expect("the fixture reads"); + assert!(matches!(read.signature(), EnvelopeBlock::Absent)); + assert!(matches!(read.key_id(), EnvelopeBlock::Absent)); + assert!(read.parse_unverified_manifest().is_err()); + + let blocks = (1, manifest.to_vec(), archive.to_vec()); + assert_eq!(raw_blocks(&container), blocks); + assert_eq!(raw_blocks(&rewrapped(&container)), blocks); +} From 3eecaa9a3da2905e9586fff838a7f923dc22c06b Mon Sep 17 00:00:00 2001 From: sehkone Date: Sun, 27 Sep 2026 20:26:41 +0900 Subject: [PATCH 2/3] Rewrap an overlong test doc comment --- src/payload/raw_block_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/payload/raw_block_tests.rs b/src/payload/raw_block_tests.rs index 056493a..f3e9bba 100644 --- a/src/payload/raw_block_tests.rs +++ b/src/payload/raw_block_tests.rs @@ -140,8 +140,8 @@ fn assert_blocks( } /// A current-format container over arbitrary blocks, with envelope blocks of -/// whatever length is given, an empty one recorded absent: `base ‖ manifest ‖ archive ‖ signature ‖ key_id ‖ -/// footer`. +/// whatever length is given and an empty one recorded absent: +/// `base ‖ manifest ‖ archive ‖ signature ‖ key_id ‖ footer`. fn version_2(manifest: &[u8], archive: &[u8], signature: &[u8], key_id: &[u8]) -> Vec { let len = |bytes: &[u8]| u64::try_from(bytes.len()).expect("a fixture length fits a u64"); let manifest_offset = len(BASE); From 7a0132842d03e593fe907805f2c0530914784a4b Mon Sep 17 00:00:00 2001 From: sehkone Date: Sun, 27 Sep 2026 20:43:16 +0900 Subject: [PATCH 3/3] Test a failed seek to the raw archive block raw_archive_block documents PayloadError::Io when its seek fails, and nothing exercised that path. Part of #131 --- src/payload/raw_block_tests.rs | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/payload/raw_block_tests.rs b/src/payload/raw_block_tests.rs index f3e9bba..90406bd 100644 --- a/src/payload/raw_block_tests.rs +++ b/src/payload/raw_block_tests.rs @@ -517,3 +517,42 @@ fn a_file_without_a_trailer_is_no_trailer() { Err(PayloadError::NoTrailer) )); } + +/// A `Read + Seek` source whose seeks start failing once the test says so. +struct SeekFailsLater { + inner: Cursor>, + fail: Rc>, +} + +impl Read for SeekFailsLater { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.inner.read(buf) + } +} + +impl Seek for SeekFailsLater { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + if self.fail.get() { + return Err(io::Error::other("the seek fails")); + } + self.inner.seek(pos) + } +} + +#[test] +fn a_failed_seek_to_the_archive_block_is_an_io_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let fail = Rc::new(Cell::new(false)); + let source = SeekFailsLater { + inner: Cursor::new(unsigned(dir.path())), + fail: Rc::clone(&fail), + }; + let mut container = + read_package_container(source, &ENVELOPE_BOUNDS).expect("the container reads"); + fail.set(true); + + assert!(matches!( + container.raw_archive_block(), + Err(PayloadError::Io(_)) + )); +}