crates/veilvoice-guard/src/manifest.rs

what this file is for · veilvoice-guard · 730 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! The integrity manifest: what the files were, and what they are now.
//!
//! # Format
//!
//! Deliberately a text format, one record per line:
//!
//! ```text
//! VEILGUARD1
//! <sha256 hex>  <size>  <path>
//! ...
//! ```
//!
//! Text rather than a packed binary layout because the point of the file is to
//! be checkable. Someone who suspects tampering can read it with `cat` and
//! compare a digest by hand with `sha256sum`, without this crate and without
//! trusting it. A binary format would have been marginally smaller and would
//! have made the honest response to "prove it" be "run my tool again".
//!
//! Paths are stored with forward slashes so a manifest written on Windows still
//! reads on Linux, and are rejected if they contain a newline -- otherwise a
//! filename could forge a record.
//!
//! # In plain words
//!
//! The written record of what VeilVoice's files were, so a later check can tell
//! whether they still are.
//!
//! It is plain text on purpose: you can read it, diff it and keep a copy
//! somewhere else. A record you cannot inspect is one you have to take on trust,
//! which rather defeats the point of having it.

use crate::Error;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};


/// Magic first line. The digit is a format version.
pub(crate) const MAGIC: &str = "VEILGUARD1";



/// One recorded file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
    /// Size in bytes at the time of recording.
    pub size: u64,
    /// Lowercase hex SHA-256 of the contents.
    pub digest: String,
}



/// How a file differs from its record.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Change {
    /// Recorded, still present, and its contents differ.
    Modified {
        /// The path, as recorded.
        path: String,
        /// What the digest was.
        was: String,
        /// What it is now.
        now: String,
    },
    /// Recorded and no longer there.
    Removed {
        /// The path, as recorded.
        path: String,
    },
    /// Present in the watched set and not recorded.
    Added {
        /// The path found.
        path: String,
    },
    /// Recorded but unreadable, which is not the same as absent -- a
    /// permissions change is itself worth reporting.
    Unreadable {
        /// The path, as recorded.
        path: String,
        /// Why it could not be read.
        why: String,
    },
}


impl Change {

    /// The path this change concerns.
    pub fn path(&self) -> &str {
        match self {
            Change::Modified { path, .. }
            | Change::Removed { path }
            | Change::Added { path }
            | Change::Unreadable { path, .. } => path,
        }
    }



    /// A single line for a terminal or a log.
    pub fn describe(&self) -> String {
        match self {
            Change::Modified { path, was, now } => format!(
                "modified: {path} ({}... -> {}...)",
                &was[..8.min(was.len())],
                &now[..8.min(now.len())]
            ),
            Change::Removed { path } => format!("removed:  {path}"),
            Change::Added { path } => format!("added:    {path}"),
            Change::Unreadable { path, why } => format!("unreadable: {path} ({why})"),
        }
    }

}


/// The result of checking a manifest against the disk.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
    /// Everything that differs, in a stable order.
    pub changes: Vec<Change>,
    /// How many files matched their record exactly.
    pub unchanged: usize,
}


impl Report {

    /// Whether anything at all differs.
    pub fn is_clean(&self) -> bool {
        self.changes.is_empty()
    }

}


/// A record of a set of files.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Manifest {
    /// Recorded files, keyed by their normalised path. A `BTreeMap` so the
    /// serialised form is byte-identical for the same input, which is what
    /// makes two manifests comparable at all.
    entries: BTreeMap<String, Entry>,
}



/// Normalise a path for storage: forward slashes, no leading `./`.
fn normalise(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}



/// Why this path cannot go in a manifest, if it cannot. **F-83.**
///
/// The record is a line-oriented text file that gets printed to a terminal, and
/// those two facts decide what a path may contain.
///
/// A line break would end the record early and let one entry forge a second.
/// A carriage return returns the cursor to the start of the line, so a crafted
/// path overwrites what the report has already printed and the report says
/// something other than what is recorded. An escape character does more again:
/// colour, cursor movement, clearing the screen. The product of this module is
/// a report somebody reads to decide whether their files have been altered, so
/// a report that can be made to lie is the whole thing failing.
///
/// The refusal covers the C0 and C1 control ranges rather than the two
/// characters that were found, because listing the ones somebody thought of is
/// how the next one gets in. Refusing rather than stripping is deliberate: a
/// path this format cannot represent faithfully is one it must not claim to
/// hold. Such a filename is legal on Unix and vanishingly rare, and being told
/// so is better than a record that quietly describes a different file.
fn unrecordable(path: &str) -> Option<&'static str> {
    if path.contains('\n') {
        return Some("a line break");
    }
    if path.contains('\r') {
        return Some("a carriage return");
    }
    if path.contains('\u{1b}') {
        return Some("an escape character");
    }
    if path.chars().any(char::is_control) {
        return Some("a control character");
    }
    None
}



fn digest_of(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hasher
        .finalize()
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}


impl Manifest {

    /// Record every readable file in `paths`.
    ///
    /// A path that cannot be read is skipped rather than failing the whole
    /// manifest: recording nine of ten files is more useful than recording
    /// none, and the tenth shows up as `Removed` at check time, which is the
    /// honest description of a file this build cannot see.
    pub fn of<P: AsRef<Path>>(paths: &[P]) -> Result<Self, Error> {
        let mut entries = BTreeMap::new();
        for path in paths {
            let path = path.as_ref();
            let Ok(bytes) = std::fs::read(path) else {
                continue;
            };
            let key = normalise(path);
            if let Some(bad) = unrecordable(&key) {
                return Err(Error::Malformed(format!(
                    "path contains {bad} and cannot be recorded: {key:?}"
                )));
            }
            entries.insert(
                key,
                Entry {
                    size: bytes.len() as u64,
                    digest: digest_of(&bytes),
                },
            );
        }
        Ok(Self { entries })
    }



    /// How many files are recorded.
    pub fn len(&self) -> usize {
        self.entries.len()
    }



    /// Whether nothing is recorded.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }



    /// The recorded paths, in order.
    pub fn paths(&self) -> impl Iterator<Item = &str> {
        self.entries.keys().map(String::as_str)
    }



    /// Compare the record against what is on disk now.
    ///
    /// `extra` is the set of paths currently in the watched location, so a file
    /// that has *appeared* can be reported. Pass an empty slice to check only
    /// what was recorded.
    pub fn check<P: AsRef<Path>>(&self, extra: &[P]) -> Report {
        let mut report = Report::default();

        for (path, entry) in &self.entries {
            match std::fs::read(path) {
                Ok(bytes) => {
                    let now = digest_of(&bytes);
                    if now == entry.digest {
                        report.unchanged += 1;
                    } else {
                        report.changes.push(Change::Modified {
                            path: path.clone(),
                            was: entry.digest.clone(),
                            now,
                        });
                    }
                }
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                    report.changes.push(Change::Removed { path: path.clone() });
                }
                Err(e) => {
                    report.changes.push(Change::Unreadable {
                        path: path.clone(),
                        why: e.to_string(),
                    });
                }
            }
        }

        for path in extra {
            let key = normalise(path.as_ref());
            if !self.entries.contains_key(&key) {
                report.changes.push(Change::Added { path: key });
            }
        }

        report.changes.sort_by(|a, b| a.path().cmp(b.path()));
        report
    }



    /// Serialise to the text format described at the top of this module.
    pub fn to_text(&self) -> String {
        let mut out = String::from(MAGIC);
        out.push('\n');
        for (path, entry) in &self.entries {
            out.push_str(&format!("{}  {}  {}\n", entry.digest, entry.size, path));
        }
        out
    }



    /// Parse the text format.
    pub fn parse(text: &str) -> Result<Self, Error> {
        let mut lines = text.lines();
        match lines.next() {
            Some(first) if first.trim() == MAGIC => {}
            Some(other) => {
                return Err(Error::Malformed(format!(
                    "expected {MAGIC} on the first line, found {other:?}"
                )))
            }
            None => return Err(Error::Malformed("the manifest is empty".into())),
        }

        let mut entries = BTreeMap::new();
        for (number, line) in lines.enumerate() {
            if line.trim().is_empty() {
                continue;
            }
            // Split on the double space, and only twice: a path may contain
            // single spaces, and routinely does on Windows.
            let mut parts = line.splitn(3, "  ");
            let digest = parts.next().unwrap_or_default().trim();
            let size = parts.next().unwrap_or_default().trim();
            let path = parts.next().unwrap_or_default().trim();

            if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
                return Err(Error::Malformed(format!(
                    "line {}: {digest:?} is not a SHA-256 digest",
                    number + 2
                )));
            }
            let size: u64 = size
                .parse()
                .map_err(|_| Error::Malformed(format!("line {}: bad size {size:?}", number + 2)))?;
            if path.is_empty() {
                return Err(Error::Malformed(format!("line {}: no path", number + 2)));
            }
            // **F-83.** The same refusal `Manifest::of` makes when it writes.
            //
            // Those two ends disagreed. `of` refused to record a path with a
            // line break in it, and `parse` accepted one, so VeilVoice would
            // not write a record it was perfectly happy to read from somebody
            // else. `veilvoice guard check` reads whichever file is at the
            // path it is given, and a record is exactly the kind of thing that
            // gets handed to you.
            //
            // What it costs to accept one is not theoretical. The product of
            // this whole module is a report somebody reads to decide whether
            // their files have been altered, and that report is printed to a
            // terminal. A carriage return in a path returns the cursor to the
            // start of the line, so everything already printed is overwritten
            // by whatever follows: a crafted path can make the report say
            // something other than what is recorded. An escape character does
            // more than that, and can colour, move the cursor or clear the
            // screen.
            //
            // So the whole control range is refused rather than the two
            // characters the fuzzer happened to find, and the line is named.
            // Refusing is right rather than sanitising: a record this format
            // cannot represent faithfully is one it should not claim to hold.
            if let Some(bad) = unrecordable(path) {
                return Err(Error::Malformed(format!(
                    "line {}: path contains {bad} and cannot be recorded: {path:?}",
                    number + 2
                )));
            }
            // Normalised on the way in, exactly as `Manifest::of` normalises on
            // the way out. Without this, a manifest written by hand (or by an
            // older build) with backslashes produced entries keyed differently
            // from the ones `check`'s `extra` argument is keyed by, so every
            // recorded file was *also* reported as newly added. A tamper report
            // full of false positives is one nobody reads, which defeats the
            // only thing this module does.
            let path = path.replace('\\', "/");
            entries.insert(
                path,
                Entry {
                    size,
                    digest: digest.to_ascii_lowercase(),
                },
            );
        }
        Ok(Self { entries })
    }



    /// Write the manifest to `path` in the clear.
    pub fn save(&self, path: &Path) -> Result<(), Error> {
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent)?;
            }
        }
        std::fs::write(path, self.to_text())?;
        Ok(())
    }



    /// Read a manifest written by [`Manifest::save`].
    pub fn load(path: &Path) -> Result<Self, Error> {
        Self::parse(&std::fs::read_to_string(path)?)
    }



    /// Seal the manifest under a passphrase.
    ///
    /// This is what makes the record worth more than a courtesy: rewriting it
    /// undetectably then needs the passphrase as well as write access. Keep
    /// that passphrase somewhere other than beside the manifest, or the
    /// exercise is circular.
    ///
    /// It is still not proof. An attacker present while you type the passphrase
    /// has everything, which is the same caveat every password in this project
    /// carries.
    pub fn seal(&self, password: &[u8]) -> Result<Vec<u8>, Error> {
        Ok(veilvoice_crypto::container::seal_with_password(
            password,
            self.to_text().as_bytes(),
            veilvoice_crypto::kdf::KdfParams::default(),
        )?)
    }



    /// Open a manifest sealed by [`Manifest::seal`].
    ///
    /// # Why the unattended cost ceiling, and not the generous one
    ///
    /// F-92. This used the same ceiling as any other container, which is four
    /// gigabytes of Argon2 memory, and that ceiling is right for a `.veil` a
    /// person was sent and chose to open: it is slow, they can decide to stop
    /// waiting, and refusing a legitimate-but-expensive file would be worse.
    ///
    /// Nobody chooses to open this one. It sits at a known path beside the app
    /// lock, and since the desktop application started checking it at every
    /// unlock, it is read automatically whenever somebody logs in. Anybody who
    /// can write that directory can leave a sealed manifest declaring four
    /// gigabytes, and on a modest machine that is not a wait, it is an
    /// allocation failure, and this workspace aborts on one. The window would
    /// die immediately after a correct passphrase.
    ///
    /// This is the same defect F-91 fixed on the app-lock file, in the second
    /// place it applies. Fixing one and not the other is the exclusion list
    /// naming the files somebody happened to think of, which is the failure
    /// this project has already recorded twice.
    pub fn open_sealed(password: &[u8], sealed: &[u8]) -> Result<Self, Error> {
        let text = veilvoice_crypto::container::open_with_password_within(
            password,
            sealed,
            veilvoice_crypto::kdf::KdfParams::UNATTENDED_MAX_M_COST,
        )?;
        let text = String::from_utf8(text)
            .map_err(|_| Error::Malformed("sealed manifest is not text".into()))?;
        Self::parse(&text)
    }

}


/// Every file directly inside `dir`, for use as `check`'s `extra` argument.
///
/// Not recursive on purpose: the caller decides what is being watched, and a
/// silent recursive walk of a directory somebody pointed this at is a good way
/// to hash a home folder by accident.
pub fn files_in(dir: &Path) -> Result<Vec<PathBuf>, Error> {
    let mut found = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        if entry.file_type()?.is_file() {
            found.push(entry.path());
        }
    }
    found.sort();
    Ok(found)
}


#[cfg(test)]
mod tests {
    use super::*;

    /// **F-83.** A record somebody hands you cannot contain a character that
    /// rewrites the report.
    ///
    /// `Manifest::of` refused a path with a line break in it and `parse`
    /// accepted one, so VeilVoice would not write a record it was happy to
    /// read from somebody else. `veilvoice guard check` reads whichever file
    /// is at the path it is given.
    ///
    /// The carriage return is the one the coverage-guided campaign found. The
    /// rest are here because listing the characters somebody thought of is how
    /// the next one gets in.
    #[test]
    fn a_path_that_could_rewrite_the_report_is_refused_on_the_way_in() {
        let digest = "a".repeat(64);
        for (label, bad) in [
            ("carriage return", "some\rthing"),
            ("escape", "some\u{1b}[2Kthing"),
            ("bell", "some\u{7}thing"),
            ("nul", "some\0thing"),
            ("backspace", "some\u{8}thing"),
        ] {
            let text = format!("{MAGIC}\n{digest}  12  {bad}\n");
            let parsed = Manifest::parse(&text);
            assert!(
                matches!(parsed, Err(Error::Malformed(_))),
                "{label} was accepted: {parsed:?}"
            );
        }
    }

    /// And an ordinary path, including the awkward but legitimate ones, still
    /// parses. A refusal that catches real filenames is a worse bug than the
    /// one it fixes.
    #[test]
    fn an_ordinary_path_is_still_recorded() {
        let digest = "a".repeat(64);
        for good in [
            "notes.wav",
            "a folder/with spaces/recording.wav",
            "C:/Users/somebody/My Documents/x.wav",
            "unicode \u{e9}\u{fc}\u{4e2d}\u{6587}.wav",
            "punctuation!@#$%^&()_+-=[]{};'.wav",
        ] {
            let text = format!("{MAGIC}\n{digest}  12  {good}\n");
            let parsed = Manifest::parse(&text);
            assert!(parsed.is_ok(), "{good:?} was refused: {parsed:?}");
        }
    }

    /// The two ends agree: what `of` will not write, `parse` will not read.
    ///
    /// That asymmetry *was* the finding, so it is the thing to hold rather
    /// than the individual characters.
    #[test]
    fn what_cannot_be_written_cannot_be_read() {
        for bad in ["a\rb", "a\nb", "a\u{1b}b", "a\0b"] {
            assert!(
                unrecordable(bad).is_some(),
                "{bad:?} would be written but not read, or the other way round"
            );
        }
        for good in ["a/b", "a b", "\u{e9}"] {
            assert!(unrecordable(good).is_none(), "{good:?}");
        }
    }

    fn write(dir: &Path, name: &str, body: &[u8]) -> PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, body).unwrap();
        path
    }

    #[test]
    fn an_untouched_set_of_files_is_clean() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "veilvoice.exe", b"binary");
        let b = write(dir.path(), "applock.bin", b"lock");

        let manifest = Manifest::of(&[&a, &b]).unwrap();
        assert_eq!(manifest.len(), 2);

        let report = manifest.check::<&Path>(&[]);
        assert!(report.is_clean(), "{:?}", report.changes);
        assert_eq!(report.unchanged, 2);
    }

    #[test]
    fn a_modified_file_is_reported_with_both_digests() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "veilvoice.exe", b"binary");
        let manifest = Manifest::of(&[&a]).unwrap();

        write(dir.path(), "veilvoice.exe", b"binary, but not the same one");
        let report = manifest.check::<&Path>(&[]);

        assert_eq!(report.changes.len(), 1);
        match &report.changes[0] {
            Change::Modified { was, now, .. } => {
                assert_ne!(was, now);
                assert_eq!(was.len(), 64);
            }
            other => panic!("expected a modification, got {other:?}"),
        }
        assert!(report.changes[0].describe().starts_with("modified:"));
    }

    /// A file of the same *length* with different contents is the case a
    /// size-only check would miss, which is why the digest is the check.
    #[test]
    fn a_same_length_substitution_is_still_caught() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "veilvoice.exe", b"aaaaaaaa");
        let manifest = Manifest::of(&[&a]).unwrap();
        write(dir.path(), "veilvoice.exe", b"bbbbbbbb");

        let report = manifest.check::<&Path>(&[]);
        assert!(matches!(report.changes[0], Change::Modified { .. }));
    }

    #[test]
    fn a_removed_file_is_reported() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "gone.bin", b"here for now");
        let manifest = Manifest::of(&[&a]).unwrap();
        std::fs::remove_file(&a).unwrap();

        let report = manifest.check::<&Path>(&[]);
        assert!(matches!(report.changes[0], Change::Removed { .. }));
        assert_eq!(report.unchanged, 0);
    }

    #[test]
    fn a_new_file_in_the_watched_set_is_reported() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "known.bin", b"recorded");
        let manifest = Manifest::of(&[&a]).unwrap();
        write(dir.path(), "surprise.dll", b"not recorded");

        let present = files_in(dir.path()).unwrap();
        let report = manifest.check(&present);
        assert_eq!(report.changes.len(), 1);
        assert!(report.changes[0].path().ends_with("surprise.dll"));
        assert!(matches!(report.changes[0], Change::Added { .. }));
    }

    #[test]
    fn the_text_format_round_trips_exactly() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "one.bin", b"first");
        let b = write(dir.path(), "two with spaces.bin", b"second");

        let manifest = Manifest::of(&[&a, &b]).unwrap();
        let text = manifest.to_text();
        assert!(text.starts_with(MAGIC));

        let back = Manifest::parse(&text).unwrap();
        assert_eq!(manifest, back);
        assert_eq!(back.to_text(), text, "serialisation is not stable");
    }

    /// Paths with spaces are ordinary on Windows, and splitting naively would
    /// truncate them.
    #[test]
    fn a_path_containing_spaces_survives() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "Program Files thing.exe", b"x");
        let manifest = Manifest::of(&[&a]).unwrap();
        let back = Manifest::parse(&manifest.to_text()).unwrap();
        assert!(back.paths().any(|p| p.ends_with("Program Files thing.exe")));
        assert!(back.check::<&Path>(&[]).is_clean());
    }

    #[test]
    fn a_malformed_manifest_is_rejected_rather_than_half_read() {
        assert!(Manifest::parse("").is_err());
        assert!(Manifest::parse("NOT-THE-MAGIC\n").is_err());
        assert!(Manifest::parse(&format!("{MAGIC}\nnothex  12  a.bin\n")).is_err());
        assert!(Manifest::parse(&format!("{MAGIC}\n{}  x  a.bin\n", "a".repeat(64))).is_err());
        assert!(Manifest::parse(&format!("{MAGIC}\n{}  12  \n", "a".repeat(64))).is_err());
        // A blank line in the middle is tolerated, not an error.
        assert!(Manifest::parse(&format!("{MAGIC}\n\n")).is_ok());
    }

    /// A hand-written manifest using backslashes must key the same way one this
    /// crate wrote does, or every recorded file also reports as newly added and
    /// the report becomes noise.
    #[test]
    fn a_manifest_written_with_backslashes_keys_the_same_way() {
        let digest = "a".repeat(64);
        let back = Manifest::parse(&format!("{MAGIC}\n{digest}  1  C:\\dir\\thing.exe\n")).unwrap();
        let forward =
            Manifest::parse(&format!("{MAGIC}\n{digest}  1  C:/dir/thing.exe\n")).unwrap();
        assert_eq!(back, forward);
        assert_eq!(back.paths().collect::<Vec<_>>(), ["C:/dir/thing.exe"]);
        // And it survives a second round trip unchanged.
        assert_eq!(Manifest::parse(&back.to_text()).unwrap(), back);
    }

    #[test]
    fn saving_and_loading_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "one.bin", b"first");
        let manifest = Manifest::of(&[&a]).unwrap();

        let store = dir.path().join("nested").join("manifest.txt");
        manifest.save(&store).unwrap();
        assert_eq!(Manifest::load(&store).unwrap(), manifest);
    }

    /// The sealed form is what makes the record more than a courtesy: without
    /// the passphrase it cannot be rewritten to match a tampered file.
    #[test]
    fn a_sealed_manifest_needs_its_passphrase() {
        let dir = tempfile::tempdir().unwrap();
        let a = write(dir.path(), "one.bin", b"first");
        let manifest = Manifest::of(&[&a]).unwrap();

        let sealed = manifest.seal(b"a passphrase kept elsewhere").unwrap();
        assert!(
            !sealed.windows(MAGIC.len()).any(|w| w == MAGIC.as_bytes()),
            "the manifest is readable inside its own container"
        );
        assert_eq!(
            Manifest::open_sealed(b"a passphrase kept elsewhere", &sealed).unwrap(),
            manifest
        );
        assert!(Manifest::open_sealed(b"the wrong one", &sealed).is_err());
    }

    /// F-92. The sealed record is read automatically at every unlock, so the
    /// cost it declares is not a cost anybody chose to pay.
    #[test]
    fn a_sealed_manifest_cannot_demand_more_memory_than_the_machine_has() {
        let mut sealed = veilvoice_crypto::container::seal_with_password(
            b"a passphrase",
            b"nothing that matters",
            veilvoice_crypto::kdf::KdfParams::default(),
        )
        .unwrap();

        // The cost sits at offset 12 in the header and is authenticated, so an
        // edit here makes the tag fail rather than the derivation run. What is
        // being tested is the order: the ceiling has to be consulted *before*
        // the memory is asked for, so a refusal is what comes back rather than
        // an allocation.
        sealed[12..16].copy_from_slice(&(4u32 * 1024 * 1024).to_le_bytes());

        let err = Manifest::open_sealed(b"a passphrase", &sealed).unwrap_err();
        let text = err.to_string();
        assert!(
            text.contains("ceiling"),
            "a four gigabyte cost should be refused by the ceiling, before the \
             memory is asked for, rather than attempted and then failed on the \
             authentication tag: {text}"
        );
    }

    #[test]
    fn an_unreadable_path_is_skipped_when_recording() {
        let dir = tempfile::tempdir().unwrap();
        let real = write(dir.path(), "real.bin", b"x");
        let missing = dir.path().join("never-existed.bin");

        let manifest = Manifest::of(&[&real, &missing]).unwrap();
        assert_eq!(manifest.len(), 1, "the missing file must not be recorded");
    }

    #[test]
    fn recording_nothing_is_allowed_and_checks_clean() {
        let manifest = Manifest::of::<&Path>(&[]).unwrap();
        assert!(manifest.is_empty());
        assert!(manifest.check::<&Path>(&[]).is_clean());
    }
}