fuzz/fuzz_targets/release_contents.rs

what this file is for · fuzz · 86 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 release contents list parser, coverage-guided.
//!
//! **Roadmap item 97.** `CONTENTS.sha256` lists every file inside every release
//! archive with its SHA-256, and a verifier reads it to decide which paths on
//! disk to open and what to compare them against. It is covered by the signed
//! `SHA256SUMS`, and every caller is told to check that before parsing.
//!
//! "Every caller is told to" is not a property of the code. A caller can get
//! the order wrong, a future front end can be written by somebody who did not
//! read the note, and the consequence would be a file of somebody else's
//! choosing deciding which paths a verifier opens. So the parser is fuzzed as
//! though nothing had checked it, which is the only assumption that stays true.
//!
//! What this is looking for, specifically: a path that escapes the release
//! directory, a panic on a line that is not what the release job writes, and a
//! digest that is accepted without being one.

#![no_main]

use libfuzzer_sys::fuzz_target;
use veilvoice_verify::check::contents;

fuzz_target!(|data: &[u8]| {
    // The caller reads this file as text, so anything that is not UTF-8 never
    // reaches the parser.
    let Ok(text) = std::str::from_utf8(data) else {
        return;
    };
    let Ok(all) = contents::parse(text) else {
        return;
    };

    for archive in &all {
        // An archive name is used to look a section up and is never joined onto
        // a path, but an empty one would match a caller's empty string and
        // hand back the wrong section.
        assert!(
            !archive.archive.is_empty(),
            "a section with no archive name was accepted"
        );
        assert_eq!(
            contents::for_archive(&all, &archive.archive).is_some(),
            true,
            "a section that parsed cannot be found again"
        );

        for member in &archive.members {
            let path = &member.path;
            // The property the whole parser exists for: these strings are
            // joined onto a directory and opened.
            assert!(!path.is_empty(), "an empty path was accepted");
            assert!(!path.starts_with('/'), "an absolute path was accepted: {path:?}");
            assert!(!path.contains('\\'), "a backslash was accepted: {path:?}");
            assert!(
                !path.split('/').any(|part| part == ".." || part == "."),
                "a path that leaves the release was accepted: {path:?}"
            );
            assert!(
                path.as_bytes().get(1) != Some(&b':'),
                "a drive letter was accepted: {path:?}"
            );

            // And the digest is a digest, because a caller compares it against
            // one it computed and a shorter string would compare unequal
            // forever rather than loudly.
            assert_eq!(member.digest.len(), 64, "a short digest was accepted");
            assert!(
                member.digest.bytes().all(|b| b.is_ascii_hexdigit()),
                "a digest with a non-hex character was accepted: {:?}",
                member.digest
            );
        }

        // `roots` is what an extras walk starts from, so an empty component
        // there would walk the directory holding the archive rather than the
        // release inside it.
        for root in archive.roots() {
            assert!(!root.is_empty(), "an empty root directory was produced");
        }
    }

    // `check` and `extras` are deliberately not called: both read the
    // filesystem, which is slow and nondeterministic and would make a crash
    // unreproducible. Every path they would open has been asserted above.
});