crates/veilvoice-cli/src/mandate.rs

what this file is for · veilvoice-cli · 339 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
//! `veilvoice mandate` -- the two things VeilVoice insists on, and how to stop.
//!
//! The command-line front end to [`veilvoice_policy::Mandate`]. That module
//! holds the data and the history; this file decides what is printed, and makes
//! relaxing a requirement a deliberate act rather than a flag somebody typed
//! once.
//!
//! # This is the opposite tool to `veilvoice policy`
//!
//! A sealed policy can only ever make VeilVoice **stricter**, and is meant for
//! somebody setting rules for somebody else. The mandate is your own baseline
//! for your own machine, and it is the only thing here that can be **relaxed**.
//!
//! The two compose in one direction only: the effective requirement is the
//! mandate **or** whatever the sealed policy adds. So relaxing the mandate
//! cannot switch off something an administrator has fixed, and `status` says so
//! when that is what is happening, rather than reporting "not required" at
//! somebody who will then find it is still required.
//!
//! # Why relaxing asks twice
//!
//! Turning off encryption at rest means every recording VeilVoice writes from
//! then on is a plain file of everything that was said. De-identification took
//! the voiceprint out; it did not take the words out. That is a real choice
//! somebody may have real reasons to make, so it is offered, but it is not
//! offered casually, and it is written down with the date.
//!
//! # In plain words
//!
//! VeilVoice asks for a password for itself and encrypts your recordings unless
//! you tell it not to. This is where you tell it not to, and where you can see
//! when you did.

use crate::theme::{colour, field, heading, ok, paint, warn};
use std::path::PathBuf;
use veilvoice_policy::{Field as MField, Mandate};
use veilvoice_policy::{Policy, Requirement};


/// Where the mandate file lives.
fn path() -> Result<PathBuf, String> {
    veilvoice_policy::mandate_path().ok_or_else(|| {
        "this platform did not say where to keep configuration (no APPDATA, \
         XDG_CONFIG_HOME or HOME), so there is nowhere to keep a mandate"
            .to_string()
    })
}



/// The sealed policy and where it was read from, so an error can name the
/// file.
fn load() -> Result<(PathBuf, Mandate), String> {
    let path = path()?;
    let mandate = Mandate::load(&path)?;
    Ok((path, mandate))
}



/// Whether the sealed policy independently fixes this requirement on.
///
/// Read so that `status` can tell the difference between "you are insisting on
/// this" and "you stopped insisting, and it is required anyway". Reporting the
/// second as though it were "not required" would be the more dangerous error in
/// the opposite direction: a user relaxing a requirement and being told it is
/// off when it is not.
fn sealed_also_requires(f: MField) -> bool {
    let Some(dir) = crate::policy::policy_dir() else {
        return false;
    };
    let requirement = match f {
        MField::AppLock => Requirement::AppLock,
        MField::Encryption => Requirement::EncryptRecordings,
    };
    matches!(Policy::load(&dir), Ok(Some(p)) if p.requires(&requirement))
}



/// One requirement in the words a person would use for it, not the field name.
fn describe(f: MField) -> &'static str {
    match f {
        MField::AppLock => "a password for VeilVoice itself, asked for at launch",
        MField::Encryption => "every recording encrypted where it is stored",
    }
}



/// What is required now, and how it got that way.
pub fn status() -> Result<(), String> {
    println!("{}", heading("What VeilVoice insists on"));
    let (path, mandate) = load()?;

    for f in [MField::AppLock, MField::Encryption] {
        let yours = mandate.requires(f);
        let sealed = sealed_also_requires(f);
        let value = match (yours, sealed) {
            (true, _) => ok("required"),
            (false, true) => warn("required by the sealed policy, not by you"),
            (false, false) => warn("not required"),
        };
        println!("{}", field(f.key(), &value));
        println!("    {}", describe(f));
    }

    println!();
    if mandate.is_default() {
        println!("{}", field("state", "the default: both required"));
        println!("  Nothing here has been turned off. To stop insisting on one:");
        println!();
        println!("    veilvoice mandate relax --encryption");
        println!();
    } else {
        println!("{}", field("state", "relaxed from the default"));
        println!("  To go back to insisting on both:");
        println!();
        println!("    veilvoice mandate reset");
        println!();
    }

    println!("{}", field("file", &path.display().to_string()));
    println!("{}", field("changes", &mandate.history().len().to_string()));
    if !mandate.history().is_empty() {
        println!("  `veilvoice mandate history` lists them.");
    }
    Ok(())
}



/// The log of every change, oldest first.
pub fn history() -> Result<(), String> {
    println!("{}", heading("How the requirements got this way"));
    let (path, mandate) = load()?;

    if mandate.history().is_empty() {
        println!("{}", field("changes", "none"));
        println!();
        println!("  Both requirements are as they came. Nothing has been turned off");
        println!("  or back on, so there is nothing to show.");
        return Ok(());
    }

    println!();
    for change in mandate.history() {
        let line = change.describe();
        let colour = if change.to {
            colour::GREEN
        } else {
            colour::YELLOW
        };
        println!("  {}", paint(colour, &line));
    }
    println!();
    println!("{}", field("file", &path.display().to_string()));
    Ok(())
}



/// Stop insisting on one or both requirements.
pub fn relax(app_lock: bool, encryption: bool, yes: bool) -> Result<(), String> {
    change(app_lock, encryption, false, yes)
}



/// Insist on one or both requirements again.
pub fn insist(app_lock: bool, encryption: bool) -> Result<(), String> {
    change(app_lock, encryption, true, true)
}



/// The fields the flags name, refusing when they name none.
///
/// An empty selection is an error rather than a no-op, because `veilvoice
/// mandate release` with no flag reads as "release everything" and must not
/// quietly do nothing instead.
fn wanted(app_lock: bool, encryption: bool) -> Result<Vec<MField>, String> {
    let mut fields = Vec::new();
    if app_lock {
        fields.push(MField::AppLock);
    }
    if encryption {
        fields.push(MField::Encryption);
    }
    if fields.is_empty() {
        return Err("name at least one of --app-lock or --encryption".to_string());
    }
    Ok(fields)
}



/// Tighten or release the sealed policy, after saying what that means.
fn change(app_lock: bool, encryption: bool, to: bool, yes: bool) -> Result<(), String> {
    let fields = wanted(app_lock, encryption)?;
    println!(
        "{}",
        heading(if to {
            "Insist on this again"
        } else {
            "Stop insisting"
        })
    );
    let (path, mut mandate) = load()?;

    // Relaxing is the direction that removes a protection, so it is the
    // direction that has to be confirmed. Insisting only ever adds one back and
    // needs no ceremony.
    if !to && !yes {
        println!();
        for f in &fields {
            println!("  Turning off: {}", describe(*f));
            if *f == MField::Encryption {
                println!();
                println!("  Recordings written after this are plain files. VeilVoice");
                println!("  removes the voiceprint from a recording; it does not remove");
                println!("  the words. Anybody who can read the folder can read what was");
                println!("  said.");
            }
            if *f == MField::AppLock {
                println!();
                println!("  VeilVoice will open without asking for anything. Whatever is");
                println!("  in it is in it, for whoever opens the window.");
            }
            if sealed_also_requires(*f) {
                println!();
                println!(
                    "{}",
                    warn("  a sealed policy also requires this, so it stays on either way")
                );
            }
            println!();
        }
        println!("  The change is written down with today's date, and");
        println!("  `veilvoice mandate history` will show it.");
        println!();
        println!("  Re-run with --yes to proceed.");
        return Ok(());
    }

    let mut changed = false;
    for f in &fields {
        if mandate.set(*f, to) {
            changed = true;
            println!(
                "{}",
                ok(&format!(
                    "{} {}",
                    if to {
                        "insisting on"
                    } else {
                        "no longer insisting on"
                    },
                    f.key()
                ))
            );
        } else {
            println!(
                "{}",
                warn(&format!(
                    "{} was already that way; nothing written",
                    f.key()
                ))
            );
        }
    }

    if !changed {
        return Ok(());
    }
    mandate.save(&path)?;
    println!("{}", field("file", &path.display().to_string()));

    for f in &fields {
        if !to && sealed_also_requires(*f) {
            println!(
                "{}",
                warn(&format!(
                    "{} is still required: a sealed policy fixes it on, and the \
                     mandate cannot loosen that",
                    f.key()
                ))
            );
        }
    }
    Ok(())
}



/// Back to insisting on both.
pub fn reset() -> Result<(), String> {
    println!("{}", heading("Back to the default"));
    let (path, mut mandate) = load()?;
    if !mandate.reset() {
        println!("{}", warn("already the default: both required"));
        return Ok(());
    }
    mandate.save(&path)?;
    println!(
        "{}",
        ok("both the app lock and encryption are required again")
    );
    println!("{}", field("file", &path.display().to_string()));
    Ok(())
}


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

    #[test]
    fn the_mandate_sits_beside_the_app_lock() {
        let Some(path) = veilvoice_policy::mandate_path() else {
            return; // a platform with nowhere to keep configuration
        };
        let Some(lock) = veilvoice_crypto::lock::default_path() else {
            return;
        };
        assert_eq!(path.parent(), lock.parent());
    }

    #[test]
    fn naming_no_field_is_refused_rather_than_treated_as_all_of_them() {
        // A bare `veilvoice mandate relax` that meant "relax everything" would
        // be the worst possible default for the one command here that removes a
        // protection.
        let error = wanted(false, false).unwrap_err();
        assert!(error.contains("--app-lock"), "{error}");
        assert!(error.contains("--encryption"), "{error}");
    }

    #[test]
    fn naming_one_field_selects_only_that_one() {
        assert_eq!(wanted(false, true).unwrap(), vec![MField::Encryption]);
        assert_eq!(wanted(true, false).unwrap(), vec![MField::AppLock]);
        assert_eq!(
            wanted(true, true).unwrap(),
            vec![MField::AppLock, MField::Encryption]
        );
    }

    #[test]
    fn every_field_has_a_sentence_saying_what_it_costs() {
        for f in [MField::AppLock, MField::Encryption] {
            let text = describe(f);
            assert!(text.len() > 20, "{f:?} is described as {text:?}");
            assert!(
                !text.contains("--"),
                "no em dash stand-ins in prose: {text:?}"
            );
        }
    }
}