crates/veilvoice-verify/src/report.rs

what this file is for · veilvoice-verify · 385 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
//! How much this program says, and what it returns when it says nothing.
//!
//! # The exit status comes first, and that is not an accident
//!
//! This module exists because of one requirement: **there is a verbosity level
//! called "nothing"**. A tool that prints nothing and returns zero when a
//! signature did not verify is worse than a noisy one -- it is a tool that
//! reports success by staying quiet, which is the shape every failure takes
//! when nobody is watching.
//!
//! So the statuses are defined and documented first, and `--quiet` is only
//! usable *because* they are. [`Status`] gives every outcome its own number,
//! they are stable, and `--help` prints the table.
//!
//! # Failing, refusing, and the difference
//!
//! Two outcomes are both "not success" and must never be confused:
//!
//! * [`Status::Refused`] -- a check ran and the answer was wrong. The signature
//!   did not verify, or a hash did not match. Somebody may have tampered with
//!   a release.
//! * [`Status::Incomplete`] -- a check did not run. A download failed, a file
//!   was missing, a tool was not installed. **Nothing has been proven either
//!   way**, which is not the same as nothing being wrong.
//!
//! The existing reporting already keeps these apart in the words it prints.
//! This gives them separate numbers so a script can tell them apart too, since
//! a script is exactly the reader who gets no words.
//!
//! # In plain words
//!
//! This decides how much the program prints -- from every detail down to
//! absolutely nothing -- and makes sure that when it prints nothing it still
//! *tells* you the answer, through the number every program hands back when it
//! finishes. Something has to carry the answer. If it is not the text on the
//! screen, it has to be the number.
//!
//! And it keeps two different bad outcomes apart: "I checked and it was wrong"
//! is not the same as "I could not check". The first means somebody may have
//! tampered with your download. The second usually means your internet hiccuped.

use std::process::ExitCode;
use std::sync::atomic::{AtomicU8, Ordering};


/// The level in force, set once at startup and read everywhere.
///
/// A process-wide value rather than a parameter threaded through every command:
/// there are around forty places that print, the level is the same for all of
/// them for the whole run, and a parameter that has to reach all forty is a
/// parameter that will one day not reach one of them.
static LEVEL: AtomicU8 = AtomicU8::new(Loudness::Normal as u8);



/// Set the level. Called once, from `main`, before anything is printed.
pub fn set_level(level: Loudness) {
    LEVEL.store(level as u8, Ordering::Relaxed);
}



/// The level in force.
pub fn level() -> Loudness {
    match LEVEL.load(Ordering::Relaxed) {
        0 => Loudness::Nothing,
        1 => Loudness::Minimal,
        3 => Loudness::Everything,
        // Anything unrecognised reads as the default rather than as silence.
        // Of the two ways to be wrong here, printing too much is the one that
        // cannot hide an answer.
        _ => Loudness::Normal,
    }
}



/// What happened, as a number a script can read.
///
/// These are **stable**. A number that changes meaning between versions breaks
/// every script that trusted it, silently, in the direction of "it worked".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Status {
    /// Everything asked for was done and every check passed.
    Success = 0,

    /// The command line could not be understood. Nothing was attempted.
    ///
    /// One rather than two, because this is the one outcome that is certainly
    /// the caller's own doing and never a statement about a release.
    Usage = 1,

    /// **A check ran and failed.** A signature did not verify, or a hash did
    /// not match the one that was published.
    ///
    /// This is the number that means *do not run what you downloaded*.
    Refused = 2,

    /// A check could not be completed: a download failed, a file was missing,
    /// a needed tool was not there.
    ///
    /// Nothing was proven, and nothing was disproven.
    Incomplete = 3,

    /// A build was attempted and the compiler stopped.
    ///
    /// Separate from [`Status::Incomplete`] because it is actionable in a
    /// different place: the output of the build says what is wrong, and no
    /// amount of retrying changes it.
    BuildFailed = 4,

    /// A build finished, and what came out does **not** match the published
    /// build for this platform.
    ///
    /// Deliberately not [`Status::Refused`]. A reproducibility difference is a
    /// finding to look into and publish -- most causes are boring, and calling
    /// it tampering would be a claim this program cannot support.
    NotReproducible = 5,

    /// Build dependencies are missing and were not installed, because nobody
    /// said yes.
    ///
    /// A refusal by the *operator*, not by this program, and it exits non-zero
    /// so an unattended run does not look like it succeeded.
    DependenciesMissing = 6,

    /// Files were built or verified, and putting them in place failed.
    ///
    /// What was verified is still verified; only the copy did not happen.
    InstallFailed = 7,
}


impl Status {

    /// The number this outcome exits with.
    pub fn code(self) -> u8 {
        self as u8
    }



    /// One line, for the table in `--help`.
    pub fn meaning(self) -> &'static str {
        match self {
            Self::Success => "everything asked for was done and every check passed",
            Self::Usage => "the command line could not be understood; nothing was attempted",
            Self::Refused => "a check ran and FAILED -- do not run what you downloaded",
            Self::Incomplete => "a check could not be completed; nothing was proven either way",
            Self::BuildFailed => "the build was attempted and the compiler stopped",
            Self::NotReproducible => "the build here does not match the published build",
            Self::DependenciesMissing => "build dependencies are missing and were not installed",
            Self::InstallFailed => "the check passed; putting the files in place did not",
        }
    }



    /// Every status, for printing the table and for testing it is complete.
    pub const ALL: &'static [Status] = &[
        Status::Success,
        Status::Usage,
        Status::Refused,
        Status::Incomplete,
        Status::BuildFailed,
        Status::NotReproducible,
        Status::DependenciesMissing,
        Status::InstallFailed,
    ];



    /// The table, as `--help` prints it.
    pub fn table() -> String {
        let mut out = String::from("EXIT STATUS\n");
        for status in Self::ALL {
            out.push_str(&format!("  {}   {}\n", status.code(), status.meaning()));
        }
        out
    }

}

impl From<Status> for ExitCode {

    fn from(status: Status) -> Self {
        ExitCode::from(status.code())
    }

}


/// How much to print.
///
/// Ordered, so a message can ask "am I loud enough to be said".
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Loudness {
    /// Not a word. The exit status is the whole answer, which is why
    /// [`Status`] exists and is documented.
    Nothing,
    /// The verdict and nothing else: one line at the end, and refusals.
    Minimal,
    /// Each check as it passes, and every refusal in full.
    #[default]
    Normal,
    /// Everything: the commands run, the paths used, the hashes compared.
    Everything,
}


impl Loudness {

    /// The flag that selects this level.
    pub fn flag(self) -> &'static str {
        match self {
            Self::Nothing => "--quiet",
            Self::Minimal => "--brief",
            Self::Normal => "(default)",
            Self::Everything => "--verbose",
        }
    }



    /// What this level shows.
    pub fn describes(self) -> &'static str {
        match self {
            Self::Nothing => "nothing at all -- read the exit status",
            Self::Minimal => "the verdict, and refusals",
            Self::Normal => "each check as it passes, and refusals in full",
            Self::Everything => "the above, plus every command, path and hash",
        }
    }



    /// Read the level out of the arguments, removing the flags that set it.
    ///
    /// The **loudest** flag given wins rather than the last one. `--quiet
    /// --verbose` is a contradiction, and of the two possible readings, the one
    /// that prints more is the one that cannot hide an answer.
    pub fn take_from(args: &mut Vec<String>) -> Loudness {
        let mut chosen: Option<Loudness> = None;
        args.retain(|arg| {
            let level = match arg.as_str() {
                "--quiet" | "-q" => Loudness::Nothing,
                "--brief" => Loudness::Minimal,
                "--normal" => Loudness::Normal,
                "--verbose" | "-v" => Loudness::Everything,
                _ => return true,
            };
            chosen = Some(match chosen {
                Some(already) => already.max(level),
                None => level,
            });
            false
        });
        chosen.unwrap_or_default()
    }



    /// The table, as `--help` prints it.
    pub fn table() -> String {
        let mut out = String::from("HOW MUCH IT SAYS\n");
        for level in [
            Loudness::Nothing,
            Loudness::Minimal,
            Loudness::Normal,
            Loudness::Everything,
        ] {
            out.push_str(&format!("  {:<12} {}\n", level.flag(), level.describes()));
        }
        out
    }

}

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

    /// The numbers are stable. Written out rather than derived, because the
    /// point of the test is to fail when somebody reorders the enum.
    #[test]
    fn every_status_has_the_number_it_has_always_had() {
        assert_eq!(Status::Success.code(), 0);
        assert_eq!(Status::Usage.code(), 1);
        assert_eq!(Status::Refused.code(), 2);
        assert_eq!(Status::Incomplete.code(), 3);
        assert_eq!(Status::BuildFailed.code(), 4);
        assert_eq!(Status::NotReproducible.code(), 5);
        assert_eq!(Status::DependenciesMissing.code(), 6);
        assert_eq!(Status::InstallFailed.code(), 7);
    }

    /// No two outcomes share a number, and every one is in `ALL`.
    #[test]
    fn the_statuses_are_distinct_and_the_list_is_complete() {
        let mut codes: Vec<u8> = Status::ALL.iter().map(|s| s.code()).collect();
        let count = codes.len();
        codes.sort_unstable();
        codes.dedup();
        assert_eq!(codes.len(), count, "two outcomes share a number");
        assert_eq!(codes, (0..count as u8).collect::<Vec<_>>(), "no gaps");
    }

    /// Only success is zero. This is the whole reason the module exists: at
    /// `--quiet` the number is the entire answer.
    #[test]
    fn nothing_but_success_returns_zero() {
        for status in Status::ALL {
            assert_eq!(
                status.code() == 0,
                *status == Status::Success,
                "{status:?} returns {}",
                status.code()
            );
        }
    }

    /// Checked-and-wrong and could-not-check must have different numbers, or
    /// a script cannot tell tampering from a network hiccup.
    #[test]
    fn a_failed_check_and_an_unfinished_one_are_different_numbers() {
        assert_ne!(Status::Refused.code(), Status::Incomplete.code());
        assert!(Status::Refused.meaning().contains("do not run"));
        assert!(Status::Incomplete.meaning().contains("nothing was proven"));
    }

    /// A difference between this build and the published one is a finding, not
    /// an accusation, and it gets its own number to say so.
    #[test]
    fn a_reproducibility_difference_is_not_reported_as_tampering() {
        assert_ne!(Status::NotReproducible.code(), Status::Refused.code());
        assert!(!Status::NotReproducible.meaning().contains("do not run"));
    }

    #[test]
    fn the_default_is_normal() {
        assert_eq!(Loudness::default(), Loudness::Normal);
        let mut args = vec!["auto".to_string()];
        assert_eq!(Loudness::take_from(&mut args), Loudness::Normal);
        assert_eq!(args, vec!["auto".to_string()]);
    }

    #[test]
    fn each_flag_selects_its_level_and_is_removed() {
        for (flag, want) in [
            ("--quiet", Loudness::Nothing),
            ("-q", Loudness::Nothing),
            ("--brief", Loudness::Minimal),
            ("--normal", Loudness::Normal),
            ("--verbose", Loudness::Everything),
            ("-v", Loudness::Everything),
        ] {
            let mut args = vec!["file".to_string(), flag.to_string(), "x".to_string()];
            assert_eq!(Loudness::take_from(&mut args), want, "{flag}");
            assert_eq!(args, vec!["file".to_string(), "x".to_string()], "{flag}");
        }
    }

    /// Contradictory flags resolve towards saying more. Of the two readings,
    /// only one can hide an answer, so it is not the one taken.
    #[test]
    fn the_loudest_flag_wins_rather_than_the_last() {
        let mut args = vec!["--quiet".to_string(), "--verbose".to_string()];
        assert_eq!(Loudness::take_from(&mut args), Loudness::Everything);

        let mut args = vec!["--verbose".to_string(), "--quiet".to_string()];
        assert_eq!(Loudness::take_from(&mut args), Loudness::Everything);
        assert!(args.is_empty());
    }

    /// The levels are ordered, because every message asks "am I loud enough".
    #[test]
    fn the_levels_are_ordered_from_silent_to_everything() {
        assert!(Loudness::Nothing < Loudness::Minimal);
        assert!(Loudness::Minimal < Loudness::Normal);
        assert!(Loudness::Normal < Loudness::Everything);
    }

    /// Both tables have to be printable and complete: they are the whole
    /// documentation of the quiet mode.
    #[test]
    fn the_help_tables_name_every_level_and_every_status() {
        let statuses = Status::table();
        for status in Status::ALL {
            assert!(
                statuses.contains(status.meaning()),
                "{status:?} missing from the table"
            );
            assert!(statuses.contains(&format!("  {}   ", status.code())));
        }

        let levels = Loudness::table();
        for level in [
            Loudness::Nothing,
            Loudness::Minimal,
            Loudness::Normal,
            Loudness::Everything,
        ] {
            assert!(levels.contains(level.describes()), "{level:?}");
        }
        assert!(levels.contains("--quiet"), "{levels}");
        assert!(
            levels.contains("read the exit status"),
            "the quiet level has to say where the answer went: {levels}"
        );
    }
}