crates/veilvoice-cli/src/sentry.rs
what this file is for · veilvoice-cli · 386 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 sentry` -- canaries, baselines, and what changed since.
//!
//! The command-line front end to [`veilvoice_guard::sentry`]. All of the logic is in
//! that crate; this file decides where the state lives, prints it, and chooses
//! an exit code.
//!
//! # Where the state lives
//!
//! Beside the app lock, under the platform's usual per-user configuration
//! directory:
//!
//! ```text
//! <config>/veilvoice/sentry/nest.txt the planted canaries
//! <config>/veilvoice/sentry/<16 hex>.txt one baseline per watched directory
//! ```
//!
//! Baselines are named from a digest of the directory they describe, so two
//! directories cannot silently overwrite each other's baseline and the state
//! directory's listing does not say what somebody is watching. Each file
//! records its own root, so `check` reads them rather than needing an index.
//!
//! # The exit code answers one question and not the other
//!
//! `veilvoice sentry check` exits non-zero when **a canary tripped**, because
//! that is a fact: a file nothing uses was changed, moved or removed. It exits
//! zero for churn at any level, however high, because churn is a question --
//! a backup restore produces the same numbers as anything else, and a command
//! that fails a scheduled task every time somebody copies a folder is a
//! command somebody removes from the scheduled task.
//!
//! # This detects, and stops nothing
//!
//! [`veilvoice_guard::sentry::SCOPE`] is printed by `status` rather than paraphrased
//! here, so there is one wording and the tests guard it.
//!
//! # In plain words
//!
//! The command line for the tripwires: the decoy files that should never change,
//! and how much of a folder has changed since you last looked.
//!
//! Both are early warnings and neither stops anything. What they buy is finding
//! out quickly.
use crate::theme::{colour, err, field, heading, ok, paint, warn};
use std::path::{Path, PathBuf};
use veilvoice_guard::sentry::canary::Nest;
use veilvoice_guard::sentry::rate::{self, Concern, Limits, Snapshot, Threshold};
/// Where the canaries and baselines are kept.
///
/// Derived from the app lock's location rather than resolved again, so there
/// is one answer to "where does VeilVoice keep things" and it cannot drift.
pub fn state_dir() -> Option<PathBuf> {
veilvoice_crypto::lock::default_path().map(|lock| lock.with_file_name("").join("sentry"))
}
fn nest_path() -> Result<PathBuf, String> {
Ok(state_dir()
.ok_or_else(|| {
"this platform did not say where to keep configuration (no APPDATA, \
XDG_CONFIG_HOME or HOME), so there is nowhere to record canaries"
.to_string()
})?
.join("nest.txt"))
}
/// Read the nest, treating "no file yet" as "nothing planted".
///
/// A missing file is the ordinary state before anything is planted and must
/// not read as an error. A file that exists and will not parse is a different
/// matter and is reported: quietly starting again from an empty nest would
/// lose the record of every canary and report none of them as gone.
fn load_nest() -> Result<Nest, String> {
let path = nest_path()?;
match Nest::load(&path) {
Ok(nest) => Ok(nest),
Err(veilvoice_guard::sentry::Error::Io(error))
if error.kind() == std::io::ErrorKind::NotFound =>
{
Ok(Nest::new())
}
Err(error) => Err(format!("{}: {error}", path.display())),
}
}
fn save_nest(nest: &Nest) -> Result<(), String> {
let path = nest_path()?;
nest.save(&path)
.map_err(|error| format!("could not write {}: {error}", path.display()))
}
/// Every saved baseline, with the path it came from.
fn baselines() -> Result<Vec<(PathBuf, Snapshot)>, String> {
let Some(dir) = state_dir() else {
return Ok(Vec::new());
};
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("could not read {}: {error}", dir.display())),
};
let mut found = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path
.file_name()
.map(|name| name == "nest.txt")
.unwrap_or(false)
{
continue;
}
if path.extension().map(|ext| ext != "txt").unwrap_or(true) {
continue;
}
match Snapshot::load(&path) {
Ok(snapshot) => found.push((path, snapshot)),
// Reported, not skipped: a baseline that will not parse means the
// next comparison for that directory silently does not happen.
Err(error) => {
println!("{}", err(&format!("{}: {error}", path.display())));
}
}
}
found.sort_by(|a, b| a.1.root.cmp(&b.1.root));
Ok(found)
}
/// What is planted, what is watched, and what this is worth.
pub fn status() -> Result<(), String> {
println!("{}", heading("Sentry"));
let nest = load_nest()?;
println!("{}", field("canaries planted", &nest.len().to_string()));
let watched = baselines()?;
println!(
"{}",
field("directories watched", &watched.len().to_string())
);
println!();
if nest.is_empty() && watched.is_empty() {
println!(" Nothing is set up yet.");
println!();
println!(" veilvoice sentry plant <DIR> put a canary in a folder");
println!(" veilvoice sentry baseline <DIR> record what a folder holds now");
println!(" veilvoice sentry check look at both");
println!();
}
for canary in nest.canaries() {
println!("{}", field("canary", &canary.path));
}
for (_, snapshot) in &watched {
println!(
"{}",
field(
"watching",
&format!("{} ({} files)", snapshot.root, snapshot.len())
)
);
}
println!();
println!("{}", paint(colour::YELLOW, "WHAT THIS IS WORTH"));
for line in wrap(veilvoice_guard::sentry::SCOPE, 72) {
println!(" {line}");
}
Ok(())
}
/// Put a canary in `dir`.
pub fn plant(dir: &Path, name: Option<&str>) -> Result<(), String> {
println!("{}", heading("Plant a canary"));
let mut nest = load_nest()?;
let path = nest
.plant(dir, name)
.map_err(|error| format!("could not plant a canary: {error}"))?;
save_nest(&nest)?;
println!("{}", ok(&format!("planted {}", path.display())));
println!();
println!(" Nothing reads that file. If it ever changes, VeilVoice will say so.");
println!(" Remove it with `veilvoice sentry pull-up` rather than deleting it,");
println!(" or the deletion is itself reported as a change.");
println!();
println!(
"{}",
warn(
"A canary only fires if whatever is running reaches that folder. A quiet \
canary is not evidence that nothing happened."
)
);
Ok(())
}
/// Stop watching a canary, and delete it.
pub fn pull_up(path: &Path) -> Result<(), String> {
println!("{}", heading("Pull up a canary"));
let mut nest = load_nest()?;
nest.pull_up(path)
.map_err(|error| format!("could not pull it up: {error}"))?;
save_nest(&nest)?;
println!("{}", ok(&format!("removed {}", path.display())));
Ok(())
}
/// Record what `dir` holds now, as the thing to compare against later.
pub fn baseline(dir: &Path, limits: Limits) -> Result<(), String> {
println!("{}", heading("Record a baseline"));
let Some(state) = state_dir() else {
return Err("this platform did not say where to keep configuration".to_string());
};
let snapshot = Snapshot::take(dir, limits)
.map_err(|error| format!("could not read {}: {error}", dir.display()))?;
let path = state.join(rate::baseline_name(dir));
snapshot
.save(&path)
.map_err(|error| format!("could not write {}: {error}", path.display()))?;
println!("{}", field("directory", &snapshot.root));
println!("{}", field("files recorded", &snapshot.len().to_string()));
if snapshot.truncated {
println!(
"{}",
warn(
"a limit was reached, so this records what was looked at rather than \
everything that is there"
)
);
}
for complaint in &snapshot.unreadable {
println!("{}", warn(&format!("could not read {complaint}")));
}
println!();
println!("{}", ok("baseline recorded"));
println!(" Run `veilvoice sentry check` later to see what changed since.");
Ok(())
}
/// Look at every canary and every baseline.
///
/// Returns `true` when a canary tripped, which is what the exit code reports.
/// Churn never sets it -- see the note at the top of this file.
pub fn check(threshold: Threshold, limits: Limits) -> Result<bool, String> {
println!("{}", heading("Sentry check"));
let nest = load_nest()?;
let watched = baselines()?;
if nest.is_empty() && watched.is_empty() {
println!("{}", warn("nothing is planted and nothing is watched"));
println!(" `veilvoice sentry status` says how to set either up.");
return Ok(false);
}
let mut tripped = false;
if !nest.is_empty() {
println!("{}", paint(colour::BLUE, "CANARIES"));
for sighting in nest.check() {
let line = format!("{}: {}", sighting.canary.path, sighting.state.describe());
if sighting.state.is_trip() {
tripped = true;
println!("{}", err(&line));
} else {
println!("{}", ok(&line));
}
}
println!();
}
for (path, before) in &watched {
println!("{}", paint(colour::BLUE, &before.root));
let after = match Snapshot::take(Path::new(&before.root), limits) {
Ok(after) => after,
Err(error) => {
println!("{}", err(&format!("could not read it now: {error}")));
continue;
}
};
let churn = rate::compare(before, &after);
println!(" {}", churn.describe());
let level = rate::concern(&churn, &threshold);
let line = format!(" {}", rate::Concern::describe(&level));
match level {
Concern::Quiet => println!("{}", paint(colour::GREEN, &line)),
Concern::Elevated => println!("{}", paint(colour::YELLOW, &line)),
Concern::High => println!("{}", paint(colour::RED, &line)),
}
println!(
" {}",
paint(
colour::MUTED,
&format!(
"baseline recorded {}s ago in {}",
churn.window_secs,
path.display()
)
)
);
println!();
}
if tripped {
println!(
"{}",
err(
"A canary changed. That means something wrote to a file nothing uses. \
It does not say what, and nothing here stopped it."
)
);
}
Ok(tripped)
}
/// Wrap `text` to `width` columns on spaces, for the scope note.
///
/// A paragraph printed as one line is a paragraph nobody reads in an
/// eighty-column terminal, and the scope note is the paragraph here that most
/// needs reading.
///
/// Shared with [`crate::policy`], which prints its own crate's scope note the
/// same way. One wrapper rather than two that drift over what a column is.
pub fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if !current.is_empty() && current.len() + 1 + word.len() > width {
lines.push(std::mem::take(&mut current));
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
lines.push(current);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_state_directory_sits_beside_the_app_lock() {
let Some(dir) = state_dir() else {
return; // a platform with nowhere to keep configuration
};
assert!(dir.ends_with("sentry"), "{}", dir.display());
let lock = veilvoice_crypto::lock::default_path().unwrap();
assert_eq!(
dir.parent(),
lock.parent(),
"the sentry directory must live beside the lock, not somewhere new"
);
}
/// Baselines are found by their own recorded root, so the filename never
/// has to be reversed back into a path.
#[test]
fn a_baseline_filename_is_derived_and_not_the_path() {
let name = rate::baseline_name(Path::new("/home/somebody/Documents"));
assert!(!name.contains("Documents"));
assert!(name.ends_with(".txt"));
}
#[test]
fn wrapping_keeps_every_word_and_respects_the_width() {
let lines = wrap(veilvoice_guard::sentry::SCOPE, 72);
assert!(lines.len() > 1, "the scope note is longer than one line");
for line in &lines {
assert!(line.len() <= 72, "too long: {line:?}");
}
let rejoined = lines.join(" ");
let original: Vec<&str> = veilvoice_guard::sentry::SCOPE.split_whitespace().collect();
assert_eq!(rejoined.split_whitespace().collect::<Vec<_>>(), original);
}
#[test]
fn wrapping_handles_nothing_and_one_long_word() {
assert!(wrap("", 10).is_empty());
assert_eq!(
wrap("supercalifragilistic", 5),
vec!["supercalifragilistic"]
);
}
}