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.
This commit is contained in:
parent
d177cc8d82
commit
037c6e54c9
16 changed files with 2688 additions and 834 deletions
224
tests/common/mod.rs
Normal file
224
tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue