diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 294d573..aca6e69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,15 @@ jobs: - name: Install cargo-binutils run: cargo install cargo-binutils --locked + # `ota/tests/apply.rs` builds its FAT32 volumes with `mkfs.vfat` and + # judges the result with `fsck.vfat`. Testing a filesystem writer + # against another implementation of the same filesystem is the whole + # point — a check written here could only agree with the code it is + # checking — so these tests fail rather than skip without the tools, + # and CI has to supply them. + - name: Install dosfstools + run: sudo apt-get update && sudo apt-get install -y dosfstools + - uses: Swatinem/rust-cache@v2 with: workspaces: | diff --git a/ota/CHANGELOG.md b/ota/CHANGELOG.md index 3d3bfa2..b73adb0 100644 --- a/ota/CHANGELOG.md +++ b/ota/CHANGELOG.md @@ -10,6 +10,48 @@ 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. +## [Unreleased] + +### Added + +- **`apply`**, behind the feature of the same name: the other half of an + update. It takes a validated bundle and a `resident-fat` volume and + writes every entry where its path says, in an order the crate imposes + rather than one the bundle chooses — ordinary files, then Raspberry Pi + firmware, then `config.txt`, then the kernel. The kernel is last + because while a board has one boot image that write *is* the commit, so + everything that could fail has to have failed already. + + Nested destinations are created as needed, since a bundle can carry a + path and `write_file` resolves a parent rather than making one. + +- **Entries the card already holds are read and not rewritten.** The same + function answers both halves of the question — before a write it + decides whether to write at all, and after one it *is* the + verification — so a skipped entry is checked exactly as strictly as a + written one. A bundle carrying the Raspberry Pi firmware carries about + 3 MB of it, and that changes roughly once a year; on hardware, applying + an unchanged bundle now costs 1055 ms and **no card writes at all**, + against 3724 ms to write the same thing in full. + +- **`Progress`**, which is how timing stays with the caller. Every method + defaults to doing nothing and `()` implements the whole trait, so a + caller names only what it wants. The boundaries separate the write from + the read-back deliberately: those are not the same operation and do not + have the same fix, so one figure covering both would hide which of them + an improvement had touched. + +- **`Checksum`**, the streaming form of `checksum`, for checking a file + already on a card against a fixed scratch buffer rather than a second + copy of it in memory. + +### Changed + +- `tests/` is no longer published. The suite builds its FAT32 volumes + with `mkfs.vfat` and judges them with `fsck.vfat`, so it fails rather + than skips without `dosfstools` — a suite that cannot run from the + tarball it ships in says nothing about the crate. + ## [0.1.0] - 2026-09-01 First release. diff --git a/ota/Cargo.toml b/ota/Cargo.toml index 92a5a91..cc275cb 100644 --- a/ota/Cargo.toml +++ b/ota/Cargo.toml @@ -15,11 +15,21 @@ categories = ["embedded", "no-std", "filesystem"] # 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"] +# Repository-only, and shipping either would be worse than leaving it out. +# +# `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. +# +# `tests/` is the entry worth explaining, because shipping a test suite is +# usually a kindness. Here it would be a broken one: `tests/apply.rs` builds +# its FAT32 volumes with `mkfs.vfat` and judges them with `fsck.vfat`, so it +# fails rather than skips on a machine without dosfstools. A suite that +# cannot run from the tarball it ships in is worse than no suite, because a +# red result there says nothing about this crate. The place to run these is +# the repository, where CI installs the tools. +exclude = ["rust-toolchain.toml", "tests/"] # 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 @@ -53,6 +63,13 @@ crc = { version = "3", default-features = false } # opt-in there -- so nothing needs turning off. resident-fat = { version = "0.1.0", optional = true } +[dev-dependencies] +# Named again, though the `apply` feature already pulls it in, because the +# integration tests implement `BlockDevice` and mount a volume themselves — +# they are a consumer of it in their own right, not merely a consumer of +# this crate. No `mbr`: the tests mount a whole device, not a partition. +resident-fat = "0.1.0" + [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 diff --git a/ota/src/apply.rs b/ota/src/apply.rs new file mode 100644 index 0000000..af5d192 --- /dev/null +++ b/ota/src/apply.rs @@ -0,0 +1,350 @@ +//! Installing a validated bundle onto a FAT volume. +//! +//! This is the half that knows what a boot partition is. It takes the +//! entries [`Bundle::parse`] handed back and writes them where they say, +//! in an order chosen so that a failure part-way through leaves a board +//! that still boots. +//! +//! # What the caller still supplies +//! +//! The transport and the reboot: how a bundle arrived is application +//! shaped, and a crate that took it would be choosing the web framework. +//! And the measurement — [`Progress`] reports what happened and the caller +//! decides what to time, which is what keeps an async runtime's clock and +//! whatever counts device commands out of this crate. + +use resident_fat::{BlockDevice, FileSystem}; + +use crate::bundle::{self, Bundle, Checksum, Entry, Format, Role}; + +/// Bytes read at a time when checking what is already on the card. +/// +/// Not a stack buffer: this is allocated once per [`apply`] call and reused, +/// because the size is the whole point. A card charges per command far more +/// than per block, so reading a 3 MB file 512 bytes at a time would cost +/// thousands of commands and defeat the comparison it exists to make cheap. +/// 64 KiB is 128 blocks a call — enough that the per-command cost stops +/// mattering, small enough to be nothing beside the bundle already in +/// memory. +const SCRATCH: usize = 64 * 1024; + +/// What installing a bundle touched. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Report { + /// Size of the kernel installed, when the bundle carried one. + /// + /// `None` is not a failure: a bundle with no kernel is how a website or + /// a settings file is updated without rewriting an image that has not + /// changed. + pub kernel_len: Option, + /// Entries written. + pub written: u32, + /// Entries the card already held, and which were therefore not written. + pub skipped: u32, +} + +/// Told what the installer is doing, so that timing stays with the caller. +/// +/// Every method does nothing by default, so an implementation names only +/// what it cares about, and `()` implements the whole thing for a caller +/// that cares about none of it. +/// +/// The boundaries are where they are so that a caller can time the write +/// and the read-back separately. Those are not the same operation and do +/// not have the same fix — a write is erase-block program cycles and a read +/// is not — so a single figure covering both would hide which of them an +/// improvement had touched. +pub trait Progress { + /// About to consider `entry`; nothing has been read or written yet. + fn starting(&mut self, entry: Entry<'_>) { + let _ = entry; + } + + /// `entry` has been written and is about to be read back. + fn wrote(&mut self, entry: Entry<'_>) { + let _ = entry; + } + + /// `entry` has been read back and matched. + fn verified(&mut self, entry: Entry<'_>) { + let _ = entry; + } + + /// The card already held `entry`, so nothing was written. It has still + /// been read in full — that is how this was established. + fn skipped(&mut self, entry: Entry<'_>) { + let _ = entry; + } +} + +impl Progress for () {} + +/// Why an install was refused or failed. +/// +/// Generic over the device error, unlike [`bundle::Error`], which is why the +/// two are separate types: a host that only ever builds bundles would +/// otherwise have to name a block-device error it does not have. +#[derive(Debug)] +pub enum Error { + /// The bundle was rejected before anything was written. + Bundle(bundle::Error), + /// The card or the filesystem failed. + Storage(resident_fat::Error), + /// A file read back after writing did not match what was written. + VerifyFailed, +} + +impl Error { + /// The HTTP status to answer this with, for a device that took the + /// bundle over HTTP. + /// + /// A rejected bundle is the sender's fault and a card that failed + /// mid-write is not, and answering the same to both sends whoever is + /// updating to look at the wrong thing. + pub fn http_status(&self) -> u16 { + match self { + Error::Bundle(error) => error.http_status(), + Error::Storage(_) | Error::VerifyFailed => 500, + } + } +} + +impl From for Error { + fn from(error: bundle::Error) -> Error { + Error::Bundle(error) + } +} + +impl From> for Error { + fn from(error: resident_fat::Error) -> Error { + Error::Storage(error) + } +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Error::Bundle(error) => write!(f, "{error}"), + Error::Storage(error) => write!(f, "{error}"), + Error::VerifyFailed => f.write_str("write verification failed"), + } + } +} + +impl core::error::Error for Error {} + +/// Installs `bundle` onto `volume`. +/// +/// Does **not** reboot. The caller answers whoever sent the bundle first, +/// because a failure that nobody is told about is worse than a slow one. +/// +/// # Order +/// +/// Entries are written in role order rather than the order they were +/// packed, and the crate imposes it rather than trusting the bundle — the +/// same reasoning that made the kernel a role instead of a position: +/// +/// 1. [`Role::File`] — assets, settings, blobs. Nothing here is loaded by +/// the firmware, so a failure leaves a board that still boots what it +/// booted last time. +/// 2. [`Role::Firmware`] — `start*.elf`, `fixup*.dat`, `bootcode.bin`. +/// 3. [`Role::Config`] — `config.txt`, which the firmware does read, but +/// which cannot on its own stop a board booting: lose it and the +/// firmware falls back to loading `kernel7.img`/`kernel8.img`. +/// 4. [`Role::Kernel`] — last, because with one boot image this write **is** +/// the commit: the board boots whatever it leaves behind. +/// +/// Steps 3 and 4 swap once there are two kernel slots to write to. The +/// kernel then goes to whichever is not running — which is no longer the +/// live image, and no longer the commit — and rewriting `config.txt`'s +/// `kernel=` line takes both jobs over. +/// +/// # `bootcode.bin` has no safety net +/// +/// The ROM loads it by name, so there is no slot to write it to and no +/// `config.txt` line to point elsewhere: a failed write there means the +/// card comes out. Three things keep that acceptable rather than merely +/// unavoidable — it is about 50 KB, it changes almost never, and on the +/// Pi 4 it lives in SPI EEPROM and is not on the card at all. Every other +/// boot file has a way out. +pub fn apply( + volume: &mut FileSystem, + format: &Format, + bundle: &[u8], + progress: &mut P, +) -> Result> +where + D: BlockDevice, + P: Progress, +{ + // Every check that is a statement about the bundle happens here, before + // a byte reaches the card. + let parsed = Bundle::parse(format, bundle)?; + let installed = install(volume, &parsed, progress); + + // Unconditional, and outside `install` so a failure syncs too. The + // allocation table lives in memory, so nothing written above has + // necessarily reached the card in full; what a failed install did manage + // to write is on the card either way, and leaving the table behind is + // the one thing that turns a half-finished update into a corrupt volume. + let synced = volume.sync(); + installed.and_then(|report| synced.map(|()| report).map_err(Error::from)) +} + +/// The install itself, so [`apply`] can sync after it either way. +fn install( + volume: &mut FileSystem, + bundle: &Bundle<'_>, + progress: &mut P, +) -> Result> +where + D: BlockDevice, + P: Progress, +{ + let mut scratch = alloc::vec![0u8; SCRATCH]; + let mut report = Report::default(); + + for role in [Role::File, Role::Firmware, Role::Config] { + for entry in bundle.iter().filter(|entry| entry.role == role) { + record( + &mut report, + install_entry(volume, entry, progress, &mut scratch)?, + ); + } + } + + if let Some(entry) = bundle.kernel() { + report.kernel_len = Some(entry.data.len() as u32); + record( + &mut report, + install_entry(volume, entry, progress, &mut scratch)?, + ); + } + + Ok(report) +} + +/// Counts one entry as written or skipped. +fn record(report: &mut Report, written: bool) { + if written { + report.written += 1; + } else { + report.skipped += 1; + } +} + +/// Puts one entry where its path says. Returns whether it had to be written. +fn install_entry( + volume: &mut FileSystem, + entry: Entry<'_>, + progress: &mut P, + scratch: &mut [u8], +) -> Result> +where + D: BlockDevice, + P: Progress, +{ + progress.starting(entry); + + // Asked before writing, because the answer is often yes and a read costs + // a fraction of a write. A bundle that carries the Raspberry Pi firmware + // carries about 3 MB of it, and that changes roughly once a year. + if on_card(volume, entry.path, entry.data, scratch)? { + progress.skipped(entry); + return Ok(false); + } + + create_parents(volume, entry.path)?; + // One call, and that is the point: the length is known before a byte is + // written, so the whole cluster chain is allocated at once and the file + // comes out contiguous. A file grown a write at a time gets whatever the + // allocator had spare each time, which is how a transfer ends up as one + // device command per fragment. + volume.write_file(entry.path, entry.data)?; + progress.wrote(entry); + + // The read-back is the point of the whole exercise: a write the card + // accepted but did not durably store produces a board that will not + // boot, discovered on the reboot that was supposed to complete the + // update. No `sync` first -- what this crate keeps in memory is the + // allocation table and the directories, and file data goes to the card + // inside `write_file`, so a read comes off the card either way. + if !on_card(volume, entry.path, entry.data, scratch)? { + return Err(Error::VerifyFailed); + } + progress.verified(entry); + Ok(true) +} + +/// Whether the card already holds exactly `data` at `path`. +/// +/// Used for both halves of the same question: before a write it decides +/// whether to write at all, and after one it is the verification. Sharing +/// the code is not only tidiness — it means a skipped entry has been +/// checked exactly as strictly as a written one. +/// +/// The comparison is a checksum rather than the byte-for-byte compare an +/// in-memory copy would allow. That is a real if small weakening, and it +/// buys the ability to check a file of any size against a fixed scratch +/// buffer: the alternative is holding a 3 MB read-back beside the 3 MB +/// already in the bundle. +fn on_card( + volume: &mut FileSystem, + path: &str, + data: &[u8], + scratch: &mut [u8], +) -> Result> +where + D: BlockDevice, +{ + let file = match volume.open(path) { + Ok(file) => file, + // Absent is an answer, not a failure. Any other error is the card + // and must not be reported as "different". + Err(resident_fat::Error::NotFound { .. }) => return Ok(false), + Err(error) => return Err(Error::Storage(error)), + }; + + // Length first, because it is free and settles most of the cases a + // checksum would have to read a whole file to settle. + if file.len() as usize != data.len() { + return Ok(false); + } + + let mut checksum = Checksum::new(); + let mut at = 0u64; + while at < file.len() as u64 { + let read = volume.read_at(&file, at, scratch)?; + if read == 0 { + // Short of the length the directory entry claims. Whatever that + // is, it is not the file being asked about. + return Ok(false); + } + checksum.update(&scratch[..read]); + at += read as u64; + } + + Ok(checksum.finish() == bundle::checksum(data)) +} + +/// Creates every directory above `path` that is not already there. +/// +/// A path in a bundle can be nested, and `write_file` resolves a parent +/// rather than making one, so a first update onto a card that has never held +/// the directory would otherwise fail at the first asset. One level at a +/// time, because that is what `create_dir` does. +fn create_parents(volume: &mut FileSystem, path: &str) -> Result<(), Error> +where + D: BlockDevice, +{ + let mut at = 0; + while let Some(offset) = path[at..].find('/') { + let end = at + offset; + let directory = &path[..end]; + if volume.open_dir(directory).is_err() { + volume.create_dir(directory)?; + } + at = end + 1; + } + Ok(()) +} diff --git a/ota/src/bundle.rs b/ota/src/bundle.rs index a5f7ff8..0f822f9 100644 --- a/ota/src/bundle.rs +++ b/ota/src/bundle.rs @@ -55,7 +55,11 @@ const TRAILER_LEN: usize = 4; 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); +/// +/// A `static` rather than a `const` because [`Checksum`] borrows it for the +/// life of the program: a `const` is a fresh temporary at every mention and +/// cannot be borrowed past the expression naming it. +static CRC32: Crc = Crc::::new(&CRC_32_ISO_HDLC); /// The IEEE CRC-32 of `data`, as it appears in a bundle. /// @@ -67,6 +71,46 @@ pub fn checksum(data: &[u8]) -> u32 { CRC32.checksum(data) } +/// The same checksum, over data that arrives in pieces. +/// +/// [`checksum`] is what a caller holding the whole thing wants. This is for +/// the other side of the comparison — a file already on a card, read a chunk +/// at a time precisely so that checking it needs no second copy of it in +/// memory. +pub struct Checksum(crc::Digest<'static, u32>); + +impl Checksum { + /// Starts one. + pub fn new() -> Checksum { + Checksum(CRC32.digest()) + } + + /// Adds the next piece. + pub fn update(&mut self, data: &[u8]) { + self.0.update(data); + } + + /// The checksum of everything added, comparable with [`checksum`] of the + /// same bytes. + pub fn finish(self) -> u32 { + self.0.finalize() + } +} + +impl Default for Checksum { + fn default() -> Checksum { + Checksum::new() + } +} + +impl core::fmt::Debug for Checksum { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + // `crc::Digest` is not `Debug`, and what it holds mid-stream is not + // something a reader could act on anyway. + f.write_str("Checksum(..)") + } +} + /// What an application must agree with its packer about. /// /// Everything else a bundle needs to describe is in the bundle. diff --git a/ota/src/lib.rs b/ota/src/lib.rs index 4963d5d..14bdad2 100644 --- a/ota/src/lib.rs +++ b/ota/src/lib.rs @@ -66,7 +66,13 @@ extern crate alloc; pub mod bundle; -pub use bundle::{Bundle, Entry, Error, Format, Role, checksum}; +#[cfg(feature = "apply")] +pub mod apply; + +pub use bundle::{Bundle, Checksum, Entry, Error, Format, Role, checksum}; #[cfg(feature = "alloc")] pub use bundle::encode; + +#[cfg(feature = "apply")] +pub use apply::{Progress, Report, apply}; diff --git a/ota/tests/apply.rs b/ota/tests/apply.rs new file mode 100644 index 0000000..190a296 --- /dev/null +++ b/ota/tests/apply.rs @@ -0,0 +1,448 @@ +//! `apply` against a real FAT32 volume, with `fsck.vfat` as the oracle. +//! +//! A unit test could only check this module against itself. What matters +//! here is whether the volume afterwards is one somebody else's filesystem +//! implementation agrees is well formed — a bundle that installs and leaves +//! a card `fsck` complains about is a bundle that installed and broke a +//! board. So the image is made by `mkfs.vfat`, written by this crate, and +//! judged by `fsck.vfat`. +//! +//! Requires `dosfstools`. The tests fail rather than skip when it is +//! missing: a suite that quietly checks nothing is worse than one that +//! cannot run. + +use std::path::PathBuf; +use std::process::Command; + +use resident_fat::{BlockDevice, FileSystem}; +use rpi_loader_ota::apply::{Progress, Report, apply}; +use rpi_loader_ota::{Entry, Format, Role, encode}; + +/// Thirty-four megabytes, which is the smallest that yields a *correct* +/// FAT32 volume rather than merely one `mkfs.vfat` will produce. +/// +/// FAT32 requires at least 65,525 clusters; below that, `fsck.vfat` warns +/// that the filesystem "may lead to problems on some systems" and the image +/// is not really the thing being tested. At 512 bytes per cluster this is +/// the first size clear of that, and 68,528 clusters comes out silent. +/// `resident-fat` itself would accept a smaller one — it enforces only a +/// maximum — which is exactly why the test must not. +const IMAGE_BYTES: u64 = 34 * 1024 * 1024; + +const FORMAT: Format = Format { + magic: *b"TEST", + max_entries: 16, +}; + +/// A volume in memory, so a test can hand the same bytes to this crate and +/// then to `fsck`. +struct Ram { + blocks: Vec, + /// Every device call, which is how the skip test proves a write did not + /// happen rather than merely that the bytes are unchanged. + reads: usize, + writes: usize, +} + +#[derive(Debug)] +struct OutOfRange; + +impl BlockDevice for Ram { + type Error = OutOfRange; + + fn read(&mut self, start_block: u64, blocks: &mut [u8]) -> Result<(), OutOfRange> { + let at = start_block as usize * 512; + let end = at + blocks.len(); + if end > self.blocks.len() { + return Err(OutOfRange); + } + blocks.copy_from_slice(&self.blocks[at..end]); + self.reads += 1; + Ok(()) + } + + fn write(&mut self, start_block: u64, blocks: &[u8]) -> Result<(), OutOfRange> { + let at = start_block as usize * 512; + let end = at + blocks.len(); + if end > self.blocks.len() { + return Err(OutOfRange); + } + self.blocks[at..end].copy_from_slice(blocks); + self.writes += 1; + Ok(()) + } + + fn block_count(&mut self) -> Result, OutOfRange> { + Ok(Some(self.blocks.len() as u64 / 512)) + } +} + +/// A freshly formatted FAT32 volume, made by `mkfs.vfat`. +fn blank_volume(name: &str) -> Ram { + let path = scratch(name); + std::fs::write(&path, vec![0u8; IMAGE_BYTES as usize]).expect("creating the image"); + let made = Command::new("mkfs.vfat") + .args(["-F", "32", "-n", "TEST"]) + .arg(&path) + .output() + .expect("running mkfs.vfat — is dosfstools installed?"); + assert!( + made.status.success(), + "mkfs.vfat failed: {}", + String::from_utf8_lossy(&made.stderr) + ); + let blocks = std::fs::read(&path).expect("reading the image back"); + let _ = std::fs::remove_file(&path); + Ram { + blocks, + reads: 0, + writes: 0, + } +} + +fn scratch(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("rpi-loader-ota-{name}-{}.img", std::process::id())) +} + +/// Runs `fsck.vfat` over a volume and fails the test with what it said. +/// +/// `-n` so it reports rather than repairs: a filesystem this crate wrote is +/// either right or it is a bug, and letting `fsck` quietly fix one would +/// hide exactly what the test is for. +/// +/// # Why the exit status is not the oracle +/// +/// Because it is not one. `fsck.vfat -n` exits **0** even when it has found +/// something: a volume whose two FAT copies disagree reports `FATs differ` +/// and still exits 0, since in no-change mode it has nothing to report the +/// *outcome* of. Asserting on the status would be a test that cannot fail, +/// which is worse than no test because it looks like one. +/// +/// So the output is the oracle. A clean run prints exactly two lines — the +/// version banner and a `N files, X/Y clusters` summary — and every +/// complaint is an extra line. Filtering those two out and requiring +/// nothing to remain catches whatever `fsck` decides to say, rather than a +/// list of problem strings guessed at in advance. +fn fsck(volume: &Ram, name: &str) { + let path = scratch(name); + std::fs::write(&path, &volume.blocks).expect("writing the image out"); + let checked = Command::new("fsck.vfat") + .arg("-n") + .arg(&path) + .output() + .expect("running fsck.vfat — is dosfstools installed?"); + let _ = std::fs::remove_file(&path); + + let output = format!( + "{}{}", + String::from_utf8_lossy(&checked.stdout), + String::from_utf8_lossy(&checked.stderr) + ); + let complaints: Vec<&str> = output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .filter(|line| !line.starts_with("fsck.fat ")) + .filter(|line| !(line.contains(" files, ") && line.ends_with(" clusters"))) + .collect(); + + assert!( + complaints.is_empty(), + "fsck.vfat found fault with the volume:\n {}", + complaints.join("\n ") + ); +} + +/// Records the order entries were handled in, and how. +#[derive(Default)] +struct Order { + events: Vec, +} + +impl Progress for Order { + fn wrote(&mut self, entry: Entry<'_>) { + self.events.push(format!("wrote {}", entry.path)); + } + + fn skipped(&mut self, entry: Entry<'_>) { + self.events.push(format!("skipped {}", entry.path)); + } +} + +fn file<'a>(path: &'a str, data: &'a [u8]) -> Entry<'a> { + Entry { + role: Role::File, + path, + data, + } +} + +/// Reads a file back off a volume the way anything else would. +fn read(volume: &mut FileSystem, path: &str) -> Vec { + let handle = volume + .open(path) + .unwrap_or_else(|e| panic!("opening {path}: {e:?}")); + volume.read_all(&handle).expect("reading it") +} + +#[test] +fn installs_every_entry_where_its_path_says() { + let entries = [ + Entry { + role: Role::Kernel, + path: "KERNEL7.IMG", + data: b"kernel bytes", + }, + file("WWW/INDEX.HTM", b""), + file("WWW/CSS/SITE.CSS", b"body{}"), + Entry { + role: Role::Config, + path: "CONFIG.TXT", + data: b"arm_64bit=0\n", + }, + ]; + let bytes = encode(&FORMAT, &entries).unwrap(); + + let mut volume = FileSystem::mount(blank_volume("install")).expect("mounting"); + let report = apply(&mut volume, &FORMAT, &bytes, &mut ()).expect("applying"); + + assert_eq!(report.written, 4); + assert_eq!(report.skipped, 0); + assert_eq!(report.kernel_len, Some(12)); + + assert_eq!(read(&mut volume, "KERNEL7.IMG"), b"kernel bytes"); + assert_eq!(read(&mut volume, "CONFIG.TXT"), b"arm_64bit=0\n"); + // Nested, on a volume that had no such directories a moment ago. + assert_eq!(read(&mut volume, "WWW/INDEX.HTM"), b""); + assert_eq!(read(&mut volume, "WWW/CSS/SITE.CSS"), b"body{}"); + + fsck(volume.device(), "install"); +} + +#[test] +fn the_kernel_is_written_last() { + // The whole safety argument in one assertion: with one boot image, that + // write is the commit, so everything that could fail has to have failed + // already. + let entries = [ + Entry { + role: Role::Kernel, + path: "KERNEL7.IMG", + data: b"kernel", + }, + Entry { + role: Role::Firmware, + path: "BOOTCODE.BIN", + data: b"boot", + }, + Entry { + role: Role::Config, + path: "CONFIG.TXT", + data: b"x=1\n", + }, + file("WWW/INDEX.HTM", b""), + ]; + let bytes = encode(&FORMAT, &entries).unwrap(); + + let mut volume = FileSystem::mount(blank_volume("order")).expect("mounting"); + let mut order = Order::default(); + apply(&mut volume, &FORMAT, &bytes, &mut order).expect("applying"); + + assert_eq!( + order.events, + vec![ + "wrote WWW/INDEX.HTM", + "wrote BOOTCODE.BIN", + "wrote CONFIG.TXT", + "wrote KERNEL7.IMG", + ], + "entries were not written in role order" + ); +} + +#[test] +fn an_unchanged_entry_is_read_and_not_rewritten() { + let entries = [ + Entry { + role: Role::Firmware, + path: "START.ELF", + data: &[0xA5; 40_000], + }, + Entry { + role: Role::Firmware, + path: "FIXUP.DAT", + data: b"fixup", + }, + file("SETTINGS.CFG", b"zone=1"), + ]; + let bytes = encode(&FORMAT, &entries).unwrap(); + + let mut volume = FileSystem::mount(blank_volume("skip")).expect("mounting"); + apply(&mut volume, &FORMAT, &bytes, &mut ()).expect("first install"); + + // Same bundle again. Nothing has changed, so nothing should be written. + let before = volume.device().writes; + let mut order = Order::default(); + let report = apply(&mut volume, &FORMAT, &bytes, &mut order).expect("second install"); + + assert_eq!(report.written, 0); + assert_eq!(report.skipped, 3); + assert!( + order + .events + .iter() + .all(|event| event.starts_with("skipped")), + "{:?}", + order.events + ); + // The device is the witness, not the byte contents: an installer that + // wrote the same bytes back would pass a content check and cost the card + // the same erase cycles. + assert_eq!( + volume.device().writes, + before, + "the card was written to for an update that changed nothing" + ); + + fsck(volume.device(), "skip"); +} + +#[test] +fn a_changed_entry_beside_unchanged_ones_is_the_only_one_written() { + let unchanged: &[u8] = &[0x5A; 20_000]; + let first = [ + Entry { + role: Role::Firmware, + path: "START.ELF", + data: unchanged, + }, + Entry { + role: Role::Firmware, + path: "FIXUP.DAT", + data: b"fixup", + }, + file("SETTINGS.CFG", b"zone=1"), + ]; + let mut volume = FileSystem::mount(blank_volume("partial")).expect("mounting"); + apply( + &mut volume, + &FORMAT, + &encode(&FORMAT, &first).unwrap(), + &mut (), + ) + .expect("first"); + + let second = [ + Entry { + role: Role::Firmware, + path: "START.ELF", + data: unchanged, + }, + Entry { + role: Role::Firmware, + path: "FIXUP.DAT", + data: b"fixup", + }, + file("SETTINGS.CFG", b"zone=2"), + ]; + let mut order = Order::default(); + let report = apply( + &mut volume, + &FORMAT, + &encode(&FORMAT, &second).unwrap(), + &mut order, + ) + .expect("second"); + + assert_eq!(report.written, 1); + assert_eq!(report.skipped, 2); + assert_eq!(read(&mut volume, "SETTINGS.CFG"), b"zone=2"); + fsck(volume.device(), "partial"); +} + +#[test] +fn a_same_length_change_is_still_noticed() { + // The length check is the fast path, not the answer. Two files of equal + // length and different content must not be mistaken for each other. + let mut volume = FileSystem::mount(blank_volume("samelen")).expect("mounting"); + let before = [file("A.TXT", b"aaaaaaaa")]; + apply( + &mut volume, + &FORMAT, + &encode(&FORMAT, &before).unwrap(), + &mut (), + ) + .expect("first"); + + let after = [file("A.TXT", b"bbbbbbbb")]; + let report = apply( + &mut volume, + &FORMAT, + &encode(&FORMAT, &after).unwrap(), + &mut (), + ) + .expect("second"); + + assert_eq!(report.written, 1, "a same-length change was skipped"); + assert_eq!(read(&mut volume, "A.TXT"), b"bbbbbbbb"); +} + +#[test] +fn a_bundle_with_no_kernel_installs_and_reports_none() { + let entries = [file("WWW/INDEX.HTM", b"")]; + let bytes = encode(&FORMAT, &entries).unwrap(); + + let mut volume = FileSystem::mount(blank_volume("nokernel")).expect("mounting"); + let report = apply(&mut volume, &FORMAT, &bytes, &mut ()).expect("applying"); + + assert_eq!( + report, + Report { + kernel_len: None, + written: 1, + skipped: 0, + } + ); + fsck(volume.device(), "nokernel"); +} + +#[test] +fn a_rejected_bundle_writes_nothing() { + let mut volume = FileSystem::mount(blank_volume("rejected")).expect("mounting"); + let bytes = encode(&FORMAT, &[file("A.TXT", b"a")]).unwrap(); + + let other = Format { + magic: *b"OTHR", + max_entries: 16, + }; + let before = volume.device().writes; + apply(&mut volume, &other, &bytes, &mut ()).expect_err("should be refused"); + + assert_eq!( + volume.device().writes, + before, + "a bundle refused at the header still touched the card" + ); +} + +#[test] +fn a_failed_install_leaves_a_volume_fsck_accepts() { + // The install runs out of room part-way through, which is the shape of + // every real failure here: some entries are on the card and some are + // not. What must not happen is that the volume itself is left corrupt, + // because that is a card that has to be reformatted rather than an + // update that has to be retried. + // On the heap rather than a repeat-expression literal, which would be + // promoted to a static of the same size and put it in the binary. + let huge = vec![0x11u8; IMAGE_BYTES as usize + 1024 * 1024]; + let entries = [file("SMALL.TXT", b"fits"), file("HUGE.BIN", &huge)]; + let bytes = encode(&FORMAT, &entries).unwrap(); + + let mut volume = FileSystem::mount(blank_volume("failed")).expect("mounting"); + apply(&mut volume, &FORMAT, &bytes, &mut ()).expect_err("should not fit"); + + // The entry that did fit is there and readable; the one that did not is + // not half-written into the directory. + assert_eq!(read(&mut volume, "SMALL.TXT"), b"fits"); + fsck(volume.device(), "failed"); +}