crates/veilvoice-gui/src/policy.rs

what this file is for · veilvoice-gui · 320 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! The policy in force, and what the interface does about it.
//!
//! A thin layer over [`veilvoice_policy`]: read the plain file once at startup,
//! hand the answers to the controls it fixes, and draw the reason beside each
//! one.
//!
//! # A control that is disabled without a reason is a bug report
//!
//! Every requirement carries its own sentence
//! ([`veilvoice_policy::Requirement::describe`]), and this module draws it
//! under the control it has taken away. That is the whole user-facing point:
//! somebody who cannot turn encryption off should be able to see, without
//! asking anybody, that it was fixed deliberately and by what.
//!
//! # Enforcement is not the drawing code
//!
//! Disabling a checkbox is a claim about pixels. The values a job actually uses
//! come from [`crate::VeilVoiceApp`]'s constrained posture, so a policy holds
//! even if a control is drawn wrongly, and the tests assert the behaviour
//! rather than the layout, the same rule the at-rest dialogue follows.
//!
//! # Reading it costs nothing, and proves nothing
//!
//! [`InForce::load`] never asks for a passphrase and never blocks. It can
//! therefore say only that a policy is in force, not that it is the one
//! somebody sealed; `veilvoice policy verify` is where that question is asked.
//! The reason it is safe to apply an unverified policy is the one-way property
//! [`veilvoice_policy`] is built around, and [`InForce::panel`] states it
//! rather than leaving the reader to infer it.
//!
//! # In plain words
//!
//! Reads the rules that say which settings must stay on, and makes the window obey
//! them.
//!
//! The controls show the value that will actually be used, rather than one that
//! silently changes when you press the button. A slider showing something a job
//! will not honour is worse than a slider you cannot move.

use crate::theme::palette as p;
use egui::{RichText, Ui};
use std::path::PathBuf;
use veilvoice_policy::{Policy, Posture, Requirement, Verification};


/// The policy this machine is running under, if any.
#[derive(Clone, Debug, Default)]
pub struct InForce {
    policy: Option<Policy>,
    /// Where it was read from, shown so the user can go and look at it.
    from: Option<PathBuf>,
    /// Why there is none, when a file exists and would not parse.
    ///
    /// Reported rather than swallowed: a policy file that does not parse means
    /// requirements somebody wrote are not being applied, and silently running
    /// unrestricted is the failure mode this project has found in itself most
    /// often.
    problem: Option<String>,
}



/// Where the policy files live, beside everything else VeilVoice keeps.
///
/// The same directory the command line uses. Resolved from the app lock's path
/// rather than worked out again, so the two front ends cannot end up looking in
/// different places.
pub fn default_dir() -> Option<PathBuf> {
    veilvoice_crypto::lock::default_path().map(|lock| lock.with_file_name("").join("policy"))
}


impl InForce {

    /// No policy. What tests and `Default` use, so neither touches the disk.
    pub fn none() -> Self {
        Self::default()
    }



    /// A policy supplied directly, for tests.
    pub fn from_policy(policy: Policy) -> Self {
        Self {
            policy: Some(policy),
            from: None,
            problem: None,
        }
    }



    /// Read the plain policy from the usual place. Never asks for a passphrase.
    pub fn load() -> Self {
        let Some(dir) = default_dir() else {
            return Self::none();
        };
        match Policy::load(&dir) {
            Ok(Some(policy)) => Self {
                policy: Some(policy),
                from: Some(dir),
                problem: None,
            },
            Ok(None) => Self {
                policy: None,
                from: Some(dir),
                problem: None,
            },
            Err(error) => Self {
                policy: None,
                from: Some(dir),
                problem: Some(error.to_string()),
            },
        }
    }



    /// Whether anything is fixed.
    pub fn is_active(&self) -> bool {
        self.policy
            .as_ref()
            .map(|policy| !policy.is_empty())
            .unwrap_or(false)
    }



    /// Whether a particular requirement is in force.
    pub fn requires(&self, requirement: &Requirement) -> bool {
        self.policy
            .as_ref()
            .map(|policy| policy.requires(requirement))
            .unwrap_or(false)
    }



    /// The intensity floor, or 0.0 when none is set.
    pub fn minimum_intensity(&self) -> f32 {
        self.policy
            .as_ref()
            .map(Policy::minimum_intensity)
            .unwrap_or(0.0)
    }



    /// Apply the policy to a posture. Only ever tightens.
    pub fn constrain(&self, posture: Posture) -> Posture {
        match &self.policy {
            Some(policy) => policy.constrain(posture),
            None => posture,
        }
    }



    /// Draw the reason a control is fixed, under that control.
    ///
    /// Does nothing when the requirement is not in force, so a call site can be
    /// unconditional and there is no `if` for somebody to get backwards.
    pub fn note(&self, ui: &mut Ui, requirement: &Requirement) {
        if !self.requires(requirement) {
            return;
        }
        ui.label(
            RichText::new(format!("fixed by policy: {}", requirement.describe()))
                .small()
                .color(p::yellow()),
        );
    }



    /// The summary panel, for the about tab.
    pub fn panel(&self, ui: &mut Ui) {
        ui.label(RichText::new("Policy").color(p::blue()).small());

        if let Some(problem) = &self.problem {
            ui.label(
                RichText::new(format!(
                    "a policy file is present and could not be read, so nothing from it \
                     is being applied: {problem}"
                ))
                .color(p::red()),
            );
            return;
        }

        let Some(policy) = &self.policy else {
            ui.label(
                RichText::new(
                    "None. Every setting is yours. `veilvoice policy` can fix some of \
                     them so the interface cannot turn them off.",
                )
                .color(p::fg()),
            );
            return;
        };

        if policy.is_empty() {
            ui.label(RichText::new("A policy file exists and requires nothing.").color(p::fg()));
            return;
        }

        if let Some(note) = policy.note() {
            ui.label(RichText::new(note).color(p::fg()));
            ui.add_space(4.0);
        }
        for requirement in policy.requirements() {
            ui.label(RichText::new(format!("· {}", requirement.describe())).color(p::fg()));
        }
        ui.add_space(6.0);
        if let Some(from) = &self.from {
            ui.label(
                RichText::new(from.display().to_string())
                    .small()
                    .color(p::muted()),
            );
        }
        // Said here rather than left to be inferred: this application has not
        // checked the seal and is not going to, because checking it needs a
        // passphrase and reading a policy must not cost the user a prompt.
        ui.label(
            RichText::new(Verification::Unchecked.describe())
                .small()
                .color(p::muted()),
        );
        ui.label(
            RichText::new("`veilvoice policy verify` checks the seal, and needs the passphrase.")
                .small()
                .color(p::muted()),
        );
    }

}

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

    fn requiring(requirements: &[Requirement]) -> InForce {
        let mut policy = Policy::new();
        for requirement in requirements {
            policy.require(*requirement);
        }
        InForce::from_policy(policy)
    }

    #[test]
    fn no_policy_constrains_nothing() {
        let none = InForce::none();
        assert!(!none.is_active());
        assert_eq!(none.minimum_intensity(), 0.0);
        let posture = Posture::most_permissive();
        assert_eq!(none.constrain(posture), posture);
        for requirement in [
            Requirement::EncryptRecordings,
            Requirement::CleanMetadata,
            Requirement::NeutraliseAccent,
            Requirement::AppLock,
        ] {
            assert!(!none.requires(&requirement));
        }
    }

    /// An empty policy file is a policy file, and it fixes nothing.
    #[test]
    fn an_empty_policy_is_not_active() {
        assert!(!InForce::from_policy(Policy::new()).is_active());
    }

    #[test]
    fn a_policy_tightens_the_posture_it_is_given() {
        let policy = requiring(&[
            Requirement::EncryptRecordings,
            Requirement::CleanMetadata,
            Requirement::MinimumIntensity(70),
        ]);
        assert!(policy.is_active());
        let after = policy.constrain(Posture::most_permissive());
        assert!(after.encrypt_recordings);
        assert!(after.clean_metadata);
        assert!((after.intensity - 0.7).abs() < 1e-6);
        // And leaves alone what it does not mention.
        assert!(!after.neutralise_accent);
        assert!(!after.app_lock);
    }

    /// Loading must never change the machine, and must never panic on a
    /// machine that has no configuration directory at all.
    #[test]
    fn loading_reads_and_changes_nothing() {
        let first = InForce::load();
        let second = InForce::load();
        assert_eq!(first.is_active(), second.is_active());
        assert_eq!(first.minimum_intensity(), second.minimum_intensity());
    }

    /// Every panel state renders with no window, including the one nobody
    /// wants: a policy file that will not parse.
    #[test]
    fn every_panel_state_renders_without_a_window() {
        let unreadable = InForce {
            policy: None,
            from: Some(PathBuf::from("/somewhere/policy")),
            problem: Some("line 2: unknown keyword".to_string()),
        };
        let states = [
            InForce::none(),
            InForce::from_policy(Policy::new()),
            requiring(&[Requirement::AppLock, Requirement::MinimumIntensity(50)]),
            unreadable,
        ];
        for state in states {
            let ctx = egui::Context::default();
            let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
                egui::CentralPanel::default().show(ui, |ui| {
                    state.panel(ui);
                    state.note(ui, &Requirement::AppLock);
                    state.note(ui, &Requirement::EncryptRecordings);
                });
            });
        }
    }

    /// A policy file that will not parse must say so rather than reading as
    /// "no policy" -- the difference is between "nothing was asked for" and
    /// "something was asked for and is not being applied".
    #[test]
    fn an_unreadable_policy_is_reported_rather_than_treated_as_absent() {
        let unreadable = InForce {
            policy: None,
            from: None,
            problem: Some("line 2: unknown keyword".to_string()),
        };
        assert!(!unreadable.is_active());
        assert!(unreadable.problem.is_some());
    }
}