crates/veilvoice-watch/src/appctl.rs

what this file is for · veilvoice-watch · 798 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
//! Learn what normally runs on this machine, then notice what does not.
//!
//! # What this is, said before anything else
//!
//! **This is not application control.** It does not stop a program starting,
//! it cannot stop one starting, and nothing in this crate tries. It is a
//! *baseline*: you tell it to watch for a while, it records what it sees, and
//! afterwards it can tell you when something runs that was not in that
//! picture.
//!
//! The name is the roadmap's and it is kept because renaming it would leave two
//! names for one thing, but [`SCOPE`] is the wording every front end must show,
//! and it says outright that nothing here prevents anything. Real enforcement
//! means a kernel driver or a signed policy blob and an application identity to
//! sign it with, and this project is published under a pseudonym on purpose.
//! Shipping something called "app control" that quietly only *watches* would be
//! the exact failure this project's second rule exists to prevent.
//!
//! # Learning, and why it has an end
//!
//! [`Baseline::learning`] records what runs. It is not left on: a baseline that
//! is always learning has learned nothing, because whatever an attacker starts
//! becomes part of the picture the moment it starts. So learning is a phase
//! with an end, and [`Baseline::freeze`] closes it.
//!
//! # Grants expire, and that is the whole design
//!
//! Allowing something for ever is how an allowlist becomes a list of everything
//! anybody ever ran. [`Grant`]s carry an expiry, [`Baseline::allowed`] checks
//! it against the clock it is given, and an expired grant is simply not a
//! grant. Nothing sweeps them: an expired entry is kept, because *"this was
//! allowed until Tuesday"* is worth more to somebody reading the log than a row
//! that vanished.
//!
//! Permanent grants exist and are spelled [`Grant::forever`], so that choosing
//! one is a thing somebody typed rather than a default they never saw.
//!
//! # The log is append-only, and it is the point
//!
//! Every decision is recorded: what was seen, whether it was known, which grant
//! covered it and when that grant ends. A control whose decisions cannot be
//! reviewed afterwards is a control nobody can check, and this one is *only*
//! ever going to be reviewable, since it does not enforce.
//!
//! # In plain words
//!
//! For a while, this watches which programs you normally run and writes them
//! down. After that, it can tell you when something starts that was not on that
//! list.
//!
//! It does **not** block anything. It cannot. It is a way of noticing, not a
//! lock on the door, and anything that told you otherwise would be lying to
//! you about how safe you are.
//!
//! You can allow a program you recognise, and when you do you say for how long
//! -- an hour, a day, or permanently if you really mean it. Temporary is the
//! normal case, because a list that only ever grows stops meaning anything.
//! Everything it decides is written to a log you can read.

use std::collections::BTreeMap;
use std::time::{Duration, SystemTime};


/// The file format's first line. The digit is a version.
const MAGIC: &str = "VEILAPPCTL1";



/// How long a grant lasts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Grant {
    /// Until this moment, and not one second after.
    Until(SystemTime),
    /// For good.
    ///
    /// Spelled out rather than represented by a very large date, so that a
    /// reader of the file and a reader of the code both see the difference
    /// between "allowed until 2027" and "allowed for ever". A far-future
    /// timestamp is a permanent grant wearing a disguise.
    Forever,
}


impl Grant {

    /// A grant lasting `how_long` from `now`.
    ///
    /// Returns `None` when the duration would run off the end of the clock,
    /// rather than saturating: a grant that silently became permanent because
    /// somebody typed too many digits is precisely what [`Grant::Forever`]
    /// exists to make explicit.
    pub fn for_duration(now: SystemTime, how_long: Duration) -> Option<Grant> {
        now.checked_add(how_long).map(Grant::Until)
    }



    /// A permanent grant. Named, so choosing one is deliberate.
    pub fn forever() -> Grant {
        Grant::Forever
    }



    /// Whether this grant still covers `now`.
    pub fn covers(&self, now: SystemTime) -> bool {
        match self {
            Grant::Forever => true,
            Grant::Until(end) => now < *end,
        }
    }



    /// How this reads in a report.
    pub fn describe(&self, now: SystemTime) -> String {
        match self {
            Grant::Forever => "for ever".to_string(),
            Grant::Until(end) => match end.duration_since(now) {
                Ok(left) => format!("for another {}", plain_duration(left)),
                Err(_) => "expired".to_string(),
            },
        }
    }

}


/// A duration a person can read.
fn plain_duration(left: Duration) -> String {
    let seconds = left.as_secs();
    if seconds >= 86_400 {
        let days = seconds / 86_400;
        format!("{days} day{}", if days == 1 { "" } else { "s" })
    } else if seconds >= 3_600 {
        let hours = seconds / 3_600;
        format!("{hours} hour{}", if hours == 1 { "" } else { "s" })
    } else if seconds >= 60 {
        let minutes = seconds / 60;
        format!("{minutes} minute{}", if minutes == 1 { "" } else { "s" })
    } else {
        format!("{seconds} second{}", if seconds == 1 { "" } else { "s" })
    }
}



/// What a baseline says about one program.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// Seen while learning. Ordinary for this machine.
    Known,
    /// Not in the baseline, but covered by a grant that has not expired.
    Granted,
    /// Not in the baseline and not granted.
    ///
    /// **Not "blocked".** Nothing was stopped. The program is running; this is
    /// a statement about the baseline, not about the machine.
    Unknown,
    /// Still learning, so there is nothing to compare against yet.
    ///
    /// Deliberately its own answer rather than [`Verdict::Known`]: during
    /// learning everything would be "known", which is true and useless, and a
    /// front end must be able to say "still learning" instead of implying the
    /// machine has been checked.
    Learning,
}


impl Verdict {

    /// The wording a front end should use.
    pub fn phrasing(self) -> &'static str {
        match self {
            Self::Known => "was running while the baseline was learned",
            Self::Granted => "is not in the baseline, and you allowed it",
            Self::Unknown => {
                "is not in the baseline and has not been allowed -- it is still \
                 running, because nothing here stops anything"
            }
            Self::Learning => "was recorded; the baseline is still learning",
        }
    }

}


/// One line of the decision log.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
    /// When it was decided, as seconds since the Unix epoch.
    pub at: u64,
    /// The process name.
    pub program: String,
    /// What was decided.
    pub verdict: Verdict,
    /// How the covering grant reads, when there was one.
    pub grant: Option<String>,
}



/// What normally runs here, what has been allowed, and what has been decided.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Baseline {
    /// Programs seen while learning.
    known: BTreeMap<String, ()>,
    /// Programs allowed since, and until when.
    grants: BTreeMap<String, Grant>,
    /// Every decision, oldest first.
    log: Vec<Entry>,
    /// Whether the learning phase is still open.
    learning: bool,
}


impl Baseline {

    /// A baseline that has learned nothing and is not learning.
    pub fn new() -> Self {
        Self::default()
    }



    /// A baseline in its learning phase.
    pub fn learning() -> Self {
        Self {
            learning: true,
            ..Self::default()
        }
    }



    /// Whether the learning phase is open.
    pub fn is_learning(&self) -> bool {
        self.learning
    }



    /// Close the learning phase.
    ///
    /// Refuses to close on an empty baseline. A baseline that learned nothing
    /// calls everything unknown, which is the same as calling nothing unknown:
    /// the reader gets a page of noise, learns to ignore it, and is worse off
    /// than before they started.
    pub fn freeze(&mut self) -> Result<usize, Error> {
        if !self.learning {
            return Err(Error::NotLearning);
        }
        if self.known.is_empty() {
            return Err(Error::LearnedNothing);
        }
        self.learning = false;
        Ok(self.known.len())
    }



    /// How many programs the baseline holds.
    pub fn len(&self) -> usize {
        self.known.len()
    }



    /// Whether the baseline holds nothing.
    pub fn is_empty(&self) -> bool {
        self.known.is_empty()
    }



    /// Every program in the baseline, in order.
    pub fn programs(&self) -> impl Iterator<Item = &str> {
        self.known.keys().map(String::as_str)
    }



    /// Allow a program until a moment, or for good.
    ///
    /// A grant for something already in the baseline is refused: it would be a
    /// row in the allowlist that never does anything, and later reads as
    /// though somebody had to permit an ordinary program.
    pub fn allow(&mut self, program: &str, grant: Grant) -> Result<(), Error> {
        let name = normalise(program);
        if name.is_empty() {
            return Err(Error::NoProgram);
        }
        if self.known.contains_key(&name) {
            return Err(Error::AlreadyKnown(name));
        }
        self.grants.insert(name, grant);
        Ok(())
    }



    /// Withdraw a grant.
    pub fn revoke(&mut self, program: &str) {
        self.grants.remove(&normalise(program));
    }



    /// The grant covering a program, expired or not.
    pub fn grant(&self, program: &str) -> Option<Grant> {
        self.grants.get(&normalise(program)).copied()
    }



    /// Whether this program is allowed to be running, as of `now`.
    pub fn allowed(&self, program: &str, now: SystemTime) -> bool {
        matches!(
            self.verdict(program, now),
            Verdict::Known | Verdict::Granted | Verdict::Learning
        )
    }



    /// What this baseline says about a program, without recording anything.
    pub fn verdict(&self, program: &str, now: SystemTime) -> Verdict {
        let name = normalise(program);
        if self.learning {
            return Verdict::Learning;
        }
        if self.known.contains_key(&name) {
            return Verdict::Known;
        }
        match self.grants.get(&name) {
            // An expired grant is not a grant. It is left in place on purpose:
            // "this was allowed until Tuesday" is worth more to a reader than
            // a row that quietly disappeared.
            Some(grant) if grant.covers(now) => Verdict::Granted,
            _ => Verdict::Unknown,
        }
    }



    /// Record what is running now, and return what was decided.
    ///
    /// While learning, everything seen joins the baseline. Afterwards, nothing
    /// does -- which is the whole point of the phase having an end.
    pub fn observe(&mut self, running: &[String], now: SystemTime) -> Vec<Entry> {
        let at = now
            .duration_since(SystemTime::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let mut decided = Vec::new();
        for program in running {
            let name = normalise(program);
            if name.is_empty() {
                continue;
            }
            let verdict = self.verdict(&name, now);
            if self.learning {
                self.known.insert(name.clone(), ());
            }
            let entry = Entry {
                at,
                program: name.clone(),
                verdict,
                grant: self.grants.get(&name).map(|g| g.describe(now)),
            };
            // Only the interesting decisions are logged. Writing a line for
            // every ordinary program every time it is seen produces a log
            // nobody reads, and a log nobody reads is not a control.
            if matches!(verdict, Verdict::Unknown | Verdict::Granted) {
                self.log.push(entry.clone());
            }
            decided.push(entry);
        }
        decided
    }



    /// The decision log, oldest first.
    pub fn log(&self) -> &[Entry] {
        &self.log
    }



    /// Everything running that is neither known nor granted.
    pub fn unknown(&self, running: &[String], now: SystemTime) -> Vec<String> {
        let mut out: Vec<String> = running
            .iter()
            .map(|p| normalise(p))
            .filter(|name| !name.is_empty() && self.verdict(name, now) == Verdict::Unknown)
            .collect();
        out.sort_unstable();
        out.dedup();
        out
    }



    /// Write the baseline as text.
    ///
    /// Plain text on purpose, like the project format: it can be read, edited
    /// and diffed, and a control whose state is opaque is a control nobody can
    /// audit. The log is included, because a decision record kept separately
    /// from the decisions is one that can be lost separately.
    pub fn to_text(&self) -> String {
        let mut out = String::from(MAGIC);
        out.push('\n');
        out.push_str(
            "# What normally runs on this machine. This does NOT block anything;\n\
             # see the scope note in the application. Editing this file changes\n\
             # what is treated as ordinary, so treat it as a security setting.\n",
        );
        out.push_str(&format!("learning  {}\n", self.learning));
        for program in self.known.keys() {
            out.push_str(&format!("known  {program}\n"));
        }
        for (program, grant) in &self.grants {
            match grant {
                Grant::Forever => out.push_str(&format!("grant  {program}  forever\n")),
                Grant::Until(end) => {
                    let seconds = end
                        .duration_since(SystemTime::UNIX_EPOCH)
                        .map(|d| d.as_secs())
                        .unwrap_or(0);
                    out.push_str(&format!("grant  {program}  {seconds}\n"));
                }
            }
        }
        for entry in &self.log {
            out.push_str(&format!(
                "log  {}  {}  {}\n",
                entry.at,
                verdict_key(entry.verdict),
                entry.program
            ));
        }
        out
    }



    /// Read a baseline back.
    pub fn parse(text: &str) -> Result<Self, Error> {
        let mut lines = text.lines();
        match lines.next().map(str::trim) {
            Some(first) if first == MAGIC => {}
            _ => return Err(Error::NotABaseline),
        }
        let mut baseline = Baseline::new();
        for line in lines {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let (keyword, rest) = take_token(line);
            let rest = rest.trim();
            match keyword {
                "learning" => baseline.learning = rest == "true",
                "known" => {
                    baseline.known.insert(normalise(rest), ());
                }
                "grant" => {
                    let (program, when) = take_token(rest);
                    let when = when.trim();
                    let grant = if when == "forever" {
                        Grant::Forever
                    } else {
                        let seconds: u64 = when
                            .parse()
                            .map_err(|_| Error::Malformed(line.to_string()))?;
                        Grant::Until(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds))
                    };
                    baseline.grants.insert(normalise(program), grant);
                }
                "log" => {
                    let (at, rest) = take_token(rest);
                    let (verdict, program) = take_token(rest.trim());
                    baseline.log.push(Entry {
                        at: at.parse().map_err(|_| Error::Malformed(line.to_string()))?,
                        verdict: verdict_from_key(verdict)
                            .ok_or_else(|| Error::Malformed(line.to_string()))?,
                        program: normalise(program.trim()),
                        grant: None,
                    });
                }
                other => return Err(Error::UnknownKeyword(other.to_string())),
            }
        }
        Ok(baseline)
    }

}


/// The first whitespace-separated token, and the rest.
fn take_token(line: &str) -> (&str, &str) {
    match line.find(char::is_whitespace) {
        Some(at) => (&line[..at], &line[at..]),
        None => (line, ""),
    }
}



/// A process name as this crate compares them.
fn normalise(program: &str) -> String {
    program.trim().to_ascii_lowercase()
}



/// The word written to the file for a verdict.
fn verdict_key(verdict: Verdict) -> &'static str {
    match verdict {
        Verdict::Known => "known",
        Verdict::Granted => "granted",
        Verdict::Unknown => "unknown",
        Verdict::Learning => "learning",
    }
}



/// The verdict a word means.
fn verdict_from_key(key: &str) -> Option<Verdict> {
    Some(match key {
        "known" => Verdict::Known,
        "granted" => Verdict::Granted,
        "unknown" => Verdict::Unknown,
        "learning" => Verdict::Learning,
        _ => return None,
    })
}



/// Why something was refused.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Error {
    /// The file does not begin with this format's first line.
    NotABaseline,
    /// A line could not be read.
    Malformed(String),
    /// A keyword this build does not know.
    ///
    /// Refused rather than skipped: a line this build ignores is a security
    /// setting somebody wrote and this program did not apply.
    UnknownKeyword(String),
    /// A grant was asked for with no program named.
    NoProgram,
    /// The program is already in the baseline.
    AlreadyKnown(String),
    /// The learning phase was already closed.
    NotLearning,
    /// Nothing was seen while learning.
    LearnedNothing,
}


impl std::fmt::Display for Error {

    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotABaseline => write!(f, "that file does not start with {MAGIC}"),
            Self::Malformed(line) => write!(f, "this line could not be read: {line}"),
            Self::UnknownKeyword(word) => write!(
                f,
                "\"{word}\" is not something this version understands. Refused rather \
                 than skipped: a line this build ignores is a setting you wrote and it \
                 did not apply"
            ),
            Self::NoProgram => write!(f, "no program was named"),
            Self::AlreadyKnown(program) => write!(
                f,
                "{program} is already in the baseline, so allowing it would add a rule \
                 that never does anything"
            ),
            Self::NotLearning => write!(f, "the baseline is not learning"),
            Self::LearnedNothing => write!(
                f,
                "nothing was seen while learning. A baseline that learned nothing calls \
                 everything unknown, which is the same as calling nothing unknown"
            ),
        }
    }

}

impl std::error::Error for Error {}


/// What a reader must be told, in the words to tell them.
pub const SCOPE: &str = "\
This does not block anything and cannot. It learns which programs normally run \
on this machine and afterwards tells you when something runs that was not in \
that picture -- a way of noticing, not a lock on the door. A program it calls \
unknown is still running. Real enforcement needs a kernel driver or a signed \
system policy, and neither is something this project ships. It also only sees \
programs running as you, so an empty report is not proof that nothing is there.";


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

    fn at(seconds: u64) -> SystemTime {
        SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
    }

    fn names(list: &[&str]) -> Vec<String> {
        list.iter().map(|s| s.to_string()).collect()
    }

    /// **The most important test here.** Nothing this crate says may suggest it
    /// stopped anything, because it did not and cannot.
    #[test]
    fn nothing_this_crate_says_claims_to_have_blocked_anything() {
        let mut sentences = vec![SCOPE.to_string()];
        for verdict in [
            Verdict::Known,
            Verdict::Granted,
            Verdict::Unknown,
            Verdict::Learning,
        ] {
            sentences.push(verdict.phrasing().to_string());
        }
        for sentence in &sentences {
            let lower = sentence.to_lowercase();
            for claim in ["was blocked", "prevented it", "stopped it", "denied access"] {
                assert!(!lower.contains(claim), "\"{claim}\" in:\n{sentence}");
            }
        }
        // And the unknown verdict has to say the program is still running,
        // because that is the thing a reader will otherwise assume.
        assert!(Verdict::Unknown.phrasing().contains("still"));
        assert!(Verdict::Unknown.phrasing().contains("nothing here stops"));

        let scope = SCOPE.to_lowercase();
        assert!(
            scope.contains("does not block anything and cannot"),
            "{scope}"
        );
        assert!(scope.contains("still running"), "{scope}");
        assert!(scope.contains("not proof that nothing is there"), "{scope}");
    }

    /// A grant that has run out is not a grant, and the check is against the
    /// clock rather than against a sweep that may not have run.
    #[test]
    fn a_grant_stops_covering_the_moment_it_expires() {
        let mut base = Baseline::learning();
        base.observe(&names(&["explorer.exe"]), at(0));
        base.freeze().unwrap();

        base.allow(
            "thing.exe",
            Grant::for_duration(at(100), Duration::from_secs(60)).unwrap(),
        )
        .unwrap();

        assert_eq!(base.verdict("thing.exe", at(120)), Verdict::Granted);
        assert_eq!(base.verdict("thing.exe", at(159)), Verdict::Granted);
        // Exactly at the boundary it is over: `now < end`, not `<=`.
        assert_eq!(base.verdict("thing.exe", at(160)), Verdict::Unknown);
        assert_eq!(base.verdict("thing.exe", at(9_999)), Verdict::Unknown);

        // And the expired grant is still on record, because "allowed until"
        // is worth more to a reader than a row that vanished.
        assert!(base.grant("thing.exe").is_some());
        assert_eq!(
            base.grant("thing.exe").unwrap().describe(at(9_999)),
            "expired"
        );
    }

    /// Permanent is spelled out, never a very large date.
    #[test]
    fn forever_is_its_own_thing_and_not_a_distant_timestamp() {
        assert_eq!(Grant::forever(), Grant::Forever);
        assert!(Grant::Forever.covers(at(0)));
        assert!(Grant::Forever.covers(at(u32::MAX as u64)));
        assert_eq!(Grant::Forever.describe(at(0)), "for ever");

        // A duration that runs off the clock is refused rather than saturating
        // into an accidental permanent grant.
        assert_eq!(Grant::for_duration(at(0), Duration::MAX), None);
    }

    /// Learning has an end, and a baseline that learned nothing may not be
    /// frozen -- it would call everything unknown, which is noise.
    #[test]
    fn learning_must_end_and_must_have_learned_something() {
        let mut empty = Baseline::learning();
        assert_eq!(empty.freeze(), Err(Error::LearnedNothing));
        assert!(empty.is_learning(), "a refused freeze changes nothing");

        let mut base = Baseline::learning();
        base.observe(&names(&["a.exe", "b.exe"]), at(0));
        assert_eq!(base.freeze(), Ok(2));
        assert!(!base.is_learning());
        assert_eq!(base.freeze(), Err(Error::NotLearning));
    }

    /// After learning ends, nothing joins the baseline by running. That is the
    /// entire reason the phase has an end.
    #[test]
    fn a_frozen_baseline_does_not_learn_from_what_runs_next() {
        let mut base = Baseline::learning();
        base.observe(&names(&["known.exe"]), at(0));
        base.freeze().unwrap();
        assert_eq!(base.len(), 1);

        base.observe(&names(&["stranger.exe"]), at(10));
        assert_eq!(
            base.len(),
            1,
            "a stranger must not become ordinary by running"
        );
        assert_eq!(base.verdict("stranger.exe", at(10)), Verdict::Unknown);
    }

    /// While learning, the verdict says so rather than saying everything is
    /// fine -- which is true and useless and reads as a clean bill of health.
    #[test]
    fn while_learning_the_answer_is_learning_and_not_known() {
        let mut base = Baseline::learning();
        let decided = base.observe(&names(&["anything.exe"]), at(0));
        assert_eq!(decided[0].verdict, Verdict::Learning);
        assert_ne!(decided[0].verdict, Verdict::Known);
        assert!(Verdict::Learning.phrasing().contains("still learning"));
    }

    /// Only the decisions worth reading are logged. A line for every ordinary
    /// program every time it is seen is a log nobody reads.
    #[test]
    fn the_log_records_what_is_worth_reading_and_not_every_sighting() {
        let mut base = Baseline::learning();
        base.observe(&names(&["ordinary.exe"]), at(0));
        base.freeze().unwrap();

        for _ in 0..5 {
            base.observe(&names(&["ordinary.exe"]), at(10));
        }
        assert!(base.log().is_empty(), "{:?}", base.log());

        base.observe(&names(&["stranger.exe"]), at(20));
        assert_eq!(base.log().len(), 1);
        assert_eq!(base.log()[0].verdict, Verdict::Unknown);
        assert_eq!(base.log()[0].program, "stranger.exe");
        assert_eq!(base.log()[0].at, 20);
    }

    /// Allowing something already ordinary is refused: the rule would never do
    /// anything, and later reads as though it had been needed.
    #[test]
    fn a_grant_for_something_already_in_the_baseline_is_refused() {
        let mut base = Baseline::learning();
        base.observe(&names(&["ordinary.exe"]), at(0));
        base.freeze().unwrap();

        let refused = base.allow("ordinary.exe", Grant::Forever);
        assert_eq!(refused, Err(Error::AlreadyKnown("ordinary.exe".into())));
        assert!(base.grant("ordinary.exe").is_none());

        assert_eq!(base.allow("   ", Grant::Forever), Err(Error::NoProgram));
    }

    /// Names compare the same however they are written.
    #[test]
    fn a_name_is_matched_however_it_is_typed() {
        let mut base = Baseline::learning();
        base.observe(&names(&["Explorer.EXE"]), at(0));
        base.freeze().unwrap();
        for spelling in ["explorer.exe", "EXPLORER.EXE", "  Explorer.exe  "] {
            assert_eq!(base.verdict(spelling, at(0)), Verdict::Known, "{spelling}");
        }
    }

    /// Every shape a baseline can be in survives being written and read back.
    #[test]
    fn every_shape_of_baseline_round_trips() {
        let mut shapes = vec![Baseline::new(), Baseline::learning()];

        let mut full = Baseline::learning();
        full.observe(&names(&["a.exe", "b.exe", "c.exe"]), at(1_000));
        full.freeze().unwrap();
        full.allow("temp.exe", Grant::Until(at(5_000))).unwrap();
        full.allow("always.exe", Grant::Forever).unwrap();
        full.observe(&names(&["stranger.exe", "temp.exe"]), at(2_000));
        shapes.push(full);

        let mut granted_only = Baseline::new();
        granted_only.allow("x.exe", Grant::Forever).unwrap();
        shapes.push(granted_only);

        for shape in shapes {
            let text = shape.to_text();
            let back = Baseline::parse(&text).unwrap_or_else(|e| panic!("{e}\n---\n{text}"));
            let again = Baseline::parse(&back.to_text()).expect("stable");
            assert_eq!(back, again, "reading twice must agree:\n{text}");
            assert_eq!(back.learning, shape.learning);
            assert_eq!(back.known, shape.known);
            assert_eq!(back.grants, shape.grants);
            assert_eq!(back.log.len(), shape.log.len());
        }
    }

    /// A keyword this build does not know is **refused**, never skipped: a line
    /// silently ignored is a security setting somebody wrote and this program
    /// did not apply.
    #[test]
    fn an_unreadable_line_is_refused_rather_than_skipped() {
        let text = format!("{MAGIC}\nknown  a.exe\nblockall  yes\n");
        let refused = Baseline::parse(&text).unwrap_err();
        assert_eq!(refused, Error::UnknownKeyword("blockall".into()));
        assert!(refused.to_string().contains("did not apply"));

        assert_eq!(Baseline::parse("nonsense\n"), Err(Error::NotABaseline));
        assert!(Baseline::parse(&format!("{MAGIC}\ngrant  x.exe  soon\n")).is_err());
    }

    /// The file holds no secrets, and says what it is.
    #[test]
    fn the_file_explains_itself_and_carries_no_credentials() {
        let mut base = Baseline::learning();
        base.observe(&names(&["a.exe"]), at(0));
        base.freeze().unwrap();
        let text = base.to_text();
        assert!(text.starts_with(MAGIC));
        assert!(text.contains("does NOT block anything"));
        let data: Vec<&str> = text
            .lines()
            .filter(|line| !line.starts_with('#') && !line.is_empty())
            .collect();
        for line in data {
            let lower = line.to_lowercase();
            for secret in ["password", "passphrase", "key ", "token"] {
                assert!(!lower.contains(secret), "{line}");
            }
        }
    }

    #[test]
    fn what_is_unknown_is_listed_once_and_in_order() {
        let mut base = Baseline::learning();
        base.observe(&names(&["ok.exe"]), at(0));
        base.freeze().unwrap();
        base.allow("allowed.exe", Grant::Forever).unwrap();

        let running = names(&["ok.exe", "zeta.exe", "allowed.exe", "alpha.exe", "zeta.exe"]);
        assert_eq!(base.unknown(&running, at(1)), vec!["alpha.exe", "zeta.exe"]);
    }

    #[test]
    fn durations_read_as_english() {
        assert_eq!(plain_duration(Duration::from_secs(1)), "1 second");
        assert_eq!(plain_duration(Duration::from_secs(90)), "1 minute");
        assert_eq!(plain_duration(Duration::from_secs(7_200)), "2 hours");
        assert_eq!(plain_duration(Duration::from_secs(172_800)), "2 days");
    }
}