crates/veilvoice-cli/src/appctl.rs

what this file is for · veilvoice-cli · 286 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 appctl` learns what normally runs, so it can notice what does not.
//!
//! Every subcommand prints the scope note. Not once at setup, not behind a
//! flag: **every time**, because the one thing a reader must not come away
//! believing is that this stopped something. It did not and it cannot, and a
//! warning shown once is a warning forgotten by the second week.
//!
//! # In plain words
//!
//! Run `learn` for a while and VeilVoice writes down which programs you
//! normally use. Run `check` afterwards and it tells you about anything running
//! that was not on that list.
//!
//! It does not block anything. It is a way of noticing.

use crate::theme::{colour, field, heading, paint, warn};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use veilvoice_watch::appctl::{Baseline, Grant, Verdict};


/// Where the baseline is kept.
pub fn baseline_path() -> Result<PathBuf, String> {
    let base = if cfg!(windows) {
        std::env::var_os("APPDATA").map(PathBuf::from)
    } else {
        std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
    };
    let base = base.ok_or_else(|| {
        "this system offers no per-user configuration directory, so there is nowhere \
         to keep a baseline"
            .to_string()
    })?;
    Ok(base.join("veilvoice").join("appctl.conf"))
}



fn load(path: &Path) -> Result<Baseline, String> {
    match std::fs::read_to_string(path) {
        Ok(text) => Baseline::parse(&text).map_err(|e| format!("{}: {e}", path.display())),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Baseline::new()),
        Err(error) => Err(format!("{}: {error}", path.display())),
    }
}



fn save(path: &Path, baseline: &Baseline) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
    }
    // F-75. Readable by this account and no other.
    //
    // This file decides what counts as ordinary on this machine, which makes it
    // a security setting rather than a convenience. Written with the default
    // permissions, another local account could add a line and have a program of
    // their choosing treated as unremarkable for ever, or read the list to learn
    // exactly what runs here and when.
    //
    // The project already has one place that gets file permissions right, and
    // the important part is that it sets them **as the file is created** rather
    // than afterwards: a file that exists for even a moment with the wrong
    // permissions is a file somebody else's program may have read in that
    // moment.
    veilvoice_crypto::privatefile::write_owner_only(path, baseline.to_text().as_bytes())
        .map_err(|e| format!("{}: {e}", path.display()))
}



/// The note that goes with every answer.
fn scope() {
    println!();
    println!("{}", paint(colour::YELLOW, "WHAT THIS DOES NOT DO"));
    for line in crate::sentry::wrap(veilvoice_watch::appctl::SCOPE, 72) {
        println!("  {line}");
    }
}



/// What is running now, through the shared listing.
fn running() -> (Vec<String>, Vec<String>) {
    veilvoice_watch::proc::running()
}



/// Record what is running as ordinary.
pub fn learn(finish: bool) -> Result<(), String> {
    let path = baseline_path()?;
    let mut baseline = load(&path)?;

    if finish {
        let count = baseline.freeze().map_err(|e| format!("{e}"))?;
        save(&path, &baseline)?;
        println!("{}", heading("Baseline closed"));
        println!("{}", field("programs recorded", &count.to_string()));
        println!(
            "{}",
            paint(
                colour::MUTED,
                "  Nothing joins the baseline by running from now on. That is the point \
                 of the phase having an end: a baseline that is always learning has \
                 learned nothing, because whatever starts becomes part of the picture \
                 the moment it starts.",
            )
        );
        scope();
        return Ok(());
    }

    if !baseline.is_learning() {
        let mut fresh = Baseline::learning();
        std::mem::swap(&mut baseline, &mut fresh);
    }

    let (names, problems) = running();
    for problem in &problems {
        println!("{}", warn(problem));
    }
    let seen = baseline.observe(&names, SystemTime::now());
    save(&path, &baseline)?;

    println!("{}", heading("Learning what is ordinary here"));
    println!("{}", field("seen this run", &seen.len().to_string()));
    println!("{}", field("in the baseline", &baseline.len().to_string()));
    println!("{}", field("kept in", &path.display().to_string()));
    println!();
    println!(
        "{}",
        paint(
            colour::MUTED,
            "  Run this again over the next few days while you work normally, then\n  \
             `veilvoice appctl learn --finish` to close it.",
        )
    );
    scope();
    Ok(())
}



/// Compare what is running against the baseline.
pub fn check() -> Result<(), String> {
    let path = baseline_path()?;
    let mut baseline = load(&path)?;
    let now = SystemTime::now();

    if baseline.is_empty() && !baseline.is_learning() {
        println!("{}", heading("No baseline yet"));
        println!(
            "{}",
            paint(
                colour::MUTED,
                "  `veilvoice appctl learn` records what normally runs here. Until then \
                 there is nothing to compare against, and this will not guess.",
            )
        );
        scope();
        return Ok(());
    }

    let (names, problems) = running();
    for problem in &problems {
        println!("{}", warn(problem));
    }
    baseline.observe(&names, now);
    save(&path, &baseline)?;

    println!(
        "{}",
        heading("What is running that the baseline does not know")
    );
    if baseline.is_learning() {
        println!(
            "{}",
            paint(
                colour::YELLOW,
                "  The baseline is still learning, so nothing can be called unknown yet.",
            )
        );
        scope();
        return Ok(());
    }

    let unknown = baseline.unknown(&names, now);
    if unknown.is_empty() {
        println!(
            "{}",
            paint(colour::MUTED, "  nothing outside the baseline is running")
        );
    }
    for program in &unknown {
        println!("{}", paint(colour::YELLOW, &format!("  {program}")));
        println!("      {}", Verdict::Unknown.phrasing());
    }

    // Grants in force, so somebody can see what they allowed and until when.
    let granted: Vec<&String> = names
        .iter()
        .filter(|name| baseline.verdict(name, now) == Verdict::Granted)
        .collect();
    if !granted.is_empty() {
        println!();
        println!("{}", paint(colour::BLUE, "  Allowed by you, and running"));
        for program in granted {
            let how_long = baseline
                .grant(program)
                .map(|g| g.describe(now))
                .unwrap_or_default();
            println!("{}", field(&format!("    {program}"), &how_long));
        }
    }
    scope();
    Ok(())
}



/// Allow a program, for a while or for good.
pub fn allow(program: &str, hours: Option<u64>) -> Result<(), String> {
    let path = baseline_path()?;
    let mut baseline = load(&path)?;
    let now = SystemTime::now();

    let grant = match hours {
        Some(hours) => {
            Grant::for_duration(now, Duration::from_secs(hours * 3_600)).ok_or_else(|| {
                format!("{hours} hours runs off the end of the clock; use --forever if you mean it")
            })?
        }
        None => Grant::forever(),
    };
    baseline.allow(program, grant).map_err(|e| format!("{e}"))?;
    save(&path, &baseline)?;

    println!("{}", heading("Allowed"));
    println!("{}", field(program, &grant.describe(now)));
    if hours.is_none() {
        println!(
            "{}",
            paint(
                colour::MUTED,
                "  Permanent. A list that only ever grows stops meaning anything, so \
                 prefer `--hours` unless you mean this one for good.",
            )
        );
    }
    scope();
    Ok(())
}



/// Withdraw a grant.
pub fn revoke(program: &str) -> Result<(), String> {
    let path = baseline_path()?;
    let mut baseline = load(&path)?;
    baseline.revoke(program);
    save(&path, &baseline)?;
    println!("{}", heading("Withdrawn"));
    println!("{}", field(program, "no longer allowed"));
    scope();
    Ok(())
}



/// Show the decision log.
pub fn log() -> Result<(), String> {
    let path = baseline_path()?;
    let baseline = load(&path)?;
    println!("{}", heading("Every decision this baseline has made"));
    if baseline.log().is_empty() {
        println!(
            "{}",
            paint(
                colour::MUTED,
                "  nothing yet. Only the decisions worth reading are recorded: a line \
                 for every ordinary program every time it is seen is a log nobody \
                 reads, and a log nobody reads is not a control.",
            )
        );
    }
    for entry in baseline.log() {
        let verdict = match entry.verdict {
            Verdict::Unknown => paint(colour::YELLOW, "unknown"),
            Verdict::Granted => paint(colour::MUTED, "allowed"),
            other => paint(
                colour::MUTED,
                if other == Verdict::Known {
                    "known"
                } else {
                    "learning"
                },
            ),
        };
        println!("  {}  {verdict}  {}", entry.at, entry.program);
    }
    scope();
    Ok(())
}