breadcrumbs/tests/common/mod.rs
Breadway 037c6e54c9 Harden breadcrumbs: fix real bugs, restructure as lib, stop storing PSKs twice
Bug fixes:
- mask() panicked on multi-byte UTF-8 passwords (byte-slicing a char
  boundary); now masks by char count and never echoes a real character
- `cd --shell` interpolated the config path into a shell -c string via
  Debug formatting, which doesn't neutralize shell metacharacters; now
  passed as a positional shell argument instead
- connecting to open (no-password) networks failed because an empty PSK
  was always sent to nmcli, which nmcli treats as secured-with-no-password
  instead of open; the password arg is now omitted entirely when empty
- five nmcli terse-output parse sites used a raw splitn(2, ':'), which
  mis-splits any device/connection name containing a literal ':'; unified
  on the existing escape-aware field splitter
- watch's health classifier silently read a config-deleted profile as
  "healthy" off a bare internet check instead of surfacing the misconfig
- the nmcli-monitor thread seeded its debounce clock with
  `Instant::now() - 10s`, which panics on the monotonic clock near boot —
  exactly when the generated systemd unit tends to start the watcher

Architecture:
- extracted src/lib.rs + src/app.rs so command logic can be exercised
  in-process by tests instead of only by spawning the compiled binary
- added a Runner trait (src/util.rs) so subprocess calls can be faked in
  tests; flow::run and watch::classify are now covered by real in-process
  tests of the connect state machine and health transitions, not just
  their pure helpers
- Wi-Fi passwords are no longer kept in breadcrumbs' config once
  NetworkManager durably holds them: NetworkDef.password is now optional,
  and a successful password-based connect clears + persists it
  immediately, so it's never sent again on subsequent connects
- saved networks (SSID + optional local password) moved out of
  breadcrumbs.toml into a separate networks.toml; old configs with
  inline [[networks]] still load and migrate automatically on next save
- corrected a false README claim that passwords are never in nmcli argv

Test count: 20 -> 89 (52 unit, 24 CLI integration, 13 in-process
state-machine tests). Full clean run: cargo build/build --release/
test/clippy --all-targets, verified from a `cargo clean` rebuild.
2026-07-22 06:58:47 +08:00

224 lines
7.2 KiB
Rust

//! Shared test infrastructure for in-process integration tests (as opposed
//! to `tests/cli.rs`'s black-box `Sandbox`, which spawns the compiled
//! binary). This module is `mod`-included by each test file that needs it —
//! see `tests/flow_watch.rs`.
//!
//! Two pieces:
//!
//! - [`FakeRunner`]: a `breadcrumbs::util::Runner` implementation driven by
//! rules ("if the program+args match this predicate, return this canned
//! `Output`"), which also records every invocation so a test can assert
//! exactly what was — or, just as importantly, was *not* — passed (e.g.
//! that a password argument never reaches a fake `nmcli`).
//! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does
//! its own best-effort file logging via `notify::log`, which resolves a
//! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a
//! throwaway tempdir for the duration of a test so nothing lands in the
//! developer's real `~/.local/state/breadcrumbs`. Mutating process env is
//! inherently cross-test-within-this-binary racy, so it's guarded by a
//! process-wide mutex — tests using it serialize against each other but
//! not against unrelated tests (each `tests/*.rs` file is its own binary).
#![allow(dead_code)] // not every test file uses every helper here
use std::cell::RefCell;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use breadcrumbs::util::{Output, Runner};
/// One recorded call to the fake `Runner::run`.
#[derive(Debug, Clone)]
pub struct RecordedCall {
pub prog: String,
pub args: Vec<String>,
pub stdin: Option<String>,
}
impl RecordedCall {
/// Convenience for glob-style assertions, e.g.
/// `call.argv().contains(&"connection")`.
pub fn argv(&self) -> Vec<&str> {
std::iter::once(self.prog.as_str())
.chain(self.args.iter().map(String::as_str))
.collect()
}
}
type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>;
/// A canned, rule-based [`Runner`]. Rules are tried in registration order;
/// the first whose matcher returns `true` supplies the response. No rule
/// matching falls back to [`Output::failed`] — the same "closed" default the
/// old empty-`PATH` sandbox relied on, so an un-anticipated call fails loud
/// (a wrong exit code) rather than silently returning success.
pub struct FakeRunner {
rules: Vec<(Matcher, Output)>,
commands: HashSet<String>,
calls: Rc<RefCell<Vec<RecordedCall>>>,
}
impl FakeRunner {
pub fn new() -> Self {
FakeRunner {
rules: Vec::new(),
commands: HashSet::new(),
calls: Rc::new(RefCell::new(Vec::new())),
}
}
/// Handle to inspect recorded calls after the runner has been consumed by
/// [`breadcrumbs::util::with_runner`] (which takes it by value).
pub fn calls_handle(&self) -> Rc<RefCell<Vec<RecordedCall>>> {
self.calls.clone()
}
/// Make `breadcrumbs::util::command_exists(name)` report present.
pub fn with_command(mut self, name: &str) -> Self {
self.commands.insert(name.to_string());
self
}
/// Register a canned response: the first registered matcher that returns
/// `true` for a given `(prog, args)` supplies the `Output`.
pub fn on(mut self, matcher: impl Fn(&str, &[&str]) -> bool + 'static, output: Output) -> Self {
self.rules.push((Box::new(matcher), output));
self
}
/// Shorthand for matching on `prog` plus a whitespace-joined view of
/// `args` containing `substr` (handy for `nmcli`/`tailscale` calls, whose
/// interesting bit is usually a subcommand somewhere in the middle).
pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self {
self.on(
move |p, args| p == prog && args.join(" ").contains(substr),
output,
)
}
}
impl Default for FakeRunner {
fn default() -> Self {
Self::new()
}
}
impl Runner for FakeRunner {
fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, _timeout: Duration) -> Output {
self.calls.borrow_mut().push(RecordedCall {
prog: prog.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
stdin: stdin.map(|s| s.to_string()),
});
for (matcher, out) in &self.rules {
if matcher(prog, args) {
return out.clone();
}
}
Output::failed()
}
fn command_exists(&self, name: &str) -> bool {
self.commands.contains(name)
}
}
pub fn ok(stdout: &str) -> Output {
Output {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
}
}
pub fn ok_empty() -> Output {
ok("")
}
pub fn fail(stderr: &str) -> Output {
Output {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
}
}
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
static SANDBOX_COUNTER: AtomicU32 = AtomicU32::new(0);
/// Points `HOME` / `XDG_CONFIG_HOME` / `XDG_STATE_HOME` at a throwaway
/// tempdir for its lifetime, so any real filesystem side effect
/// (`notify::log`'s best-effort log file, `Config::save`, …) that in-process
/// logic performs during a test lands there instead of the developer's real
/// home directory. Holds a process-wide lock for its lifetime — construct
/// one per test, drop it (or let it go out of scope) before the test ends.
pub struct EnvSandbox {
_guard: MutexGuard<'static, ()>,
root: PathBuf,
prev: Vec<(&'static str, Option<String>)>,
}
const ENV_VARS: [&str; 3] = ["HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME"];
impl EnvSandbox {
pub fn new() -> Self {
let guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let n = SANDBOX_COUNTER.fetch_add(1, Ordering::SeqCst);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"breadcrumbs-inproc-{}-{}-{}",
std::process::id(),
n,
nanos
));
std::fs::create_dir_all(&root).expect("create EnvSandbox root");
let prev: Vec<(&'static str, Option<String>)> = ENV_VARS
.iter()
.map(|v| (*v, std::env::var(v).ok()))
.collect();
std::env::set_var("HOME", &root);
std::env::set_var("XDG_CONFIG_HOME", root.join("config"));
std::env::set_var("XDG_STATE_HOME", root.join("state"));
EnvSandbox {
_guard: guard,
root,
prev,
}
}
pub fn root(&self) -> &Path {
&self.root
}
}
impl Default for EnvSandbox {
fn default() -> Self {
Self::new()
}
}
impl Drop for EnvSandbox {
fn drop(&mut self) {
for (k, v) in &self.prev {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
let _ = std::fs::remove_dir_all(&self.root);
}
}