crates/veilvoice-gui/src/security.rs

what this file is for · veilvoice-gui · 2403 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 application lock, and the at-rest encryption of what VeilVoice writes.
//!
//! # Two passwords, and why
//!
//! There are two, deliberately:
//!
//! - the **app lock**, which decides whether VeilVoice will open at all, and
//! - the **recording passphrase**, which encrypts the files it produces.
//!
//! Collapsing them into one would mean that unlocking the app also unseals
//! every recording it has ever written, which is the opposite of what a lock is
//! for. [`veilvoice_crypto::lock`] additionally domain-separates its verifier,
//! so even a user who types the same string in both places does not end up with
//! two copies of one value.
//!
//! # What the lock is worth
//!
//! Not much against an attacker with the disk, and the UI says so in
//! [`veilvoice_crypto::lock::SCOPE`], shown on the unlock screen itself rather
//! than buried in an about page. It stops the person who picks up your unlocked
//! laptop. It does not stop someone who takes the drive.
//!
//! # A limitation of typing a password into a window
//!
//! A text field owns a `String`, so a passphrase exists as ordinary heap bytes
//! while it is being typed. That window cannot be removed, because something has
//! to receive the keystrokes, but it can be kept short, and it is:
//!
//! - the typing buffer is wiped the moment the passphrase is confirmed;
//! - the confirmed passphrase is held only as a [`veilvoice_crypto::Secret`],
//!   page-locked and zeroized on drop, for the rest of the session;
//! - locking the app, or changing the passphrase, wipes both.
//!
//! It used to be kept as a plain `String` for the whole session, which was a
//! much larger window for no benefit.
//!
//! None of this defends against someone who can read this process's memory. If
//! they can, they have already won, and `docs/WHITEPAPER.md` §7 says so rather
//! than implying otherwise. What it does is stop a passphrase lingering in a
//! heap allocation long after it was needed, where a core dump or a swapped
//! page could pick it up.
//!
//! # In plain words
//!
//! The lock on the window, and the encryption of the files VeilVoice writes.
//!
//! There are two passphrases and they do different jobs. One opens the
//! application. The other encrypts a recording, and it is asked for separately
//! because they protect different things and losing one should not mean losing the
//! other.
//!
//! The panel says what the lock is worth and what it is not: it stops somebody who
//! picks up your unlocked computer, and it does not stop somebody who has the
//! disk. Encrypting the recording is what protects the recording.

use crate::theme::palette as p;
use egui::{Color32, RichText};
use std::path::PathBuf;
use std::sync::mpsc;
use veilvoice_crypto::{container, kdf, lock, LockStore, Secret};
use veilvoice_policy::{Field as MField, Mandate};
use zeroize::Zeroize;


/// Move a typed passphrase out of its `String` and into page-locked storage,
/// wiping the buffer it came from.
///
/// No `unsafe`, so the intermediate `Vec` is a genuine second copy for a
/// moment; `Secret::new` wipes it before returning. Writing through
/// `String::as_bytes_mut` would avoid the copy and is not worth an `unsafe`
/// block in a crate that has none.
fn into_secret(typed: &mut String) -> Secret {
    let mut bytes = typed.as_bytes().to_vec();
    let secret = Secret::new(&mut bytes);
    typed.zeroize();
    secret
}



/// How the recording that comes out of a job is protected.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Sealing {
    /// Argon2id over a passphrase held for this session.
    Password,
    /// X25519 + ML-KEM-768 to a recipient's public key file.
    PublicKey,
    /// Argon2id over the **app-lock** passphrase, so everything VeilVoice
    /// writes is sealed without anybody choosing a second secret.
    ///
    /// **Roadmap item 86.** This reverses a decision the crypto crate states in as
    /// many words, and the reversal is deliberate rather than accidental, so
    /// the cost is written here as well as in the documentation: one passphrase
    /// now opens the application *and* everything it has ever written.
    /// Somebody compelled to unlock VeilVoice in front of another person used
    /// to reveal the session; with this on, they reveal the archive too.
    ///
    /// The container is sealed under the passphrase itself rather than under a
    /// key derived from the lock file. That is what keeps a deleted or damaged
    /// lock from taking the recordings with it: the file carries its own salt
    /// and cost, so `veilvoice decrypt` opens it with the same passphrase on
    /// any machine, with or without a lock.
    AppLock,
}



/// What a background lock operation was trying to do.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Op {
    Unlock,
    Set,
    Change,
    Remove,
    /// Clear an outstanding interference report. Needs the passphrase, which is
    /// the whole reason it is an operation and not a button.
    Acknowledge,
}



/// A finished lock operation: the store as it now stands, and how it went.
type OpResult = (Option<LockStore>, Result<Op, String>, Option<StoreKey>);


/// The obfuscated store's key, derived on the worker thread beside the unlock.
///
/// It has to be derived there. It costs a full Argon2id run at the configured
/// cost, and doing that on the UI thread would freeze the window for the
/// fraction of a second the unlock was supposed to have finished in. The
/// unlock is already paying for one run on that thread, so the key rides back
/// with the result rather than being asked for again.
use veilvoice_crypto::hoard::StoreKey;


/// Everything about locking the app and sealing its output.
pub struct Security {
    /// Where the lock file lives, or `None` if the platform did not say.
    path: Option<PathBuf>,
    /// The configured lock, if there is one.
    store: Option<LockStore>,
    /// A problem reading the lock, reported rather than treated as "no lock".
    load_error: Option<String>,
    /// Whether the app is currently locked. Only ever true with a `store`.
    locked: bool,
    /// Whether the current lock was the autolock's doing rather than a person's.
    ///
    /// Read by the lock screen for one line of text, and cleared on unlock. It
    /// is not persisted: a lock that survives a restart is a lock whose reason
    /// nobody can still be wondering about.
    auto_locked: bool,
    /// Whether the lock reported interference. Shown once the app is open, not
    /// on the lock screen: telling a stranger at the lock screen that their
    /// last attempt was noticed is telling them something they can use.
    tampered: bool,
    /// The obfuscated store's key, derived beside the unlock that produced it.
    ///
    /// Held for exactly one caller, like the passphrase beside it, and for the
    /// same reason: whoever takes it owns it for the session. It is not
    /// re-derivable without another Argon2id run, so a caller that drops it
    /// leaves the folder unreadable until the next unlock.
    just_unlocked_key: Option<StoreKey>,
    /// The passphrase that just opened the lock, held for exactly one caller to
    /// collect. See [`Security::take_unlock_passphrase`].
    just_unlocked: Option<String>,
    /// The app-lock passphrase, kept for the session so [`Sealing::AppLock`]
    /// can seal with it.
    ///
    /// Only ever populated when that mode is already chosen, which is the
    /// point: a user who has not asked for it keeps the old behaviour, where
    /// the passphrase is wiped the instant it has been checked. Page-locked
    /// and zeroed on drop, like every other secret here, and cleared by
    /// [`Security::lock_now`].
    app_secret: Option<Secret>,

    // --- unlock screen ---
    entry: String,
    message: Option<(String, Color32)>,
    pending: Option<mpsc::Receiver<OpResult>>,

    // --- set / change form ---
    current: String,
    fresh: String,
    repeat: String,

    // --- recording encryption ---
    /// On by default. Turning it off goes through [`Self::confirm_disable`].
    pub encrypt_recordings: bool,
    /// Which container mode sealing uses.
    pub sealing: Sealing,
    /// Recipient public key, for [`Sealing::PublicKey`].
    pub public_key: Option<PathBuf>,
    /// The key picker, while it is open.
    choosing_key: crate::dialog::Pending,
    /// The typing buffer. Held in a `String` only because that is what the
    /// text widget requires, and wiped the moment it is confirmed.
    passphrase: String,
    /// The confirmed session passphrase, in page-locked storage.
    ///
    /// Kept here rather than as the `String` above so that the plaintext
    /// version exists only while it is being typed, instead of for the
    /// whole session. That does not make it safe against someone who can
    /// read this process's memory -- nothing can -- it shortens the window.
    held: Option<Secret>,
    /// Whether `passphrase` has been confirmed against `passphrase_repeat`.
    passphrase_set: bool,
    passphrase_repeat: String,
    /// Whether the "write it unencrypted?" dialogue is open.
    confirm_disable: bool,
    /// A policy requires encryption at rest, so it cannot be turned off here.
    ///
    /// Set once from [`crate::policy::InForce`]. When true the checkbox is
    /// disabled *and* [`Self::encrypt_recordings`] is forced on, because a
    /// disabled checkbox is a claim about pixels and this is a claim about
    /// behaviour.
    pub encryption_pinned: bool,
    /// A policy requires the app lock to be set. Shown on the lock tab when it
    /// is not; never used to refuse entry.
    ///
    /// Refusing would lock somebody out of their own recordings because of a
    /// file in their own configuration directory, which is a worse outcome
    /// than an unlocked application saying plainly that it should be locked.
    pub lock_required: bool,
    /// The user's own baseline: what VeilVoice insists on unless told otherwise.
    ///
    /// Held here, rather than read at each use, so that turning encryption off
    /// in this window is the same recorded act as `veilvoice mandate relax
    /// --encryption`. Before this, the checkbox was a setting that lasted until
    /// the process exited and left no trace; the two front ends now share one
    /// baseline and one history.
    mandate: Mandate,
    /// Where that baseline is stored, or `None` in tests, which never write.
    mandate_path: Option<PathBuf>,
    /// Why the baseline is the strict default, when a file exists and would not
    /// parse. Shown rather than swallowed: a mandate file that does not parse
    /// means requirements somebody set are not being applied.
    pub mandate_problem: Option<String>,
}


impl Default for Security {

    /// The safe state, and deliberately free of I/O so tests and
    /// `VeilVoiceApp::default()` never touch the real lock file. The running
    /// app calls [`Security::load`].
    fn default() -> Self {
        Self {
            path: None,
            store: None,
            load_error: None,
            locked: false,
            auto_locked: false,
            just_unlocked_key: None,
            tampered: false,
            just_unlocked: None,
            app_secret: None,
            entry: String::new(),
            message: None,
            pending: None,
            current: String::new(),
            fresh: String::new(),
            repeat: String::new(),
            encrypt_recordings: true,
            sealing: Sealing::Password,
            public_key: None,
            choosing_key: crate::dialog::Pending::new(),
            passphrase: String::new(),
            held: None,
            passphrase_set: false,
            passphrase_repeat: String::new(),
            confirm_disable: false,
            encryption_pinned: false,
            lock_required: false,
            mandate: Mandate::default(),
            mandate_path: None,
            mandate_problem: None,
        }
    }

}

impl Drop for Security {

    fn drop(&mut self) {
        self.wipe_secrets();
    }

}

impl Security {

    /// Read the lock file for this machine and start locked if one is set.
    pub fn load() -> Self {
        let path = lock::default_path();
        // Field-by-field rather than struct-update syntax: `Security` has a
        // `Drop` that wipes its secrets, and `..Default::default()` would have
        // to move fields out of a value that owns one.
        let mut security = Self::default();
        security.path = path.clone();
        let Some(_path) = path else {
            security.load_error =
                Some("cannot find a configuration directory on this platform".into());
            return security;
        };
        match lock::open_default() {
            Ok((Some(mut store), restored)) => {
                if restored {
                    // A copy of the lock had gone and was rebuilt from the
                    // other. Files do not delete themselves, so this is a
                    // report, and it is made to stick: `report_tamper` holds it
                    // in memory now and the next successful unlock writes it
                    // where a restart cannot lose it.
                    store.report_tamper();
                }
                security.tampered = restored || store.tampered();
                security.store = Some(store);
                security.locked = true;
            }
            Ok((None, _)) => {}
            // A lock that will not parse must never read as an absent lock.
            Err(e) => {
                security.load_error = Some(format!("the app lock could not be read: {e}"));
                security.locked = true;
            }
        }
        security
    }



    /// Take the passphrase that just opened the lock, once.
    ///
    /// Returns `Some` on exactly the frame after a successful unlock and `None`
    /// on every other. It exists so [`crate::integrity`] can open a record
    /// sealed under the app-lock passphrase without this module keeping that
    /// passphrase for the life of the session. The caller must wipe what it
    /// gets; the worker that receives it does.
    pub fn take_unlock_passphrase(&mut self) -> Option<String> {
        self.just_unlocked.take()
    }



    /// Collect the obfuscated store's key from the unlock that just happened.
    ///
    /// One caller, one frame, like the passphrase. See
    /// [`crate::vault_store::VaultStore`] for what it opens and what that is
    /// worth.
    pub fn take_unlock_store_key(&mut self) -> Option<StoreKey> {
        self.just_unlocked_key.take()
    }



    /// Start in [`Sealing::AppLock`], because the user asked for that last time.
    ///
    /// Applied at startup, before the window is drawn and so before anything
    /// can be unlocked, which matters: the passphrase is captured as the lock
    /// opens and only when this mode is already chosen.
    pub fn prefer_app_lock_sealing(&mut self, on: bool) {
        if on && self.store.is_some() {
            self.sealing = Sealing::AppLock;
        }
    }



    /// Whether the app-lock sealing mode is currently chosen, so the window can
    /// have the choice remembered.
    pub fn seals_with_app_lock(&self) -> bool {
        self.sealing == Sealing::AppLock
    }



    /// Whether the lock reported having been interfered with.
    ///
    /// Stays true until an unlock acknowledges it, which needs the passphrase,
    /// so nobody can dismiss the banner except the person who can open the app.
    pub fn tampered(&self) -> bool {
        self.tampered
    }



    /// Whether the unlock screen should be shown instead of the app.
    pub fn is_locked(&self) -> bool {
        self.locked
    }



    /// Whether a lock is configured at all.
    /// Set an app lock from the first-run setup.
    ///
    /// The same worker and the same `Op::Set` the security tab uses, so there
    /// is one path that creates a lock rather than two that can disagree --
    /// which is exactly how F-141 happened.
    pub fn set_lock_from_setup(&mut self, passphrase: String) {
        if self.store.is_none() && !passphrase.is_empty() {
            self.spawn(Op::Set, passphrase, String::new());
        }
    }



    /// Whether a recording passphrase is held for this session.
    pub fn has_recording_passphrase(&self) -> bool {
        self.passphrase_set
    }



    /// Take a recording passphrase from the first-run setup.
    ///
    /// Moved into page-locked storage immediately, like the one the security
    /// tab takes: a `String` typed into a text field is ordinary heap memory,
    /// and the point of `Secret` is that it does not stay that way.
    pub fn set_recording_passphrase(&mut self, mut passphrase: String) {
        if passphrase.is_empty() {
            return;
        }
        self.held = Some(into_secret(&mut passphrase));
        self.passphrase_set = true;
    }



    /// Whether an app lock is configured on this machine.
    pub fn has_lock(&self) -> bool {
        self.store.is_some()
    }



    /// Lock the app now, wiping the session passphrase with it.
    ///
    /// This is the deliberate one: somebody pressed Lock. The screen says
    /// nothing about how it got there, because the person reading it already
    /// knows.
    pub fn lock_now(&mut self) {
        self.lock_inner(false);
    }



    /// Lock because nobody has touched the window for a while.
    ///
    /// Identical to [`Self::lock_now`] except that the lock screen says so.
    /// Coming back to a locked window you did not lock is the moment to
    /// wonder whether somebody else has been at the machine, and answering
    /// that costs nothing and saves a bad minute.
    pub fn lock_after_idle(&mut self) {
        self.lock_inner(true);
    }



    /// Lock, remembering whether the person did it or the idle timer did.
    ///
    /// The distinction is carried because the lock screen says which, and
    /// "locked after twenty minutes idle" answers a question that "locked"
    /// leaves open.
    fn lock_inner(&mut self, by_idle: bool) {
        if self.store.is_some() {
            self.locked = true;
            self.auto_locked = by_idle;
            self.wipe_secrets();
        }
    }



    /// Wipe every plaintext secret this struct is holding.
    fn wipe_secrets(&mut self) {
        // The store key goes with the passphrase. It is not a `String`, so it
        // is dropped rather than zeroized here; `Secret` wipes itself on drop,
        // which is the whole reason the key is held in one.
        self.just_unlocked_key = None;
        for field in [
            &mut self.entry,
            &mut self.current,
            &mut self.fresh,
            &mut self.repeat,
            &mut self.passphrase,
            &mut self.passphrase_repeat,
        ] {
            field.zeroize();
        }
        // The one-frame handover to the integrity record, wiped here in case
        // nobody collected it. Locking the window again must not leave a
        // passphrase behind because a caller happened not to look.
        if let Some(mut carried) = self.just_unlocked.take() {
            carried.zeroize();
        }
        // Roadmap item 86's session copy of the app-lock passphrase goes with
        // everything else. Locking the window has to put back the state a
        // fresh launch would be in, or "lock" is a picture of a lock.
        self.app_secret = None;
        self.held = None;
        self.passphrase_set = false;
    }



    /// Whether a job may start: either encryption is off, or there is something
    /// to encrypt with.
    pub fn ready_to_write(&self) -> bool {
        if !self.encrypt_recordings {
            return true;
        }
        match self.sealing {
            Sealing::Password => self.held.is_some(),
            Sealing::PublicKey => self.public_key.is_some(),
            Sealing::AppLock => self.app_secret.is_some(),
        }
    }



    /// Why a job cannot start yet, for the button's tooltip.
    pub fn blocked_reason(&self) -> Option<&'static str> {
        if self.ready_to_write() {
            return None;
        }
        Some(match self.sealing {
            Sealing::Password => "set a recording passphrase first",
            Sealing::PublicKey => "choose a recipient public key first",
            // The passphrase is captured as the lock opens, so this is what a
            // user sees who turned the mode on after unlocking. Saying "lock
            // and unlock" is the actual remedy; "no passphrase" would not be.
            Sealing::AppLock => "lock the app and unlock it again to use this",
        })
    }



    /// How the next job should protect its output.
    ///
    /// Returns the material by value so the worker thread owns it; the copy
    /// held here stays for the next file.
    pub fn plan(&self) -> Plan {
        if !self.encrypt_recordings {
            return Plan::Plaintext;
        }
        match self.sealing {
            Sealing::Password => match &self.held {
                Some(secret) => Plan::Password(secret.clone()),
                None => Plan::Missing,
            },
            Sealing::PublicKey => match &self.public_key {
                Some(path) => Plan::PublicKey(path.clone()),
                // Unreachable through the UI, which gates on `ready_to_write`,
                // but falling back to plaintext here would silently do the one
                // thing the user did not ask for.
                None => Plan::Missing,
            },
            Sealing::AppLock => match &self.app_secret {
                // A password plan, because that is exactly what it is: the
                // container is sealed under the app-lock passphrase and
                // carries its own salt, so nothing about opening it later
                // depends on the lock file still existing.
                Some(secret) => Plan::Password(secret.clone()),
                None => Plan::Missing,
            },
        }
    }



    /// Run a lock operation on a thread, so the window keeps drawing.
    ///
    /// Argon2id at 256 MiB takes long enough to be felt. Doing it on the
    /// drawing thread would freeze the window for the duration, which reads as
    /// a crash.
    fn spawn(&mut self, op: Op, password: String, replacement: String) {
        let store = self.store.take();
        let path = self.path.clone();
        let (tx, rx) = mpsc::channel();
        self.pending = Some(rx);
        self.message = None;

        // Argon2id at the default cost takes a noticeable fraction of a second;
        // running it on the UI thread would freeze the window mid-keystroke.
        std::thread::spawn(move || {
            let _ = tx.send(run_op(op, store, path, password, replacement));
        });
    }



    /// Collect a finished lock operation. Returns true if anything changed.
    fn poll(&mut self) -> bool {
        let Some(rx) = &self.pending else {
            return false;
        };
        let (store, outcome, store_key) = match rx.try_recv() {
            Ok(v) => v,
            Err(mpsc::TryRecvError::Empty) => return false,
            Err(mpsc::TryRecvError::Disconnected) => (
                None,
                Err("the lock operation stopped unexpectedly".to_string()),
                None,
            ),
        };
        self.pending = None;
        self.store = store;

        // Whatever the operation was, the store is the authority on whether a
        // report is outstanding. Reading it back here means one place decides,
        // rather than each arm remembering to.
        self.tampered = self.store.as_ref().is_some_and(LockStore::tampered) || self.tampered;

        match outcome {
            Ok(Op::Unlock) => {
                self.locked = false;
                self.auto_locked = false;
                // Moved rather than wiped, for one caller and one frame. The
                // integrity record is sealed under this passphrase and the
                // unlock is the only moment it exists, so wiping it here would
                // mean the record could never be opened. Whoever takes it is
                // responsible for wiping it; `take_unlock_passphrase` says so,
                // and `wipe_secrets` catches the case where nobody does.
                let opened = std::mem::take(&mut self.entry);
                // Roadmap item 86. Kept for the session only when the mode that
                // needs it is already chosen. A user who has not asked for
                // this keeps the old behaviour exactly: the passphrase is
                // wiped the moment it has been checked, and never sits in
                // memory waiting for a feature nobody switched on.
                if self.sealing == Sealing::AppLock {
                    let mut copy = opened.clone();
                    self.app_secret = Some(into_secret(&mut copy));
                }
                self.just_unlocked = Some(opened);
                self.just_unlocked_key = store_key;
                self.message = None;
            }
            Ok(Op::Acknowledge) => {
                self.tampered = false;
                self.wipe_form();
                self.message = Some(("interference report cleared".into(), p::green()));
            }
            Ok(Op::Set) => {
                self.wipe_form();
                self.message = Some(("app lock set".into(), p::green()));
            }
            Ok(Op::Change) => {
                self.wipe_form();
                // F-94. The session copy is the *old* passphrase, and keeping
                // it would seal every recording made from here with a password
                // the user has just replaced and may never type again. Dropped
                // rather than quietly updated: the new one went to the worker
                // thread and was wiped there, so the honest move is to say the
                // mode needs another unlock, which `blocked_reason` already
                // does.
                let had_secret = self.app_secret.take().is_some();
                self.message = Some(if had_secret {
                    (
                        "app lock password changed. Lock and unlock to seal new \
                         recordings with it; ones already written still open with the \
                         old password."
                            .into(),
                        p::yellow(),
                    )
                } else {
                    ("app lock password changed".into(), p::green())
                });
            }
            Ok(Op::Remove) => {
                self.wipe_form();
                self.locked = false;
                self.auto_locked = false;
                self.message = Some(("app lock removed".into(), p::yellow()));
            }
            Err(e) => {
                self.entry.zeroize();
                self.message = Some((e, p::red()));
            }
        }
        true
    }



    /// Clear what was typed, so a passphrase does not sit in a field after
    /// use.
    fn wipe_form(&mut self) {
        self.current.zeroize();
        self.fresh.zeroize();
        self.repeat.zeroize();
    }



    /// Whether an operation is in flight, so the panel can refuse a second
    /// one.
    fn busy(&self) -> bool {
        self.pending.is_some()
    }



    /// Whether a lock operation is running, so the window keeps repainting and
    /// the spinner actually spins.
    pub fn is_busy(&self) -> bool {
        self.busy()
    }



    /// The full-window unlock screen. Nothing else is drawn while this is up.
    pub fn unlock_screen(&mut self, ui: &mut egui::Ui, motion: crate::prefs::Motion) {
        self.poll();

        // Vertically centred rather than pinned near the top. A locked window
        // has one thing in it, and one thing sitting a fifth of the way down
        // an empty panel looks like a page that failed to load. The space
        // above is a third of what is left over, which puts the mark slightly
        // above centre -- where an eye looks first.
        let content_height = 260.0;
        let slack = (ui.available_height() - content_height).max(0.0);
        ui.add_space((slack / 3.0).clamp(16.0, 120.0));

        ui.vertical_centered(|ui| {
            // The mark, moving, in the space the explanation used to take.
            //
            // It is the same soundbar the header draws and the website draws,
            // and it obeys the same motion preference, so somebody who asked
            // their system for less movement gets it at rest. A locked window
            // is a window somebody is looking at while they remember a
            // passphrase; something alive in it is worth more than a paragraph
            // that helps whoever should not be reading it.
            //
            // In the icon's badge here rather than bare. On the header the
            // bars alone read as VeilVoice because the header is full of other
            // VeilVoice furniture; on an empty locked window they read as a
            // stray animation, and the badge is what makes the window say
            // whose it is.
            crate::soundbar::badge(ui, 96.0, motion, ui.input(|i| i.time) as f32);
            ui.add_space(14.0);
            ui.label(
                RichText::new("VeilVoice")
                    .size(26.0)
                    .color(p::fg())
                    .strong(),
            );
            ui.label(RichText::new("locked").color(p::yellow()));

            // Whether the window locked itself, said once and then dropped.
            //
            // Somebody who comes back to a locked window they did not lock
            // should not have to wonder whether they left it open or somebody
            // else closed it. It says which, and then it gets out of the way:
            // the moment a character is typed the line is gone, because by
            // then it has been read and the person is busy.
            //
            // It tells a stranger nothing the empty room did not already: that
            // whoever owns this walked away. What it does not say is when, how
            // long the delay was, or how many attempts have been made, all of
            // which are about the owner rather than about the lock.
            if self.auto_locked && self.entry.is_empty() {
                ui.add_space(6.0);
                ui.label(
                    RichText::new("locked itself after a while unused")
                        .color(p::muted())
                        .small(),
                );
            }
        });
        ui.add_space(24.0);

        // **Roadmap item 74.** A locked window says it is locked and nothing else.
        //
        // It used to say a great deal: what the lock is and is not worth, where
        // the file lives, and that deleting that file starts over and is not a
        // bypass. Every one of those sentences is true, and every one of them
        // is addressed to the wrong person. The reader of a locked window is
        // either its owner, who does not need any of it right now, or somebody
        // who picked the machine up, who should not be handed the location of
        // the file and the news that removing it works.
        //
        // The account of what the lock is worth has not been dropped. It is in
        // `docs/USER_GUIDE.md` and on the security tab of the *unlocked*
        // application, which are the two places its owner reads it.
        if self.load_error.is_some() {
            ui.vertical_centered(|ui| {
                ui.label(RichText::new("This copy cannot be unlocked here.").color(p::yellow()));
                ui.label(
                    RichText::new("See the user guide, under the app lock.")
                        .color(p::muted())
                        .small(),
                );
            });
            return;
        }

        let cooldown = self.store.as_ref().and_then(|s| s.cooldown());
        let busy = self.busy();

        // Centred under the mark, rather than pinned to the left edge while
        // everything above it sits in the middle. `vertical_centered` centres
        // each child it is given, and a `horizontal` row counts as one child,
        // so the label, the field and the button move together as a group.
        crate::layout::centred_row(ui, |ui| {
            // Worked out before the row borrows the field it reads, which is
            // the same condition either way.
            let ready = !busy && cooldown.is_none();
            let pressable = ready && !self.entry.is_empty();
            let (field, button) = unlock_row(ui, &mut self.entry, ready, pressable);
            let submitted = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
            let clicked = button.clicked();
            if (submitted || clicked) && !self.entry.is_empty() && cooldown.is_none() && !busy {
                let entry = std::mem::take(&mut self.entry);
                self.spawn(Op::Unlock, entry, String::new());
            }
        });

        if busy {
            crate::layout::centred_row(ui, |ui| {
                ui.spinner();
                ui.label(RichText::new("deriving key…").color(p::muted()));
            });
        }

        ui.vertical_centered(|ui| {
            if let Some(wait) = cooldown {
                ui.label(
                    RichText::new(format!(
                        "too many attempts, {} s before the next one",
                        wait.as_secs()
                    ))
                    .color(p::yellow()),
                );
            } else if let Some((text, colour)) = &self.message {
                ui.label(RichText::new(text).color(*colour));
            }
        });

        // The count of failed attempts is not shown here either. It tells the
        // owner nothing they did not just do, and it tells somebody else how
        // many people have tried and how recently, which is information about
        // the owner rather than about the lock. It is on the security tab,
        // where the person reading it has already proved who they are.
    }



    /// The standing report that the lock file was interfered with.
    ///
    /// **Roadmap item 76.** It is drawn here rather than on the lock screen, and the
    /// distinction matters. The lock screen is read by whoever is holding the
    /// machine, and telling them their edit was noticed tells them to try
    /// something else. This side of the lock is read only by somebody who has
    /// already produced the passphrase.
    ///
    /// It will not go away on its own. Clearing it runs
    /// [`veilvoice_crypto::LockStore::acknowledge`], which asks for the
    /// passphrase again, so the only person who can dismiss the report is the
    /// one who could have opened the lock anyway.
    fn interference_banner(&mut self, ui: &mut egui::Ui) {
        if !self.tampered {
            return;
        }
        let busy = self.busy();
        egui::Frame::new()
            .fill(p::bg_dark())
            .stroke(egui::Stroke::new(1.0, p::red()))
            .corner_radius(8)
            .inner_margin(egui::Margin::symmetric(14, 12))
            .show(ui, |ui| {
                ui.label(
                    RichText::new("The app lock was interfered with")
                        .color(p::red())
                        .strong(),
                );
                ui.add_space(4.0);
                ui.label(
                    RichText::new(
                        "Either the stored lock was edited by somebody who did not know \
                         your passphrase, or one of its two copies was deleted and had to \
                         be rebuilt from the other. Your passphrase still works and the \
                         lock is still in force. Nothing here says anything about your \
                         recordings, which have their own password.",
                    )
                    .color(p::fg()),
                );
                ui.add_space(4.0);
                ui.label(
                    RichText::new(
                        "If this was you, moving files about or restoring a backup, clear \
                         it. If it was not, treat the machine as one somebody else has had \
                         their hands on.",
                    )
                    .color(p::muted())
                    .small(),
                );
                ui.add_space(8.0);
                ui.add_enabled_ui(!busy, |ui| {
                    password_row(ui, "passphrase", &mut self.current);
                });
                if ui
                    .add_enabled(
                        !busy && !self.current.is_empty(),
                        egui::Button::new("clear this report"),
                    )
                    .clicked()
                {
                    let current = std::mem::take(&mut self.current);
                    self.spawn(Op::Acknowledge, current, String::new());
                }
            });
        ui.add_space(12.0);
    }



    /// The security tab: manage the lock, and see what it is worth.
    pub fn tab(&mut self, ui: &mut egui::Ui) {
        self.poll();

        // Whatever the key picker answered while the reader was browsing. Taken
        // before anything is drawn, so a chosen key is in place by the time the
        // line that shows it is painted.
        if let Some(path) = self.choosing_key.taken() {
            self.public_key = Some(path);
        }

        self.interference_banner(ui);

        ui.add_space(4.0);
        ui.label(RichText::new("App lock").color(p::blue()).small());
        match &self.path {
            Some(path) => {
                ui.label(
                    RichText::new(path.display().to_string())
                        .color(p::muted())
                        .small(),
                );
            }
            None => {
                ui.label(
                    RichText::new(
                        "No configuration directory could be found on this platform, so \
                         a lock cannot be stored. The CLI's `veilvoice lock --path` can \
                         put one wherever you choose.",
                    )
                    .color(p::yellow()),
                );
                return;
            }
        }

        // A policy can require a lock and cannot impose one: setting it needs a
        // passphrase only the user has. So the requirement is stated, loudly,
        // beside the control that satisfies it -- and the application stays
        // usable, because refusing to open would lock somebody out of their own
        // recordings over a file in their own configuration directory.
        if self.lock_required && !self.has_lock() {
            ui.add_space(6.0);
            ui.label(
                RichText::new(format!(
                    "fixed by policy: {}",
                    veilvoice_policy::Requirement::AppLock.describe()
                ))
                .color(p::yellow()),
            );
            ui.label(
                RichText::new(
                    "No lock is set. VeilVoice cannot set one for you, because it needs a \
                     passphrase only you have, so it says so here instead of refusing to \
                     open.",
                )
                .small()
                .color(p::yellow()),
            );
            ui.add_space(6.0);
        }

        let busy = self.busy();
        if self.has_lock() {
            ui.label(RichText::new("a lock is set").color(p::green()));
            ui.add_space(8.0);

            ui.add_enabled_ui(!busy, |ui| {
                password_row(ui, "current", &mut self.current);
                password_row(ui, "new", &mut self.fresh);
                password_row(ui, "repeat", &mut self.repeat);
            });
            let matched = !self.fresh.is_empty() && self.fresh == self.repeat;

            button_column(ui, |ui| {
                if ui
                    .add_enabled(
                        !busy && matched && !self.current.is_empty(),
                        egui::Button::new("change password"),
                    )
                    .clicked()
                {
                    let (current, fresh) = (
                        std::mem::take(&mut self.current),
                        std::mem::take(&mut self.fresh),
                    );
                    self.repeat.zeroize();
                    self.spawn(Op::Change, current, fresh);
                }
                if ui
                    .add_enabled(
                        !busy && !self.current.is_empty(),
                        egui::Button::new(RichText::new("remove lock").color(p::red())),
                    )
                    .clicked()
                {
                    let current = std::mem::take(&mut self.current);
                    self.spawn(Op::Remove, current, String::new());
                }
                if ui
                    .add_enabled(!busy, egui::Button::new("lock now"))
                    .clicked()
                {
                    self.lock_now();
                }
            });
        } else {
            ui.label(RichText::new("no lock is set").color(p::muted()));
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "Use a different password here than the one you use for encrypted \
                     recordings. They are separate on purpose, so that opening the app \
                     is not the same act as unsealing everything it has written.",
                )
                .color(p::muted())
                .small(),
            );
            ui.add_enabled_ui(!busy, |ui| {
                password_row(ui, "password", &mut self.fresh);
                password_row(ui, "repeat", &mut self.repeat);
            });
            let matched = !self.fresh.is_empty() && self.fresh == self.repeat;
            let set = button_column(ui, |ui| {
                ui.add_enabled(!busy && matched, egui::Button::new("set app lock"))
            });
            if set.clicked() {
                let fresh = std::mem::take(&mut self.fresh);
                self.repeat.zeroize();
                self.spawn(Op::Set, fresh, String::new());
            }
            if !self.fresh.is_empty() && !matched {
                ui.label(
                    RichText::new("the two entries differ")
                        .color(p::yellow())
                        .small(),
                );
            }
        }

        if busy {
            ui.horizontal(|ui| {
                ui.spinner();
                ui.label(RichText::new("deriving key…").color(p::muted()));
            });
        }
        if let Some((text, colour)) = &self.message {
            ui.label(RichText::new(text).color(*colour));
        }

        if self.store.as_ref().is_some_and(|s| !s.every_copy_current()) {
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "The second copy of this lock is kept where only an administrator \
                     can write it, and could not be updated from here. It still holds \
                     the previous password. Run VeilVoice once as an administrator to \
                     finish the change.",
                )
                .color(p::yellow()),
            );
        }

        ui.add_space(16.0);
        ui.separator();
        ui.label(
            RichText::new("What this lock is worth")
                .color(p::yellow())
                .small(),
        );
        ui.label(RichText::new(lock::SCOPE).color(p::fg()));
    }



    /// Read the baseline from disk and apply it to the checkbox.
    ///
    /// Called once at startup by the running app. A file that will not parse
    /// leaves the strict default in place and records why, because the safe
    /// direction and the silent direction are not the same thing.
    pub fn load_mandate(&mut self) {
        let Some(path) = veilvoice_policy::mandate_path() else {
            return;
        };
        match Mandate::load(&path) {
            Ok(mandate) => {
                self.encrypt_recordings = mandate.requires_encryption();
                self.mandate = mandate;
            }
            Err(problem) => self.mandate_problem = Some(problem),
        }
        self.mandate_path = Some(path);
    }



    /// Whether the baseline insists on the app lock.
    pub fn mandate_requires_app_lock(&self) -> bool {
        self.mandate.requires_app_lock()
    }



    /// Whether the baseline insists on encryption at rest.
    pub fn mandate_requires_encryption(&self) -> bool {
        self.mandate.requires_encryption()
    }



    /// The change log, for the panel that shows it.
    pub fn mandate_history(&self) -> &[veilvoice_policy::Change] {
        self.mandate.history()
    }



    /// Record a change to the baseline, and write it down.
    ///
    /// A no-op when the value is already that, so re-drawing a frame cannot
    /// fill the history with entries nobody made. Failing to write is reported
    /// rather than swallowed: a relaxation the user believes is recorded, and
    /// is not, is the failure this whole module exists to avoid.
    fn record(&mut self, field: MField, required: bool) {
        if !self.mandate.set(field, required) {
            return;
        }
        let Some(path) = self.mandate_path.clone() else {
            return; // tests, which never write
        };
        if let Err(problem) = self.mandate.save(&path) {
            self.mandate_problem = Some(problem);
        }
    }



    /// The at-rest controls that sit inside the file tab.
    pub fn recording_controls(&mut self, ui: &mut egui::Ui) {
        ui.label(RichText::new("At rest").color(p::blue()).small());

        // A baseline file that would not parse left the strict default in
        // place, and that has to be said rather than swallowed: it means a
        // requirement somebody set is not the one being applied. The CLI's
        // `veilvoice mandate status` says the same thing.
        if let Some(problem) = &self.mandate_problem {
            ui.label(
                RichText::new(format!(
                    "your saved baseline could not be read ({problem}), so both \
                     requirements are on, which is the safe direction",
                ))
                .color(p::yellow())
                .small(),
            );
        }

        if self.encryption_pinned {
            // Forced here as well as drawn disabled. The dialogue that turns
            // this off is reachable from more than one frame's worth of state,
            // and a policy that held only while the checkbox was drawn would
            // not be a policy.
            self.encrypt_recordings = true;
            self.confirm_disable = false;
        }

        let mut wanted = self.encrypt_recordings;
        let changed = ui
            .add_enabled(
                !self.encryption_pinned,
                egui::Checkbox::new(&mut wanted, "encrypt the result at rest"),
            )
            .changed();
        if changed && !self.encryption_pinned {
            if wanted {
                self.encrypt_recordings = true;
                self.record(MField::Encryption, true);
            } else {
                // Stay on until the warning has been read and answered.
                self.confirm_disable = true;
            }
        }
        if self.encryption_pinned {
            ui.label(
                RichText::new(format!(
                    "fixed by policy: {}",
                    veilvoice_policy::Requirement::EncryptRecordings.describe()
                ))
                .small()
                .color(p::yellow()),
            );
        }

        if !self.encrypt_recordings {
            ui.label(
                RichText::new(
                    "the recording will be written unencrypted, so anyone who reads the \
                     file can still hear every word",
                )
                .color(p::red())
                .small(),
            );
            return;
        }

        ui.horizontal(|ui| {
            ui.selectable_value(&mut self.sealing, Sealing::Password, "passphrase");
            ui.selectable_value(&mut self.sealing, Sealing::PublicKey, "public key");
            // Offered only where there is a lock to seal with. Showing a mode
            // that cannot work, greyed out, invites the reading that VeilVoice
            // is withholding something.
            if self.store.is_some() {
                ui.selectable_value(&mut self.sealing, Sealing::AppLock, "app lock");
            }
        });

        match self.sealing {
            Sealing::Password if self.held.is_some() => {
                ui.horizontal(|ui| {
                    ui.label(RichText::new("passphrase set for this session").color(p::green()));
                    if ui.button("change").clicked() {
                        self.passphrase.zeroize();
                        self.held = None;
                        self.passphrase_set = false;
                    }
                });
            }
            Sealing::Password => {
                password_row(ui, "passphrase", &mut self.passphrase);
                password_row(ui, "repeat", &mut self.passphrase_repeat);
                let matched =
                    !self.passphrase.is_empty() && self.passphrase == self.passphrase_repeat;
                if ui
                    .add_enabled(matched, egui::Button::new("use this passphrase"))
                    .clicked()
                {
                    self.held = Some(into_secret(&mut self.passphrase));
                    self.passphrase_repeat.zeroize();
                    self.passphrase_set = true;
                }
                if !self.passphrase.is_empty() && !matched {
                    ui.label(
                        RichText::new("the two entries differ")
                            .color(p::yellow())
                            .small(),
                    );
                }
                ui.label(
                    RichText::new(
                        "Argon2id, 256 MiB. Separate from the app-lock password, and \
                         there is no way to recover it.",
                    )
                    .color(p::muted())
                    .small(),
                );
            }
            Sealing::AppLock => {
                if self.app_secret.is_some() {
                    ui.label(
                        RichText::new("every recording is sealed with your app-lock password")
                            .color(p::green()),
                    );
                } else {
                    ui.label(
                        RichText::new(
                            "lock the app and unlock it again to start using this. The \
                             password is taken as the lock opens, which is the only \
                             moment it exists.",
                        )
                        .color(p::yellow()),
                    );
                }
                ui.add_space(4.0);
                ui.label(
                    RichText::new(
                        "One password for the application and for everything it writes. \
                         That is the convenience and it is also the whole of the cost: \
                         anybody who makes you unlock VeilVoice has opened every \
                         recording as well, not just this session. Two separate \
                         passwords keep those apart.",
                    )
                    .color(p::muted())
                    .small(),
                );
                ui.label(
                    RichText::new(
                        "Recordings stay openable if the lock is ever removed: each file \
                         carries its own salt, so `veilvoice decrypt` opens it with the \
                         same password on any machine. Forgetting that password still \
                         loses them, and nothing can undo that.",
                    )
                    .color(p::muted())
                    .small(),
                );
            }
            Sealing::PublicKey => {
                ui.horizontal(|ui| {
                    if ui.button("choose public key…").clicked() {
                        self.choosing_key
                            .start(crate::dialog::Ask::open_filtered("public key", &["pub"]));
                    }
                    match &self.public_key {
                        Some(path) => {
                            ui.label(RichText::new(path.display().to_string()).color(p::cyan()))
                        }
                        None => ui.label(RichText::new("no key chosen").color(p::muted())),
                    };
                });
                ui.label(
                    RichText::new(
                        "X25519 + ML-KEM-768 hybrid: breaking it requires breaking both, \
                         so a recording stored today survives a quantum adversary later. \
                         Generate a pair with `veilvoice keygen`.",
                    )
                    .color(p::muted())
                    .small(),
                );
            }
        }

        self.mandate_history_panel(ui);
    }



    /// The log of every time a requirement was turned off or back on.
    ///
    /// The same history the CLI prints from `veilvoice mandate history`, shown
    /// here so that a relaxation made in this window is not a change with no
    /// visible record. Collapsed by default, because on a machine nobody has
    /// relaxed anything on it says only "none", and that is the common case.
    fn mandate_history_panel(&mut self, ui: &mut egui::Ui) {
        let history = self.mandate.history();
        if history.is_empty() {
            return;
        }
        ui.add_space(8.0);
        let summary = if history.len() == 1 {
            "one change to what is required".to_string()
        } else {
            format!("{} changes to what is required", history.len())
        };
        egui::CollapsingHeader::new(RichText::new(summary).color(p::muted()).small())
            .id_salt("mandate-history")
            .show(ui, |ui| self.mandate_history_rows(ui));
    }



    /// One coloured line per change, newest concern last: green for a
    /// requirement put back, yellow for one turned off. Split from the header
    /// so a test can read the rows without opening a collapsed section.
    fn mandate_history_rows(&self, ui: &mut egui::Ui) {
        for change in self.mandate.history() {
            let colour = if change.to { p::green() } else { p::yellow() };
            ui.label(RichText::new(change.describe()).color(colour).small());
        }
    }



    /// The dialogue shown when the user turns at-rest encryption off.
    ///
    /// Returns true while it is open, so the caller can disable the rest of the
    /// window rather than let a click land behind it.
    pub fn disable_dialogue(&mut self, ctx: &egui::Context) -> bool {
        if !self.confirm_disable {
            return false;
        }
        egui::Window::new(RichText::new("Write recordings unencrypted?").color(p::red()))
            .collapsible(false)
            .resizable(false)
            .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
            .show(ctx, |ui| {
                ui.set_max_width(460.0);
                for paragraph in DISABLE_WARNING {
                    ui.label(RichText::new(*paragraph).color(p::fg()));
                    ui.add_space(6.0);
                }
                ui.add_space(6.0);
                ui.horizontal(|ui| {
                    if ui
                        .button(RichText::new("  keep it encrypted  ").strong())
                        .clicked()
                    {
                        self.confirm_disable = false;
                    }
                    if ui
                        .button(RichText::new("write it unencrypted").color(p::red()))
                        .clicked()
                    {
                        self.encrypt_recordings = false;
                        self.confirm_disable = false;
                        // The same act as `veilvoice mandate relax
                        // --encryption`, and written down the same way.
                        self.record(MField::Encryption, false);
                    }
                });
            });
        true
    }

}


/// What the user is told before recordings stop being encrypted.
///
/// Kept as data so the test suite can assert it still says the uncomfortable
/// part, exactly as the CLI's equivalent does.
pub const DISABLE_WARNING: &[&str] = &[
    "VeilVoice destroys the voiceprint, not the words. An unencrypted result is \
     still a recording of everything that was said.",
    "Anyone who can read the file (a backup, a cloud sync client, anyone who \
     later gets the disk) can hear all of it.",
    "Deleting it afterwards is not a fix. On an SSD, SD card or USB stick the \
     original blocks can survive every overwrite.",
    "That is why at-rest encryption is the default rather than something you \
     have to go and find.",
    "The file will be created readable only by your account. That is a file \
     permission and nothing more: it does not survive a copy, a backup, or \
     anyone who has the disk.",
];



/// What a finished job should do with its bytes.
#[derive(Clone, PartialEq, Eq)]
pub enum Plan {
    /// Seal with Argon2id over this passphrase.
    Password(Secret),
    /// Seal to the hybrid public key in this file.
    PublicKey(PathBuf),
    /// Write in the clear, as explicitly chosen.
    Plaintext,
    /// Encryption was asked for with nothing to encrypt to. Never produced by
    /// the UI, and refused rather than downgraded if it ever were.
    Missing,
}


impl Plan {

    /// Seal `wav` if the plan says to, and write it. Returns where it landed.
    ///
    /// Runs on the job thread, never the UI thread: Argon2id is meant to be
    /// slow. `wav` is the in-memory encoding, so an encrypted recording never
    /// exists on disk in the clear.
    ///
    /// `params` is the caller's, rather than being read from
    /// [`kdf::KdfParams::default`] in here. The app passes the default; the
    /// tests pass a cheap profile, because a unit test that allocates 256 MiB
    /// and runs three passes of Argon2 is not testing the thing it claims to,
    /// it is testing the runner's memory, and on a CI machine running several
    /// such tests at once it stops being a test at all.
    pub fn write(
        &self,
        path: &std::path::Path,
        wav: &[u8],
        params: kdf::KdfParams,
    ) -> Result<PathBuf, String> {
        let sealed = match self {
            Plan::Plaintext => {
                // Owner-only, the same as the command line's identical branch.
                //
                // An unencrypted recording is still a recording of everything
                // that was said, so at minimum it is not left readable by every
                // other account on the machine. `veilvoice anonymise --encrypt
                // false` had written 0600 since it was written; this, the
                // window's version of exactly the same decision, wrote 0644.
                //
                // A file permission is a much weaker thing than the encryption
                // being declined here, and the interface says so rather than
                // letting it read as a consolation.
                veilvoice_crypto::privatefile::write_owner_only(path, wav)
                    .map_err(|e| format!("{}: {e}", path.display()))?;
                return Ok(path.to_path_buf());
            }
            Plan::Missing => {
                return Err("encryption was requested but no key or passphrase was set".into())
            }
            Plan::Password(passphrase) => {
                container::seal_with_password(passphrase.expose(), wav, params)
                    .map_err(|e| e.to_string())?
            }
            Plan::PublicKey(key_path) => {
                let encoded =
                    std::fs::read(key_path).map_err(|e| format!("{}: {e}", key_path.display()))?;
                let pk = veilvoice_crypto::hybrid::PublicKey::from_bytes(&encoded)
                    .map_err(|e| e.to_string())?;
                container::seal_to_public_key(&pk, wav).map_err(|e| e.to_string())?
            }
        };
        let out = container::veil_path(path);
        std::fs::write(&out, &sealed).map_err(|e| format!("{}: {e}", out.display()))?;
        Ok(out)
    }

}

/// Deliberately opaque about the passphrase, so a plan cannot reach a log line
/// through `{:?}`, the same rule [`veilvoice_crypto::Secret`] follows.
impl std::fmt::Debug for Plan {

    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Password(_) => f.write_str("Plan::Password(redacted)"),
            Self::PublicKey(path) => write!(f, "Plan::PublicKey({})", path.display()),
            Self::Plaintext => f.write_str("Plan::Plaintext"),
            Self::Missing => f.write_str("Plan::Missing"),
        }
    }

}


/// Run one lock operation, off the UI thread.
fn run_op(
    op: Op,
    store: Option<LockStore>,
    path: Option<PathBuf>,
    mut password: String,
    mut replacement: String,
) -> OpResult {
    let outcome: OpResult = match (op, store) {
        // The store is handed back on failure too: it now carries the recorded
        // attempt, and dropping it would reset the rate limit.
        (Op::Unlock, Some(mut store)) => match store.unlock(password.as_bytes()) {
            Ok(()) => {
                // Derived here, while the passphrase is in hand and this
                // thread is already the one paying for Argon2.
                let key = store.store_key(password.as_bytes()).ok();
                (Some(store), Ok(Op::Unlock), key)
            }
            Err(e) => (Some(store), Err(e.to_string()), None),
        },
        (Op::Acknowledge, Some(mut store)) => match store.acknowledge(password.as_bytes()) {
            Ok(()) => (Some(store), Ok(Op::Acknowledge), None),
            Err(e) => (Some(store), Err(e.to_string()), None),
        },
        // **F-141.** Through the vault, which is what `Security::load` reads
        // from. This called `LockStore::create`, which writes the single
        // pre-vault file `applock.bin` -- so a lock set in the window was
        // written somewhere the window never looked. It appeared to work,
        // vanished on the next launch, and the *second* attempt failed with
        // "could not read or write the app-lock file", because the file it was
        // about to create was already there.
        //
        // `path` is still carried for the message below and for `reopen`.
        (Op::Set, None) => match path {
            None => (None, Err("no configuration directory".into()), None),
            Some(path) => {
                let base = path.parent().unwrap_or(&path).to_path_buf();
                match lock::create_in(&base, password.as_bytes(), kdf::KdfParams::default()) {
                    Ok(store) => (Some(store), Ok(Op::Set), None),
                    Err(e) => (None, Err(e.to_string()), None),
                }
            }
        },
        (Op::Change, Some(mut store)) => {
            match store.change_password(password.as_bytes(), replacement.as_bytes()) {
                Ok(()) => (Some(store), Ok(Op::Change), None),
                Err(e) => (Some(store), Err(e.to_string()), None),
            }
        }
        (Op::Remove, Some(store)) => match store.remove(password.as_bytes()) {
            Ok(()) => (None, Ok(Op::Remove), None),
            // `remove` consumed the store, so it is reopened to keep the
            // recorded failure. A lock that vanished because the password was
            // wrong would be a spectacular own goal.
            Err(e) => (reopen(path.as_deref()), Err(e.to_string()), None),
        },
        // The UI never offers these combinations; refusing beats guessing.
        (_, store) => (
            store,
            Err("the app lock changed underneath this action".into()),
            None,
        ),
    };

    password.zeroize();
    replacement.zeroize();
    outcome
}



/// Re-open the lock store from disk, or `None` when there is nothing to open.
fn reopen(path: Option<&std::path::Path>) -> Option<LockStore> {
    path.and_then(|p| LockStore::open(p).ok().flatten())
}



/// The width every passphrase label is given, so every field starts level.
///
/// Wide enough for "passphrase", which is the longest of them.
const PASSWORD_LABEL_WIDTH: f32 = 82.0;



/// How wide every passphrase field is drawn, on this tab and on the lock
/// screen. One number, because two fields that are nearly the same width read
/// as a mistake rather than as two sizes.
const PASSWORD_FIELD_WIDTH: f32 = 260.0;



/// One labelled passphrase field, with the field in the same place every time.
///
/// # Why the label gets a column of its own
///
/// These labels used to be padded with trailing spaces to line the fields up:
/// `"current"`, `"new"`, `"repeat"`, `"password"`, against a
/// bare `"passphrase"`. That aligns nothing outside a terminal. The interface
/// font is proportional, so a space is not the width of a letter and eight
/// letters plus two spaces is not the width of ten letters; and egui gives
/// trailing whitespace no reliable width at all.
///
/// The visible result was that fields sat at slightly different places on
/// different screens, and the screens that differed most were the ones drawn
/// only after setup, because those carry the labels that needed the most
/// padding. Buttons underneath inherited the same drift. Giving the label a
/// fixed column puts every field, and everything lined up beneath it, at one
/// x on every screen.
///
/// Returns the field itself, so a caller, or a test, can ask where it landed.
/// Draw `contents` in the same column the passphrase fields occupy.
///
/// The buttons under a passphrase field act on that field, and they were
/// starting at the panel's left edge while the fields they belong to started
/// one label-width in. The eye reads a left edge as a grouping, so the buttons
/// looked like they belonged to the section rather than to the fields directly
/// above them, and the further down the tab you went the more obviously the two
/// columns disagreed.
///
/// Indented by the same [`PASSWORD_LABEL_WIDTH`] the labels reserve, so there
/// is one column rather than two. Taking the width from that constant rather
/// than repeating the number is what keeps them in step: changing the label
/// column moves the buttons with it.
fn button_column<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
    ui.horizontal(|ui| {
        // The label column *plus* the gap between it and the field. A field sits
        // at `left + PASSWORD_LABEL_WIDTH + item_spacing`, because the row lays
        // the label column and the field out as two items; a button indented by
        // the width alone lands one gap short, which is the eight pixels this
        // was out by. Read from the style rather than written as a number, so a
        // theme with different spacing keeps the two in step.
        ui.add_space(PASSWORD_LABEL_WIDTH + ui.spacing().item_spacing.x);
        contents(ui)
    })
    .inner
}



/// One passphrase field with its label, at the shared width.
fn password_row(ui: &mut egui::Ui, label: &str, value: &mut String) -> egui::Response {
    ui.horizontal(|ui| {
        ui.allocate_ui_with_layout(
            egui::vec2(PASSWORD_LABEL_WIDTH, ui.spacing().interact_size.y),
            egui::Layout::left_to_right(egui::Align::Center),
            |ui| {
                // `allocate_ui_with_layout` asks for this width but gives back
                // only what the contents used, so a short label would consume a
                // short column and the field would move left again.
                // `set_min_width` is what makes the column a column.
                ui.set_min_width(PASSWORD_LABEL_WIDTH);
                ui.label(RichText::new(label).color(p::muted()))
            },
        );
        ui.add(
            egui::TextEdit::singleline(value)
                .password(true)
                .desired_width(PASSWORD_FIELD_WIDTH),
        )
    })
    .inner
}



/// The password row on the lock screen: the label, the field and the button.
///
/// **Finding F-196.** Split out of `unlock_screen` so that a test can draw
/// exactly what ships rather than a replica of it. The measurements that
/// justify the sizes below are only worth something if they are measurements
/// of this row.
///
/// The three sizes are one size. The field was a default `TextEdit`, 19.1
/// points tall, and the button was the word "unlock" padded with two literal
/// spaces on each side, 27.0 tall and 98.3 wide: nearly eight points of height
/// between them, their middles four points apart, and a width that depended on
/// how wide a space happens to be in the interface font. Padding a label with
/// spaces to size a control is the mistake [`crate::layout::column`] already
/// exists to stop somebody making, in a proportional font, for the second
/// time.
///
/// So the row agrees on a height first, from
/// [`crate::layout::button_height`], and the field is grown to it rather than
/// the button squashed down to the field. The button is
/// [`crate::layout::LOCK_WIDTH`] wide, which is what the lock button in the
/// header is drawn at: the control that locks the window and the control that
/// unlocks it are the same control to a reader, and were two different sizes.
fn unlock_row(
    ui: &mut egui::Ui,
    entry: &mut String,
    typeable: bool,
    pressable: bool,
) -> (egui::Response, egui::Response) {
    let row = crate::layout::button_height(ui);
    ui.label(RichText::new("password").color(p::muted()));
    let field = ui.add_enabled(
        typeable,
        egui::TextEdit::singleline(entry)
            .password(true)
            .desired_width(PASSWORD_FIELD_WIDTH)
            // `min_size` rather than a margin: the margin that would make this
            // frame the right height is whole points, and the height to reach
            // is not, so a margin lands near it and this lands on it.
            .min_size(egui::vec2(PASSWORD_FIELD_WIDTH, row))
            // Which leaves the text at the top of a frame that is now taller
            // than the text. Centred, or the field is the button's height and
            // still looks wrong.
            .vertical_align(egui::Align::Center),
    );
    let button = ui.add_enabled(
        pressable,
        crate::layout::lock_button(RichText::new("unlock").strong(), row),
    );
    (field, button)
}


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

    /// The buttons under the passphrase fields start where the fields do.
    ///
    /// Measured rather than eyeballed: this renders a real `password_row` and a
    /// real button in the same column and compares where each begins.
    #[test]
    fn a_lock_button_lines_up_with_the_field_above_it() {
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(640.0, 400.0),
            )),
            ..Default::default()
        };
        let mut field_x = 0.0f32;
        let mut button_x = 0.0f32;
        let _ = crate::headless_frame(&ctx, input, |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                let mut value = String::new();
                field_x = password_row(ui, "current", &mut value).rect.left();
                button_x = button_column(ui, |ui| ui.button("change password"))
                    .rect
                    .left();
            });
        });
        assert!(
            (field_x - button_x).abs() < 0.5,
            "the field starts at {field_x} and the button under it at {button_x}"
        );
    }

    /// Every passphrase field starts at the same x, whatever its label says.
    ///
    /// The defect: the labels were padded with trailing spaces to fake a
    /// column, which lines nothing up in a proportional font. Screens drawn
    /// only after setup carry the labels that needed the most padding, so
    /// their fields, and the buttons under them, sat at a different place
    /// from the ones present at launch.
    ///
    /// The shortest and the longest label in use are drawn here. If the fields
    /// ever part company again this fails with both positions.
    #[test]
    fn every_passphrase_field_starts_in_the_same_place() {
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(640.0, 400.0),
            )),
            ..Default::default()
        };

        let mut starts: Vec<(&str, f32)> = Vec::new();
        let _ = crate::headless_frame(&ctx, input, |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                for label in ["new", "password", "passphrase"] {
                    let mut value = String::new();
                    // Where the field actually landed, read back from the
                    // widget. Deriving it from the column width instead would
                    // be the test agreeing with itself: it would pass whether
                    // or not the column was ever applied.
                    let field = password_row(ui, label, &mut value);
                    starts.push((label, field.rect.left()));
                }
            });
        });

        let first = starts[0].1;
        for (label, at) in &starts {
            assert!(
                (at - first).abs() < 0.5,
                "the field after {label:?} starts at {at}, and the first starts at {first}"
            );
        }
    }

    /// No passphrase label is padded with spaces to fake its width.
    ///
    /// The column is what aligns these now. A label that comes back padded is
    /// somebody reaching for the old trick, and it would drift again the first
    /// time the font changed.
    #[test]
    fn no_passphrase_label_is_padded_with_spaces() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        for line in source.lines() {
            let trimmed = line.trim_start();
            if !trimmed.starts_with("password_row(ui, \"") {
                continue;
            }
            let label = trimmed.split('"').nth(1).expect("a quoted label");
            assert_eq!(
                label,
                label.trim(),
                "the label {label:?} is padded with spaces; give it the column instead"
            );
        }
    }

    /// **Roadmap item 74.** The locked window explains nothing.
    ///
    /// It used to explain a great deal: what the lock is and is not worth,
    /// where its file lives, and that deleting that file starts over. All true,
    /// all addressed to the wrong person. The reader of a locked window is
    /// either its owner, who does not need any of it at that moment, or
    /// somebody who picked the machine up.
    ///
    /// This reads the source of `unlock_screen` rather than rendering it,
    /// because what is being held is that certain sentences are not reachable
    /// from that function at all. A rendering test would only prove they were
    /// absent from one frame.
    /// Roadmap item 86. The passphrase is kept only for the mode that needs it.
    ///
    /// A user who has not asked for app-lock sealing must keep the old
    /// behaviour exactly: the passphrase is wiped the instant it has been
    /// checked. Holding it "just in case" would be a security regression paid
    /// for by everybody, to make a feature nobody switched on slightly more
    /// convenient.
    #[test]
    fn the_app_lock_passphrase_is_kept_only_when_it_is_going_to_be_used() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let poll = source.find("fn poll(&mut self)").expect("poll exists");
        let end = source[poll..]
            .find("\n    fn wipe_form")
            .map(|at| poll + at)
            .unwrap_or(source.len());
        let body = &source[poll..end];
        assert!(
            body.contains("if self.sealing == Sealing::AppLock"),
            "the capture is unconditional, so every user now carries their \
             app-lock passphrase in memory for the session"
        );
    }

    /// F-94. Changing the app-lock password must not leave the old one sealing
    /// new recordings. A user who changes their password and keeps working
    /// would otherwise produce files that open with a password they have just
    /// replaced, and be told nothing.
    #[test]
    fn changing_the_password_drops_the_passphrase_that_was_sealing_with_it() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let start = source.find("Ok(Op::Change) => {").expect("the arm exists");
        // To the next arm rather than a fixed number of bytes: the first
        // version of this used 900 and reported a message that was there.
        let end = source[start..]
            .find("Ok(Op::Remove)")
            .map(|at| start + at)
            .unwrap_or(source.len());
        let arm = &source[start..end];
        assert!(
            arm.contains("self.app_secret.take()"),
            "the old passphrase survives a password change and keeps sealing \
             recordings with it"
        );
        // Two short phrases rather than one long one: the message is written
        // across several source lines with Rust's string continuation, so the
        // sentence never appears contiguously in the file. The first version of
        // this searched for the whole sentence and failed on a message that was
        // there and correct.
        for phrase in ["Lock and unlock", "old password"] {
            assert!(
                arm.contains(phrase),
                "the message does not mention {phrase:?}: a user whose \
                 already-written recordings still use the previous password has \
                 to be told, or they will delete it"
            );
        }
    }

    /// Roadmap item 86. Locking the window must put the state back where a fresh
    /// launch would leave it, or the lock is a picture of a lock.
    #[test]
    fn locking_the_window_drops_the_sealing_passphrase() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let wipe = source
            .find("fn wipe_secrets(&mut self)")
            .expect("wipe_secrets exists");
        let end = source[wipe..]
            .find("\n    /// ")
            .unwrap_or(source.len() - wipe);
        assert!(
            source[wipe..wipe + end].contains("self.app_secret = None"),
            "the session copy of the app-lock passphrase outlives a lock"
        );
    }

    /// Roadmap item 86. The plan has to be a password plan, because that is what
    /// keeps the recordings openable after the lock is gone.
    #[test]
    fn app_lock_sealing_produces_a_container_that_outlives_the_lock() {
        let mut security = Security::default();
        security.encrypt_recordings = true;
        security.sealing = Sealing::AppLock;

        // Nothing captured yet: refused rather than quietly written in clear.
        assert!(matches!(security.plan(), Plan::Missing));
        assert!(!security.ready_to_write());

        let mut typed = String::from("the app lock passphrase");
        security.app_secret = Some(into_secret(&mut typed));
        assert!(security.ready_to_write());

        let Plan::Password(secret) = security.plan() else {
            panic!(
                "app-lock sealing must produce a password plan, so the file \
                    carries its own salt and needs no lock file to open"
            );
        };
        assert_eq!(secret.expose(), b"the app lock passphrase");
    }

    /// The mode is only offered where there is a lock to seal with.
    #[test]
    fn app_lock_sealing_is_not_offered_without_a_lock() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        assert!(
            source.contains("if self.store.is_some() {")
                && source.contains("Sealing::AppLock, \"app lock\""),
            "the app-lock mode must be offered only when a lock exists"
        );
    }

    /// Roadmap item 76. The report has to be reachable from the tab and only from
    /// the tab, and clearing it has to go through the passphrase rather than
    /// through a flag the drawing code can set.
    #[test]
    fn the_interference_report_is_behind_the_lock_and_behind_the_passphrase() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let tab = source
            .find("    /// The security tab")
            .map(|at| &source[at..])
            .expect("the tab has to exist");
        assert!(
            tab.contains("self.interference_banner(ui)"),
            "the report has to be drawn somewhere its owner will see it"
        );

        let banner = source
            .find("fn interference_banner")
            .map(|at| &source[at..])
            .expect("the banner has to exist");
        assert!(
            banner.contains("Op::Acknowledge"),
            "clearing the report has to run the acknowledgement, which asks for \
             the passphrase, not just clear a flag"
        );
        let clears: usize = banner
            .split("fn tab")
            .next()
            .unwrap_or("")
            .matches("self.tampered = false")
            .count();
        assert_eq!(
            clears, 0,
            "the banner must not clear the report itself; only the finished \
             acknowledgement may, and that has already proved the passphrase"
        );
    }

    #[test]
    fn the_locked_window_tells_a_stranger_nothing() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let start = source
            .find("pub fn unlock_screen")
            .expect("the unlock screen has to exist");
        let end = source[start..]
            .find("\n    /// The standing report")
            .map(|at| start + at)
            .unwrap_or(source.len());
        // Comments stripped first. The first version of this flagged its own
        // explanation of why the count is not shown, which is the same honest
        // failure `veilvoice-watch`'s privilege probe records: what matters is
        // what the function *draws*, so that is what is searched.
        let body: String = source[start..end]
            .lines()
            .filter(|line| !line.trim_start().starts_with("//"))
            .collect::<Vec<_>>()
            .join("\n");

        for forbidden in [
            "lock::SCOPE",
            "path.display()",
            "Delete the lock file",
            "failed attempt",
            // Roadmap item 76. The interference report is the same mistake in a new
            // shape: telling whoever is holding the machine that their last
            // edit was noticed tells them to try a different one.
            "interference_banner",
        ] {
            assert!(
                !body.contains(forbidden),
                "the locked window mentions {forbidden:?}, which is for its owner \
                 and not for whoever is holding the machine"
            );
        }

        // And the account itself has not simply been deleted: the tab, which
        // only an unlocked application draws, still carries it.
        let tab = source
            .find("pub fn tab")
            .map(|at| &source[at..])
            .unwrap_or("");
        assert!(
            tab.contains("lock::SCOPE"),
            "what the lock is worth has to be somewhere its owner reads it"
        );
    }

    /// Cheap on purpose: these tests exercise the plan, not Argon2.
    fn weak() -> kdf::KdfParams {
        kdf::KdfParams::weak_for_tests()
    }

    #[test]
    fn encryption_at_rest_is_the_default() {
        let s = Security::default();
        assert!(
            s.encrypt_recordings,
            "recordings must be encrypted unless the user says otherwise"
        );
        assert!(!s.is_locked(), "no lock file means no lock");
        assert!(!s.has_lock());
    }

    /// A job must not be able to start with encryption on and nothing to
    /// encrypt with, or the "default" would silently degrade to plaintext.
    #[test]
    fn a_job_is_blocked_until_there_is_something_to_encrypt_with() {
        let mut s = Security::default();
        assert!(!s.ready_to_write());
        assert_eq!(s.blocked_reason(), Some("set a recording passphrase first"));

        s.sealing = Sealing::PublicKey;
        assert_eq!(
            s.blocked_reason(),
            Some("choose a recipient public key first")
        );
        s.public_key = Some(PathBuf::from("someone.pub"));
        assert!(s.ready_to_write());

        // Turning encryption off is the one way to proceed with nothing set.
        let mut s = Security::default();
        s.encrypt_recordings = false;
        assert!(s.ready_to_write());
        assert_eq!(s.blocked_reason(), None);
    }

    /// Unticking the box must not take effect until the warning is answered.
    #[test]
    fn disabling_encryption_needs_the_dialogue_to_be_answered() {
        let mut s = Security::default();
        s.confirm_disable = true;
        assert!(
            s.encrypt_recordings,
            "encryption must stay on while the question is open"
        );
        // The dialogue's destructive button is the only thing that clears it.
        s.encrypt_recordings = false;
        s.confirm_disable = false;
        assert!(matches!(s.plan(), Plan::Plaintext));
    }

    #[test]
    fn the_warning_states_the_actual_consequence() {
        let text = DISABLE_WARNING.join(" ").to_lowercase();
        assert!(text.contains("everything that was said"));
        assert!(text.contains("deleting it afterwards is not a fix"));
        assert!(text.contains("default"));
        for reassurance in ["safe", "secure", "protected"] {
            assert!(
                !text.contains(reassurance),
                "reassuring word: {reassurance}"
            );
        }
    }

    #[test]
    fn turning_encryption_off_in_the_window_is_written_down_and_survives_a_restart() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("mandate.conf");

        let mut s = Security::default();
        s.mandate_path = Some(path.clone());
        assert!(s.mandate_requires_encryption(), "the default insists on it");
        assert!(s.mandate_history().is_empty());

        s.record(MField::Encryption, false);
        assert!(!s.mandate_requires_encryption());
        assert_eq!(s.mandate_history().len(), 1);
        assert!(s.mandate_problem.is_none(), "{:?}", s.mandate_problem);

        // The point of writing it down: the command line, or the next launch,
        // reads the same relaxation rather than the strict default. Before
        // this, turning the checkbox off lasted until the process exited and
        // left no trace that it had ever happened.
        let again = Mandate::load(&path).expect("what was just written must parse");
        assert!(!again.requires_encryption());
        assert!(again.requires_app_lock(), "only the one field was relaxed");
        assert_eq!(again.history().len(), 1);
        assert!(!again.history()[0].to);

        // And the reverse direction is recorded too, so the log is a history
        // rather than a list of things that were switched off.
        s.record(MField::Encryption, true);
        assert!(s.mandate_requires_encryption());
        assert_eq!(s.mandate_history().len(), 2);
        assert!(Mandate::load(&path).unwrap().requires_encryption());
    }

    #[test]
    fn redrawing_the_frame_does_not_fill_the_history_with_entries_nobody_made() {
        // `recording_controls` runs every frame, sixty times a second. A record
        // call that logged the value it was already at would turn a minute of
        // an open window into thousands of entries and make the history
        // useless, which is the failure mode that matters here.
        let dir = tempfile::tempdir().unwrap();
        let mut s = Security::default();
        s.mandate_path = Some(dir.path().join("mandate.conf"));

        s.record(MField::Encryption, false);
        for _ in 0..100 {
            s.record(MField::Encryption, false);
        }
        assert_eq!(s.mandate_history().len(), 1);
    }

    /// The text a panel renders, gathered by walking egui's output.
    fn rendered_text(
        security: &mut Security,
        draw: impl Fn(&mut Security, &mut egui::Ui),
    ) -> String {
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            screen_rect: Some(egui::Rect::from_min_size(
                egui::pos2(0.0, 0.0),
                egui::vec2(640.0, 480.0),
            )),
            ..Default::default()
        };
        let mut text = String::new();
        let output = crate::headless_frame(&ctx, input, |ui| {
            egui::CentralPanel::default().show(ui, |ui| draw(security, ui));
        });
        for shape in output.shapes {
            if let egui::epaint::Shape::Text(t) = shape.shape {
                text.push_str(t.galley.text());
                text.push('\n');
            }
        }
        text
    }

    #[test]
    fn the_window_shows_the_history_the_command_line_would_print() {
        // The "with history" half of the feature has to be visible in the
        // window too, or a relaxation made here is a change with no record a
        // person can see without opening a terminal. A collapsing header is
        // collapsed by default, so it is opened before the panel is read.
        let mut s = Security::default();
        s.record(MField::Encryption, false);
        let seen = rendered_text(&mut s, |s, ui| s.mandate_history_rows(ui));

        // A timestamp (the exact date format is the policy crate's to test) and
        // the change described in words, both drawn into the panel.
        assert!(seen.contains("UTC"), "no timestamp in the panel:\n{seen}");
        assert!(
            seen.contains("stopped insisting") && seen.contains("encryption"),
            "the change is not described:\n{seen}"
        );

        // And the collapsed header still names the count, so the rows are
        // discoverable without a terminal.
        let header = rendered_text(&mut s, |s, ui| s.mandate_history_panel(ui));
        assert!(
            header.contains("one change to what is required"),
            "the header does not summarise the history:\n{header}"
        );
    }

    #[test]
    fn a_baseline_that_would_not_parse_is_said_in_the_window() {
        // A silent fallback to the strict default would tell somebody their
        // relaxation is in force when it is not, or hide that a requirement
        // they set is being ignored. The CLI says so; the window must too.
        let mut s = Security::default();
        s.mandate_problem = Some("line 3: not a setting anyone wrote".to_string());
        let seen = rendered_text(&mut s, |s, ui| s.recording_controls(ui));
        assert!(
            seen.contains("could not be read"),
            "the parse problem is not surfaced:\n{seen}"
        );
    }

    #[test]
    fn a_clean_baseline_shows_no_history_and_no_problem() {
        // The common case: nobody has relaxed anything. Neither the history
        // header nor a problem line should appear, or the panel cries wolf.
        let mut s = Security::default();
        let seen = rendered_text(&mut s, |s, ui| s.recording_controls(ui));
        assert!(!seen.contains("could not be read"), "{seen}");
        assert!(!seen.contains("change to what is required"), "{seen}");
        assert!(!seen.contains("changes to what is required"), "{seen}");
    }

    #[test]
    fn a_test_built_security_never_writes_to_the_real_configuration() {
        // `Security::default()` has no path, so `record` changes the value in
        // memory and writes nothing. Every other test in this file depends on
        // that being true.
        let mut s = Security::default();
        assert!(s.mandate_path.is_none());
        s.record(MField::Encryption, false);
        assert!(!s.mandate_requires_encryption());
        assert!(s.mandate_problem.is_none());
    }

    #[test]
    fn a_plan_with_nothing_set_refuses_rather_than_writing_plaintext() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("clip.wav");
        assert!(Plan::Missing.write(&path, b"audio", weak()).is_err());
        assert!(!path.exists(), "nothing may be written on refusal");
    }

    #[test]
    fn a_password_plan_seals_beside_the_recording_and_leaves_no_plaintext() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("clip.veiled.wav");
        let wav = b"RIFF....WAVEfake but recognisable".to_vec();

        let mut typed = String::from("a recording passphrase");
        let out = Plan::Password(into_secret(&mut typed))
            .write(&path, &wav, weak())
            .unwrap();
        assert!(typed.is_empty(), "the typing buffer must be wiped");
        assert_eq!(out, container::veil_path(&path));
        assert!(!path.exists(), "the plaintext must never reach the disk");

        let sealed = std::fs::read(&out).unwrap();
        assert!(!sealed.windows(4).any(|w| w == b"RIFF"));
        assert_eq!(
            container::open_with_password(b"a recording passphrase", &sealed).unwrap(),
            wav
        );
    }

    #[test]
    fn a_plaintext_plan_writes_the_file_as_asked() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("clip.wav");
        let out = Plan::Plaintext
            .write(&path, b"audio bytes", weak())
            .unwrap();
        assert_eq!(out, path);
        assert_eq!(std::fs::read(&path).unwrap(), b"audio bytes");
    }

    /// An unencrypted recording is still readable only by this account.
    ///
    /// `veilvoice anonymise --encrypt false` had written 0600 since it was
    /// written. This, the window's version of the same decision, wrote 0644,
    /// so turning encryption off in the interface left the recording readable
    /// by every other account on the machine and turning it off on the command
    /// line did not.
    #[cfg(unix)]
    #[test]
    fn a_plaintext_plan_still_writes_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("clip.wav");
        Plan::Plaintext
            .write(&path, b"audio bytes", weak())
            .unwrap();
        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o600,
            "an unencrypted recording is {mode:o}, so anyone with an account here can play it"
        );
    }

    /// The confirmed passphrase must not linger as ordinary heap bytes. It used
    /// to be kept as a `String` for the whole session; it is now moved into a
    /// page-locked `Secret` the moment it is confirmed, and the typing buffer
    /// is wiped.
    #[test]
    fn the_confirmed_passphrase_leaves_no_plaintext_buffer_behind() {
        let mut typed = String::from("a recording passphrase");
        let secret = into_secret(&mut typed);

        assert!(typed.is_empty(), "the typing buffer must be wiped");
        assert_eq!(secret.expose(), b"a recording passphrase");
        assert!(
            format!("{secret:?}").contains("redacted"),
            "a secret must not be printable"
        );
    }

    /// And the plan handed to the worker thread carries the `Secret`, not a
    /// copy of the text.
    #[test]
    fn the_plan_carries_page_locked_material_not_a_string() {
        let mut s = Security::default();
        let mut typed = String::from("session passphrase");
        s.held = Some(into_secret(&mut typed));
        s.passphrase_set = true;

        assert!(s.ready_to_write());
        match s.plan() {
            Plan::Password(secret) => assert_eq!(secret.expose(), b"session passphrase"),
            other => panic!("expected a password plan, got {other:?}"),
        }
    }

    /// Locking must take the session passphrase with it, or "locked" would be
    /// a screen rather than a state.
    /// A real lock in a temporary directory, at test cost.
    ///
    /// `lock_now` refuses to lock when there is nothing to unlock with, which
    /// is the right behaviour and means these tests need an actual store.
    fn locked_security() -> (Security, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let store =
            veilvoice_crypto::LockStore::create(&dir.path().join("applock.bin"), b"pw", weak())
                .unwrap();
        let mut s = Security::default();
        s.store = Some(store);
        (s, dir)
    }

    #[test]
    fn a_deliberate_lock_and_an_idle_one_are_told_apart() {
        let (mut s, _dir) = locked_security();
        s.lock_now();
        assert!(s.is_locked());
        assert!(
            !s.auto_locked,
            "somebody pressed Lock; the screen must not claim the window did it"
        );

        let (mut s, _dir) = locked_security();
        s.lock_after_idle();
        assert!(s.is_locked());
        assert!(s.auto_locked);
    }

    #[test]
    fn the_auto_lock_note_is_not_shown_once_typing_starts() {
        // The note is drawn under `self.auto_locked && self.entry.is_empty()`.
        // Read from the source rather than by driving egui, which needs a
        // context this test suite does not build.
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let start = source.find("pub fn unlock_screen").unwrap();
        let body = &source[start..start + 4000];
        assert!(
            body.contains("self.auto_locked && self.entry.is_empty()"),
            "the note must disappear as soon as a character is typed"
        );
    }

    #[test]
    fn the_lock_screen_shows_the_mark_in_its_badge() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let start = source.find("pub fn unlock_screen").unwrap();
        let body = &source[start..start + 4000];
        assert!(
            body.contains("soundbar::badge"),
            "a locked window has one thing in it and it should be the logo"
        );
    }

    /// **Finding F-196.** The row that ships, measured: the field and the
    /// button are one height, on one middle, and the button is the width every
    /// other lock control is drawn at.
    ///
    /// The theme is installed first. Without it the default style makes a
    /// button and a text field the same height anyway, and this would pass
    /// while measuring nothing about this application.
    #[test]
    fn the_password_field_and_the_unlock_button_are_one_size() {
        let ctx = egui::Context::default();
        crate::theme::install(&ctx);
        let mut entry = String::from("a passphrase");
        let (mut field, mut button) = (egui::Rect::NOTHING, egui::Rect::NOTHING);
        let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
            egui::CentralPanel::default().show(ui, |ui| {
                ui.horizontal(|ui| {
                    let (a, b) = unlock_row(ui, &mut entry, true, true);
                    field = a.rect;
                    button = b.rect;
                });
            });
        });

        assert!(
            field.height() > 0.0 && button.height() > 0.0,
            "nothing drawn"
        );
        assert!(
            (field.height() - button.height()).abs() < 0.5,
            "the field is {:.1} tall and the button {:.1}",
            field.height(),
            button.height()
        );
        assert!(
            (field.center().y - button.center().y).abs() < 0.5,
            "the button sits {:.1} points off the field's middle",
            field.center().y - button.center().y
        );
        assert!(
            (button.width() - crate::layout::LOCK_WIDTH).abs() < 0.5,
            "the unlock button is {:.1} wide, not the {:.1} every lock control \
             is drawn at",
            button.width(),
            crate::layout::LOCK_WIDTH
        );
        assert!(
            (field.width() - PASSWORD_FIELD_WIDTH).abs() < 1.0,
            "growing the field's height changed its width: {:.1}",
            field.width()
        );
    }

    /// The row is not padded with spaces, which is how it was sized before and
    /// is not a width in a proportional font.
    #[test]
    fn nothing_in_the_unlock_row_is_sized_with_spaces() {
        let source = include_str!("security.rs").replace("\r\n", "\n");
        let start = source.find("fn unlock_row").unwrap();
        let body = &source[start..start + 1600];
        assert!(
            !body.contains("\"  unlock  \""),
            "the button is padded with literal spaces again"
        );
        assert!(
            body.contains("layout::lock_button"),
            "the button has to be the shared one, or it is one size by accident"
        );
    }

    #[test]
    fn locking_wipes_the_session_passphrase() {
        let mut s = Security::default();
        s.passphrase = "a recording passphrase".into();
        s.passphrase_set = true;
        s.wipe_secrets();
        assert!(s.passphrase.is_empty());
        assert!(!s.passphrase_set);
        assert!(!s.ready_to_write(), "a wiped passphrase must block the job");
    }

    #[test]
    fn locking_does_nothing_when_no_lock_is_configured() {
        let mut s = Security::default();
        s.lock_now();
        assert!(!s.is_locked(), "there is nothing to unlock it with");
    }
}