crates/veilvoice-policy/src/mandate.rs
what this file is for · veilvoice-policy · 539 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 two things VeilVoice insists on unless you say otherwise.
//!
//! # What this is
//!
//! By default VeilVoice requires **an app lock** and **encryption of every
//! recording at rest**. Both matter for the same reason: de-identification
//! removes the voiceprint but keeps the words, so a veiled recording is still a
//! recording of everything that was said, and an unlocked application sitting
//! open is still a window into what you have processed.
//!
//! So both are on, by default, without anybody choosing them. This module is
//! how you *stop* insisting on one or both -- a deliberate, recorded choice
//! rather than a setting that quietly drifts.
//!
//! # It is not the sealed policy, and the difference is the point
//!
//! [`crate::Policy`] is the sealed, administrator-set policy that can only ever
//! make VeilVoice **stricter** and cannot be weakened without the passphrase.
//! This is the opposite tool for the opposite person: it is *your own* baseline,
//! plainly stored, that you may relax. The two compose safely -- the effective
//! requirement is this baseline OR whatever the sealed policy adds -- so an
//! administrator can still force on something you turned off, and never the
//! other way round.
//!
//! # Why it keeps a history
//!
//! Turning off encryption or the app lock is exactly the kind of change someone
//! should be able to see was made, when, and away from what. So every change is
//! appended to a log with its timestamp and whether the value it left was the
//! default. Nothing here is secret -- it is your own record of your own
//! decisions -- so it is a plain file you can read.
//!
//! # In plain words
//!
//! VeilVoice asks for a password for itself and encrypts your recordings,
//! unless you deliberately turn one or both off. It remembers when you did, and
//! what it was before, so the choice is never a mystery later.
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// The magic on the first line, so a stray file is not mistaken for this one.
const MAGIC: &str = "VEILMANDATE1";
/// Which requirement a change concerns.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
/// The application lock.
AppLock,
/// Encryption of recordings at rest.
Encryption,
}
impl Field {
/// The word used in the file and on the command line.
pub fn key(self) -> &'static str {
match self {
Self::AppLock => "app-lock",
Self::Encryption => "encryption",
}
}
/// The field a key in the file names, or `None` for one this version does
/// not know.
///
/// An unknown key is not an error here: it is a file written by a newer
/// VeilVoice, and refusing to read the rest of it would lose settings this
/// version does understand.
fn from_key(key: &str) -> Option<Self> {
match key {
"app-lock" => Some(Self::AppLock),
"encryption" => Some(Self::Encryption),
_ => None,
}
}
}
/// One recorded change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
/// Unix seconds when it was made.
pub at: i64,
/// Which requirement.
pub field: Field,
/// What it was.
pub from: bool,
/// What it became.
pub to: bool,
}
/// The current requirements, and the log of how they got there.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mandate {
require_app_lock: bool,
require_encryption: bool,
history: Vec<Change>,
}
impl Default for Mandate {
/// Both required. This is what a machine that has never been told otherwise
/// insists on.
fn default() -> Self {
Self {
require_app_lock: true,
require_encryption: true,
history: Vec::new(),
}
}
}
/// The clock as seconds since the epoch, without panicking on a clock set
/// before it.
///
/// A machine whose clock is behind 1970 gives a negative answer rather than an
/// error, because a timestamp in the history is a record of when something
/// happened and a broken clock is not a reason to refuse to record it.
fn now() -> i64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => d.as_secs().min(i64::MAX as u64) as i64,
Err(e) => -(e.duration().as_secs().min(i64::MAX as u64) as i64),
}
}
impl Mandate {
/// Whether an app lock is required.
pub fn requires_app_lock(&self) -> bool {
self.require_app_lock
}
/// Whether encryption of recordings at rest is required.
pub fn requires_encryption(&self) -> bool {
self.require_encryption
}
/// The value of one field.
pub fn requires(&self, field: Field) -> bool {
match field {
Field::AppLock => self.require_app_lock,
Field::Encryption => self.require_encryption,
}
}
/// Whether this is still the default: both required, nothing turned off.
pub fn is_default(&self) -> bool {
self.require_app_lock && self.require_encryption
}
/// The change log, oldest first.
pub fn history(&self) -> &[Change] {
&self.history
}
/// Set one requirement, recording the change if it is actually a change.
///
/// Returns whether anything changed. Setting a field to the value it
/// already has is a no-op and is not logged, so the history stays a record
/// of real decisions rather than repeated commands.
pub fn set(&mut self, field: Field, value: bool) -> bool {
self.set_at(field, value, now())
}
/// Set one field as of `at`, answering whether anything actually changed.
///
/// Taking the time as an argument rather than reading the clock is what
/// lets the history be tested. An unchanged value writes nothing, so
/// re-applying a setting does not fill the log with entries that say
/// nothing happened.
fn set_at(&mut self, field: Field, value: bool, at: i64) -> bool {
let slot = match field {
Field::AppLock => &mut self.require_app_lock,
Field::Encryption => &mut self.require_encryption,
};
if *slot == value {
return false;
}
let from = *slot;
*slot = value;
self.history.push(Change {
at,
field,
from,
to: value,
});
true
}
/// Return to the default (both required), recording the changes.
pub fn reset(&mut self) -> bool {
self.reset_at(now())
}
/// Turn every requirement on as of `at`, answering whether anything
/// changed.
///
/// Every field here only ever tightens, so the reset is to the strictest
/// position rather than to a default.
fn reset_at(&mut self, at: i64) -> bool {
let a = self.set_at(Field::AppLock, true, at);
let b = self.set_at(Field::Encryption, true, at);
a || b
}
/// Parse the file format.
pub fn parse(text: &str) -> Result<Self, String> {
let mut lines = text.lines();
match lines.next().map(str::trim) {
Some(MAGIC) => {}
other => {
return Err(format!(
"expected {MAGIC} on the first line, found {other:?}"
))
}
}
let mut mandate = Mandate {
require_app_lock: true,
require_encryption: true,
history: Vec::new(),
};
for (index, raw) in lines.enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let number = index + 2;
let parts: Vec<&str> = line.split_whitespace().collect();
match parts.as_slice() {
["require", key, value] => {
let field = Field::from_key(key)
.ok_or_else(|| format!("line {number}: unknown requirement {key:?}"))?;
let on = parse_bool(value)
.ok_or_else(|| format!("line {number}: not a yes or no: {value:?}"))?;
match field {
Field::AppLock => mandate.require_app_lock = on,
Field::Encryption => mandate.require_encryption = on,
}
}
["change", at, key, from, to] => {
let field = Field::from_key(key)
.ok_or_else(|| format!("line {number}: unknown requirement {key:?}"))?;
mandate.history.push(Change {
at: at.parse().map_err(|_| format!("line {number}: bad time"))?,
field,
from: parse_bool(from).ok_or_else(|| format!("line {number}: bad from"))?,
to: parse_bool(to).ok_or_else(|| format!("line {number}: bad to"))?,
});
}
_ => return Err(format!("line {number}: not understood: {line:?}")),
}
}
Ok(mandate)
}
/// Render the file format.
pub fn to_text(&self) -> String {
let mut out = String::from(MAGIC);
out.push('\n');
out.push_str(&format!(
"require app-lock {}\n",
yesno(self.require_app_lock)
));
out.push_str(&format!(
"require encryption {}\n",
yesno(self.require_encryption)
));
for change in &self.history {
out.push_str(&format!(
"change {} {} {} {}\n",
change.at,
change.field.key(),
yesno(change.from),
yesno(change.to)
));
}
out
}
/// Load from `path`, or the default if it is not there.
///
/// A file that will not parse is an error rather than a silent default:
/// silently defaulting would turn a corrupt file into "both required",
/// which is the safe direction but hides that something is wrong. The
/// caller decides what to do with the error; the desktop application shows
/// it and keeps the strict default.
pub fn load(path: &Path) -> Result<Self, String> {
match std::fs::read_to_string(path) {
Ok(text) => Self::parse(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(format!("could not read the mandate file: {e}")),
}
}
/// Write to `path`, owner-only.
pub fn save(&self, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
veilvoice_crypto::privatefile::write_owner_only(path, self.to_text().as_bytes())
.map_err(|e| e.to_string())
}
}
/// Where the mandate file lives: beside the app lock, under its own name.
pub fn default_path() -> Option<PathBuf> {
veilvoice_crypto::lock::default_path().map(|lock| lock.with_file_name("mandate.conf"))
}
/// A yes or no in any of the spellings a person might write, or `None`.
///
/// Anything unrecognised is refused rather than read as false: a policy file
/// with a typo in it must not quietly become a policy that requires nothing.
fn parse_bool(value: &str) -> Option<bool> {
match value.to_ascii_lowercase().as_str() {
"yes" | "true" | "on" | "1" => Some(true),
"no" | "false" | "off" | "0" => Some(false),
_ => None,
}
}
/// A boolean in the spelling this file is written in.
fn yesno(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
impl Change {
/// When the change was made, as a UTC civil timestamp.
pub fn when(&self) -> String {
utc(self.at)
}
/// A whole sentence describing the change, for a log a person reads.
pub fn describe(&self) -> String {
let what = match self.field {
Field::AppLock => "the app lock",
Field::Encryption => "encryption of recordings at rest",
};
if self.to {
format!("{} insisted on {what} again", self.when())
} else {
format!("{} stopped insisting on {what}", self.when())
}
}
}
/// Unix seconds as `YYYY-MM-DD HH:MM:SS UTC`.
///
/// Written out rather than pulled from a date crate. The history is the one
/// place this program shows a wall-clock time it did not get from the operating
/// system's own formatter, and a dependency whose whole job is this line would
/// be a supply chain nobody has read, for one line.
///
/// UTC, always, and it says so. A local time here would be a time whose meaning
/// depends on where the reader was standing when they read it, in a log whose
/// entire purpose is to settle when something happened.
pub fn utc(seconds: i64) -> String {
// `div_euclid` rather than `/`: a timestamp before 1970 is negative, and
// truncating division would put it in the wrong day and then compute a
// negative time of day from it.
let days = seconds.div_euclid(86_400);
let rest = seconds.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
rest / 3600,
(rest % 3600) / 60,
rest % 60
)
}
/// Days since 1970-01-01 to a civil year, month and day.
///
/// Howard Hinnant's `civil_from_days`, which is exact for the whole proleptic
/// Gregorian calendar and needs no table of month lengths or leap years: the
/// era arithmetic makes 1 March the start of the year, so the leap day lands at
/// the end where it stops being a special case.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11], March-based
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_epoch_and_the_dates_that_usually_break_this() {
// Checked against a calendar, not against another run of this code.
assert_eq!(utc(0), "1970-01-01 00:00:00 UTC");
assert_eq!(utc(86_399), "1970-01-01 23:59:59 UTC");
assert_eq!(utc(86_400), "1970-01-02 00:00:00 UTC");
// A leap day, in a year divisible by four.
assert_eq!(utc(1_709_164_800), "2024-02-29 00:00:00 UTC");
// 2000 is a leap year (divisible by 400) and 1900 was not (divisible
// by 100 but not 400). Both are where naive leap-year code goes wrong.
assert_eq!(utc(951_782_400), "2000-02-29 00:00:00 UTC");
assert_eq!(utc(-2_203_891_200), "1900-03-01 00:00:00 UTC");
// The end of a year, and the start of the next.
assert_eq!(utc(1_767_225_599), "2025-12-31 23:59:59 UTC");
assert_eq!(utc(1_767_225_600), "2026-01-01 00:00:00 UTC");
}
#[test]
fn a_time_before_the_epoch_does_not_wrap_into_a_negative_clock() {
// Truncating division would render this as the wrong day with a
// negative hour. Every field has to stay in range.
let text = utc(-1);
assert_eq!(text, "1969-12-31 23:59:59 UTC");
assert!(!text.contains('-') || text.starts_with("1969"));
}
#[test]
fn a_change_describes_itself_in_both_directions() {
let mut m = Mandate::default();
m.set_at(Field::Encryption, false, 1_767_225_600);
let off = m.history()[0].describe();
assert!(off.contains("2026-01-01"), "{off}");
assert!(off.contains("stopped insisting"), "{off}");
assert!(off.contains("encryption"), "{off}");
m.set_at(Field::Encryption, true, 1_767_225_600);
let on = m.history()[1].describe();
assert!(on.contains("insisted on"), "{on}");
assert!(!on.contains("stopped"), "{on}");
}
#[test]
fn the_default_requires_both() {
let m = Mandate::default();
assert!(m.requires_app_lock());
assert!(m.requires_encryption());
assert!(m.is_default());
assert!(m.history().is_empty());
}
#[test]
fn turning_one_off_records_it() {
let mut m = Mandate::default();
assert!(m.set_at(Field::Encryption, false, 1000));
assert!(!m.requires_encryption());
assert!(m.requires_app_lock());
assert!(!m.is_default());
assert_eq!(m.history().len(), 1);
assert_eq!(
m.history()[0],
Change {
at: 1000,
field: Field::Encryption,
from: true,
to: false
}
);
}
#[test]
fn setting_a_field_to_what_it_already_is_changes_nothing() {
let mut m = Mandate::default();
assert!(!m.set_at(Field::AppLock, true, 1));
assert!(m.history().is_empty());
}
#[test]
fn reset_puts_both_back_and_logs_only_what_moved() {
let mut m = Mandate::default();
m.set_at(Field::AppLock, false, 10);
m.set_at(Field::Encryption, false, 20);
let reset = m.reset_at(30);
assert!(reset);
assert!(m.is_default());
// Two off-changes, then two on-changes at reset.
assert_eq!(m.history().len(), 4);
assert!(m.history()[2..].iter().all(|c| c.to));
}
#[test]
fn reset_from_default_does_nothing() {
let mut m = Mandate::default();
assert!(!m.reset_at(1));
assert!(m.history().is_empty());
}
#[test]
fn it_round_trips_through_its_text_format() {
let mut m = Mandate::default();
m.set_at(Field::AppLock, false, 111);
m.set_at(Field::Encryption, false, 222);
m.set_at(Field::Encryption, true, 333);
let text = m.to_text();
let back = Mandate::parse(&text).unwrap();
assert_eq!(back, m);
}
#[test]
fn a_file_without_the_magic_is_refused() {
assert!(Mandate::parse("require app-lock no\n").is_err());
}
#[test]
fn a_missing_file_is_the_default_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let m = Mandate::load(&dir.path().join("nope.conf")).unwrap();
assert!(m.is_default());
}
#[test]
fn it_survives_a_real_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mandate.conf");
let mut m = Mandate::default();
m.set_at(Field::Encryption, false, 999);
m.save(&path).unwrap();
let back = Mandate::load(&path).unwrap();
assert_eq!(back, m);
}
#[test]
fn a_corrupt_file_is_an_error_rather_than_a_silent_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mandate.conf");
std::fs::write(&path, "garbage that is not a mandate").unwrap();
assert!(Mandate::load(&path).is_err());
}
#[test]
fn unknown_requirements_are_refused_rather_than_ignored() {
assert!(Mandate::parse("VEILMANDATE1\nrequire telepathy no\n").is_err());
}
}