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:
Breadway 2026-07-22 06:58:47 +08:00
parent d177cc8d82
commit 037c6e54c9
16 changed files with 2688 additions and 834 deletions

View file

@ -35,21 +35,48 @@ impl Sandbox {
/// Binary invocation with an isolated, side-effect-free environment.
fn cmd(&self, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
self.cmd_env(args, &[])
}
/// Like [`cmd`], with extra environment variables layered on top of the
/// isolated base (e.g. `EDITOR` for the `edit` command).
fn cmd_env(&self, args: &[&str], extra: &[(&str, &str)]) -> std::process::Output {
let mut c = Command::new(BIN);
c.args(args)
.env_clear()
.env("HOME", &self.root)
.env("XDG_CONFIG_HOME", self.root.join("config"))
.env("XDG_STATE_HOME", self.root.join("state"))
// Empty bin dir => no external commands resolve.
.env("PATH", self.root.join("bin"))
.output()
.expect("failed to spawn breadcrumbs")
.env("PATH", self.root.join("bin"));
for (k, v) in extra {
c.env(k, v);
}
c.output().expect("failed to spawn breadcrumbs")
}
fn config_file(&self) -> PathBuf {
self.root.join("config/breadcrumbs/breadcrumbs.toml")
}
/// Saved networks live in their own file, split out of `breadcrumbs.toml`
/// (see `config::networks_path`).
fn networks_file(&self) -> PathBuf {
self.root.join("config/breadcrumbs/networks.toml")
}
/// Write an executable shell script into the sandbox's PATH dir so a
/// test can stand in for an external command (e.g. `$EDITOR`).
fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf {
let path = self.root.join("bin").join(name);
fs::write(&path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}
path
}
}
impl Drop for Sandbox {
@ -62,6 +89,10 @@ fn stdout(o: &std::process::Output) -> String {
String::from_utf8_lossy(&o.stdout).to_string()
}
fn stderr(o: &std::process::Output) -> String {
String::from_utf8_lossy(&o.stderr).to_string()
}
#[test]
fn help_lists_all_commands() {
let sb = Sandbox::new();
@ -142,3 +173,391 @@ fn unknown_profile_override_is_reported() {
let o = sb.cmd(&["--profile", "nope", "init"]);
assert!(!o.status.success());
}
#[test]
fn profile_set_unknown_reports_available_profiles() {
let sb = Sandbox::new();
sb.cmd(&["list"]); // bootstrap the config
let o = sb.cmd(&["profile", "set", "bogus"]);
assert!(!o.status.success());
let err = stderr(&o);
assert!(err.contains("unknown profile"), "stderr: {err}");
assert!(err.contains("home") && err.contains("work") && err.contains("away"));
}
#[test]
fn profile_list_marks_exactly_the_current_profile() {
let sb = Sandbox::new();
sb.cmd(&["profile", "set", "home", "--no-apply"]);
let o = sb.cmd(&["profile", "list"]);
assert!(o.status.success());
let out = stdout(&o);
assert!(out.contains("* home"), "out: {out}");
assert_eq!(
out.lines().filter(|l| l.trim_start().starts_with('*')).count(),
1,
"expected exactly one marked profile, got: {out}"
);
}
#[test]
fn add_with_multibyte_password_does_not_crash_list() {
// Regression test for a byte-slicing panic in the password-masking code:
// `list` used to index into the first *byte* of the password, which
// panicked whenever that byte fell mid-character in a multi-byte UTF-8
// password (e.g. non-Latin scripts or an emoji as the first character).
let sb = Sandbox::new();
let add = sb.cmd(&["add", "CafeWifi", "日本語パスワード🔒"]);
assert!(add.status.success(), "stderr: {}", stderr(&add));
let list = sb.cmd(&["list"]);
assert!(
list.status.success(),
"list crashed on multibyte password — stderr: {}",
stderr(&list)
);
assert!(!stdout(&list).contains("日本語パスワード🔒"));
}
#[test]
fn list_hides_password_by_default_and_reveals_with_flag() {
let sb = Sandbox::new();
sb.cmd(&["add", "CafeWifi", "hunter2"]);
let hidden = sb.cmd(&["list"]);
assert!(!stdout(&hidden).contains("hunter2"));
let shown = sb.cmd(&["list", "--show-passwords"]);
assert!(stdout(&shown).contains("hunter2"));
}
#[test]
fn add_to_profile_persists_in_config_priority_list() {
let sb = Sandbox::new();
sb.cmd(&["list"]); // bootstrap
let o = sb.cmd(&["add", "CafeWifi", "pw", "--to", "home"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let text = fs::read_to_string(sb.config_file()).unwrap();
let home_section = text.split("[profiles.home]").nth(1).unwrap_or("");
assert!(
home_section.contains("CafeWifi"),
"CafeWifi not attached under [profiles.home]: {text}"
);
}
#[test]
fn forget_removes_network_from_config() {
let sb = Sandbox::new();
sb.cmd(&["add", "CafeWifi", "pw", "--to", "away"]);
let o = sb.cmd(&["forget", "CafeWifi"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
// Networks live in networks.toml (see the split-secrets test below) —
// that's the file that actually needs to lose the entry.
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(
!networks.contains("CafeWifi"),
"network still in networks.toml: {networks}"
);
// ...and it should never have been in breadcrumbs.toml to begin with.
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(!text.contains("CafeWifi"), "network leaked into config: {text}");
}
#[test]
fn detect_without_wifi_adapter_errors() {
let sb = Sandbox::new();
let o = sb.cmd(&["detect"]);
assert!(!o.status.success());
assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o));
}
#[test]
fn doctor_reports_missing_nmcli_in_sandbox() {
let sb = Sandbox::new();
let o = sb.cmd(&["doctor"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert!(stdout(&o).contains("MISSING"));
}
#[test]
fn status_runs_without_crashing_when_offline() {
let sb = Sandbox::new();
let o = sb.cmd(&["status"]);
// No adapter/internet in the sandbox, so this reports unhealthy (exit 1)
// rather than crashing.
assert_eq!(o.status.code(), Some(1));
let out = stdout(&o);
assert!(out.contains("breadcrumbs"));
assert!(out.contains("needs attention"));
}
#[test]
fn cd_prints_the_config_directory() {
let sb = Sandbox::new();
let o = sb.cmd(&["cd"]);
assert!(o.status.success());
let printed = PathBuf::from(stdout(&o).trim());
assert_eq!(printed, sb.root.join("config/breadcrumbs"));
}
#[test]
fn install_service_no_enable_writes_valid_unit_file() {
let sb = Sandbox::new();
let o = sb.cmd(&["install-service", "--no-enable"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let unit_path = sb.root.join(".config/systemd/user/breadcrumbs.service");
assert!(unit_path.exists());
let text = fs::read_to_string(unit_path).unwrap();
assert!(text.contains("ExecStart="));
assert!(text.contains("breadcrumbs watch"));
assert!(text.contains("[Install]"));
assert!(text.contains("WantedBy=default.target"));
}
#[test]
fn edit_invokes_editor_then_validates_config() {
let sb = Sandbox::new();
sb.write_fake_bin("fake-editor", "#!/bin/sh\nexit 0\n");
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor")]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert!(stdout(&o).contains("config OK"));
assert!(sb.config_file().exists());
}
#[test]
fn edit_reports_editor_failure() {
let sb = Sandbox::new();
sb.write_fake_bin("fake-editor", "#!/bin/sh\nexit 1\n");
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor")]);
assert!(!o.status.success());
assert!(stderr(&o).contains("editor exited with error"));
}
// -----------------------------------------------------------------------
// Split secrets file (item 5)
// -----------------------------------------------------------------------
#[test]
fn networks_are_stored_separately_from_settings_and_profiles() {
let sb = Sandbox::new();
let o = sb.cmd(&["add", "CafeWifi", "hunter2", "--to", "away"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let settings = fs::read_to_string(sb.config_file()).unwrap();
assert!(
!settings.contains("hunter2") && !settings.contains("[[networks]]"),
"breadcrumbs.toml should hold settings/profiles only, not network credentials: {settings}"
);
// The profile's priority list (an SSID *reference*, not a credential)
// does still live in breadcrumbs.toml — that's expected and fine.
assert!(settings.contains("[profiles.away]"));
assert!(settings.contains("CafeWifi"));
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(networks.contains("CafeWifi") && networks.contains("hunter2"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "networks.toml should be owner-only");
}
}
#[test]
fn add_with_empty_password_is_stored_as_no_password() {
// An explicitly empty password (e.g. `add SSID ""`, or a blank response
// at the interactive prompt) means "this is an open network" — it must
// round-trip as an absent `password` key, the same as a cleared one,
// not as `password = ""` (which `nm::connect_verbose` would send as a
// literal empty PSK and fail against a real open SSID).
let sb = Sandbox::new();
let o = sb.cmd(&["add", "OpenCafe", ""]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(networks.contains("OpenCafe"));
assert!(!networks.contains("password"), "networks.toml: {networks}");
let list = sb.cmd(&["list"]);
assert!(stdout(&list).contains("managed by NetworkManager"));
}
// -----------------------------------------------------------------------
// NM-owned credentials (item 4): a password is only ever needed once.
// -----------------------------------------------------------------------
/// A fake `nmcli` that behaves statefully enough to exercise the "first
/// connect creates a profile with a password, second connect reuses it
/// without one" path: it records every invocation's argv (one per line) to
/// `$HOME/.nmcli-calls`, and remembers — via a marker file, also under
/// `$HOME` — that "device wifi connect TestNet" has already run, so a
/// following `connection show` reports a saved profile exists.
const FAKE_NMCLI_STATEFUL: &str = r#"#!/bin/sh
record="$HOME/.nmcli-calls"
marker="$HOME/.nmcli-profile-created"
echo "$@" >> "$record"
args="$*"
case "$args" in
"-t -f DEVICE,TYPE device status")
echo "wlan0:wifi" ;;
"radio wifi on") ;;
"device wifi rescan"*) ;;
"-t -f SSID device wifi list ifname wlan0")
echo "TestNet" ;;
"-t -f NAME,TYPE connection show")
if [ -f "$marker" ]; then
echo "TestNet:802-11-wireless"
fi
;;
*"device wifi connect TestNet"*)
# `: > file` (truncate-or-create via a shell builtin + redirection)
# not `touch`, which is an external binary and the sandbox's PATH
# deliberately contains nothing but this fake nmcli itself.
: > "$marker" ;;
*"connection up TestNet"*) ;;
*"802-11-wireless-security.psk"*) ;;
"-g GENERAL.CON-UUID device show wlan0")
echo "uuid-1" ;;
*"ipv4.ignore-auto-dns"*) ;;
"device reapply wlan0") ;;
"-t -f DEVICE,STATE device status")
echo "wlan0:connected" ;;
*) ;;
esac
exit 0
"#;
#[test]
fn password_is_cleared_after_first_connect_and_never_sent_again() {
let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_STATEFUL);
let add = sb.cmd(&["add", "TestNet", "hunter2"]);
assert!(add.status.success(), "stderr: {}", stderr(&add));
// "away" is the default profile and defaults to include_all_known, so
// TestNet is already a connect candidate with no `--to` needed.
let record = sb.root.join(".nmcli-calls");
// First connect: no saved NM profile yet, so breadcrumbs creates one via
// `device wifi connect ... password hunter2 ...`.
let first = sb.cmd(&["init"]);
assert!(first.status.success(), "stderr: {}", stderr(&first));
let first_calls = fs::read_to_string(&record).unwrap_or_default();
assert!(
first_calls.contains("device wifi connect TestNet") && first_calls.contains("hunter2"),
"first connect should create a new NM profile with the password: {first_calls}"
);
// The local copy is gone from disk immediately after.
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(
!networks.contains("hunter2"),
"password should have been cleared from networks.toml: {networks}"
);
assert!(networks.contains("TestNet"), "network entry itself should remain");
// Reset the recording so the second run's argv can be checked in isolation.
fs::write(&record, "").unwrap();
// Second connect: a saved profile now exists (per the fake nmcli's own
// bookkeeping) and breadcrumbs has no local password anymore, so it must
// reuse the profile via `connection up` and never send a PSK argument.
let second = sb.cmd(&["init"]);
assert!(second.status.success(), "stderr: {}", stderr(&second));
let second_calls = fs::read_to_string(&record).unwrap_or_default();
assert!(
second_calls.contains("connection up TestNet"),
"second connect should reuse the existing NM profile: {second_calls}"
);
assert!(
!second_calls.to_lowercase().contains("hunter2")
&& !second_calls.contains("psk")
&& !second_calls.contains("password"),
"second connect must never send a password argument: {second_calls}"
);
}
// -----------------------------------------------------------------------
// CLI-level coverage through fake nmcli/tailscale (item 3)
// -----------------------------------------------------------------------
const FAKE_NMCLI_HEALTHY: &str = r#"#!/bin/sh
args="$*"
case "$args" in
"-t -f DEVICE,TYPE device status")
echo "wlan0:wifi" ;;
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
echo "yes:HomeWifi" ;;
"-g IP4.ADDRESS device show wlan0")
echo "192.168.1.50/24" ;;
*) ;;
esac
exit 0
"#;
#[test]
fn status_reports_healthy_through_fake_nmcli_and_curl() {
let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY);
sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n");
let o = sb.cmd(&["status"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(out.contains("HomeWifi"), "out: {out}");
assert!(out.contains("healthy"), "out: {out}");
}
#[test]
fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() {
let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY);
sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n");
let o = sb.cmd(&["doctor"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(out.contains("nmcli") && out.contains("present"), "out: {out}");
assert!(!out.contains("MISSING"), "out: {out}");
}
const FAKE_NMCLI_DETECT: &str = r#"#!/bin/sh
args="$*"
case "$args" in
"-t -f DEVICE,TYPE device status")
echo "wlan0:wifi" ;;
"radio wifi on") ;;
"device wifi rescan"*) ;;
"-t -f SSID device wifi list ifname wlan0")
echo "CorpWifi" ;;
*) ;;
esac
exit 0
"#;
#[test]
fn detect_picks_profile_whose_detect_ssids_are_visible() {
let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT);
sb.cmd(&["list"]); // bootstrap the default config (home/work/away)
// Attach a marker SSID to "work" so detection has something to match —
// the skeleton config ships with empty detect_ssids everywhere.
let text = fs::read_to_string(sb.config_file()).unwrap();
let patched = text.replace(
"[profiles.work]",
"[profiles.work]\ndetect_ssids = [\"CorpWifi\"]",
);
fs::write(sb.config_file(), patched).unwrap();
let o = sb.cmd(&["detect"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert_eq!(stdout(&o).trim(), "work");
}

224
tests/common/mod.rs Normal file
View 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);
}
}

472
tests/flow_watch.rs Normal file
View file

@ -0,0 +1,472 @@
//! In-process tests for the actual state machine (`flow::run`) and the watch
//! loop's health classification (`watch::classify`), driven entirely through
//! a faked `breadcrumbs::util::Runner` (see `tests/common`) — no subprocess
//! is ever spawned. This complements `tests/cli.rs`'s black-box coverage
//! (which spawns the real binary against fake-bin shell scripts) with fast,
//! precise coverage of the logic itself: candidate priority order, the
//! bootstrap+Tailscale gate, and every `watch::Health` transition.
mod common;
use std::collections::BTreeMap;
use breadcrumbs::config::{Config, NetworkDef, Profile, Settings};
use breadcrumbs::flow;
use breadcrumbs::util::with_runner;
use breadcrumbs::watch::{classify, Health};
use common::{fail, ok, EnvSandbox, FakeRunner};
fn net(ssid: &str, password: Option<&str>) -> NetworkDef {
NetworkDef {
ssid: ssid.to_string(),
password: password.map(str::to_string),
hidden: false,
}
}
fn hidden_net(ssid: &str, password: Option<&str>) -> NetworkDef {
NetworkDef {
ssid: ssid.to_string(),
password: password.map(str::to_string),
hidden: true,
}
}
fn base_config() -> Config {
Config {
settings: Settings::default(),
networks: Vec::new(),
profiles: BTreeMap::new(),
}
}
/// Wires up the nmcli plumbing every `flow::run` call needs regardless of
/// scenario: a Wi-Fi interface exists, radio/rescan calls are no-ops, no
/// saved NM connection profiles exist yet (so every connect takes the
/// "create via `device wifi connect`" path), DNS enforcement succeeds, and
/// the device reports connected after any successful connect attempt.
fn base_nm(visible_ssids: &[&str]) -> FakeRunner {
let visible = visible_ssids.join("\n");
FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "radio wifi on", ok(""))
.on_contains("nmcli", "wifi rescan", ok(""))
.on_contains("nmcli", "-f SSID device wifi list", ok(&visible))
.on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
.on_contains("nmcli", "device reapply", ok(""))
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
}
/// A successful `device wifi connect <ssid> ...` for every ssid in `ssids`.
fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner {
ssids.iter().fold(runner, |r, ssid| {
let ssid: &'static str = ssid;
r.on(
move |prog, args| prog == "nmcli" && args.contains(&"connect") && args.contains(&ssid),
ok(""),
)
})
}
// ---------------------------------------------------------------------
// flow::run — candidate priority (pass 1 / pass 2)
// ---------------------------------------------------------------------
#[test]
fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.networks = vec![
net("First", Some("pw1")),
net("Second", Some("pw2")),
];
cfg.profiles.insert(
"home".into(),
Profile {
networks: vec!["First".into(), "Second".into()],
..Default::default()
},
);
let runner = allow_connects(base_nm(&["First", "Second"]), &["First", "Second"])
.on(|prog, _| prog == "curl", ok("204"))
.with_command("curl");
let calls = runner.calls_handle();
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
match outcome {
flow::Outcome::Connected { ssid, note } => {
assert_eq!(ssid, "First");
assert_eq!(note, None);
}
other => panic!("expected Connected, got {other:?}"),
}
// Priority order actually mattered: "Second" was never dialed even
// though it was visible and would have succeeded too.
let dialed_second = calls
.borrow()
.iter()
.any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second"));
assert!(!dialed_second, "connected to Second when First should win");
// The password used for the winning connect is now NM's problem, not
// breadcrumbs' — cleared and (via clear_password_if_used) persisted.
assert_eq!(cfg.network("First").unwrap().password, None);
// Never touched, so its password is untouched too.
assert_eq!(
cfg.network("Second").unwrap().password,
Some("pw2".to_string())
);
}
#[test]
fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
// "Ghost" is neither visible nor hidden, so pass 1 *and* pass 2 both
// skip it outright — it should never be dialed.
cfg.networks = vec![
net("Ghost", Some("pw-ghost")),
hidden_net("Shadow", Some("pw-shadow")),
];
cfg.profiles.insert(
"away".into(),
Profile {
networks: vec!["Ghost".into(), "Shadow".into()],
..Default::default()
},
);
// Neither SSID shows up in the scan — "Shadow" is only reachable via the
// pass-2 "hidden and unseen" path.
let runner = allow_connects(base_nm(&[]), &["Shadow"])
.on(|prog, _| prog == "curl", ok("204"))
.with_command("curl");
let calls = runner.calls_handle();
let outcome = with_runner(runner, || flow::run(&mut cfg, "away"));
match outcome {
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Shadow"),
other => panic!("expected Connected to Shadow, got {other:?}"),
}
let dialed_ghost = calls
.borrow()
.iter()
.any(|c| c.args.iter().any(|a| a == "Ghost") && c.args.contains(&"connect".to_string()));
assert!(!dialed_ghost, "Ghost should never have been dialed");
}
#[test]
fn flow_run_unknown_profile_short_circuits_before_touching_nm() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
let runner = FakeRunner::new(); // no rules at all
let calls = runner.calls_handle();
let outcome = with_runner(runner, || flow::run(&mut cfg, "does-not-exist"));
assert!(matches!(outcome, flow::Outcome::UnknownProfile(p) if p == "does-not-exist"));
// The only `Runner::run` call on this path is `notify`/`log`'s own
// `date` timestamp lookup — nmcli (or anything network-related) is
// never touched for a profile that doesn't exist.
assert!(
calls.borrow().iter().all(|c| c.prog != "nmcli"),
"unknown-profile path should never shell out to nmcli: {:?}",
calls.borrow()
);
}
// ---------------------------------------------------------------------
// flow::run — bootstrap + Tailscale gating
// ---------------------------------------------------------------------
fn tailscale_json_ok(exit_node: &str) -> String {
format!(
r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"#
)
}
fn tailscale_json_missing() -> &'static str {
r#"{"BackendState":"Running","Peer":{}}"#
}
#[test]
fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.settings.exit_node = "exitnode".into();
cfg.networks = vec![net("Guest", Some("guest-pw")), net("Corp", Some("corp-pw"))];
cfg.profiles.insert(
"work".into(),
Profile {
bootstrap: Some("Guest".into()),
networks: vec!["Corp".into()],
tailscale: true,
..Default::default()
},
);
let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"])
.with_command("curl")
.with_command("tailscale")
.on(|prog, _| prog == "curl", ok("204"))
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")))
.on_contains("tailscale", "set", ok(""));
let calls = runner.calls_handle();
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
match outcome {
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Corp"),
other => panic!("expected Connected to Corp, got {other:?}"),
}
// Both the bootstrap and target connects used a local password, so both
// should have been cleared once NetworkManager took over.
assert_eq!(cfg.network("Guest").unwrap().password, None);
assert_eq!(cfg.network("Corp").unwrap().password, None);
let dialed_guest = calls
.borrow()
.iter()
.any(|c| c.args.iter().any(|a| a == "Guest") && c.args.contains(&"connect".to_string()));
assert!(dialed_guest, "bootstrap should have been dialed first");
}
#[test]
fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.settings.exit_node = "exitnode".into();
cfg.networks = vec![net("Guest", Some("guest-pw")), net("Corp", Some("corp-pw"))];
cfg.profiles.insert(
"work".into(),
Profile {
bootstrap: Some("Guest".into()),
networks: vec!["Corp".into()],
tailscale: true,
..Default::default()
},
);
let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"])
.with_command("curl")
.with_command("tailscale")
.on(|prog, _| prog == "curl", ok("204"))
.on(
|prog, args| prog == "tailscale" && args.contains(&"status"),
ok(tailscale_json_missing()),
)
.on(
|prog, args| prog == "tailscale" && args.contains(&"set"),
ok(""),
);
let calls = runner.calls_handle();
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
match &outcome {
flow::Outcome::TailscaleError { ssid, health } => {
assert_eq!(ssid.as_deref(), Some("Guest"));
assert_eq!(*health, breadcrumbs::tailscale::TsHealth::ExitNodeMissing);
}
other => panic!("expected TailscaleError, got {other:?}"),
}
let dialed_corp = calls
.borrow()
.iter()
.any(|c| c.args.iter().any(|a| a == "Corp") && c.args.contains(&"connect".to_string()));
assert!(
!dialed_corp,
"target network must never be dialed while Tailscale is unhealthy"
);
// The bootstrap connect *did* use a password and succeeded, so it's
// cleared even though the overall flow ends in an error.
assert_eq!(cfg.network("Guest").unwrap().password, None);
}
// ---------------------------------------------------------------------
// watch::classify — health-state transitions
// ---------------------------------------------------------------------
#[test]
fn classify_reports_unknown_profile_without_touching_nm() {
let _env = EnvSandbox::new();
let cfg = base_config(); // no profiles at all
let runner = FakeRunner::new();
let calls = runner.calls_handle();
let (health, ssid) = with_runner(runner, || classify(&cfg, "ghost"));
assert_eq!(health, Health::UnknownProfile);
assert_eq!(ssid, None);
assert!(calls.borrow().is_empty());
}
#[test]
fn classify_reports_no_adapter_when_wifi_interface_absent() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.profiles.insert("away".into(), Profile::default());
// `device status` succeeds but lists no wifi-type device.
let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet"));
let (health, _) = with_runner(runner, || classify(&cfg, "away"));
assert_eq!(health, Health::NoAdapter);
}
#[test]
fn classify_reports_down_no_net_when_internet_check_fails() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.profiles.insert("away".into(), Profile::default());
let runner = FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
.on(|prog, _| prog == "curl" || prog == "ping", fail(""));
let (health, ssid) = with_runner(runner, || classify(&cfg, "away"));
assert_eq!(health, Health::DownNoNet);
assert_eq!(ssid, Some("HomeWifi".to_string()));
}
#[test]
fn classify_reports_up_when_healthy_and_tailscale_not_required() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.profiles.insert("home".into(), Profile::default()); // tailscale: false
let runner = FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
.with_command("curl")
.on(|prog, _| prog == "curl", ok("204"));
let (health, ssid) = with_runner(runner, || classify(&cfg, "home"));
assert_eq!(health, Health::Up);
assert_eq!(ssid, Some("HomeWifi".to_string()));
}
#[test]
fn classify_reports_down_tailscale_manual_when_not_installed() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.profiles.insert(
"work".into(),
Profile {
tailscale: true,
..Default::default()
},
);
// No `with_command("tailscale")`, so `tailscale::installed()` is false —
// `status::gather` never even tries to run the `tailscale` binary.
let runner = FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
.with_command("curl")
.on(|prog, _| prog == "curl", ok("204"));
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
assert_eq!(health, Health::DownTailscaleManual);
}
#[test]
fn classify_reports_down_tailscale_manual_when_needs_login() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.profiles.insert(
"work".into(),
Profile {
tailscale: true,
..Default::default()
},
);
let runner = FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
.with_command("curl")
.with_command("tailscale")
.on(|prog, _| prog == "curl", ok("204"))
.on(
|prog, args| prog == "tailscale" && args.contains(&"status"),
ok(r#"{"BackendState":"NeedsLogin"}"#),
);
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
assert_eq!(health, Health::DownTailscaleManual);
}
#[test]
fn classify_reports_down_tailscale_other_when_exit_node_offline() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.settings.exit_node = "exitnode".into();
cfg.profiles.insert(
"work".into(),
Profile {
tailscale: true,
..Default::default()
},
);
let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"exitnode","Online":false,"ExitNode":false,"ExitNodeOption":true}}}"#;
let runner = FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
.with_command("curl")
.with_command("tailscale")
.on(|prog, _| prog == "curl", ok("204"))
.on(
|prog, args| prog == "tailscale" && args.contains(&"status"),
ok(json),
);
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
assert_eq!(health, Health::DownTailscaleOther);
}
#[test]
fn classify_reports_up_when_tailscale_healthy() {
let _env = EnvSandbox::new();
let mut cfg = base_config();
cfg.settings.exit_node = "exitnode".into();
cfg.profiles.insert(
"work".into(),
Profile {
tailscale: true,
..Default::default()
},
);
let runner = FakeRunner::new()
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
.with_command("curl")
.with_command("tailscale")
.on(|prog, _| prog == "curl", ok("204"))
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")));
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
assert_eq!(health, Health::Up);
}