Skip to content

Expose a read container's raw blocks for hashing #131

Description

@sehkone

Why

bootler's recorded install attempts bind every attempt to the exact installer payload it ran from (aicers/bootler#412, a leaf of the recovery tree under aicers/bootler#422). The binding is a fingerprint over the container's format version and its raw manifest, archive, signature and key-ID blocks, computed so that it survives bootler-release rewrap (which relocates the trailer onto a different base executable). bootler must not grow a second footer parser, so the blocks have to come from deploy-core's container reader.

deploy-core's reader already holds all three missing pieces, but none is public. At 427330ae52fddcb0f6a9bedb6666397fb7593363, read_package_container (src/payload.rs, around line 1999) returns an UnparsedContainer, whose footer_version() (around :1691) and manifest_bytes() (around :1699) are pub(crate), and whose archive_block() (around :1705) is pub(crate) and returns only an offset and a length. signature() and key_id() are already public and cover the envelope half. Nothing public streams a read container's archive block: Payload keeps its archive offsets private and only extract_to consumes them, and verify_package / verify_contents are signed single-build package verifiers that bootler#412 explicitly may not use.

deploy-core #72 considered widening these accessors and kept them pub(crate), because handing out the signed bytes "with no accompanying way to parse them under this crate's rules" invites serde_json::from_slice at the call site; it added parse_unverified_manifest instead. This consumer answers that concern differently: it only hashes the bytes, never parses them, and a parsed manifest cannot serve it, because the fingerprint is defined over the exact bytes and re-serialization is forbidden. deploy-core's own RFC 0001 (docs/rfcs/0001-image-evidence-and-package-writer.md, around line 303) records this need as a separate piece of work that must preserve signature and framing policy.

Scope

Add read-only accessors to UnparsedContainer<R: Read + Seek>, the type read_package_container already returns. Names carry the trust status, following the crate's style:

impl<R: Read + Seek> UnparsedContainer<R> {
    /// The container format version the selected footer recorded
    /// (currently 1 or 2). Not the manifest's `format_version`.
    #[must_use]
    pub fn container_version(&self) -> u8;

    /// The manifest block exactly as it sits in the container: unparsed,
    /// unauthenticated, never re-serialized. For hashing or signature
    /// checks only; decode through `parse_unverified_manifest` or
    /// `verify::verify_package`, never `serde_json` directly.
    #[must_use]
    pub fn raw_manifest_block(&self) -> &[u8];

    /// Length in bytes of the raw (compressed) archive block, as the
    /// validated footer records it.
    #[must_use]
    pub fn archive_block_len(&self) -> u64;

    /// A reader over exactly the raw archive block.
    ///
    /// # Errors
    /// `PayloadError::Io` when the seek fails.
    pub fn raw_archive_block(&mut self) -> Result<RawArchiveBlock<'_, R>, PayloadError>;
}

/// Exactly one container's archive block. `Read` only; no `Seek`.
pub struct RawArchiveBlock<'a, R> { /* borrows the source, tracks remaining */ }
impl<R: Read> Read for RawArchiveBlock<'_, R> { /* bounded by remaining */ }

Semantics:

  • container_version is the footer version (FORMAT_VERSION is 2; version-1 containers remain readable), not the manifest schema version (3–6), and requires no manifest parse.
  • raw_manifest_block returns the same bytes manifest_bytes() returns today. The rustdoc states that the bytes are unauthenticated and points decoding at parse_unverified_manifest / verify::verify_package, keeping Expose the bounded container's manifest to callers outside the crate #72's intent. manifest_bytes(), footer_version() and into_payload() may stay pub(crate) or be re-expressed through the new methods; their existing crate-internal callers keep working.
  • raw_archive_block seeks the source to the block's start once and then yields exactly archive_block_len() bytes followed by EOF. It never reads outside the block, never allocates the block and never decompresses it. A source that ends before the recorded length surfaces from read as io::ErrorKind::UnexpectedEof, never as a short successful stream.
  • The accessors work whatever the envelope state (EnvelopeBlock::Absent, Present or WrongLength) and whether or not the manifest would parse. Refusing WrongLength or an unparsable manifest remains the caller's decision.
  • Document that read_package_container reports a file without a trailer as PayloadError::NoTrailer, which is how an installer-side caller recognises an empty payload.

No existing public signature, verdict or verdict order changes: read_package_container's framing checks, signature()/key_id(), parse_unverified_manifest, open / open_path / open_current_exe, open_package*, verify_package, verify_contents, the writers and rewrap_trailer behave exactly as before. verification_never_reads_the_archive_block (src/verify.rs) stays true, because only the new method reads that block.

Add, gated like widen_envelope_blocks (#[cfg(any(test, feature = "test-support"))]), a writer for a version-1 container fixture so consumers can test version-1 input through the real reader without hand-building footers.

Acceptance criteria

  • UnparsedContainer exposes container_version, raw_manifest_block, archive_block_len and raw_archive_block with the semantics above, and RawArchiveBlock is public and Read-only.
  • A version-1 container reports 1 and an append_trailer output reports 2.
  • raw_manifest_block() equals the manifest bytes the writer emitted, byte for byte, for an unsigned container and for one written by append_trailer_signed (where it also equals the bytes handed to the signer).
  • raw_archive_block() yields exactly the archive bytes the writer emitted, and the same bytes on a rewrap_trailer output over a base of different length.
  • A counting source shows the reader reads exactly archive_block_len() bytes from the block's offset and nothing before or after it; a source truncated after validation yields UnexpectedEof.
  • The accessors work on containers whose envelope blocks are WrongLength, and on a container whose manifest does not parse.
  • The test-support version-1 writer exists and its output is accepted by read_package_container.
  • No existing public API changes; the full CI matrix (fmt, clippy with and without test-support, tests) passes.

Test plan

  • Unit tests in src/payload.rs for each acceptance item, using the crate's existing writers (append_trailer, append_trailer_signed, rewrap_trailer) and the new version-1 fixture writer.
  • A counting Read + Seek wrapper that records every seek and read range, for the bounded-stream assertion.
  • A truncating wrapper that validates normally and then ends early, for the UnexpectedEof assertion.
  • The existing verify.rs archive-block test runs unchanged.

Out of scope

  • Any bootler change (bootler#412 adopts the revision this issue lands, through its own pin move).
  • A fingerprint or hashing helper in deploy-core; the consumer hashes.
  • Changing manifest parsing, verification, the writers, EnvelopeBounds, or making read_package_container_bounded public.
  • Session, executor or spawn APIs.

Pointers

  • src/payload.rs at 427330a: UnparsedContainer and its footer_version / manifest_bytes / archive_block (around 1691–1705), parse_unverified_manifest (around 1722), signature / key_id (around 1735–1742), EnvelopeBlock (around 1779), read_package_container (around 1999), validate_footer (around 826), FORMAT_VERSION (around 126), widen_envelope_blocks (around 932), rewrap_trailer (around 1587).
  • src/verify.rs: ENVELOPE_BOUNDS, verification_never_reads_the_archive_block.
  • deploy-core Expose the bounded container's manifest to callers outside the crate #72 (the decision to keep the raw accessors private) and docs/rfcs/0001-image-evidence-and-package-writer.md around line 303.
  • aicers/bootler#412 (the consumer contract).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions