diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e106f9..294d573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +26,17 @@ jobs: steps: - uses: actions/checkout@v7 - # The firmware pins its own toolchain in firmware/rust-toolchain.toml - # -- stable, plus the two bare-metal targets and the components these - # recipes invoke -- and rustup reads that from the directory it runs - # in. The repository root deliberately has no toolchain file. - - name: Install the firmware's pinned toolchain - working-directory: firmware + # Two packages pin a toolchain, each for its own reasons: the firmware + # needs both bare-metal targets and the llvm-tools behind `cargo + # objcopy`, and the OTA package needs one bare-metal target for the + # `no_std` half of `make clippy-ota`. rustup reads a + # `rust-toolchain.toml` from the directory a command runs in, so + # materialising them means running `rustup show` in each. The + # repository root deliberately has no toolchain file. + - name: Install the pinned toolchains run: | - rustup show + (cd firmware && rustup show) + (cd ota && rustup show) cargo --version # Unlike rpi-hal, this repository's build does not stop at `cargo @@ -48,17 +51,20 @@ jobs: workspaces: | firmware cli + ota - run: make fmt-check - run: make clippy - run: make clippy64 - run: make clippy-cli + - run: make clippy-ota - run: make build-bcm2837 - run: make build64-bcm2837 - run: make build-bcm2711 - run: make build64-bcm2711 - run: make build-cli - run: make test-cli + - run: make test-ota - run: make doc # Proof the release job's artifacts are actually produced, caught @@ -66,9 +72,10 @@ jobs: - name: Both images exist run: test -s firmware/target/kernel7.img && test -s firmware/target/kernel8.img - # Guards the `rust-version` claim in both manifests. Without this an MSRV + # Guards the `rust-version` claim in every manifest. Without this an MSRV # is a comment that rots the first time a dependency or a `core` API - # moves. Both packages have one now that the firmware builds on stable. + # moves. All three packages have one now that the firmware builds on + # stable. # # Each package is checked against its own declared floor, and the # `+toolchain` argument overrides firmware/rust-toolchain.toml, which is @@ -83,11 +90,25 @@ jobs: strategy: fail-fast: false matrix: + # `targets` is a plain space-separated list, read by both the + # install and the build step below. It used to carry the `--target` + # flags for the install while the build step named the same targets + # again in a hardcoded loop, which meant a package added here built + # for the firmware's targets whatever this column said. + # + # `features` exists because an MSRV claim covers every configuration + # a package offers, and the OTA package's floor comes from a feature + # that a default build does not enable. include: - package: cli targets: "" + features: "" - package: firmware - targets: "--target armv7a-none-eabi --target aarch64-unknown-none-softfloat" + targets: "armv7a-none-eabi aarch64-unknown-none-softfloat" + features: "" + - package: ota + targets: "armv7a-none-eabi" + features: "--all-features" steps: - uses: actions/checkout@v7 @@ -103,39 +124,46 @@ jobs: - name: Install that toolchain, with any targets the package needs run: | + flags="" + for target in ${{ matrix.targets }}; do + flags="$flags --target $target" + done rustup toolchain install ${{ steps.msrv.outputs.version }} \ - --profile minimal ${{ matrix.targets }} + --profile minimal $flags # Build only, not test: the tests pull in dev-dependencies whose own # floors are their business, and it is the shipped artifact this - # claim is about. The firmware builds for both bare-metal targets; - # the CLI builds for the host. + # claim is about. A package with no targets listed builds for the + # host; otherwise it builds for each of the ones it named. - name: Build working-directory: ${{ matrix.package }} run: | - if [ -n "${{ matrix.targets }}" ]; then - for target in armv7a-none-eabi aarch64-unknown-none-softfloat; do - cargo +${{ steps.msrv.outputs.version }} build --release --target "$target" - done + if [ -z "${{ matrix.targets }}" ]; then + cargo +${{ steps.msrv.outputs.version }} build --release ${{ matrix.features }} else - cargo +${{ steps.msrv.outputs.version }} build --release + for target in ${{ matrix.targets }}; do + cargo +${{ steps.msrv.outputs.version }} build --release \ + ${{ matrix.features }} --target "$target" + done fi # `cargo package` finishes by building the packaged tarball, which catches # a whole class of "works here, broken on crates.io" problems: a file the # manifest excludes but the build needs, or a path that only resolves in - # this working copy. Only the CLI is publishable -- the firmware sets - # `publish = false`. + # this working copy. Two of the three packages are publishable -- the + # firmware sets `publish = false` -- and each is verified separately, + # because they are released independently of one another. # - # Deliberately uncached. `make package` sends the verification build to + # Deliberately uncached. Both recipes send their verification build to # its own CARGO_TARGET_DIR, and there is nothing to gain by keeping a - # throwaway target directory for one small crate. + # throwaway target directory for two small crates. package: name: package verifies runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: make package + - run: make package-ota # Hardware-in-the-loop testing is deliberately absent: every check above # runs against a fake device on a pty, which pins the wire format but diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 793dc60..a15fd3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,27 @@ name: Release # Publishing to crates.io is permanent -- a version can be yanked but never # replaced or deleted -- so this never triggers on a branch push. Pushing a -# `v*` tag is the deliberate act that starts a release, and -# `workflow_dispatch` covers re-running it after a failure without moving -# the tag. +# tag is the deliberate act that starts a release, and `workflow_dispatch` +# covers re-running one after a failure without moving the tag. +# +# Two independent releases share this file, told apart by their tag: +# +# v the CLI and the loader firmware, which ship together +# ota-v the `rpi-loader-ota` library, which does not +# +# The prefixes do not overlap -- `refs/tags/ota-v0.1.0` does not start with +# `refs/tags/v` -- so each job below runs for exactly one of them. A +# dispatch has no tag to read, so it says which package it means. on: push: - tags: ["v*"] + tags: ["v*", "ota-v*"] workflow_dispatch: + inputs: + package: + description: Which package to release + type: choice + options: [cli, ota] + required: true env: CARGO_TERM_COLOR: always @@ -21,6 +35,7 @@ permissions: jobs: release: name: publish the CLI and upload the images + if: startsWith(github.ref, 'refs/tags/v') || inputs.package == 'cli' runs-on: ubuntu-latest environment: crates-io steps: @@ -147,3 +162,104 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: cargo publish + + # The library. Same guards in the same order as the job above, and + # deliberately not factored together with it: the two release different + # things on different schedules, and a shared job with conditionals in it + # would be harder to read than the repetition, on the one workflow where + # being able to read what will happen matters most. + # + # No images and no firmware toolchain -- this package builds for the host + # like any other library, and `cargo package` is the only artifact. + ota: + name: publish rpi-loader-ota + if: startsWith(github.ref, 'refs/tags/ota-v') || inputs.package == 'ota' + runs-on: ubuntu-latest + environment: crates-io + steps: + - uses: actions/checkout@v7 + + - name: The tag matches the manifest + id: version + run: | + version="$(sed -n 's/^version = "\(.*\)"/\1/p' ota/Cargo.toml | head -1)" + test -n "$version" || { + echo "ota/Cargo.toml has no version" >&2 + exit 1 + } + # A dispatch run has no tag in its context, so the manifest is + # the authority there and there is nothing to cross-check. + if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "ota-v$version" != "$GITHUB_REF_NAME" ]; then + echo "tag $GITHUB_REF_NAME does not match the manifest version $version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=ota-v$version" >> "$GITHUB_OUTPUT" + + # Its own changelog, because its versions are its own. Dated, for the + # same reason as the CLI's: an undated entry claims the version was + # never released. + - name: The changelog has a dated section for this version + run: | + version="${{ steps.version.outputs.version }}" + if grep -q ReleaseDate ota/CHANGELOG.md; then + echo "ota/CHANGELOG.md still has the ReleaseDate placeholder" >&2 + exit 1 + fi + if ! grep -qE "^## \[$version\] - [0-9]{4}-[0-9]{2}-[0-9]{2}" ota/CHANGELOG.md; then + echo "ota/CHANGELOG.md has no dated '## [$version] - YYYY-MM-DD' section" >&2 + exit 1 + fi + + - name: The packaged tarball builds + run: make package-ota + + - name: Extract this version's changelog section + run: | + version="${{ steps.version.outputs.version }}" + awk -v v="## [$version]" ' + index($0, v) == 1 { inside = 1; next } + inside && /^## \[/ { exit } + inside { print } + ' ota/CHANGELOG.md > release-notes.md + cat >> release-notes.md <<'NOTE' + + Add it with `cargo add rpi-loader-ota`. The `rpi-loader` CLI + builds bundles in this format; a device installs one. + NOTE + + # Before the irreversible step, and idempotent, so a re-run after a + # failed publish does not stall here. + - name: Create or update the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${{ steps.version.outputs.tag }}" + if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --notes-file release-notes.md + else + gh release create "$tag" \ + --title "rpi-loader-ota ${{ steps.version.outputs.version }}" \ + --notes-file release-notes.md + fi + + # As above: crates.io refuses a version that exists, and that refusal + # would mask whatever a re-run was actually for. + - name: Is this version already on crates.io? + id: published + run: | + version="${{ steps.version.outputs.version }}" + index="$(curl -sS https://index.crates.io/rp/i-/rpi-loader-ota || true)" + if printf '%s' "$index" | grep -q "\"vers\":\"$version\""; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "rpi-loader-ota $version is already published; skipping" + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to crates.io + if: steps.published.outputs.already == 'false' + working-directory: ota + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish diff --git a/CHANGELOG.md b/CHANGELOG.md index aec19c1..c5f6117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The firmware and the host CLI share one version and ship as one release: they are two halves of a wire protocol, and a version that identifies only one of them says nothing useful about compatibility. +The `rpi-loader-ota` library in `ota/` is not part of that pair and keeps +its own history in [`ota/CHANGELOG.md`](ota/CHANGELOG.md). Its consumers +are firmware projects in other repositories, and a renamed command-line +flag here is no reason to bump their dependency. + ## [0.2.0] - 2026-08-30 ### Added diff --git a/Makefile b/Makefile index 588df4c..81008ae 100644 --- a/Makefile +++ b/Makefile @@ -33,11 +33,19 @@ # silently relink without them otherwise. ARCH64 := aarch64-unknown-none-softfloat +# Named here because the OTA package has no `.cargo/config.toml` of its own +# to default it -- and deliberately so, since it is a library that also +# builds for the host and must not have a target imposed on it. The +# firmware's recipes below never mention the 32-bit target for the opposite +# reason: `firmware/.cargo/config.toml` already makes it the default there. +ARCH32 := armv7a-none-eabi FIRMWARE := firmware CLI := cli +OTA := ota .PHONY: build-bcm2711 build64-bcm2711 build-bcm2837 build64-bcm2837 build-cli \ - fmt fmt-check clippy clippy64 clippy-cli test-cli doc package pre-commit clean + fmt fmt-check clippy clippy64 clippy-cli clippy-ota test-cli test-ota doc \ + package package-ota pre-commit clean build-bcm2711: cd $(FIRMWARE) && cargo build --release --features bcm2711 @@ -63,10 +71,12 @@ build-cli: fmt: cd $(FIRMWARE) && cargo fmt cd $(CLI) && cargo fmt + cd $(OTA) && cargo fmt fmt-check: cd $(FIRMWARE) && cargo fmt -- --check cd $(CLI) && cargo fmt -- --check + cd $(OTA) && cargo fmt -- --check clippy: cd $(FIRMWARE) && cargo clippy --release -- -D warnings @@ -77,6 +87,25 @@ clippy64: clippy-cli: cd $(CLI) && cargo clippy --release --all-targets -- -D warnings +# Twice, and the second pass is the one that earns its keep. The OTA +# package is `no_std`, but a host build links `std` transitively and never +# says so, which means an accidental `std::` would sail through every check +# above and fail for the first person to put it on a board. Building it for +# a target that has no `std` at all is the only thing that catches it. +# +# One bare metal target rather than both of the loader's: nothing in the +# package is architecture-specific, and the absence of `std` is a property +# of the target family, not of the instruction set. Two builds would +# re-check the same thing. +# +# `--all-features` on both, because a feature that only compiles for the +# host is not a feature this package can offer. `--all-targets` on the host +# pass only -- it pulls in the test harness, which needs `std` by +# definition and cannot build for a bare metal target. +clippy-ota: + cd $(OTA) && cargo clippy --release --all-targets --all-features -- -D warnings + cd $(OTA) && cargo clippy --release --all-features --target $(ARCH32) -- -D warnings + # The CLI's tests drive the real binary against a fake device on a pty, so # they need no hardware -- but they also prove nothing about timing, which # is the half of this protocol only a real board can exercise. There is no @@ -85,6 +114,13 @@ clippy-cli: test-cli: cd $(CLI) && cargo test --release +# On the host, with no hardware and no fake device on a pty: the container +# is pure code, so unlike everything else here a test of it proves the +# whole of what it claims. `--all-features` so the install half is covered +# too and not just the format. +test-ota: + cd $(OTA) && cargo test --release --all-features + # `-D warnings` is the whole point: a plain doc build almost never fails, so # without it this catches nothing. What it does catch is broken intra-doc # links -- including the non-obvious case where a module's own `//!` links @@ -94,17 +130,23 @@ test-cli: # One target only, unlike clippy above: rustdoc's link resolution doesn't # depend on the architecture, so documenting both would re-check the same # links for the sake of the little code that is arch-gated. +# +# `--all-features` on the OTA package, matching what docs.rs is told to +# build. Without it the feature-gated half is never documented here, and a +# broken link inside it is found by the docs.rs builder after the release +# that published it. doc: cd $(FIRMWARE) && RUSTDOCFLAGS="-D warnings" cargo doc --no-deps cd $(CLI) && RUSTDOCFLAGS="-D warnings" cargo doc --no-deps + cd $(OTA) && RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features # What `cargo publish` will verify: it builds the packaged tarball, which # catches the "works in this working copy, broken on crates.io" class of # problem -- a file the build needs that packaging left out, or a path -# that only resolves here. Only the CLI is publishable; the firmware sets -# `publish = false`. cargo refuses a dirty working tree here on its own, -# which is the behaviour we want: what gets published is the committed -# state, not what happens to be on disk. +# that only resolves here. Two of the three packages are publishable; the +# firmware sets `publish = false`. cargo refuses a dirty working tree here +# on its own, which is the behaviour we want: what gets published is the +# committed state, not what happens to be on disk. # # The separate CARGO_TARGET_DIR is not tidiness. The verification build # compiles the *extracted tarball* (target/package/rpi-loader-/) @@ -119,8 +161,16 @@ doc: package: cd $(CLI) && CARGO_TARGET_DIR=target/verify cargo package -pre-commit: fmt clippy clippy64 clippy-cli build-bcm2711 build64-bcm2711 build-bcm2837 build64-bcm2837 build-cli test-cli doc +# Separate from `package` rather than another line inside it, because the +# two are released independently -- the CLI and the firmware ship as one +# version, and the OTA package carries its own. A target that verified both +# would imply they move together. +package-ota: + cd $(OTA) && CARGO_TARGET_DIR=target/verify cargo package + +pre-commit: fmt clippy clippy64 clippy-cli clippy-ota build-bcm2711 build64-bcm2711 build-bcm2837 build64-bcm2837 build-cli test-cli test-ota doc clean: cd $(FIRMWARE) && cargo clean cd $(CLI) && cargo clean + cd $(OTA) && cargo clean diff --git a/README.md b/README.md index 362ba1b..10fcca0 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,13 @@ and SD/FAT access. The FAT filesystem layer is can build. - `cli/` — the host-side driver that talks to a running loader over serial. This is the package published to crates.io as `rpi-loader`, - and the only half of the project `cargo install` can build. + and the only part of the project `cargo install` can build. +- `ota/` — the over-the-air update bundle format, published separately as + `rpi-loader-ota`. A `no_std` library rather than a tool: the CLI uses it + to pack a bundle, and a board's own firmware links it to validate and + install one. It carries its own version, because its consumers are + firmware projects elsewhere and there is no reason a renamed CLI flag + should bump their dependency. - The repository root has no cargo configuration on purpose. Cargo discovers `.cargo/config.toml` by walking up from the working directory, so a root-level one naming a bare metal target would be diff --git a/RELEASING.md b/RELEASING.md index a54e8b9..7320a61 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,23 @@ # Releasing -How to cut a release of `rpi-loader`. Maintainer-facing; nothing here is -needed to *use* the loader or the CLI. +How to cut a release. Maintainer-facing; nothing here is needed to *use* +the loader, the CLI or the library. -A release is two things at once: the `rpi-loader` CLI published to -crates.io, and the four loader images attached to a GitHub release. The -publish is permanent — a version can be yanked but never replaced or -deleted, and the version number can never be reused. Most of what follows +**There are two kinds of release, and they are independent.** + +| Tag | Ships | Where | +| --- | --- | --- | +| `v` | the `rpi-loader` CLI, and the four loader images | crates.io + a GitHub release | +| `ota-v` | the `rpi-loader-ota` library | crates.io | + +The CLI and the firmware move together because they are two halves of a +wire protocol. The library does not, because its consumers are firmware +projects in other repositories and a renamed command-line flag is no +reason to bump their dependency. Each has its own changelog: `CHANGELOG.md` +for the pair, `ota/CHANGELOG.md` for the library. + +Either publish is permanent — a version can be yanked but never replaced +or deleted, and the number can never be reused. Most of what follows exists to make a mistake fail *before* that point. ## One-time setup @@ -14,8 +25,9 @@ exists to make a mistake fail *before* that point. Only needed once per repository (or when a token expires). - **crates.io API token.** Create one under Account Settings → API Tokens - with the **publish-update** scope — plus **publish-new** for the very - first release — then store it as a repository secret: + with the **publish-update** scope — plus **publish-new** for any release + that claims a name not yet on crates.io — then store it as a repository + secret: ```sh gh secret set CARGO_REGISTRY_TOKEN @@ -24,6 +36,12 @@ Only needed once per repository (or when a token expires). Secrets do not carry over from another repository, so having published `rpi-hal` does not cover this one. + **Check the token's scope before releasing a package for the first + time.** crates.io tokens can be limited to named crates as well as to + actions, and a token created to publish updates of one crate cannot + claim another. That failure lands at the last step of the workflow, + after the GitHub release object already exists. + - **The `crates-io` environment.** `.github/workflows/release.yml` declares it. Create it under Settings → Environments and add yourself as a **required reviewer**: the tag push then parks the workflow at @@ -35,14 +53,52 @@ Only needed once per repository (or when a token expires). While the repository is private, every one of those is a 404 for anyone reading the crates.io page. -## Per-release steps +## Releasing the library + +`rpi-loader-ota` is the short version of everything below, because it has +no firmware, no images and no wire protocol — just a Rust API and a bundle +format. + +1. On a branch: set `version` in `ota/Cargo.toml`, run `make test-ota` so + `ota/Cargo.lock` is refreshed, and give `ota/CHANGELOG.md` a dated + `## [] - ` heading plus a link reference at the + bottom. The workflow greps for that date and refuses to publish without + it. +2. `make package-ota` on a clean tree. +3. Merge the PR, then tag and push: + + ```sh + git checkout main && git pull + git tag ota-v && git push origin ota-v + ``` +4. Approve the parked workflow. + +**The CLI depends on this package, so it has to be published first.** +`cargo package` on the CLI resolves `rpi-loader-ota` from crates.io — the +path dependency is stripped when packaging, which is the point of writing +both a `version` and a `path` — and a version that is not there yet fails +the CLI's release before it starts. That is also why CI's package job is +the first thing to go red if this package is ever bumped without being +released. + +**What counts as breaking:** the Rust API as usual, and the bundle format +itself. A change to the container's bytes is breaking in a way a semver +bump cannot really express, because a bundle is parsed by the firmware +already running and installs the firmware that replaces it — so a board +can never be sent a container its current build does not understand. +Changing it means reaching every deployed board some other way once. The +version byte in the header exists to make that a clean rejection rather +than a puzzle. + +## Releasing the CLI and the firmware ### 1. Decide the version Semantic versioning, with the usual pre-1.0 caveat that `0.x` bumps the -*minor* for breaking changes. Both packages carry the same version and -move together — see the note at the top of `CHANGELOG.md` — and the -release workflow refuses to run if the two manifests disagree. +*minor* for breaking changes. The CLI and the firmware carry the same +version and move together — see the note at the top of `CHANGELOG.md` — +and the release workflow refuses to run if those two manifests disagree. +`ota/Cargo.toml` is not part of that check and is not expected to match. What counts as breaking here is wider than a Rust API, because most of what this project exposes is not one: @@ -158,14 +214,19 @@ crates.io page is what people read. | Guard | Where | Symptom if it trips | | --- | --- | --- | -| Both manifests carry the same version | `release.yml` | Release job fails before publishing | -| Tag matches the manifests | `release.yml` | Same. Skipped on a `workflow_dispatch` run, which has no tag | +| The CLI and firmware manifests carry the same version | `release.yml` | Release job fails before publishing | +| Tag matches the manifest it names | `release.yml` | Same. Skipped on a `workflow_dispatch` run, which has no tag | | Changelog has a dated section for the version | `release.yml` | Same | -| Packaged tarball actually builds | `make package`, in both CI and the release job | Same | +| Packaged tarball actually builds | `make package` / `make package-ota`, in both CI and the release jobs | Same | | Images were actually produced | `ci.yml` | CI fails on the pull request, long before a tag exists | -| The CLI still builds on its declared MSRV | `ci.yml` | Same | +| Every package still builds on its declared MSRV | `ci.yml` | Same | | PRs required on `main` | Repository ruleset | Direct pushes rejected | +A `v*` tag runs only the CLI job and an `ota-v*` tag only the library job; +the prefixes cannot both match, since `refs/tags/ota-v0.1.0` does not start +with `refs/tags/v`. A `workflow_dispatch` run has no tag to read and asks +which package it means. + One coupling to know about: the ruleset's required status checks are matched against the **job names** in `ci.yml`. Renaming a job there leaves the ruleset waiting on a name that never reports, and every PR blocks diff --git a/ota/CHANGELOG.md b/ota/CHANGELOG.md new file mode 100644 index 0000000..3d3bfa2 --- /dev/null +++ b/ota/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +Notable changes to `rpi-loader-ota`, in the format of +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This package +follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Its own file, and its own versions. The `rpi-loader` CLI and the loader +firmware beside it ship as one release because they are two halves of a +wire protocol; this is a library, its consumers are firmware projects in +other repositories, and tying it to that version would bump their +dependency every time a command-line flag was renamed. + +## [0.1.0] - 2026-09-01 + +First release. + +### Added + +- **The bundle container**, version 2 of the format: a list of + (path, role, bytes) under an IEEE CRC-32, encoded on a host and + validated on a device by the same code. An update can therefore replace + anything on a boot partition — a settings file, a firmware blob, a + certificate, `config.txt`, the Raspberry Pi firmware itself — rather + than a kernel and one hardcoded directory of assets. + +- **`Bundle::parse`, which allocates nothing.** It validates in place and + borrows, so a device holds one buffer — the upload as it arrived — and + nothing else. `encode`, behind the default `alloc` feature, is the + other direction. + +- **Roles.** An entry is a `File`, the `Kernel`, `Firmware` or `Config`, + and the installer decides what that means. Naming the boot image by + role rather than by position or filename is what lets the device choose + its own destination, which matters to anything running two kernel + slots. + +- **Rules enforced identically in both directions**, so a packer cannot + build what a device would reject: no absolute paths, `..` components, + empty components or backslashes; no duplicate destinations; at most one + kernel and one `config.txt`; and a `start*.elf` only alongside its + matching `fixup*.dat`, since the two are released together and a + mismatched pair does not boot. + +- **`checksum`**, so a device checksums a file already on its card the + same way the packer checksummed the entry, and can skip rewriting one + whose bytes have not changed. + +[0.1.0]: https://github.com/joeferner/rpi-loader/releases/tag/ota-v0.1.0 diff --git a/ota/Cargo.lock b/ota/Cargo.lock new file mode 100644 index 0000000..7e4004c --- /dev/null +++ b/ota/Cargo.lock @@ -0,0 +1,90 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "resident-fat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e9d9402fec47df435439edd966dc2e24d6594067f2b0b04fc65d4392fd9179" +dependencies = [ + "thiserror", +] + +[[package]] +name = "rpi-loader-ota" +version = "0.1.0" +dependencies = [ + "crc", + "resident-fat", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/ota/Cargo.toml b/ota/Cargo.toml new file mode 100644 index 0000000..92a5a91 --- /dev/null +++ b/ota/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "rpi-loader-ota" +version = "0.1.0" +edition = "2024" +description = "Over-the-air update bundle format for bare-metal Raspberry Pi firmware — one container, packed on a host and validated on the device" +license = "MIT OR Apache-2.0" +repository = "https://github.com/joeferner/rpi-loader" +documentation = "https://docs.rs/rpi-loader-ota" +readme = "README.md" +keywords = ["raspberry-pi", "bare-metal", "ota", "firmware", "no-std"] +categories = ["embedded", "no-std", "filesystem"] +# Set by the edition rather than by anything in the source: 1.85 is the +# toolchain that stabilised edition 2024. The `apply` feature would pin the +# same floor anyway through `resident-fat`, so there is no version below +# this that any configuration of this package could build on. +rust-version = "1.85" + +# Repository-only. `rust-toolchain.toml` exists so `make clippy-ota`'s bare +# metal pass works from a fresh checkout; rustup reads that file from the +# directory a command runs in, never from a dependency's source, so a copy +# inside a downloaded crate does nothing but take up space. +exclude = ["rust-toolchain.toml"] + +# Unlike the CLI beside it, this package is versioned on its own. It is a +# library whose consumers are firmware projects in other repositories, and +# tying it to the CLI's version would force a semver bump on every one of +# them each time a command-line flag was renamed. The CLI and the loader +# firmware still ship as one release, because those two are halves of a +# wire protocol; this is not. + +[features] +default = ["alloc"] +# The encoder needs a growable buffer and the decoder returns a collection, +# so everything here that produces something needs an allocator. It is a +# feature rather than an assumption because validating a bundle in place — +# checking the header and the checksum, then walking entries — does not, +# and a device that only wants to know whether a bundle is well formed +# should not have to have a heap to find out. +alloc = [] +# Writing a validated bundle to a FAT volume: the write/verify/commit +# sequence, and the two-slot kernel scheme. Opt-in because the host CLI +# packs bundles and never installs one, and making it depend on a +# filesystem implementation to write a file on a host would be absurd. +apply = ["alloc", "dep:resident-fat"] + +[dependencies] +# no_std by default; the same implementation has to produce the same +# checksum on the host that packs a bundle and on the device that checks +# it, which is the whole reason this package exists. +crc = { version = "3", default-features = false } +# The filesystem the apply half writes through. Its default features are +# already empty -- the `embedded-sdmmc` bridge and the MBR parser are both +# opt-in there -- so nothing needs turning off. +resident-fat = { version = "0.1.0", optional = true } + +[package.metadata.docs.rs] +# Document the apply half as well as the format; without this, docs.rs +# builds the default features and the `apply` module is simply absent from +# the published documentation. +# +# No `--cfg docsrs` and no `doc_cfg` annotations on the gated items: that +# attribute is still nightly-only, and this repository's `make doc` runs on +# stable. Worth revisiting if the annotations start being missed. +all-features = true diff --git a/ota/LICENSE-APACHE b/ota/LICENSE-APACHE new file mode 100644 index 0000000..7beb721 --- /dev/null +++ b/ota/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2026 Joe Ferner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/ota/LICENSE-MIT b/ota/LICENSE-MIT new file mode 100644 index 0000000..23f4225 --- /dev/null +++ b/ota/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2026 Joe Ferner + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/ota/README.md b/ota/README.md new file mode 100644 index 0000000..d35335c --- /dev/null +++ b/ota/README.md @@ -0,0 +1,54 @@ +# rpi-loader-ota + +[![CI](https://img.shields.io/github/actions/workflow/status/joeferner/rpi-loader/ci.yml?branch=main&label=CI)](https://github.com/joeferner/rpi-loader/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/rpi-loader-ota.svg)](https://crates.io/crates/rpi-loader-ota) + +The over-the-air update bundle a bare-metal Raspberry Pi installs on +itself: one container, packed on a host and validated on the device. + +A bundle carries a kernel image and the files that ship beside it under a +single checksum, so a board can reject a damaged transfer before it writes +anything to its card. The kernel goes to the boot partition, the rest +alongside it. + +This is `no_std`, and it is not Pi-specific beyond the shape of the +problem — anything that boots a raw image from a FAT partition and can +receive a few megabytes over some transport can use it. + +## Why a crate + +Because **the encoder and the decoder are the same code**. A wire format +described in two places is a format that has already drifted: the two +implementations this one was collected from agreed on every byte and +disagreed on what they enforced, each accepting bundles the other +rejected. + +The host end is the [`rpi-loader`](https://crates.io/crates/rpi-loader) +CLI, which packs bundles and can upload one to a running board. The device +end is this crate, linked into the firmware. + +## Features + +| | Feature | Pulls in | +| --- | --- | --- | +| The container: encode, decode, checksum | `alloc` *(default)* | nothing | +| Installing one onto a FAT volume | `apply` | [`resident-fat`](https://crates.io/crates/resident-fat) | + +The split keeps the host CLI from depending on a filesystem implementation +in order to write a file on a host, and keeps a device that only installs +bundles from compiling an encoder it never calls. + +## What your application still supplies + +**The transport and the reboot.** How a bundle arrives — an HTTP route, a +job handed to another core, a command over serial — is application shaped, +and a crate that took it would be choosing your web framework. + +**The measurement.** What an update costs the card is usually the number +worth having, and collecting it here would mean depending on an async +runtime for a clock and on whatever is counting device commands. The +install reports its progress; the caller decides what to time. + +## License + +MIT OR Apache-2.0, at your option. diff --git a/ota/rust-toolchain.toml b/ota/rust-toolchain.toml new file mode 100644 index 0000000..63a3204 --- /dev/null +++ b/ota/rust-toolchain.toml @@ -0,0 +1,22 @@ +[toolchain] +# Stable. Nothing here needs an unstable feature, and rustup ships a +# precompiled `core` for the bare metal target below, so there is no +# `-Zbuild-std` either. +channel = "stable" +# `rustfmt` and `clippy` arrive with the default profile, so naming them is +# redundant on a normal install — but only until something materialises the +# toolchain with a minimal profile, and then `make fmt-check` fails with +# "'cargo-fmt' is not installed for the toolchain". Naming every component +# the Makefile invokes makes that failure impossible rather than dependent +# on how the toolchain arrived. +components = ["rustfmt", "clippy"] +# So `make clippy-ota`'s second pass works from a fresh checkout without a +# separate `rustup target add`. That pass is what proves the package is +# actually `no_std`: a host build links `std` transitively and never +# complains, so an accidental `std::` would otherwise reach CI green. +# +# One target, not both of the loader's. Nothing in this package is +# architecture-specific, and what the bare metal build checks is the +# absence of `std`, which is a property of the target family rather than of +# the instruction set. +targets = ["armv7a-none-eabi"] diff --git a/ota/src/bundle.rs b/ota/src/bundle.rs new file mode 100644 index 0000000..a5f7ff8 --- /dev/null +++ b/ota/src/bundle.rs @@ -0,0 +1,1183 @@ +//! The container: what a bundle is on the wire, and how to check one. +//! +//! # Layout +//! +//! Little-endian throughout. +//! +//! ```text +//! magic : 4 bytes per-application; see [`Format`] +//! version : u8 = 2 +//! reserved: u8 = 0 +//! count : u16 number of entries +//! entries : count x { +//! role : u8 see [`Role`] +//! path_len: u8 +//! path : [u8; path_len] +//! size : u32 +//! data : [u8; size] +//! } +//! crc32 : u32 IEEE CRC-32 over every preceding byte +//! ``` +//! +//! A bundle is a list of **(path, role, bytes)**. Where each entry lands is +//! the bundle's business, not the receiving firmware's — which is the whole +//! difference from the format this replaces, where a kernel came first and +//! everything after it went into one directory compiled into the device. +//! +//! # Why there is no per-entry checksum +//! +//! It was in the design and did not survive being written down. Its purpose +//! was to let a device skip rewriting a file whose bytes are already on the +//! card — but the entry's data is in memory beside the comparison, so the +//! expected checksum can be computed there with [`checksum`] for nothing. +//! A copy on the wire would be four bytes per entry that no reader needs, +//! and a second place for the same number to be wrong. +//! +//! The whole-bundle checksum stays, because that one covers something no +//! local computation can: that the bytes which arrived are the bytes that +//! were sent. + +use crc::{CRC_32_ISO_HDLC, Crc}; + +/// Bundle format version this crate reads and writes. +/// +/// Version 1 existed, carried a kernel plus a flat list of names, and is +/// deliberately not readable here — see the crate documentation. +pub const VERSION: u8 = 2; + +/// Magic, version, reserved byte and entry count. +const HEADER_LEN: usize = 8; + +/// The trailing whole-bundle CRC-32. +const TRAILER_LEN: usize = 4; + +/// A header and a checksum with nothing between them. +const MIN_LEN: usize = HEADER_LEN + TRAILER_LEN; + +/// IEEE CRC-32, the same one the host packer computes. +const CRC32: Crc = Crc::::new(&CRC_32_ISO_HDLC); + +/// The IEEE CRC-32 of `data`, as it appears in a bundle. +/// +/// Exposed so that a device comparing a file on its card against an entry +/// checksums both the same way. Getting that wrong would not corrupt +/// anything — it would silently rewrite every file on every update, which +/// is worse, because nothing would ever report it. +pub fn checksum(data: &[u8]) -> u32 { + CRC32.checksum(data) +} + +/// What an application must agree with its packer about. +/// +/// Everything else a bundle needs to describe is in the bundle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Format { + /// Four bytes identifying which application a bundle was built for, so + /// that one board rejects another's update. + /// + /// It is compiled into the firmware and written by the packer, and it + /// is the only compatibility check there is: nothing else in a bundle + /// says which board it belongs to. A project shipping more than one + /// architecture should therefore vary it — `WAT7` against `WAT8` — + /// since an image for the wrong architecture is otherwise a bundle that + /// installs perfectly and does not boot. + pub magic: [u8; 4], + /// Most entries to accept. + /// + /// A bound on what a single upload can ask the device to write, checked + /// before any entry is walked. + pub max_entries: usize, +} + +/// How the device should treat an entry. +/// +/// The byte values are wire values and say nothing about the order entries +/// are written in — that order is the installer's, and it is not this one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + /// Anything the application owns: assets, settings, data. + File, + /// The boot image, to be routed to whichever kernel slot is not + /// running. At most one per bundle. + Kernel, + /// A Raspberry Pi firmware file — `bootcode.bin`, `start*.elf`, + /// `fixup*.dat`. + Firmware, + /// `config.txt`. At most one per bundle, because the installer edits + /// the file this entry lands as. + Config, +} + +impl Role { + /// The wire byte for this role. + pub fn as_byte(self) -> u8 { + match self { + Role::File => 0, + Role::Kernel => 1, + Role::Firmware => 2, + Role::Config => 3, + } + } + + /// The role a wire byte names, or `None` if it names none. + pub fn from_byte(byte: u8) -> Option { + match byte { + 0 => Some(Role::File), + 1 => Some(Role::Kernel), + 2 => Some(Role::Firmware), + 3 => Some(Role::Config), + _ => None, + } + } +} + +/// One file in a bundle, borrowed from the buffer it arrived in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Entry<'a> { + /// How the device should treat it. + pub role: Role, + /// Where it goes, relative to the root of the volume. Always a valid + /// relative path when it came from [`Bundle::parse`]. + pub path: &'a str, + /// The file's contents. + pub data: &'a [u8], +} + +/// Why a bundle was rejected, reading one or building one. +/// +/// Most variants can arise either way, because most of what makes a bundle +/// wrong is a property of its entries rather than of its bytes — and one +/// vocabulary for both directions is what keeps a packer from cheerfully +/// building something no device will take. The exceptions are marked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + /// Fewer bytes than a header and a checksum. Reading only. + TooShort, + /// The magic did not match the one this application expects. Reading + /// only. + BadMagic, + /// A format version this build does not read. Carries the version seen. + /// Reading only. + BadVersion(u8), + /// The trailing CRC-32 did not match the content. Reading only. + BadChecksum, + /// No entries at all, or more than [`Format::max_entries`]. + BadEntryCount(usize), + /// An entry ran past the end of the bundle, or the entries did not fill + /// it exactly. Reading only. + Truncated, + /// An entry named a role this build does not know. Carries the byte. + /// Reading only. + BadRole(u8), + /// An entry's path was not valid UTF-8. Reading only — a path being + /// built is already a `&str`. + PathNotUtf8, + /// A path longer than the 255 bytes its length field can hold. Building + /// only. + PathTooLong, + /// A file larger than the 4 GiB its size field can hold. Building only. + DataTooLarge, + /// An entry's path was empty, absolute, or contained `.`, `..`, an + /// empty component or a backslash. + BadPath, + /// Two entries wanted the same path. + DuplicatePath, + /// More than one entry claimed a role that allows only one. + DuplicateRole(Role), + /// A `start*.elf` arrived without its `fixup*.dat`, or the reverse. + UnpairedFirmware, +} + +impl Error { + /// The HTTP status to answer this with, for a device that took the + /// bundle over HTTP. + /// + /// Always 400, and that is the content of the method rather than a + /// placeholder: every rejection in this module is a statement about the + /// bytes that arrived, so none of them is ever the receiver's fault. An + /// installer whose errors are not all client errors wraps this one and + /// answers for the rest itself. + pub fn http_status(&self) -> u16 { + 400 + } +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let text = match self { + Error::TooShort => "bundle too short", + Error::BadMagic => "not a bundle for this device", + Error::BadVersion(_) => "unsupported bundle version", + Error::BadChecksum => "checksum mismatch", + Error::BadEntryCount(_) => "bad entry count", + Error::Truncated => "bundle truncated", + Error::BadRole(_) => "unknown entry role", + Error::PathNotUtf8 => "bad file path", + Error::PathTooLong => "file path too long", + Error::DataTooLarge => "file too large", + Error::BadPath => "unsafe file path", + Error::DuplicatePath => "two entries with the same path", + Error::DuplicateRole(_) => "two entries with the same role", + Error::UnpairedFirmware => "firmware file without its pair", + }; + f.write_str(text) + } +} + +impl core::error::Error for Error {} + +/// A validated bundle: a handle onto the buffer it was parsed from. +/// +/// Holding one is the proof that every check in this module passed, which +/// is why walking it with [`iter`](Bundle::iter) cannot fail. +#[derive(Clone, Copy, Debug)] +pub struct Bundle<'a> { + /// Exactly the entry region — header and trailing checksum removed. + entries: &'a [u8], + count: usize, +} + +impl<'a> Bundle<'a> { + /// Checks `bytes` against `format` and returns a handle to it. + /// + /// Allocates nothing: the result borrows the buffer. + /// + /// # Order of checks + /// + /// The checksum is verified **before** any entry is walked. A transfer + /// that lost bytes produces lengths that still look plausible, and + /// reporting the first one that happens not to fit describes a symptom + /// rather than what went wrong. + pub fn parse(format: &Format, bytes: &'a [u8]) -> Result, Error> { + if bytes.len() < MIN_LEN { + return Err(Error::TooShort); + } + if bytes[..4] != format.magic { + return Err(Error::BadMagic); + } + if bytes[4] != VERSION { + return Err(Error::BadVersion(bytes[4])); + } + + let split = bytes.len() - TRAILER_LEN; + let stored = u32::from_le_bytes([ + bytes[split], + bytes[split + 1], + bytes[split + 2], + bytes[split + 3], + ]); + if checksum(&bytes[..split]) != stored { + return Err(Error::BadChecksum); + } + + let count = u16::from_le_bytes([bytes[6], bytes[7]]) as usize; + if count == 0 || count > format.max_entries { + return Err(Error::BadEntryCount(count)); + } + + // Walked here, and again by every `iter()` afterwards. Two passes + // over a buffer already in memory, in exchange for an iterator that + // needs no error type and a handle that cannot name a bundle it has + // not checked. + let entries = &bytes[HEADER_LEN..split]; + let mut rest = entries; + for _ in 0..count { + let (_, next) = split_entry(rest)?; + rest = next; + } + // The count and the bytes have to agree exactly. Anything left over + // is a bundle whose header does not describe it. + if !rest.is_empty() { + return Err(Error::Truncated); + } + + // Structure first, then meaning: everything above is about whether + // there is a bundle here at all, and `check_entries` is about + // whether the entries in it make sense. A torn transfer trips the + // first kind, and saying so is more use than reporting whichever + // rule the wreckage happened to break. + let bundle = Bundle { entries, count }; + check_entries(bundle.iter())?; + Ok(bundle) + } + + /// How many entries it holds. + pub fn count(&self) -> usize { + self.count + } + + /// The entries, in the order they were packed. + /// + /// Not the order they should be written in — see [`Role`]. + pub fn iter(&self) -> Iter<'a> { + Iter { + rest: self.entries, + left: self.count, + } + } + + /// The entry carrying the boot image, if the bundle has one. + /// + /// A bundle without a kernel is valid and useful: it updates a website + /// or a settings file without rewriting an image that has not changed. + pub fn kernel(&self) -> Option> { + self.iter().find(|entry| entry.role == Role::Kernel) + } +} + +/// Every rule about a set of entries that does not depend on which +/// direction they came from. +/// +/// Both halves of this module run it: [`Bundle::parse`] after it has proved +/// there is a bundle there at all, and [`encode`] before it writes one. That +/// is the point — a packer that could build what a device rejects, or the +/// reverse, is the drift this crate exists to remove. +/// +/// Takes a cloneable iterator rather than a slice so that a decoded bundle +/// can be checked without collecting it into one, which is what keeps +/// reading allocation-free. +fn check_entries<'a, I>(entries: I) -> Result<(), Error> +where + I: Iterator> + Clone, +{ + for entry in entries.clone() { + check_path(entry.path)?; + } + check_unique_paths(entries.clone())?; + check_single(entries.clone(), Role::Kernel)?; + check_single(entries.clone(), Role::Config)?; + check_firmware_pairs(entries) +} + +/// Rejects two entries wanting the same destination. +/// +/// The second would simply overwrite the first, so the file the packer +/// meant to ship is not the one that lands — a failure with no symptom at +/// the time and a confusing one later. +fn check_unique_paths<'a, I>(entries: I) -> Result<(), Error> +where + I: Iterator> + Clone, +{ + for (index, entry) in entries.clone().enumerate() { + if entries + .clone() + .skip(index + 1) + .any(|other| other.path == entry.path) + { + return Err(Error::DuplicatePath); + } + } + Ok(()) +} + +/// Rejects a second entry claiming a role that admits only one. +fn check_single<'a, I>(entries: I, role: Role) -> Result<(), Error> +where + I: Iterator>, +{ + if entries.filter(|entry| entry.role == role).count() > 1 { + return Err(Error::DuplicateRole(role)); + } + Ok(()) +} + +/// Rejects a `start*.elf` without its `fixup*.dat`, or the reverse. +/// +/// The two are released together and a mismatched pair does not boot, so +/// shipping one of them is a packing mistake whose cost is a board that +/// needs its card pulled. Checking it is a few lines here and impossible +/// anywhere later. +/// +/// Only the pairing is checked, not the names: an entry marked +/// [`Role::Firmware`] may be called anything. A list of the files Raspberry +/// Pi currently ships would reject the one they add next, and this crate +/// has no way to be right about that list over time. +fn check_firmware_pairs<'a, I>(entries: I) -> Result<(), Error> +where + I: Iterator> + Clone, +{ + for entry in entries.clone().filter(|entry| entry.role == Role::Firmware) { + let name = basename(entry.path); + let paired = if let Some(suffix) = strip_around(name, "start", ".elf") { + has_firmware(entries.clone(), "fixup", suffix, ".dat") + } else if let Some(suffix) = strip_around(name, "fixup", ".dat") { + has_firmware(entries.clone(), "start", suffix, ".elf") + } else { + // `bootcode.bin`, or anything else with no partner. + true + }; + if !paired { + return Err(Error::UnpairedFirmware); + } + } + Ok(()) +} + +/// Whether a firmware entry named `` is present, +/// comparing the way FAT does. +fn has_firmware<'a, I>(entries: I, prefix: &str, middle: &str, extension: &str) -> bool +where + I: Iterator>, +{ + entries + .filter(|entry| entry.role == Role::Firmware) + .any(|entry| { + strip_around(basename(entry.path), prefix, extension) + .is_some_and(|found| found.eq_ignore_ascii_case(middle)) + }) +} + +/// Builds a bundle from `entries`. +/// +/// The entries are written in the order given. That order carries no +/// meaning — an installer decides what to write when from +/// [`Role`], not from where an entry sits — so a packer is free to emit +/// them in whatever order is convenient to produce. +/// +/// # Errors +/// +/// Every rule [`Bundle::parse`] would apply to the result is applied here +/// first, so this cannot produce a bundle its own parser rejects, plus the +/// two limits of the container itself: [`Error::PathTooLong`] and +/// [`Error::DataTooLarge`]. +#[cfg(feature = "alloc")] +pub fn encode(format: &Format, entries: &[Entry<'_>]) -> Result, Error> { + // A count that does not fit the header's `u16` is caught here rather + // than by the cast below, which would otherwise write a plausible small + // number and produce a bundle describing a fraction of itself. + if entries.is_empty() || entries.len() > format.max_entries || entries.len() > u16::MAX as usize + { + return Err(Error::BadEntryCount(entries.len())); + } + check_entries(entries.iter().copied())?; + + let mut size = HEADER_LEN + TRAILER_LEN; + for entry in entries { + if entry.path.len() > u8::MAX as usize { + return Err(Error::PathTooLong); + } + // Unreachable where `usize` is 32 bits, and unreachable in practice + // anywhere else — a 4 GiB entry is not a thing anyone packs. Here + // because the alternative is a silently truncated length field, and + // a check that never fires costs one comparison per entry. + if entry.data.len() as u64 > u32::MAX as u64 { + return Err(Error::DataTooLarge); + } + size += 1 + 1 + entry.path.len() + 4 + entry.data.len(); + } + + // Sized up front. A bundle carrying a kernel and the Raspberry Pi + // firmware is several megabytes, and growing a buffer to that from + // nothing copies it a dozen times for no reason. + let mut out = alloc::vec::Vec::with_capacity(size); + out.extend_from_slice(&format.magic); + out.push(VERSION); + out.push(0); + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for entry in entries { + out.push(entry.role.as_byte()); + out.push(entry.path.len() as u8); + out.extend_from_slice(entry.path.as_bytes()); + out.extend_from_slice(&(entry.data.len() as u32).to_le_bytes()); + out.extend_from_slice(entry.data); + } + out.extend_from_slice(&checksum(&out).to_le_bytes()); + Ok(out) +} + +/// Walks the entries of a [`Bundle`]. +#[derive(Clone, Debug)] +pub struct Iter<'a> { + rest: &'a [u8], + left: usize, +} + +impl<'a> Iterator for Iter<'a> { + type Item = Entry<'a>; + + fn next(&mut self) -> Option> { + if self.left == 0 { + return None; + } + // `Bundle::parse` walked this exact region and rejected everything + // `split_entry` can fail on, so the failing arm is unreachable for + // any `Bundle` that exists. Ending the walk is the conservative + // response to a bug reaching it: a caller sees fewer entries than + // `count()` promised, rather than an entry that is not there. + let (entry, rest) = split_entry(self.rest).ok()?; + self.rest = rest; + self.left -= 1; + Some(entry) + } + + fn size_hint(&self) -> (usize, Option) { + // No lower bound, for the same reason `next` can stop early. + (0, Some(self.left)) + } +} + +/// Splits one entry off the front of `rest`, returning it and what follows. +fn split_entry(rest: &[u8]) -> Result<(Entry<'_>, &[u8]), Error> { + let (&role, rest) = rest.split_first().ok_or(Error::Truncated)?; + let role = Role::from_byte(role).ok_or(Error::BadRole(role))?; + + let (&path_len, rest) = rest.split_first().ok_or(Error::Truncated)?; + let (path, rest) = rest + .split_at_checked(path_len as usize) + .ok_or(Error::Truncated)?; + let path = core::str::from_utf8(path).map_err(|_| Error::PathNotUtf8)?; + + let (size, rest) = rest.split_at_checked(4).ok_or(Error::Truncated)?; + let size = u32::from_le_bytes([size[0], size[1], size[2], size[3]]) as usize; + let (data, rest) = rest.split_at_checked(size).ok_or(Error::Truncated)?; + + Ok((Entry { role, path, data }, rest)) +} + +/// Rejects a path that is not a plain relative one. +/// +/// This is about a bundle escaping the volume it is being written to, and +/// is unrelated to whether writing a particular file is a good idea — a +/// bundle is allowed to replace `config.txt` and the boot firmware, because +/// being unable to is what made a card reader necessary. +fn check_path(path: &str) -> Result<(), Error> { + if path.is_empty() { + return Err(Error::BadPath); + } + // Redundant with the empty-component rule below, which a leading + // separator also trips. Kept because "absolute paths are rejected" + // should be readable here rather than derived. + if path.starts_with('/') { + return Err(Error::BadPath); + } + // A backslash is a separator to some tools and an ordinary character to + // a FAT writer, so a path carrying one lands as a single strangely + // named file instead of the two levels whoever packed it meant. + if path.contains('\\') { + return Err(Error::BadPath); + } + for component in path.split('/') { + if component.is_empty() || component == "." || component == ".." { + return Err(Error::BadPath); + } + } + Ok(()) +} + +/// The last component of `path`. +fn basename(path: &str) -> &str { + path.rsplit_once('/').map_or(path, |(_, name)| name) +} + +/// The middle of ``, matching the ends the way FAT +/// compares names. +fn strip_around<'a>(name: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { + if name.len() < prefix.len() + suffix.len() { + return None; + } + let (head, rest) = name.split_at_checked(prefix.len())?; + if !head.eq_ignore_ascii_case(prefix) { + return None; + } + let (middle, tail) = rest.split_at_checked(rest.len() - suffix.len())?; + if !tail.eq_ignore_ascii_case(suffix) { + return None; + } + Some(middle) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FORMAT: Format = Format { + magic: *b"TEST", + max_entries: 8, + }; + + /// Builds bundles, including malformed ones. + /// + /// Deliberately not [`encode`], which cannot produce most of what the + /// decoder has to reject — and which, being the other half of the code + /// under test, would agree with it about a format neither had got + /// right. The golden vectors below are the check on that. + struct Builder { + magic: [u8; 4], + version: u8, + count: Option, + entries: Vec<(u8, Vec, Vec)>, + trailing: Vec, + corrupt: bool, + } + + impl Builder { + fn new() -> Self { + Builder { + magic: *b"TEST", + version: VERSION, + count: None, + entries: Vec::new(), + trailing: Vec::new(), + corrupt: false, + } + } + + fn entry(mut self, role: u8, path: &str, data: &[u8]) -> Self { + self.entries + .push((role, path.as_bytes().to_vec(), data.to_vec())); + self + } + + fn raw_path_entry(mut self, role: u8, path: &[u8], data: &[u8]) -> Self { + self.entries.push((role, path.to_vec(), data.to_vec())); + self + } + + fn build(self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.magic); + out.push(self.version); + out.push(0); + let count = self.count.unwrap_or(self.entries.len() as u16); + out.extend_from_slice(&count.to_le_bytes()); + for (role, path, data) in &self.entries { + out.push(*role); + out.push(path.len() as u8); + out.extend_from_slice(path); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(data); + } + out.extend_from_slice(&self.trailing); + let crc = checksum(&out) ^ if self.corrupt { 1 } else { 0 }; + out.extend_from_slice(&crc.to_le_bytes()); + out + } + } + + fn one_file() -> Vec { + Builder::new().entry(0, "WWW/INDEX.HTM", b"hello").build() + } + + fn parse(bytes: &[u8]) -> Result, Error> { + Bundle::parse(&FORMAT, bytes) + } + + fn error(bytes: &[u8]) -> Error { + parse(bytes).expect_err("expected this bundle to be rejected") + } + + #[test] + fn checksum_matches_the_standard_vector() { + // The IEEE CRC-32 check value, so a change of polynomial or of + // reflection is caught here rather than by a board that will not + // boot. + assert_eq!(checksum(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn a_well_formed_bundle_parses() { + let bytes = Builder::new() + .entry(1, "KERNEL7.IMG", b"kernel bytes") + .entry(0, "WWW/INDEX.HTM", b"") + .entry(3, "CONFIG.TXT", b"arm_64bit=0\n") + .build(); + let bundle = parse(&bytes).unwrap(); + + assert_eq!(bundle.count(), 3); + let entries: Vec<_> = bundle.iter().collect(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].role, Role::Kernel); + assert_eq!(entries[0].path, "KERNEL7.IMG"); + assert_eq!(entries[0].data, b"kernel bytes"); + assert_eq!(entries[1].role, Role::File); + assert_eq!(entries[1].path, "WWW/INDEX.HTM"); + assert_eq!(entries[2].role, Role::Config); + assert_eq!(bundle.kernel().unwrap().data, b"kernel bytes"); + } + + #[test] + fn a_bundle_with_no_kernel_is_valid() { + let bytes = Builder::new().entry(0, "WATER.CFG", b"zone=1").build(); + let bundle = parse(&bytes).unwrap(); + assert_eq!(bundle.count(), 1); + assert!(bundle.kernel().is_none()); + } + + #[test] + fn an_empty_file_is_valid() { + let bytes = Builder::new().entry(0, "EMPTY.TXT", b"").build(); + assert_eq!(parse(&bytes).unwrap().iter().next().unwrap().data, b""); + } + + #[test] + fn too_short() { + assert_eq!(error(&[]), Error::TooShort); + assert_eq!(error(&one_file()[..MIN_LEN - 1]), Error::TooShort); + } + + #[test] + fn bad_magic() { + let mut bytes = one_file(); + bytes[0] = b'X'; + assert_eq!(error(&bytes), Error::BadMagic); + } + + #[test] + fn bad_version() { + // The version this replaces, which is the one a stale bundle left + // in a `target/` directory would carry. + let mut builder = Builder::new().entry(0, "A.TXT", b"a"); + builder.version = 1; + assert_eq!(error(&builder.build()), Error::BadVersion(1)); + } + + #[test] + fn bad_checksum() { + let mut bytes = one_file(); + let last = bytes.len() - TRAILER_LEN - 1; + bytes[last] ^= 0xFF; + assert_eq!(error(&bytes), Error::BadChecksum); + } + + #[test] + fn the_checksum_is_checked_before_the_entries() { + // A count of zero and a broken checksum together: the checksum is + // what a torn transfer actually looks like, and reporting the count + // would send someone to look at the packer. + let mut builder = Builder::new().entry(0, "A.TXT", b"a"); + builder.count = Some(0); + builder.corrupt = true; + assert_eq!(error(&builder.build()), Error::BadChecksum); + } + + #[test] + fn bad_entry_count() { + let mut builder = Builder::new().entry(0, "A.TXT", b"a"); + builder.count = Some(0); + assert_eq!(error(&builder.build()), Error::BadEntryCount(0)); + + let mut builder = Builder::new(); + for index in 0..9 { + builder = builder.entry(0, &format!("F{index}.TXT"), b"x"); + } + assert_eq!(error(&builder.build()), Error::BadEntryCount(9)); + } + + #[test] + fn an_entry_running_past_the_end_is_truncated() { + let mut builder = Builder::new().entry(0, "A.TXT", b"abc"); + builder.count = Some(2); + assert_eq!(error(&builder.build()), Error::Truncated); + } + + #[test] + fn slack_between_the_last_entry_and_the_checksum_is_truncated() { + let mut builder = Builder::new().entry(0, "A.TXT", b"abc"); + builder.trailing = vec![0; 3]; + assert_eq!(error(&builder.build()), Error::Truncated); + } + + #[test] + fn bad_role() { + let bytes = Builder::new().entry(4, "A.TXT", b"a").build(); + assert_eq!(error(&bytes), Error::BadRole(4)); + } + + #[test] + fn path_not_utf8() { + let bytes = Builder::new() + .raw_path_entry(0, &[0xFF, 0xFE], b"a") + .build(); + assert_eq!(error(&bytes), Error::PathNotUtf8); + } + + #[test] + fn unsafe_paths() { + for path in [ + "", + "/ABS.TXT", + "../ESCAPE.TXT", + "WWW/../ESCAPE.TXT", + "./HERE.TXT", + "WWW//DOUBLE.TXT", + "TRAILING/", + "WWW\\INDEX.HTM", + ] { + let bytes = Builder::new().entry(0, path, b"x").build(); + assert_eq!(error(&bytes), Error::BadPath, "path {path:?} was accepted"); + } + } + + #[test] + fn nested_paths_are_fine() { + let bytes = Builder::new().entry(0, "CERTS/ROOTS/CA.PEM", b"x").build(); + assert_eq!(parse(&bytes).unwrap().count(), 1); + } + + #[test] + fn duplicate_path() { + let bytes = Builder::new() + .entry(0, "WWW/INDEX.HTM", b"one") + .entry(0, "WWW/INDEX.HTM", b"two") + .build(); + assert_eq!(error(&bytes), Error::DuplicatePath); + } + + #[test] + fn two_kernels() { + let bytes = Builder::new() + .entry(1, "KERNEL7.IMG", b"a") + .entry(1, "KERNEL8.IMG", b"b") + .build(); + assert_eq!(error(&bytes), Error::DuplicateRole(Role::Kernel)); + } + + #[test] + fn two_configs() { + let bytes = Builder::new() + .entry(3, "CONFIG.TXT", b"a") + .entry(3, "BOOT/CONFIG.TXT", b"b") + .build(); + assert_eq!(error(&bytes), Error::DuplicateRole(Role::Config)); + } + + #[test] + fn a_start_without_its_fixup_is_unpaired() { + let bytes = Builder::new().entry(2, "START.ELF", b"a").build(); + assert_eq!(error(&bytes), Error::UnpairedFirmware); + } + + #[test] + fn a_fixup_without_its_start_is_unpaired() { + let bytes = Builder::new().entry(2, "FIXUP.DAT", b"a").build(); + assert_eq!(error(&bytes), Error::UnpairedFirmware); + } + + #[test] + fn a_pair_with_mismatched_suffixes_is_unpaired() { + let bytes = Builder::new() + .entry(2, "START4.ELF", b"a") + .entry(2, "FIXUP.DAT", b"b") + .build(); + assert_eq!(error(&bytes), Error::UnpairedFirmware); + } + + #[test] + fn a_matched_pair_is_accepted_whatever_the_case_and_suffix() { + for (start, fixup) in [ + ("START.ELF", "FIXUP.DAT"), + ("start.elf", "fixup.dat"), + ("Start4.Elf", "fixup4.DAT"), + ("BOOT/start_x.elf", "FIXUP_X.DAT"), + ] { + let bytes = Builder::new() + .entry(2, start, b"a") + .entry(2, fixup, b"b") + .build(); + assert!(parse(&bytes).is_ok(), "{start} + {fixup} was rejected"); + } + } + + #[test] + fn firmware_with_no_partner_needs_none() { + let bytes = Builder::new().entry(2, "BOOTCODE.BIN", b"a").build(); + assert_eq!(parse(&bytes).unwrap().count(), 1); + } + + #[test] + fn every_rejection_is_the_senders_fault() { + assert_eq!(Error::TooShort.http_status(), 400); + assert_eq!(Error::BadChecksum.http_status(), 400); + assert_eq!(Error::UnpairedFirmware.http_status(), 400); + } + + #[test] + fn roles_round_trip_through_their_wire_bytes() { + for role in [Role::File, Role::Kernel, Role::Firmware, Role::Config] { + assert_eq!(Role::from_byte(role.as_byte()), Some(role)); + } + assert_eq!(Role::from_byte(4), None); + } + + // --- Golden vectors ------------------------------------------------- + // + // Laid out by hand from the format at the top of this module, byte by + // byte, and never regenerated from `encode`. That is the whole value of + // them: a round trip only proves the encoder and the decoder agree, and + // they would agree just as happily about a container that had drifted. + // These fail instead. + // + // The checksums were computed by Python's `zlib.crc32` — a third + // implementation, so the constant in this crate is pinned to something + // outside it. + + /// One file, nothing else. The smallest bundle that is not an error. + const GOLDEN_ONE_FILE: &[u8] = &[ + b'T', b'E', b'S', b'T', // magic + 2, // version + 0, // reserved + 1, 0, // count = 1 + 0, // [0] role = file + 5, // path_len + b'A', b'.', b'T', b'X', b'T', // path + 3, 0, 0, 0, // size = 3 + b'a', b'b', b'c', // data + 0xE9, 0xB1, 0x6D, 0xB9, // crc32 of everything above + ]; + + /// A kernel, a nested asset and a `config.txt` — one entry of every + /// role that an ordinary update carries. + const GOLDEN_FULL: &[u8] = &[ + b'T', b'E', b'S', b'T', // magic + 2, // version + 0, // reserved + 3, 0, // count = 3 + 1, // [0] role = kernel + 11, // path_len + b'K', b'E', b'R', b'N', b'E', b'L', b'7', b'.', b'I', b'M', b'G', 4, 0, 0, + 0, // size = 4 + b'k', b'r', b'n', b'l', 0, // [1] role = file + 13, // path_len + b'W', b'W', b'W', b'/', b'I', b'N', b'D', b'E', b'X', b'.', b'H', b'T', b'M', 6, 0, 0, + 0, // size = 6 + b'<', b'h', b't', b'm', b'l', b'>', 3, // [2] role = config + 10, // path_len + b'C', b'O', b'N', b'F', b'I', b'G', b'.', b'T', b'X', b'T', 12, 0, 0, 0, // size = 12 + b'a', b'r', b'm', b'_', b'6', b'4', b'b', b'i', b't', b'=', b'0', b'\n', 0xB0, 0xBD, 0xAA, + 0xA1, // crc32 + ]; + + /// A firmware pair and no kernel — an update that replaces the + /// Raspberry Pi firmware and leaves the boot image alone. + const GOLDEN_FIRMWARE: &[u8] = &[ + b'T', b'E', b'S', b'T', // magic + 2, // version + 0, // reserved + 2, 0, // count = 2 + 2, // [0] role = firmware + 9, // path_len + b'S', b'T', b'A', b'R', b'T', b'.', b'E', b'L', b'F', 2, 0, 0, 0, // size = 2 + b's', b'e', 2, // [1] role = firmware + 9, // path_len + b'F', b'I', b'X', b'U', b'P', b'.', b'D', b'A', b'T', 2, 0, 0, 0, // size = 2 + b'f', b'd', 0x4F, 0xA4, 0x7B, 0xD1, // crc32 + ]; + + /// Both directions against bytes neither direction produced. + fn check_golden(bytes: &[u8], entries: &[Entry<'_>]) { + let bundle = parse(bytes).expect("the golden vector should parse"); + assert_eq!(bundle.iter().collect::>(), entries); + assert_eq!(encode(&FORMAT, entries).unwrap(), bytes); + } + + #[test] + fn golden_one_file() { + check_golden( + GOLDEN_ONE_FILE, + &[Entry { + role: Role::File, + path: "A.TXT", + data: b"abc", + }], + ); + } + + #[test] + fn golden_full() { + check_golden( + GOLDEN_FULL, + &[ + Entry { + role: Role::Kernel, + path: "KERNEL7.IMG", + data: b"krnl", + }, + Entry { + role: Role::File, + path: "WWW/INDEX.HTM", + data: b"", + }, + Entry { + role: Role::Config, + path: "CONFIG.TXT", + data: b"arm_64bit=0\n", + }, + ], + ); + } + + #[test] + fn golden_firmware() { + check_golden( + GOLDEN_FIRMWARE, + &[ + Entry { + role: Role::Firmware, + path: "START.ELF", + data: b"se", + }, + Entry { + role: Role::Firmware, + path: "FIXUP.DAT", + data: b"fd", + }, + ], + ); + } + + // --- Encoding ------------------------------------------------------- + + #[test] + fn what_is_encoded_parses_back() { + let big = vec![0xA5; 100_000]; + let entries = [ + Entry { + role: Role::Kernel, + path: "KERNEL8.IMG", + data: &big, + }, + Entry { + role: Role::File, + path: "CERTS/ROOTS/CA.PEM", + data: b"-----BEGIN", + }, + Entry { + role: Role::File, + path: "EMPTY.BIN", + data: b"", + }, + ]; + let bytes = encode(&FORMAT, &entries).unwrap(); + let bundle = parse(&bytes).unwrap(); + assert_eq!(bundle.iter().collect::>(), entries); + assert_eq!(bundle.kernel().unwrap().data.len(), 100_000); + } + + #[test] + fn encoding_reserves_exactly_what_it_writes() { + // Not a style point: the buffer is megabytes in practice, and a + // capacity that is merely close still reallocates and copies. + let entries = [Entry { + role: Role::File, + path: "A.TXT", + data: b"abc", + }]; + let bytes = encode(&FORMAT, &entries).unwrap(); + assert_eq!(bytes.len(), bytes.capacity()); + } + + #[test] + fn another_applications_bundle_is_rejected() { + let entries = [Entry { + role: Role::File, + path: "A.TXT", + data: b"abc", + }]; + let bytes = encode( + &Format { + magic: *b"OTHR", + max_entries: 8, + }, + &entries, + ) + .unwrap(); + assert_eq!(error(&bytes), Error::BadMagic); + } + + #[test] + fn encoding_applies_every_rule_the_parser_would() { + let file = |path| Entry { + role: Role::File, + path, + data: b"x" as &[u8], + }; + + assert_eq!( + encode(&FORMAT, &[]).unwrap_err(), + Error::BadEntryCount(0), + "an empty bundle" + ); + + let many: Vec<_> = ["A", "B", "C", "D", "E", "F", "G", "H", "I"] + .iter() + .map(|path| file(path)) + .collect(); + assert_eq!( + encode(&FORMAT, &many).unwrap_err(), + Error::BadEntryCount(9), + "more entries than the format allows" + ); + + assert_eq!( + encode(&FORMAT, &[file("../ESCAPE.TXT")]).unwrap_err(), + Error::BadPath + ); + assert_eq!( + encode(&FORMAT, &[file("A.TXT"), file("A.TXT")]).unwrap_err(), + Error::DuplicatePath + ); + assert_eq!( + encode( + &FORMAT, + &[ + Entry { + role: Role::Kernel, + path: "KERNEL7.IMG", + data: b"a" + }, + Entry { + role: Role::Kernel, + path: "KERNEL8.IMG", + data: b"b" + }, + ] + ) + .unwrap_err(), + Error::DuplicateRole(Role::Kernel) + ); + assert_eq!( + encode( + &FORMAT, + &[Entry { + role: Role::Firmware, + path: "START.ELF", + data: b"a" + }] + ) + .unwrap_err(), + Error::UnpairedFirmware + ); + } + + #[test] + fn a_path_longer_than_its_length_field() { + let long = "A".repeat(256); + assert_eq!( + encode( + &FORMAT, + &[Entry { + role: Role::File, + path: &long, + data: b"x" + }] + ) + .unwrap_err(), + Error::PathTooLong + ); + + // 255 is the largest a `u8` length field can describe, so it has to + // work rather than being one past a limit nobody tested. + let limit = "A".repeat(255); + assert!( + encode( + &FORMAT, + &[Entry { + role: Role::File, + path: &limit, + data: b"x" + }] + ) + .is_ok() + ); + } +} diff --git a/ota/src/lib.rs b/ota/src/lib.rs new file mode 100644 index 0000000..4963d5d --- /dev/null +++ b/ota/src/lib.rs @@ -0,0 +1,72 @@ +//! The over-the-air update bundle a bare-metal Raspberry Pi installs on +//! itself: one container, packed on a host and validated on the device. +//! +//! A bundle carries a kernel image and the files that ship beside it — +//! a website, assets, whatever an application serves — under a single +//! checksum, so a board can reject a damaged transfer before it writes +//! anything to its card. +//! +//! The reason this is a crate rather than a file each project keeps a copy +//! of is that **the encoder and the decoder are the same code**. A wire +//! format described in two places is a format that has already drifted: +//! the two implementations this one was collected from agreed on every +//! byte and disagreed on what they enforced, each rejecting bundles the +//! other accepted. +//! +//! # How it is split +//! +//! | | Feature | Pulls in | +//! | --- | --- | --- | +//! | Reading and checking a bundle | *none* | nothing | +//! | Building one | `alloc` *(default)* | nothing | +//! | Installing one onto a FAT volume | `apply` | `resident-fat` | +//! +//! The split is not tidiness. `rpi-loader`, the host CLI that packs +//! bundles, never installs one — without the feature it would depend on a +//! filesystem implementation in order to write a file on a host. A device +//! that only installs bundles is the mirror image, compiling an encoder it +//! will never call. +//! +//! Reading needs no allocator at all: [`Bundle::parse`] validates in place +//! and borrows, so a device holds one buffer — the bundle as it arrived — +//! and nothing else. +//! +//! # Versions +//! +//! The container is at version 2 and version 1 is not readable here. The +//! two are different enough that supporting both would be a compatibility +//! path used twice and then deleted, which is worth less than the card +//! reader it would save. +//! +//! That awkwardness is inherent rather than accidental: **a bundle is +//! parsed by the firmware already running and installs the firmware that +//! replaces it**, so a board can never be sent a container its current +//! build does not understand. Changing the format means reaching the +//! board some other way once. +//! +//! # What an application still supplies +//! +//! **The transport and the reboot.** How a bundle arrives — an HTTP route, +//! a job handed to another core, a serial command — is application shaped, +//! and a crate that took it would be choosing the web framework. +//! +//! **The measurement.** What an update costs the card is the number these +//! projects care about most, and collecting it here would mean depending on +//! an async runtime for a clock and on whatever wrapper is counting device +//! commands. The install reports its progress and the caller times it. + +// `std` under `cfg(test)` only, because the test harness needs it. The +// bare-metal clippy pass in `make clippy-ota` builds without `--all-targets` +// and so compiles this crate as the device sees it: `no_std`. +#![cfg_attr(not(test), no_std)] +#![deny(missing_docs)] + +#[cfg(feature = "alloc")] +extern crate alloc; + +pub mod bundle; + +pub use bundle::{Bundle, Entry, Error, Format, Role, checksum}; + +#[cfg(feature = "alloc")] +pub use bundle::encode;