crates/veilvoice-policy/src/policy.rs

what this file is for · veilvoice-policy · 984 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 policy itself: what can be required, and what requiring it does.
//!
//! # Every requirement tightens, and there is nowhere to write one that does not
//!
//! [`Requirement`] has five variants and all five move VeilVoice in the same
//! direction. There is no `AllowPlaintext`, no `MaximumIntensity`, no
//! `SkipMetadataCleaning`. That is not an oversight to be filled in later; it
//! is the property the whole crate rests on, and
//! [`Posture::is_at_least_as_strict_as`] exists so a test can hold it.
//!
//! Anybody adding a variant should read [`crate`]'s documentation first. A
//! loosening variant does not merely add a feature: it removes the reason the
//! plain file can be read without a passphrase.
//!
//! # Format
//!
//! Text, one requirement per line, for the same reason the tamper manifest is
//! text: the point of the file is to be readable by the person it constrains.
//!
//! ```text
//! VEILPOLICY1
//! note  Set by the IT department. Ask before changing.
//! require  encrypt-recordings
//! require  clean-metadata
//! require  minimum-intensity  80
//! ```
//!
//! The floor is a whole number of hundredths, not a decimal. A policy has to
//! compare equal to its own sealed copy, and a value that reads back as
//! 0.7999999 would make [`crate::verify`] report `Differs` for ever.
//!
//! An unknown `require` keyword is an **error**, not a line to skip. A policy
//! written by a newer build says something this one cannot honour, and quietly
//! honouring the rest would leave the machine less restricted than the person
//! who wrote it believes. Refusing says so.
//!
//! # In plain words
//!
//! A way to say "these settings must always be on", so that they cannot be turned
//! off later by accident.
//!
//! Everything here only ever tightens. There is deliberately no way to write a
//! rule that makes VeilVoice do less, because a settings file that could weaken
//! the program would be the first thing worth attacking.
//!
//! If a rule and a control disagree, the rule wins and the window shows you the
//! value that will actually be used, rather than one that quietly changes when you
//! press the button.

use crate::Error;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};


/// Magic first line. The digit is a format version.
const MAGIC: &str = "VEILPOLICY1";



/// The plain policy, read at every launch and needing no passphrase.
pub const PLAIN_FILE: &str = "policy.txt";



/// The same policy sealed under a passphrase, for proving the plain one is
/// what was written.
pub const SEALED_FILE: &str = "policy.sealed";



/// One thing a policy can insist on.
///
/// **Every variant tightens.** See the module documentation before adding one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Requirement {
    /// Recordings must be encrypted at rest. The user may not turn it off.
    EncryptRecordings,
    /// Metadata must be stripped from what VeilVoice writes.
    CleanMetadata,
    /// Accent neutralisation must stay on.
    NeutraliseAccent,
    /// The app lock must be set before the program can be used.
    AppLock,
    /// The de-identification intensity has a floor.
    ///
    /// Stored as hundredths so the type stays `Ord` and the file round-trips
    /// exactly: a policy that reads back as 0.7999999 and then compares
    /// unequal to the one that was written is a policy nobody can verify.
    MinimumIntensity(u8),
}


impl Requirement {

    /// The keyword this is written as.
    pub fn keyword(&self) -> &'static str {
        match self {
            Requirement::EncryptRecordings => "encrypt-recordings",
            Requirement::CleanMetadata => "clean-metadata",
            Requirement::NeutraliseAccent => "neutralise-accent",
            Requirement::AppLock => "app-lock",
            Requirement::MinimumIntensity(_) => "minimum-intensity",
        }
    }



    /// What this means, in the words a front end should show beside the
    /// control it has taken away.
    ///
    /// A disabled control with no explanation is the thing people complain
    /// about; a disabled control with a reason is a decision somebody made.
    pub fn describe(&self) -> String {
        match self {
            Requirement::EncryptRecordings => {
                "Recordings are encrypted at rest, and that cannot be turned off here.".to_string()
            }
            Requirement::CleanMetadata => {
                "Metadata is stripped from what VeilVoice writes, and that cannot be \
                 turned off here."
                    .to_string()
            }
            Requirement::NeutraliseAccent => {
                "Accent neutralisation stays on. It removes the melody of an accent and \
                 not which sounds you make, so a strong accent may still be audible."
                    .to_string()
            }
            Requirement::AppLock => {
                "The app lock must be set before VeilVoice can be used. The lock is a \
                 verifier and not disk encryption."
                    .to_string()
            }
            Requirement::MinimumIntensity(hundredths) => format!(
                "De-identification intensity may not go below {:.2}.",
                *hundredths as f32 / 100.0
            ),
        }
    }

}


/// The settings a policy can reach, as a front end holds them.
///
/// Deliberately small. This is not a copy of the preferences file. It is the
/// subset a policy is allowed to constrain, which is the subset where being
/// *more* strict is never a loss.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Posture {
    /// Recordings are encrypted at rest.
    pub encrypt_recordings: bool,
    /// Metadata is stripped from what is written.
    pub clean_metadata: bool,
    /// Accent neutralisation is on.
    pub neutralise_accent: bool,
    /// The app lock is set.
    pub app_lock: bool,
    /// De-identification intensity, 0.0 to 1.0.
    pub intensity: f32,
}


impl Default for Posture {

    /// VeilVoice's own defaults, which are the strict ones.
    fn default() -> Self {
        Self {
            encrypt_recordings: true,
            clean_metadata: true,
            neutralise_accent: true,
            app_lock: false,
            intensity: 1.0,
        }
    }

}

impl Posture {

    /// The most permissive arrangement the controls can reach.
    ///
    /// Not a default anybody gets. It exists so a test can start from the
    /// loosest possible state and prove that applying a policy never loosens
    /// it further.
    pub fn most_permissive() -> Self {
        Self {
            encrypt_recordings: false,
            clean_metadata: false,
            neutralise_accent: false,
            app_lock: false,
            intensity: 0.0,
        }
    }



    /// Whether `self` is at least as strict as `other` in every dimension.
    ///
    /// The property the whole crate rests on: `policy.constrain(p)` must always
    /// be at least as strict as `p`, for every policy and every `p`.
    pub fn is_at_least_as_strict_as(&self, other: &Posture) -> bool {
        (self.encrypt_recordings || !other.encrypt_recordings)
            && (self.clean_metadata || !other.clean_metadata)
            && (self.neutralise_accent || !other.neutralise_accent)
            && (self.app_lock || !other.app_lock)
            && self.intensity >= other.intensity
    }

}


/// A set of requirements, and an optional note from whoever wrote them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Policy {
    /// A `BTreeSet` so the serialised form is byte-identical for the same set.
    /// A policy that serialises differently on each save cannot be compared
    /// against its own sealed copy, which is the only thing the seal is for.
    requirements: BTreeSet<Requirement>,
    note: Option<String>,
}


impl Policy {

    /// A policy that requires nothing.
    pub fn new() -> Self {
        Self::default()
    }



    /// Add a requirement.
    pub fn require(&mut self, requirement: Requirement) -> &mut Self {
        // A second floor replaces the first rather than sitting beside it: two
        // `MinimumIntensity` entries in one set would serialise as two lines
        // and the higher would silently win, which is a policy file that does
        // not say what it does.
        if let Requirement::MinimumIntensity(_) = requirement {
            self.requirements
                .retain(|held| !matches!(held, Requirement::MinimumIntensity(_)));
        }
        self.requirements.insert(requirement);
        self
    }



    /// Set the note shown beside every control the policy has fixed.
    ///
    /// Line breaks are refused rather than escaped: the format is one record
    /// per line, and a note containing a newline could forge a `require` line.
    pub fn with_note(mut self, note: &str) -> Result<Self, Error> {
        if note.contains('\n') || note.contains('\r') {
            return Err(Error::Malformed(
                "a note may not contain a line break: it would be able to forge a \
                 requirement line"
                    .into(),
            ));
        }
        self.note = if note.trim().is_empty() {
            None
        } else {
            Some(note.trim().to_string())
        };
        Ok(self)
    }



    /// The note, if there is one.
    pub fn note(&self) -> Option<&str> {
        self.note.as_deref()
    }



    /// Whether anything at all is required.
    pub fn is_empty(&self) -> bool {
        self.requirements.is_empty()
    }



    /// How many requirements there are.
    pub fn len(&self) -> usize {
        self.requirements.len()
    }



    /// The requirements, in a stable order.
    pub fn requirements(&self) -> impl Iterator<Item = &Requirement> {
        self.requirements.iter()
    }



    /// Whether a particular requirement is in force.
    pub fn requires(&self, requirement: &Requirement) -> bool {
        self.requirements.contains(requirement)
    }



    /// The intensity floor, or 0.0 when none is set.
    pub fn minimum_intensity(&self) -> f32 {
        self.requirements
            .iter()
            .filter_map(|requirement| match requirement {
                Requirement::MinimumIntensity(hundredths) => Some(*hundredths as f32 / 100.0),
                _ => None,
            })
            .fold(0.0f32, f32::max)
    }



    /// Apply the policy to a posture.
    ///
    /// Only ever tightens. The test suite holds that as a property across every
    /// subset of requirements and a range of postures, rather than trusting the
    /// five lines below to keep saying what they say today.
    pub fn constrain(&self, mut posture: Posture) -> Posture {
        if self.requires(&Requirement::EncryptRecordings) {
            posture.encrypt_recordings = true;
        }
        if self.requires(&Requirement::CleanMetadata) {
            posture.clean_metadata = true;
        }
        if self.requires(&Requirement::NeutraliseAccent) {
            posture.neutralise_accent = true;
        }
        if self.requires(&Requirement::AppLock) {
            posture.app_lock = true;
        }
        let floor = self.minimum_intensity();
        if posture.intensity < floor {
            posture.intensity = floor;
        }
        posture
    }



    /// 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');
        if let Some(note) = &self.note {
            out.push_str(&format!("note  {note}\n"));
        }
        for requirement in &self.requirements {
            match requirement {
                Requirement::MinimumIntensity(hundredths) => {
                    out.push_str(&format!(
                        "require  {}  {}\n",
                        requirement.keyword(),
                        hundredths
                    ));
                }
                other => out.push_str(&format!("require  {}\n", other.keyword())),
            }
        }
        out
    }



    /// Parse the text format.
    ///
    /// An unrecognised requirement is refused. See the module note: honouring
    /// the rest of a policy this build does not fully understand leaves the
    /// machine less restricted than whoever wrote it believes.
    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 policy file is empty".into())),
        }

        let mut policy = Policy::new();
        for (index, line) in lines.enumerate() {
            let number = index + 2;
            if line.trim().is_empty() {
                continue;
            }
            let Some((keyword, rest)) = line.split_once("  ") else {
                return Err(Error::Malformed(format!(
                    "line {number}: no keyword, found {line:?}"
                )));
            };
            match keyword {
                "note" => policy.note = Some(rest.trim().to_string()),
                "require" => {
                    let (name, argument) = match rest.split_once("  ") {
                        Some((name, argument)) => (name.trim(), Some(argument.trim())),
                        None => (rest.trim(), None),
                    };
                    policy.require(requirement_from(name, argument, number)?);
                }
                other => {
                    return Err(Error::Malformed(format!(
                        "line {number}: unknown keyword {other:?}"
                    )))
                }
            }
        }
        Ok(policy)
    }



    /// Seal the policy under a passphrase.
    ///
    /// The sealed copy is what proves the plain one is the policy that was
    /// written. It is not what makes the policy apply. See [`crate`].
    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 policy sealed by [`Policy::seal`].
    ///
    /// F-92, the third place the same question comes up. The generous
    /// four-gigabyte ceiling is for a container somebody was sent and chose to
    /// open. This file is not that: it sits at a fixed path beside the policy,
    /// and the person running `veilvoice policy verify` chose the command, not
    /// the file. [`Policy::seal`] writes it at this crate's default cost, so a
    /// ceiling of one gigabyte leaves four times the headroom anything
    /// legitimate needs and refuses a planted file instead of allocating for
    /// it.
    ///
    /// Changed at the same time as the sealed manifest, deliberately. Fixing
    /// the two places a campaign happened to point at and leaving the third
    /// would be the exclusion list that names the files somebody thought of.
    pub fn open_sealed(container: &[u8], password: &[u8]) -> Result<Self, Error> {
        let bytes = veilvoice_crypto::container::open_with_password_within(
            password,
            container,
            veilvoice_crypto::kdf::KdfParams::UNATTENDED_MAX_M_COST,
        )?;
        let text = String::from_utf8(bytes)
            .map_err(|_| Error::Malformed("the sealed policy is not text".into()))?;
        Self::parse(&text)
    }



    /// Write the plain policy into `dir`, and the sealed copy beside it.
    ///
    /// Both, always. A plain file with no sealed copy beside it is a policy
    /// nobody can check, and writing one silently would make [`verify`]'s
    /// [`Verification::NotSealed`] indistinguishable from a sealed copy that
    /// somebody deleted.
    pub fn save(&self, dir: &Path, password: &[u8]) -> Result<(), Error> {
        std::fs::create_dir_all(dir)?;
        let sealed = self.seal(password)?;
        // The sealed copy first. If the second write fails, what is left is a
        // sealed copy with no plain one -- nothing is applied, and `verify`
        // says the plain file is missing. The other order leaves a policy in
        // force that nobody can check, which is the state this crate exists to
        // avoid.
        std::fs::write(dir.join(SEALED_FILE), &sealed)?;
        std::fs::write(dir.join(PLAIN_FILE), self.to_text())?;
        Ok(())
    }



    /// Read the plain policy from `dir`. Never asks for a passphrase.
    ///
    /// `Ok(None)` when there is no policy at all, which is the ordinary state
    /// and not an error.
    pub fn load(dir: &Path) -> Result<Option<Self>, Error> {
        match std::fs::read_to_string(dir.join(PLAIN_FILE)) {
            Ok(text) => Ok(Some(Self::parse(&text)?)),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(error) => Err(Error::Io(error)),
        }
    }

}


fn requirement_from(name: &str, argument: Option<&str>, line: usize) -> Result<Requirement, Error> {
    match name {
        "encrypt-recordings" => Ok(Requirement::EncryptRecordings),
        "clean-metadata" => Ok(Requirement::CleanMetadata),
        "neutralise-accent" => Ok(Requirement::NeutraliseAccent),
        "app-lock" => Ok(Requirement::AppLock),
        "minimum-intensity" => {
            let argument = argument.ok_or_else(|| {
                Error::Malformed(format!("line {line}: minimum-intensity needs a value"))
            })?;
            let hundredths: u8 = argument.parse().map_err(|_| {
                Error::Malformed(format!(
                    "line {line}: minimum-intensity is a whole number of hundredths from \
                     0 to 100, not {argument:?}"
                ))
            })?;
            if hundredths > 100 {
                return Err(Error::Malformed(format!(
                    "line {line}: minimum-intensity is at most 100, not {hundredths}"
                )));
            }
            Ok(Requirement::MinimumIntensity(hundredths))
        }
        other => Err(Error::Malformed(format!(
            "line {line}: this build does not understand the requirement {other:?}. \
             Refusing the whole policy rather than honouring part of it."
        ))),
    }
}



/// What is known about the seal on a policy.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verification {
    /// Nobody has offered a passphrase, so the seal has not been looked at.
    /// This is what [`Policy::load`] leaves behind, and it is not a complaint.
    Unchecked,
    /// The sealed copy opened and matches the plain one.
    Matches,
    /// The sealed copy opened and says something different from the plain one.
    /// Somebody edited the plain file.
    Differs {
        /// The policy that was actually sealed.
        sealed: Box<Policy>,
    },
    /// There is a plain policy and no sealed copy beside it.
    NotSealed,
    /// There is a sealed copy and no plain policy, so nothing is being applied.
    NotApplied,
}


impl Verification {

    /// One line for a front end. Says what is known, never more.
    pub fn describe(&self) -> String {
        match self {
            Verification::Unchecked => {
                "in force, seal not checked -- a policy can only make VeilVoice stricter, \
                 so it is applied before anybody checks"
                    .to_string()
            }
            Verification::Matches => "in force, and it is the policy that was sealed".to_string(),
            Verification::Differs { .. } => {
                "in force, and it is NOT the policy that was sealed: the plain file has \
                 been edited"
                    .to_string()
            }
            Verification::NotSealed => {
                "in force, and there is no sealed copy to check it against".to_string()
            }
            Verification::NotApplied => {
                "a sealed policy exists and the plain file is missing, so nothing is \
                 being applied"
                    .to_string()
            }
        }
    }



    /// Whether this is a state somebody should look at.
    pub fn wants_attention(&self) -> bool {
        matches!(
            self,
            Verification::Differs { .. } | Verification::NotSealed | Verification::NotApplied
        )
    }

}


/// Check the plain policy in `dir` against its sealed copy.
///
/// This is the only function here that needs a passphrase, and nothing calls it
/// at launch.
pub fn verify(dir: &Path, password: &[u8]) -> Result<Verification, Error> {
    let plain = Policy::load(dir)?;
    let sealed_path: PathBuf = dir.join(SEALED_FILE);
    let sealed_bytes = match std::fs::read(&sealed_path) {
        Ok(bytes) => Some(bytes),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
        Err(error) => return Err(Error::Io(error)),
    };

    match (plain, sealed_bytes) {
        (None, None) => Ok(Verification::NotSealed),
        (Some(_), None) => Ok(Verification::NotSealed),
        (None, Some(_)) => Ok(Verification::NotApplied),
        (Some(plain), Some(bytes)) => {
            let sealed = Policy::open_sealed(&bytes, password)?;
            if sealed == plain {
                Ok(Verification::Matches)
            } else {
                Ok(Verification::Differs {
                    sealed: Box::new(sealed),
                })
            }
        }
    }
}


#[cfg(test)]
mod tests {

    /// The policy file printed in `docs/USER_GUIDE.md` has to parse.
    ///
    /// It did not. The first version of that section documented `key = value`
    /// with a single space, because that is what a policy file *looks* like it
    /// should be; the format is two spaces and no equals sign, and it also
    /// wants `VEILPOLICY1` on the first line and an intensity from 0 to 100
    /// rather than 1 to 5. Three mistakes in six lines, none of which a reader
    /// could have diagnosed from the documentation, all of which the parser
    /// rejects with a clear message the moment anybody runs it.
    ///
    /// So the documented example is extracted from the guide and parsed here.
    /// A file format described in prose beside a parser that disagrees is a
    /// documentation defect with a working reproduction, and this is it.
    #[test]
    fn the_example_in_the_user_guide_parses() {
        let guide = include_str!("../../../docs/USER_GUIDE.md").replace("\r\n", "\n");
        let at = guide
            .find("VEILPOLICY1")
            .expect("the guide has to show a policy file");
        let start = guide[..at].rfind("```").expect("inside a fenced block") + 3;
        let end = at + guide[at..].find("```").expect("the block has to close");
        let example = guide[start..end].trim_start_matches('\n');

        let policy = Policy::parse(example).unwrap_or_else(|why| {
            panic!("the policy file in docs/USER_GUIDE.md does not parse: {why}")
        });
        assert!(
            policy.requirements().count() >= 3,
            "an example with nothing in it teaches nothing"
        );
        assert!(policy.note.is_some(), "the example shows a note; keep it");
    }

    /// And the guide's table of requirements has to name real ones.
    #[test]
    fn every_requirement_the_guide_lists_is_a_real_one() {
        let guide = include_str!("../../../docs/USER_GUIDE.md").replace("\r\n", "\n");
        let at = guide
            .find("| `require` | What it insists on |")
            .expect("the guide has to have the requirements table");
        let table = &guide[at..at + 1200];
        for line in table.lines().skip(2) {
            if !line.starts_with('|') {
                break;
            }
            let Some(cell) = line.split('|').nth(1) else {
                continue;
            };
            let name = cell.trim().trim_matches('`');
            let name = name.split_whitespace().next().unwrap_or("");
            if name.is_empty() || name.starts_with("---") {
                continue;
            }
            let argument = (name == "minimum-intensity").then_some("60");
            assert!(
                requirement_from(name, argument, 0).is_ok(),
                "the guide lists {name:?}, which is not a requirement this \
                 parser accepts"
            );
        }
    }
    use super::*;

    /// A cheap KDF for the tests. The default is 256 MiB and three passes,
    /// deliberately, and running it in twenty tests would make the suite take
    /// minutes for no extra coverage of anything in this crate.
    fn sealed_with(policy: &Policy, password: &[u8]) -> Vec<u8> {
        veilvoice_crypto::container::seal_with_password(
            password,
            policy.to_text().as_bytes(),
            veilvoice_crypto::kdf::KdfParams {
                m_cost: 8,
                t_cost: 1,
                p_cost: 1,
            },
        )
        .expect("sealing should work")
    }

    fn every_requirement() -> Vec<Requirement> {
        vec![
            Requirement::EncryptRecordings,
            Requirement::CleanMetadata,
            Requirement::NeutraliseAccent,
            Requirement::AppLock,
            Requirement::MinimumIntensity(80),
        ]
    }

    /// **The property the crate rests on.** Every subset of every requirement,
    /// against a range of postures: applying a policy must never loosen one.
    ///
    /// Written as an exhaustive check rather than an example, because the
    /// interesting failure is a variant somebody adds later that goes the
    /// other way, and no example test would catch it.
    #[test]
    fn a_policy_can_only_ever_tighten() {
        let all = every_requirement();
        let postures = [
            Posture::most_permissive(),
            Posture::default(),
            Posture {
                encrypt_recordings: false,
                clean_metadata: true,
                neutralise_accent: false,
                app_lock: true,
                intensity: 0.5,
            },
            Posture {
                intensity: 1.0,
                ..Posture::most_permissive()
            },
        ];
        for mask in 0..(1u32 << all.len()) {
            let mut policy = Policy::new();
            for (index, requirement) in all.iter().enumerate() {
                if mask & (1 << index) != 0 {
                    policy.require(*requirement);
                }
            }
            for posture in postures {
                let after = policy.constrain(posture);
                assert!(
                    after.is_at_least_as_strict_as(&posture),
                    "mask {mask:b} loosened {posture:?} into {after:?}"
                );
            }
        }
    }

    /// Applying a policy twice must give the same answer as applying it once,
    /// or a front end that constrains on every frame drifts.
    #[test]
    fn constraining_is_idempotent() {
        let mut policy = Policy::new();
        for requirement in every_requirement() {
            policy.require(requirement);
        }
        let once = policy.constrain(Posture::most_permissive());
        assert_eq!(policy.constrain(once), once);
    }

    #[test]
    fn an_empty_policy_changes_nothing() {
        let policy = Policy::new();
        assert!(policy.is_empty());
        assert_eq!(policy.len(), 0);
        let posture = Posture::most_permissive();
        assert_eq!(policy.constrain(posture), posture);
        assert_eq!(policy.minimum_intensity(), 0.0);
    }

    #[test]
    fn a_floor_raises_a_low_intensity_and_leaves_a_high_one() {
        let mut policy = Policy::new();
        policy.require(Requirement::MinimumIntensity(80));
        assert_eq!(policy.minimum_intensity(), 0.8);
        let raised = policy.constrain(Posture {
            intensity: 0.1,
            ..Posture::most_permissive()
        });
        assert!((raised.intensity - 0.8).abs() < 1e-6);
        let untouched = policy.constrain(Posture {
            intensity: 1.0,
            ..Posture::most_permissive()
        });
        assert_eq!(untouched.intensity, 1.0);
    }

    /// Two floors in one policy would serialise as two lines and the higher
    /// would silently win, which is a file that does not say what it does.
    #[test]
    fn a_second_floor_replaces_the_first() {
        let mut policy = Policy::new();
        policy.require(Requirement::MinimumIntensity(50));
        policy.require(Requirement::MinimumIntensity(90));
        assert_eq!(policy.len(), 1);
        assert_eq!(policy.minimum_intensity(), 0.9);
        assert_eq!(policy.to_text().matches("minimum-intensity").count(), 1);

        // And lowering it is possible for whoever writes the policy -- the
        // one-way property is about the *user's* controls, not about the
        // administrator's ability to write a weaker policy on purpose.
        policy.require(Requirement::MinimumIntensity(10));
        assert_eq!(policy.minimum_intensity(), 0.1);
    }

    #[test]
    fn a_policy_survives_a_round_trip_through_text() {
        let mut policy = Policy::new();
        policy.require(Requirement::EncryptRecordings);
        policy.require(Requirement::MinimumIntensity(75));
        let policy = policy
            .clone()
            .with_note("Set by whoever set it. Ask before changing.")
            .unwrap();
        let text = policy.to_text();
        let read_back = Policy::parse(&text).expect("its own output must parse");
        assert_eq!(policy, read_back);
        assert_eq!(read_back.to_text(), text, "and byte for byte");
        assert_eq!(read_back.note(), policy.note());
    }

    /// The floor must come back exactly, or a policy can never equal its own
    /// sealed copy and `verify` says `Differs` for ever.
    #[test]
    fn every_floor_round_trips_exactly() {
        for hundredths in 0..=100u8 {
            let mut policy = Policy::new();
            policy.require(Requirement::MinimumIntensity(hundredths));
            let read_back = Policy::parse(&policy.to_text()).unwrap();
            assert_eq!(policy, read_back, "{hundredths} did not survive");
        }
    }

    #[test]
    fn a_note_may_not_contain_a_line_break() {
        let error = Policy::new()
            .with_note("harmless\nrequire  app-lock")
            .expect_err("a note that can forge a line must be refused");
        assert!(error.to_string().contains("line break"));
        // An empty note is simply no note.
        assert_eq!(Policy::new().with_note("   ").unwrap().note(), None);
    }

    /// A policy this build only half understands would leave the machine less
    /// restricted than whoever wrote it believes.
    #[test]
    fn an_unknown_requirement_refuses_the_whole_policy() {
        let text =
            format!("{MAGIC}\nrequire  encrypt-recordings\nrequire  something-from-the-future\n");
        let error = Policy::parse(&text).expect_err("must refuse");
        assert!(error.to_string().contains("does not understand"), "{error}");
        assert!(
            error.to_string().contains("rather than honouring part"),
            "the reason must be in the message: {error}"
        );
    }

    #[test]
    fn a_malformed_policy_is_refused_rather_than_half_read() {
        assert!(Policy::parse("").is_err(), "empty");
        assert!(Policy::parse("NOT-THE-MAGIC\n").is_err(), "wrong magic");
        for bad in [
            "require  minimum-intensity",
            "require  minimum-intensity  ten",
            "require  minimum-intensity  101",
            "require  minimum-intensity  -1",
            "whatever  1",
            "nokeyword",
        ] {
            let text = format!("{MAGIC}\n{bad}\n");
            assert!(Policy::parse(&text).is_err(), "should refuse: {bad:?}");
        }
    }

    #[test]
    fn blank_lines_are_tolerated() {
        let mut policy = Policy::new();
        policy.require(Requirement::AppLock);
        let padded = policy.to_text().replace('\n', "\n\n");
        assert_eq!(Policy::parse(&padded).unwrap(), policy);
    }

    #[test]
    fn a_sealed_policy_opens_with_the_right_passphrase_and_not_the_wrong_one() {
        let mut policy = Policy::new();
        policy.require(Requirement::EncryptRecordings);
        let container = sealed_with(&policy, b"correct horse");
        assert_eq!(
            Policy::open_sealed(&container, b"correct horse").unwrap(),
            policy
        );
        assert!(Policy::open_sealed(&container, b"wrong horse").is_err());
    }

    #[test]
    fn verification_reports_a_match() {
        let dir = tempfile::tempdir().unwrap();
        let mut policy = Policy::new();
        policy.require(Requirement::CleanMetadata);
        std::fs::write(dir.path().join(SEALED_FILE), sealed_with(&policy, b"pw")).unwrap();
        std::fs::write(dir.path().join(PLAIN_FILE), policy.to_text()).unwrap();
        assert_eq!(verify(dir.path(), b"pw").unwrap(), Verification::Matches);
        assert!(!Verification::Matches.wants_attention());
    }

    /// The case the seal exists for: somebody edited the plain file.
    #[test]
    fn verification_reports_an_edited_plain_file_and_says_what_was_sealed() {
        let dir = tempfile::tempdir().unwrap();
        let mut sealed_policy = Policy::new();
        sealed_policy.require(Requirement::EncryptRecordings);
        sealed_policy.require(Requirement::MinimumIntensity(90));
        std::fs::write(
            dir.path().join(SEALED_FILE),
            sealed_with(&sealed_policy, b"pw"),
        )
        .unwrap();

        let mut edited = Policy::new();
        edited.require(Requirement::EncryptRecordings);
        std::fs::write(dir.path().join(PLAIN_FILE), edited.to_text()).unwrap();

        match verify(dir.path(), b"pw").unwrap() {
            Verification::Differs { sealed } => {
                assert_eq!(*sealed, sealed_policy);
                assert_eq!(sealed.minimum_intensity(), 0.9);
            }
            other => panic!("expected Differs, got {other:?}"),
        }
        assert!(Verification::Differs {
            sealed: Box::new(sealed_policy)
        }
        .wants_attention());
    }

    #[test]
    fn a_plain_policy_with_no_seal_is_reported_as_unsealed() {
        let dir = tempfile::tempdir().unwrap();
        let mut policy = Policy::new();
        policy.require(Requirement::AppLock);
        std::fs::write(dir.path().join(PLAIN_FILE), policy.to_text()).unwrap();
        let checked = verify(dir.path(), b"pw").unwrap();
        assert_eq!(checked, Verification::NotSealed);
        assert!(checked.wants_attention());
    }

    /// A sealed copy with the plain file deleted means nothing is applied, and
    /// that is a different state from "no policy here".
    #[test]
    fn a_seal_with_no_plain_file_says_nothing_is_being_applied() {
        let dir = tempfile::tempdir().unwrap();
        let policy = Policy::new();
        std::fs::write(dir.path().join(SEALED_FILE), sealed_with(&policy, b"pw")).unwrap();
        let checked = verify(dir.path(), b"pw").unwrap();
        assert_eq!(checked, Verification::NotApplied);
        assert!(checked.wants_attention());
        assert!(checked.describe().contains("nothing is being applied"));
    }

    #[test]
    fn no_policy_at_all_is_not_an_error() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(Policy::load(dir.path()).unwrap(), None);
    }

    /// `save` writes both files, always.
    #[test]
    fn saving_writes_both_the_plain_and_the_sealed_copy() {
        let dir = tempfile::tempdir().unwrap();
        let mut policy = Policy::new();
        policy.require(Requirement::EncryptRecordings);
        // The real `save` uses the default 256 MiB KDF, which is the right
        // cost for a file somebody seals once. It is exercised here exactly
        // once rather than in every test.
        policy.save(dir.path(), b"pw").unwrap();
        assert!(dir.path().join(PLAIN_FILE).exists());
        assert!(dir.path().join(SEALED_FILE).exists());
        assert_eq!(Policy::load(dir.path()).unwrap().unwrap(), policy);
        assert_eq!(verify(dir.path(), b"pw").unwrap(), Verification::Matches);
    }

    /// Loading never asks for a passphrase, so what it can say about the seal
    /// is nothing -- and it says exactly that.
    #[test]
    fn the_unchecked_state_explains_why_it_is_still_applied() {
        let text = Verification::Unchecked.describe();
        assert!(text.contains("seal not checked"), "{text}");
        assert!(text.contains("only make VeilVoice stricter"), "{text}");
        assert!(
            !Verification::Unchecked.wants_attention(),
            "an unchecked seal is the ordinary state, not a complaint"
        );
    }

    /// Every requirement explains itself beside the control it disables, and
    /// none of them overstates what VeilVoice does.
    #[test]
    fn every_requirement_explains_itself_without_overclaiming() {
        for requirement in every_requirement() {
            let text = requirement.describe();
            assert!(!text.trim().is_empty(), "{requirement:?}");
            assert!(!requirement.keyword().is_empty());
            for boast in ["tamper-proof", "unbreakable", "cannot be bypassed"] {
                assert!(!text.to_lowercase().contains(boast), "{text}");
            }
        }
        // The two requirements over things with documented limits must repeat
        // those limits rather than implying the requirement removes them.
        assert!(Requirement::NeutraliseAccent
            .describe()
            .contains("may still be audible"));
        assert!(Requirement::AppLock
            .describe()
            .contains("not disk encryption"));
    }

    #[test]
    fn keywords_are_unique_and_match_what_parses() {
        for requirement in every_requirement() {
            let argument = match requirement {
                Requirement::MinimumIntensity(hundredths) => Some(hundredths.to_string()),
                _ => None,
            };
            let parsed = requirement_from(requirement.keyword(), argument.as_deref(), 1).unwrap();
            assert_eq!(parsed, requirement);
        }
    }

    #[test]
    fn the_strictness_comparison_is_honest_in_both_directions() {
        let loose = Posture::most_permissive();
        let strict = Posture {
            encrypt_recordings: true,
            clean_metadata: true,
            neutralise_accent: true,
            app_lock: true,
            intensity: 1.0,
        };
        assert!(strict.is_at_least_as_strict_as(&loose));
        assert!(!loose.is_at_least_as_strict_as(&strict));
        assert!(loose.is_at_least_as_strict_as(&loose));
        assert!(strict.is_at_least_as_strict_as(&strict));
    }
}