From 037c6e54c936cbb9a4cdf2843fc45b5a5825e7d5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 06:58:47 +0800 Subject: [PATCH] Harden breadcrumbs: fix real bugs, restructure as lib, stop storing PSKs twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 10 +- README.md | 26 +- breadcrumbs.example.toml | 21 +- networks.example.toml | 29 ++ src/app.rs | 796 +++++++++++++++++++++++++++++++++++++++ src/config.rs | 211 ++++++++++- src/flow.rs | 149 ++++++-- src/lib.rs | 19 + src/main.rs | 722 +---------------------------------- src/nm.rs | 116 ++++-- src/tailscale.rs | 73 ++++ src/util.rs | 138 ++++++- src/watch.rs | 87 ++++- tests/cli.rs | 429 ++++++++++++++++++++- tests/common/mod.rs | 224 +++++++++++ tests/flow_watch.rs | 472 +++++++++++++++++++++++ 16 files changed, 2688 insertions(+), 834 deletions(-) create mode 100644 networks.example.toml create mode 100644 src/app.rs create mode 100644 src/lib.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/flow_watch.rs diff --git a/.gitignore b/.gitignore index 21ec4d6..1cfb491 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ # Build output /target/ -# Secrets: live config holds plaintext Wi-Fi passwords. -# Keep local only; see breadcrumbs.example.toml for the schema. +# Secrets: live config holds plaintext Wi-Fi passwords (until NetworkManager +# takes over each one on first connect — see README's "Credential handling"). +# Keep local only; see breadcrumbs.example.toml / networks.example.toml for +# the schemas. /breadcrumbs.toml +/networks.toml # Legacy plaintext credential store (migrated into breadcrumbs.toml on first run) /Networks/ @@ -34,3 +37,6 @@ desktop.ini # Claude Code local state .claude/ + +# Local hygiene notes (not for commit) +CLAUDE.md diff --git a/README.md b/README.md index 8f55369..c832f66 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based - **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network - **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor` - **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers -- **Secure credential handling** — passwords fed to `nmcli` via stdin (never in argv/`ps`), config stored at 0600 +- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Note: on that first connect, the PSK is still passed to `nmcli` as a command argument, so it's briefly visible to other local users via `/proc//cmdline` for the lifetime of that `nmcli` child — a known limitation (see the note in `src/nm.rs`); a `nmcli --ask`/D-Bus secret-agent path that avoids argv exposure entirely is not yet wired up. In practice this window now only exists once per network, not on every connect. - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` @@ -34,17 +34,21 @@ cp target/release/breadcrumbs ~/.local/bin/ ## Configuration -On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` with default profiles. Copy `breadcrumbs.example.toml` as a starting point and fill in your real network credentials: +On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` (settings + profiles) and `~/.config/breadcrumbs/networks.toml` (saved networks) with default profiles. Copy `breadcrumbs.example.toml` as a starting point for the former: ```bash cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml -breadcrumbs edit # opens in $EDITOR +breadcrumbs edit # opens breadcrumbs.toml in $EDITOR ``` +...then add your real networks with `breadcrumbs add`/`scan` rather than hand-editing `networks.toml` (see `networks.example.toml` if you want to see its shape or write it by hand anyway). + Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`. ### Config structure +Settings and location profiles live in `breadcrumbs.toml` — the file people actually hand-edit or dotfile: + ```toml [settings] dns = "1.1.1.1" # DNS server pinned on every connection @@ -55,11 +59,6 @@ watch_interval = 12 # seconds between health checks (minimum 4) connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" ping_host = "1.1.1.1" -[[networks]] -ssid = "MyHomeNetwork" -password = "hunter2" -hidden = false - [profiles.home] networks = ["MyHomeNetwork"] # priority-ordered SSIDs tailscale = false @@ -74,6 +73,17 @@ exit_node = "jump-host" # per-profile override detect_ssids = ["CorpWifi", "Corp-5G"] ``` +Saved networks (SSID + optional local password) live separately, in `networks.toml`, managed via `add`/`scan`/`forget`: + +```toml +[[networks]] +ssid = "MyHomeNetwork" +password = "hunter2" # optional — see "Credential handling" below +hidden = false +``` + +`password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save. + ### Profiles Each profile defines: diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml index ead73f0..57d820c 100644 --- a/breadcrumbs.example.toml +++ b/breadcrumbs.example.toml @@ -4,6 +4,12 @@ # just run breadcrumbs once (it generates a skeleton) and then use # `breadcrumbs add` / `breadcrumbs edit` to fill in your networks. # The real breadcrumbs.toml is gitignored and never committed. +# +# Saved networks (SSID + optional local password) live in a separate file, +# networks.toml, in the same directory — not here. See +# networks.example.toml for its format; in practice you never hand-edit it, +# `breadcrumbs add` / `scan` / `forget` manage it for you. This file is just +# settings + the location profiles built from those saved networks. [settings] dns = "1.1.1.1" @@ -14,21 +20,6 @@ watch_interval = 12 connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" ping_host = "1.1.1.1" -[[networks]] -ssid = "HomeWifi" -password = "REPLACE_ME" -hidden = false - -[[networks]] -ssid = "WorkGuest" -password = "REPLACE_ME" -hidden = false - -[[networks]] -ssid = "CorpWifi" -password = "REPLACE_ME" -hidden = false - # Location state machine. Switch with: breadcrumbs profile set # # detect_ssids: list any SSIDs that reliably indicate you are at this location. diff --git a/networks.example.toml b/networks.example.toml new file mode 100644 index 0000000..90d8689 --- /dev/null +++ b/networks.example.toml @@ -0,0 +1,29 @@ +# breadcrumbs saved-networks file. +# +# Lives alongside breadcrumbs.toml at ~/.config/breadcrumbs/networks.toml, +# 0600 permissions. Shown here purely for reference — you normally never +# hand-edit this file; use `breadcrumbs add` / `scan` / `forget` instead. +# +# `password` is optional and only needed the first time breadcrumbs connects +# to a network. Once NetworkManager has durably saved the credential (either +# a brand-new connection profile, or an updated PSK on an existing one), +# breadcrumbs clears its own local copy and omits the `password` key +# entirely on the next save — both the plaintext-on-disk copy and the +# argv exposure on every subsequent connect go away for that network from +# then on. Omit `password` altogether for a genuinely open (no-security) +# network, or for one NetworkManager already knows about. + +[[networks]] +ssid = "HomeWifi" +password = "REPLACE_ME" +hidden = false + +[[networks]] +ssid = "WorkGuest" +password = "REPLACE_ME" +hidden = false + +[[networks]] +ssid = "CorpWifi" +password = "REPLACE_ME" +hidden = false diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..8830a70 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,796 @@ +//! CLI argument parsing and command handlers. This is the only module +//! `src/main.rs` calls into; everything else (the actual state machine, +//! nmcli/tailscale wrappers, config, …) is exercised directly by library +//! consumers (including the integration tests under `tests/`). + +use std::io::{BufRead, Write}; +use std::process::Command; +use std::time::Duration; + +use clap::{Parser, Subcommand}; + +use crate::config::{Config, NetworkDef}; +use crate::state::State; +use crate::util::{self, command_exists, home_dir}; +use crate::{config, flow, nm, notify, watch}; + +const C_RESET: &str = "\x1b[0m"; +const C_BOLD: &str = "\x1b[1m"; +const C_GREEN: &str = "\x1b[32m"; +const C_RED: &str = "\x1b[31m"; +const C_YELLOW: &str = "\x1b[33m"; +const C_DIM: &str = "\x1b[2m"; + +#[derive(Parser)] +#[command( + name = "breadcrumbs", + version, + about = "Profile-aware Wi-Fi state machine with Tailscale handling", + disable_help_subcommand = true +)] +struct Cli { + /// Override the active profile for this run only (does not persist) + #[arg(long, short, global = true)] + profile: Option, + + #[command(subcommand)] + cmd: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show current Wi-Fi / profile / Tailscale status (default) + Status, + /// Run the full connect sequence for the active profile + #[command(visible_aliases = ["up", "connect", "i"])] + Init, + /// Run as a daemon: watch for drops and auto-recover + Watch { + /// Skip the connect attempt on startup + #[arg(long)] + no_initial: bool, + }, + /// Get / set / list location profiles (the state machine) + Profile { + #[command(subcommand)] + action: Option, + }, + /// Guess the profile from visible networks + Detect { + /// Set + apply the detected profile + #[arg(long)] + apply: bool, + }, + /// Add or update a saved network + Add { + ssid: String, + /// Password (prompted if omitted) + password: Option, + /// Network is hidden (does not broadcast its SSID) + #[arg(long)] + hidden: bool, + /// Attach this SSID to a profile's priority list + #[arg(long)] + to: Option, + /// Position in the profile list (0 = highest priority) + #[arg(long)] + at: Option, + }, + /// Remove a saved network (config + NetworkManager) + Forget { ssid: String }, + /// Scan, pick, connect and save a network interactively + Scan { + /// Attach the saved network to this profile + #[arg(long)] + to: Option, + }, + /// List configured networks and profiles + List { + #[arg(long)] + show_passwords: bool, + }, + /// Open the config file in $EDITOR + Edit, + /// Quick connectivity / Tailscale diagnostics + Doctor { + /// Run the full diag.sh report from the config directory + #[arg(long)] + full: bool, + }, + /// Print the breadcrumbs config directory + Cd { + #[arg(long)] + shell: bool, + }, + /// Install + enable the systemd user watcher service + InstallService { + /// Install the unit but do not enable/start it + #[arg(long)] + no_enable: bool, + }, +} + +#[derive(Subcommand)] +enum ProfileCmd { + /// Print the active profile + Get, + /// Set the active profile (and apply it unless --no-apply) + Set { + name: String, + #[arg(long)] + no_apply: bool, + }, + /// List available profiles + List, +} + +/// Parse `argv` and run the requested command. Returns the process exit code. +pub fn run() -> i32 { + let cli = Cli::parse(); + match real_main(cli) { + Ok(c) => c, + Err(e) => { + eprintln!("{C_RED}error:{C_RESET} {e}"); + 1 + } + } +} + +fn active_profile(cfg: &Config, override_p: &Option) -> String { + if let Some(p) = override_p { + return p.clone(); + } + State::load(&cfg.settings.default_profile).profile +} + +fn real_main(cli: Cli) -> Result { + let cmd = cli.cmd.unwrap_or(Cmd::Status); + + // `cd` and `install-service` don't need a parsed config first. + if let Cmd::Cd { shell } = &cmd { + return cmd_cd(*shell); + } + + let mut cfg = Config::load()?; + + match cmd { + Cmd::Status => cmd_status(&cfg, &cli.profile), + Cmd::Init => { + let p = active_profile(&cfg, &cli.profile); + let outcome = flow::run(&mut cfg, &p); + print_outcome(&p, &outcome); + Ok(if outcome.ok() { 0 } else { 1 }) + } + Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), + Cmd::Profile { action } => cmd_profile(&mut cfg, action), + Cmd::Detect { apply } => cmd_detect(&mut cfg, apply), + Cmd::Add { + ssid, + password, + hidden, + to, + at, + } => cmd_add(&mut cfg, ssid, password, hidden, to, at), + Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), + Cmd::Scan { to } => cmd_scan(&mut cfg, to), + Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), + Cmd::Edit => cmd_edit(), + Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full), + Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), + Cmd::Cd { .. } => unreachable!(), + } +} + +fn print_outcome(profile: &str, o: &flow::Outcome) { + match o { + flow::Outcome::Connected { ssid, note } => { + print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})"); + match note { + Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"), + None => println!(), + } + } + flow::Outcome::TailscaleError { ssid, health } => { + println!( + "{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}", + health.describe(), + ssid.clone().unwrap_or_else(|| "—".into()) + ); + } + flow::Outcome::NoInterface => { + println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue") + } + flow::Outcome::NoNetworks => { + println!("{C_RED}no known networks in range{C_RESET} (profile {profile})") + } + flow::Outcome::UnknownProfile(p) => { + println!("{C_RED}unknown profile{C_RESET}: {p}") + } + } +} + +fn cmd_status(cfg: &Config, override_p: &Option) -> Result { + let p = active_profile(cfg, override_p); + let s = crate::status::gather(cfg, &p); + + let dot = |ok: bool| { + if ok { + format!("{C_GREEN}●{C_RESET}") + } else { + format!("{C_RED}●{C_RESET}") + } + }; + + println!("{C_BOLD}breadcrumbs{C_RESET}"); + println!(" profile {C_BOLD}{p}{C_RESET}"); + println!( + " adapter {}", + s.iface + .clone() + .unwrap_or_else(|| format!("{C_RED}none{C_RESET}")) + ); + println!( + " ssid {}", + s.ssid + .clone() + .unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) + ); + println!( + " ip {}", + s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) + ); + println!( + " internet {} {}", + dot(s.internet), + if s.internet { "ok" } else { "down" } + ); + + match (&s.tailscale, s.tailscale_required) { + (Some(h), req) => { + let ok = h.is_ok(); + println!( + " tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}", + dot(ok || !req), + h.describe(), + s.exit_node, + if req { "" } else { ", optional" } + ); + } + (None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), + } + + let healthy = s.internet + && s.iface.is_some() + && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)); + println!( + " state {}", + if healthy { + format!("{C_GREEN}healthy{C_RESET}") + } else { + format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`") + } + ); + Ok(if healthy { 0 } else { 1 }) +} + +fn cmd_profile(cfg: &mut Config, action: Option) -> Result { + match action.unwrap_or(ProfileCmd::Get) { + ProfileCmd::Get => { + println!("{}", State::load(&cfg.settings.default_profile).profile); + Ok(0) + } + ProfileCmd::List => { + let cur = State::load(&cfg.settings.default_profile).profile; + for name in cfg.profiles.keys() { + let mark = if *name == cur { "*" } else { " " }; + println!("{mark} {name}"); + } + Ok(0) + } + ProfileCmd::Set { name, no_apply } => { + if !cfg.profiles.contains_key(&name) { + let avail: Vec<&String> = cfg.profiles.keys().collect(); + return Err(format!("unknown profile '{name}'. Available: {avail:?}")); + } + let st = State { + profile: name.clone(), + updated: crate::util::timestamp(), + }; + st.save()?; + notify::log(&format!("profile set -> {name}")); + println!("profile = {C_BOLD}{name}{C_RESET}"); + if no_apply { + return Ok(0); + } + let outcome = flow::run(cfg, &name); + print_outcome(&name, &outcome); + Ok(if outcome.ok() { 0 } else { 1 }) + } + } +} + +fn detect_profile(cfg: &Config) -> Option { + let iface = nm::wifi_interface()?; + nm::radio_on(); + nm::rescan(&iface, &[]); + let visible = nm::visible_ssids(&iface); + + // Profiles are stored in a BTreeMap so iteration order is deterministic + // (alphabetical). The caller can rely on that for tie-breaking. + for (name, profile) in &cfg.profiles { + if profile.detect_ssids.is_empty() { + continue; + } + if profile + .detect_ssids + .iter() + .any(|s| visible.contains(s.as_str())) + { + return Some(name.clone()); + } + } + + // Fall back to the default profile if no markers matched. + Some(cfg.settings.default_profile.clone()) +} + +fn cmd_detect(cfg: &mut Config, apply: bool) -> Result { + match detect_profile(cfg) { + Some(p) => { + println!("{p}"); + if apply { + State { + profile: p.clone(), + updated: crate::util::timestamp(), + } + .save()?; + let outcome = flow::run(cfg, &p); + print_outcome(&p, &outcome); + return Ok(if outcome.ok() { 0 } else { 1 }); + } + Ok(0) + } + None => Err("could not detect a profile (no Wi-Fi adapter?)".into()), + } +} + +fn prompt_line(msg: &str) -> String { + print!("{msg}"); + let _ = std::io::stdout().flush(); + let mut s = String::new(); + let _ = std::io::stdin().lock().read_line(&mut s); + s.trim_end_matches(['\n', '\r']).to_string() +} + +/// An empty string entered for a password (CLI arg or a blank prompt +/// response) means "this network has no password" (open Wi-Fi) — normalize +/// it to `None` right at the point of entry so it flows the same way a +/// genuinely absent/cleared password does. Without this, `Some("")` would +/// make `nm::connect_verbose` send an empty PSK argument, which nmcli treats +/// as "secured with a blank password" rather than "open", and the connect +/// fails against a real open SSID. +fn non_empty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn prompt_secret(msg: &str) -> String { + // `util::run` redirects child stdin to /dev/null, so plain `stty -echo` + // would target the wrong fd and silently leave echo ON (leaking the + // password to the screen). `-F /dev/tty` makes stty act on the controlling + // terminal directly. If there is no tty we fall back to visible input. + let had_tty = util::run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success; + let val = prompt_line(msg); + if had_tty { + let _ = util::run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2)); + println!(); + } + val +} + +fn cmd_add( + cfg: &mut Config, + ssid: String, + password: Option, + hidden: bool, + to: Option, + at: Option, +) -> Result { + let password = match password { + Some(p) => p, + None => prompt_secret(&format!("Password for '{ssid}': ")), + }; + let password = non_empty(password); + match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { + Some(n) => { + n.password = password; + n.hidden = hidden || n.hidden; + } + None => cfg.networks.push(NetworkDef { + ssid: ssid.clone(), + password, + hidden, + }), + } + if let Some(prof_name) = to { + let prof = cfg + .profiles + .get_mut(&prof_name) + .ok_or_else(|| format!("unknown profile '{prof_name}'"))?; + prof.networks.retain(|s| s != &ssid); + let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len()); + prof.networks.insert(idx, ssid.clone()); + } + cfg.save()?; + println!("{C_GREEN}saved{C_RESET} {ssid}"); + Ok(0) +} + +fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { + let before = cfg.networks.len(); + cfg.networks.retain(|n| n.ssid != ssid); + for p in cfg.profiles.values_mut() { + p.networks.retain(|s| s != ssid); + if p.bootstrap.as_deref() == Some(ssid) { + p.bootstrap = None; + } + } + cfg.save()?; + let removed = nm::delete_connections_for_ssid(ssid); + println!( + "{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})", + if cfg.networks.len() < before { + "removed" + } else { + "not present" + }, + if removed { "removed" } else { "not present" } + ); + Ok(0) +} + +fn cmd_scan(cfg: &mut Config, to: Option) -> Result { + let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?; + nm::radio_on(); + nm::rescan(&iface, &[]); + let entries = nm::scan_list(&iface); + if entries.is_empty() { + return Err("no networks found".into()); + } + for (i, e) in entries.iter().enumerate() { + println!( + "{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}", + i + 1, + if e.ssid.is_empty() { + "" + } else { + &e.ssid + }, + e.signal, + e.security + ); + } + let sel = prompt_line("Select number: "); + let idx: usize = sel + .parse::() + .ok() + .filter(|n| *n >= 1 && *n <= entries.len()) + .ok_or("invalid selection")?; + let ssid = entries[idx - 1].ssid.clone(); + if ssid.is_empty() { + return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into()); + } + let password = non_empty(prompt_secret(&format!("Password for '{ssid}': "))); + let mut def = NetworkDef { + ssid: ssid.clone(), + password, + hidden: false, + }; + if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { + return Err(format!("failed to connect to {ssid}")); + } + // A successful connect means NetworkManager now durably holds the PSK + // (either in a freshly created profile, or one whose PSK we just set) — + // breadcrumbs no longer needs to keep its own plaintext copy. + def.password = None; + match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { + Some(n) => n.password = None, + None => cfg.networks.push(def), + } + if let Some(prof_name) = to { + if let Some(prof) = cfg.profiles.get_mut(&prof_name) { + if !prof.networks.contains(&ssid) { + prof.networks.push(ssid.clone()); + } + } + } + cfg.save()?; + println!("{C_GREEN}connected + saved{C_RESET} {ssid}"); + Ok(0) +} + +/// Mask a secret for display. Operates on chars (not bytes) so multi-byte +/// UTF-8 passwords don't panic on a mid-character byte slice, and never +/// echoes back any real character of the secret (previously the first byte +/// was shown unmasked). +fn mask(p: &str) -> String { + "•".repeat(p.chars().count().max(2)) +} + +fn cmd_list(cfg: &Config, show_pw: bool) -> Result { + println!("{C_BOLD}settings{C_RESET}"); + println!(" dns {}", cfg.settings.dns); + println!(" exit_node {}", cfg.settings.exit_node); + println!(" default {}", cfg.settings.default_profile); + println!(" watch every {}s", cfg.settings.watch_interval); + + println!("\n{C_BOLD}networks{C_RESET}"); + for n in &cfg.networks { + let pw_display = match &n.password { + Some(p) if show_pw => p.clone(), + Some(p) => mask(p), + // No local secret: NetworkManager already owns the credential for + // this SSID, so there is nothing to mask — showing dots here + // would falsely imply breadcrumbs is still hiding a password. + None => format!("{C_DIM}managed by NetworkManager{C_RESET}"), + }; + println!( + " {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}", + n.ssid, + pw_display, + if n.hidden { " (hidden)" } else { "" } + ); + } + + println!("\n{C_BOLD}profiles{C_RESET}"); + let cur = State::load(&cfg.settings.default_profile).profile; + for (name, p) in &cfg.profiles { + let mark = if *name == cur { + format!("{C_GREEN}*{C_RESET}") + } else { + " ".into() + }; + println!("{mark} {C_BOLD}{name}{C_RESET}"); + if let Some(b) = &p.bootstrap { + println!(" bootstrap {b}"); + } + if p.tailscale { + println!( + " tailscale required (exit: {})", + p.exit_node + .clone() + .unwrap_or_else(|| cfg.settings.exit_node.clone()) + ); + } + let mut order: Vec = p.networks.clone(); + if p.include_all_known { + order.push("…all other known networks".into()); + } + println!(" priority {}", order.join(" > ")); + } + Ok(0) +} + +fn cmd_edit() -> Result { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); + let path = config::config_path(); + let status = Command::new(&editor) + .arg(&path) + .status() + .map_err(|e| format!("launching {editor}: {e}"))?; + if !status.success() { + return Err("editor exited with error".into()); + } + match Config::load() { + Ok(_) => { + println!("{C_GREEN}config OK{C_RESET}"); + Ok(0) + } + Err(e) => Err(format!("config is now invalid: {e}")), + } +} + +fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result { + if full { + let script = config::config_dir().join("diag.sh"); + if !script.exists() { + return Err(format!( + "diag.sh not found (expected at {})", + script.display() + )); + } + let st = Command::new("bash") + .arg(&script) + .status() + .map_err(|e| format!("running diag: {e}"))?; + return Ok(st.code().unwrap_or(1)); + } + + let p = active_profile(cfg, override_p); + let s = crate::status::gather(cfg, &p); + println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); + println!( + " nmcli {}", + if command_exists("nmcli") { + "present" + } else { + "MISSING" + } + ); + println!( + " tailscale {}", + if command_exists("tailscale") { + "present" + } else { + "absent" + } + ); + println!( + " adapter {}", + s.iface.clone().unwrap_or_else(|| "none".into()) + ); + println!( + " ssid {}", + s.ssid.clone().unwrap_or_else(|| "—".into()) + ); + println!( + " ip {}", + s.ip.clone().unwrap_or_else(|| "—".into()) + ); + println!(" internet {}", if s.internet { "ok" } else { "DOWN" }); + if let Some(h) = &s.tailscale { + println!(" tailscale {} (exit {})", h.describe(), s.exit_node); + } + + if let Some(iface) = &s.iface { + let visible = nm::visible_ssids(iface); + let known: Vec<&str> = cfg + .networks + .iter() + .filter(|n| visible.contains(&n.ssid)) + .map(|n| n.ssid.as_str()) + .collect(); + println!( + " in range {}", + if known.is_empty() { + "none of your saved networks".into() + } else { + known.join(", ") + } + ); + } + println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}"); + Ok(0) +} + +fn cmd_cd(shell: bool) -> Result { + let dir = config::config_dir(); + if shell { + let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let err = exec_replace(&sh, &dir); + return Err(err); + } + println!("{}", dir.display()); + Ok(0) +} + +/// Re-exec into an interactive login shell inside `dir`, replacing the +/// current process. `dir` is passed as `$1` to the shell script rather than +/// interpolated into the script text — the config dir can come from +/// `$XDG_CONFIG_HOME`/`$HOME`, and string-formatting an arbitrary path +/// straight into a `sh -c` command would let shell metacharacters (`$(...)`, +/// backticks, etc.) in that path execute as commands. +fn exec_replace(prog: &str, dir: &std::path::Path) -> String { + use std::os::unix::process::CommandExt; + let e = Command::new(prog) + .arg("-lc") + .arg("cd \"$1\" && exec \"$0\"") + .arg(prog) + .arg(dir) + .exec(); + format!("exec {prog} failed: {e}") +} + +fn cmd_install_service(enable: bool) -> Result { + let unit_dir = home_dir().join(".config/systemd/user"); + std::fs::create_dir_all(&unit_dir) + .map_err(|e| format!("creating {}: {e}", unit_dir.display()))?; + let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?; + // Ordering against graphical-session.target lets the watcher inherit the + // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale + // login browser-open actually work. PATH is pinned because systemd --user + // units do not get the login shell's PATH, and the watcher shells out to + // nmcli/tailscale/sudo/xdg-open by name. + let unit = format!( + "[Unit]\n\ + Description=breadcrumbs Wi-Fi state machine watcher\n\ + After=network.target NetworkManager.service graphical-session.target\n\ + Wants=network.target graphical-session.target\n\n\ + [Service]\n\ + Type=simple\n\ + Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\ + ExecStart={bin} watch\n\ + Restart=always\n\ + RestartSec=5\n\ + Nice=5\n\n\ + [Install]\n\ + WantedBy=default.target\n", + bin = bin.display() + ); + let unit_path = unit_dir.join("breadcrumbs.service"); + std::fs::write(&unit_path, unit) + .map_err(|e| format!("writing {}: {e}", unit_path.display()))?; + println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display()); + + let _ = util::run( + "systemctl", + &["--user", "daemon-reload"], + Duration::from_secs(10), + ); + if enable { + let o = util::run( + "systemctl", + &["--user", "enable", "--now", "breadcrumbs.service"], + Duration::from_secs(15), + ); + if o.success { + println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service"); + } else { + println!( + "{C_YELLOW}unit installed{C_RESET}; enable failed: {}", + o.stderr.trim() + ); + return Ok(1); + } + } else { + println!("Run: systemctl --user enable --now breadcrumbs.service"); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mask_empty_password() { + // len() == 0 <= 2 branch: still at least 2 dots so an empty saved + // password doesn't visually collapse to nothing in `list`. + assert_eq!(mask(""), "••"); + } + + #[test] + fn mask_short_passwords_reveal_nothing() { + assert_eq!(mask("a"), "••"); + assert_eq!(mask("ab"), "••"); + } + + #[test] + fn mask_never_echoes_a_real_character() { + let pw = "hunter2"; + let masked = mask(pw); + assert_eq!(masked, "•".repeat(pw.len())); + assert!(!masked.contains('h'), "masked output leaked the first char"); + } + + #[test] + fn mask_multibyte_password_does_not_panic() { + // Regression test: the old byte-slicing `&p[..1]` panicked whenever + // the first character of the password was multi-byte UTF-8 (e.g. an + // emoji or accented character), since byte index 1 can land mid-char. + let pw = "日本語パスワード"; + let masked = mask(pw); + assert_eq!(masked.chars().count(), pw.chars().count()); + assert!(masked.chars().all(|c| c == '•')); + } + + #[test] + fn mask_emoji_first_character_does_not_panic() { + let pw = "🔒password123"; + let masked = mask(pw); + assert_eq!(masked.chars().count(), pw.chars().count()); + } +} diff --git a/src/config.rs b/src/config.rs index c258d31..747ab1c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,7 +63,16 @@ impl Default for Settings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkDef { pub ssid: String, - pub password: String, + /// A password is only needed once. On first successful connect, + /// NetworkManager durably saves the credential (a new connection + /// profile, or an updated PSK on an existing one); breadcrumbs then + /// clears this field and, on the next save, omits the key entirely + /// rather than writing a plaintext copy that's no longer needed. `None` + /// means either "NetworkManager already owns this secret" or "this is + /// an open (unsecured) network" — both cases behave the same way on + /// connect: no password argument is ever sent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, #[serde(default)] pub hidden: bool, } @@ -95,12 +104,30 @@ pub struct Profile { pub struct Config { #[serde(default)] pub settings: Settings, - #[serde(default, rename = "networks")] + /// Saved networks (SSID + optional local password). Persisted to the + /// separate `networks.toml` file (see [`networks_path`]), not to + /// `breadcrumbs.toml` — kept here, and still deserialized from a + /// `[[networks]]` block if one is present in `breadcrumbs.toml`, purely + /// for backward compatibility with configs written before the secrets + /// split: an old-format file's inline networks load in on first read and + /// migrate to `networks.toml` automatically on the next `save()`, no + /// explicit migration step required. + #[serde(default, rename = "networks", skip_serializing)] pub networks: Vec, #[serde(default)] pub profiles: BTreeMap, } +/// The on-disk shape of `networks.toml`: just the `[[networks]]` array, +/// split out of the main config so a file that's mostly just settings and +/// profiles (the parts people actually hand-edit or dotfile) doesn't also +/// carry whatever plaintext Wi-Fi credentials breadcrumbs still holds. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct NetworksFile { + #[serde(default, rename = "networks")] + networks: Vec, +} + pub fn config_dir() -> PathBuf { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -112,6 +139,13 @@ pub fn config_path() -> PathBuf { config_dir().join("breadcrumbs.toml") } +/// Where saved networks (SSID + optional local password) live, split out of +/// `breadcrumbs.toml`. Not meant to be hand-edited — managed via +/// `breadcrumbs add` / `scan` / `forget`. +pub fn networks_path() -> PathBuf { + config_dir().join("networks.toml") +} + pub fn state_dir() -> PathBuf { std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) @@ -146,29 +180,69 @@ impl Config { } let text = fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; + // May carry a legacy inline `[[networks]]` block (pre-split + // configs) — that's fine, see the field doc on `Config::networks`. let mut cfg: Config = toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?; + + let net_path = networks_path(); + if net_path.exists() { + let net_text = fs::read_to_string(&net_path) + .map_err(|e| format!("reading {}: {e}", net_path.display()))?; + let nf: NetworksFile = toml::from_str(&net_text) + .map_err(|e| format!("parsing {}: {e}", net_path.display()))?; + cfg.networks = nf.networks; + } + // else: no networks.toml yet — keep whatever legacy inline networks + // were read from breadcrumbs.toml above (or none, on a genuinely + // fresh config). The next `save()` writes them to networks.toml and + // stops writing them into breadcrumbs.toml, completing the migration. + // Self-heal: guarantee the three core profiles always exist. ensure_core_profiles(&mut cfg); Ok(cfg) } + /// Persist settings + profiles to `breadcrumbs.toml` and networks to the + /// separate `networks.toml`, both `0600`. Every mutating command (`add`, + /// `forget`, `scan`, `profile set`, and `flow::run`'s own credential + /// clearing) goes through this single method so the two files never + /// drift out of sync with each other. pub fn save(&self) -> Result<(), String> { let dir = config_dir(); fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; + let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?; let path = config_path(); fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?; - // Plaintext Wi-Fi passwords live here — keep it owner-only. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); - } + secure_permissions(&path); + + let nf = NetworksFile { + networks: self.networks.clone(), + }; + let net_text = + toml::to_string_pretty(&nf).map_err(|e| format!("serializing networks: {e}"))?; + let net_path = networks_path(); + fs::write(&net_path, net_text) + .map_err(|e| format!("writing {}: {e}", net_path.display()))?; + secure_permissions(&net_path); + Ok(()) } } +/// Any local Wi-Fi passwords still held (pre-first-connect, or a network +/// breadcrumbs doesn't yet know NetworkManager owns) live in plaintext on +/// disk — keep both config files owner-only. Best-effort: a failure here +/// isn't fatal to saving the config itself. +#[cfg(unix)] +fn secure_permissions(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +fn secure_permissions(_path: &std::path::Path) {} + /// Initial skeleton networks generated for a brand-new installation. /// Passwords are intentionally blank — secrets never live in source. /// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or @@ -274,4 +348,125 @@ mod tests { assert!(cfg.profile("work").is_some()); assert!(cfg.profile("away").is_some()); } + + #[test] + fn ensure_core_profiles_preserves_user_customized_core_profile() { + // A user-edited "home" (custom SSIDs) must not be clobbered by the + // self-heal backfill — only genuinely *missing* core profiles should + // be inserted. + let mut profiles = BTreeMap::new(); + profiles.insert( + "home".to_string(), + Profile { + networks: vec!["CustomSSID".into()], + ..Default::default() + }, + ); + let mut cfg = Config { + settings: Settings::default(), + networks: vec![], + profiles, + }; + ensure_core_profiles(&mut cfg); + assert_eq!( + cfg.profile("home").unwrap().networks, + vec!["CustomSSID".to_string()] + ); + // Still backfills the ones that were actually missing. + assert!(cfg.profile("work").is_some()); + assert!(cfg.profile("away").is_some()); + } + + #[test] + fn settings_default_matches_documented_defaults() { + let s = Settings::default(); + assert_eq!(s.dns, "1.1.1.1"); + assert_eq!(s.nmcli_wait, 8); + assert_eq!(s.default_profile, "away"); + assert_eq!(s.watch_interval, 12); + assert_eq!(s.ping_host, "1.1.1.1"); + assert!(s.exit_node.is_empty()); + } + + #[test] + fn network_def_hidden_defaults_false_when_omitted() { + let text = r#"ssid = "Cafe" +password = "pw""#; + let n: NetworkDef = toml::from_str(text).unwrap(); + assert!(!n.hidden); + } + + #[test] + fn network_def_password_defaults_to_none_when_key_absent() { + // No `password` key at all — e.g. a network whose secret breadcrumbs + // already cleared after NetworkManager took it over. + let text = r#"ssid = "Cafe" +hidden = false"#; + let n: NetworkDef = toml::from_str(text).unwrap(); + assert_eq!(n.password, None); + } + + #[test] + fn network_def_omits_password_key_entirely_when_none() { + // Round-tripping a cleared password must not write `password = ""` + // (which would read back as "has an empty secret") or any other + // stand-in — the key should be gone, full stop. + let n = NetworkDef { + ssid: "Cafe".into(), + password: None, + hidden: false, + }; + let text = toml::to_string_pretty(&n).unwrap(); + assert!(!text.contains("password"), "text: {text}"); + } + + #[test] + fn network_def_password_round_trips_through_toml() { + let n = NetworkDef { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: false, + }; + let text = toml::to_string_pretty(&n).unwrap(); + assert!(text.contains("hunter2")); + let back: NetworkDef = toml::from_str(&text).unwrap(); + assert_eq!(back.password, Some("hunter2".to_string())); + } + + // Note: `Config::save`/`Config::load`'s real filesystem behavior (the + // networks.toml split, and a cleared password actually landing on disk) + // is covered by `tests/cli.rs`'s Sandbox-isolated integration tests + // (`networks_are_stored_separately_from_settings_and_profiles`, + // `password_is_cleared_after_first_connect_and_never_sent_again`) rather + // than here — this module's tests stay pure per the project's test + // discipline (no real fs/env/subprocess access from a `#[cfg(test)]` + // unit test). + + #[test] + fn profile_default_has_no_bootstrap_or_tailscale() { + let p = Profile::default(); + assert!(p.bootstrap.is_none()); + assert!(!p.tailscale); + assert!(!p.include_all_known); + assert!(p.networks.is_empty()); + assert!(p.detect_ssids.is_empty()); + } + + #[test] + fn malformed_toml_fails_to_parse() { + let bad = "this is not [ valid toml"; + assert!(toml::from_str::(bad).is_err()); + } + + #[test] + fn config_with_only_settings_defaults_networks_and_profiles() { + // A hand-written config that only sets `[settings]` shouldn't require + // `networks`/`profiles` sections — both must fall back to `#[serde(default)]`. + let text = r#"[settings] +dns = "9.9.9.9""#; + let cfg: Config = toml::from_str(text).unwrap(); + assert_eq!(cfg.settings.dns, "9.9.9.9"); + assert!(cfg.networks.is_empty()); + assert!(cfg.profiles.is_empty()); + } } diff --git a/src/flow.rs b/src/flow.rs index 71f33f6..6a6bfcc 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -27,25 +27,54 @@ impl Outcome { } } -fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a NetworkDef> { - let mut out: Vec<&NetworkDef> = Vec::new(); +/// Resolve a profile's priority list to concrete network definitions. +/// +/// Returns owned clones rather than borrows of `cfg` — small structs, and it +/// decouples the result's lifetime from `cfg` so callers (namely [`run`]) can +/// still mutate `cfg` (to clear a used password and persist it) while a +/// candidate from this list is being acted on. +fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec { + let mut out: Vec = Vec::new(); for ssid in &p.networks { if let Some(def) = cfg.network(ssid) { if !out.iter().any(|d| d.ssid == def.ssid) { - out.push(def); + out.push(def.clone()); } } } if p.include_all_known { for def in &cfg.networks { if !out.iter().any(|d| d.ssid == def.ssid) { - out.push(def); + out.push(def.clone()); } } } out } +/// A network was just connected to using a local password, and NetworkManager +/// now durably holds that secret — either in a freshly created connection +/// profile (`device wifi connect`) or an existing one whose PSK we just +/// updated (`connection modify`). Either way breadcrumbs no longer needs its +/// own plaintext copy: clear it and persist immediately, so it can't be +/// re-sent as an argv argument on the next connect and doesn't sit on disk +/// any longer than necessary. A no-op (no save) if the network has no local +/// password to begin with. +fn clear_password_if_used(cfg: &mut Config, ssid: &str) { + let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else { + return; + }; + if def.password.is_none() { + return; + } + def.password = None; + if let Err(e) = cfg.save() { + log(&format!( + "failed to persist cleared password for {ssid}: {e}" + )); + } +} + /// Try to connect + confirm it actually carries traffic. /// Returns Ok(()) on success, Err(reason) on failure. fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { @@ -57,7 +86,13 @@ fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), } /// Run the connection state machine for `profile_name`. -pub fn run(cfg: &Config, profile_name: &str) -> Outcome { +/// +/// Takes `cfg` mutably: a successful connect that used a local password +/// clears that network's `password` field and persists the config +/// immediately (see [`clear_password_if_used`]) — this is the only way that +/// clearing happens for the `init` / `profile set --apply` / `detect --apply` +/// commands and the watch loop, all of which route through here. +pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { let profile = match cfg.profile(profile_name) { Some(p) => p.clone(), None => { @@ -111,13 +146,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { let mut on_bootstrap = false; if profile.tailscale { if let Some(bs_ssid) = profile.bootstrap.clone() { - match cfg.network(&bs_ssid) { + // Owned clone (not a borrow of `cfg`) so we're free to mutate + // `cfg` below on a successful connect. + match cfg.network(&bs_ssid).cloned() { Some(bdef) => { if visible.contains(&bdef.ssid) || bdef.hidden { - match connect_and_verify(&iface, bdef, cfg) { + match connect_and_verify(&iface, &bdef, cfg) { Ok(()) => { on_bootstrap = true; log(&format!("bootstrap connected: {}", bdef.ssid)); + clear_password_if_used(cfg, &bdef.ssid); } Err(e) => log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)), } @@ -160,6 +198,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { any_attempted = true; match connect_and_verify(&iface, def, cfg) { Ok(()) => { + clear_password_if_used(cfg, &def.ssid); let note = if internet_ok(cfg) { None } else { @@ -181,6 +220,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { any_attempted = true; match connect_and_verify(&iface, def, cfg) { Ok(()) => { + clear_password_if_used(cfg, &def.ssid); let note = if internet_ok(cfg) { None } else { @@ -206,9 +246,15 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { .clone() .unwrap_or_else(|| "bootstrap".into()); if !nm::device_connected(&iface) { - if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) { - match connect_and_verify(&iface, bdef, cfg) { - Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)), + // Owned clone (not a borrow of `cfg`) so a successful reconnect + // is free to mutate `cfg` to clear the used password. + if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s).cloned()) + { + match connect_and_verify(&iface, &bdef, cfg) { + Ok(()) => { + log(&format!("bootstrap reconnected: {}", bdef.ssid)); + clear_password_if_used(cfg, &bdef.ssid); + } Err(e) => { log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); on_bootstrap = false; @@ -279,7 +325,7 @@ mod tests { fn net(ssid: &str) -> NetworkDef { NetworkDef { ssid: ssid.into(), - password: "x".into(), + password: Some("x".into()), hidden: false, } } @@ -304,10 +350,8 @@ mod tests { networks: vec!["FallbackNet".into(), "HomeWifi".into()], ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got, vec!["FallbackNet", "HomeWifi"]); } @@ -319,10 +363,8 @@ mod tests { include_all_known: true, ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got[0], "HomeWifi"); assert_eq!(got.len(), 4); assert!(got.contains(&"WorkNet")); @@ -337,10 +379,71 @@ mod tests { networks: vec!["Ghost".into(), "WorkNet".into()], ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got, vec!["WorkNet"]); } + + #[test] + fn empty_profile_network_list_yields_no_candidates() { + let c = cfg(); + let p = Profile::default(); + assert!(resolve_candidates(&c, &p).is_empty()); + } + + #[test] + fn duplicate_ssids_within_profile_list_are_deduped() { + let c = cfg(); + let p = Profile { + networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()], + ..Default::default() + }; + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); + assert_eq!(got, vec!["HomeWifi", "WorkNet"]); + } + + #[test] + fn include_all_known_with_full_priority_list_appends_nothing_new() { + let c = cfg(); + let p = Profile { + networks: vec![ + "HomeWifi".into(), + "WorkNet".into(), + "CafeWifi".into(), + "FallbackNet".into(), + ], + include_all_known: true, + ..Default::default() + }; + let got = resolve_candidates(&c, &p); + assert_eq!(got.len(), 4); + } + + #[test] + fn include_all_known_on_empty_priority_list_returns_all_networks() { + let c = cfg(); + let p = Profile { + include_all_known: true, + ..Default::default() + }; + assert_eq!(resolve_candidates(&c, &p).len(), 4); + } + + #[test] + fn outcome_ok_is_true_only_for_connected() { + assert!(Outcome::Connected { + ssid: "x".into(), + note: None + } + .ok()); + assert!(!Outcome::NoInterface.ok()); + assert!(!Outcome::NoNetworks.ok()); + assert!(!Outcome::UnknownProfile("ghost".into()).ok()); + assert!(!Outcome::TailscaleError { + ssid: None, + health: crate::tailscale::TsHealth::NotInstalled + } + .ok()); + } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d8f9929 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +//! breadcrumbs library crate. +//! +//! All actual logic lives here; `src/main.rs` is a thin binary shim that +//! parses no arguments itself — it just calls [`app::run`]. Splitting things +//! this way means integration tests can link against `breadcrumbs` as an +//! ordinary library crate and drive the real state machine (`flow::run`, +//! `watch::classify`, …) in-process, instead of only being able to spawn the +//! compiled binary. + +pub mod app; +pub mod config; +pub mod flow; +pub mod nm; +pub mod notify; +pub mod state; +pub mod status; +pub mod tailscale; +pub mod util; +pub mod watch; diff --git a/src/main.rs b/src/main.rs index 548da4b..5263504 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,721 +1,7 @@ -mod config; -mod flow; -mod nm; -mod notify; -mod state; -mod status; -mod tailscale; -mod util; -mod watch; - -use std::io::{BufRead, Write}; -use std::process::Command; -use std::time::Duration; - -use clap::{Parser, Subcommand}; - -use config::{Config, NetworkDef}; -use state::State; -use util::{command_exists, home_dir, run}; - -const C_RESET: &str = "\x1b[0m"; -const C_BOLD: &str = "\x1b[1m"; -const C_GREEN: &str = "\x1b[32m"; -const C_RED: &str = "\x1b[31m"; -const C_YELLOW: &str = "\x1b[33m"; -const C_DIM: &str = "\x1b[2m"; - -#[derive(Parser)] -#[command( - name = "breadcrumbs", - version, - about = "Profile-aware Wi-Fi state machine with Tailscale handling", - disable_help_subcommand = true -)] -struct Cli { - /// Override the active profile for this run only (does not persist) - #[arg(long, short, global = true)] - profile: Option, - - #[command(subcommand)] - cmd: Option, -} - -#[derive(Subcommand)] -enum Cmd { - /// Show current Wi-Fi / profile / Tailscale status (default) - Status, - /// Run the full connect sequence for the active profile - #[command(visible_aliases = ["up", "connect", "i"])] - Init, - /// Run as a daemon: watch for drops and auto-recover - Watch { - /// Skip the connect attempt on startup - #[arg(long)] - no_initial: bool, - }, - /// Get / set / list location profiles (the state machine) - Profile { - #[command(subcommand)] - action: Option, - }, - /// Guess the profile from visible networks - Detect { - /// Set + apply the detected profile - #[arg(long)] - apply: bool, - }, - /// Add or update a saved network - Add { - ssid: String, - /// Password (prompted if omitted) - password: Option, - /// Network is hidden (does not broadcast its SSID) - #[arg(long)] - hidden: bool, - /// Attach this SSID to a profile's priority list - #[arg(long)] - to: Option, - /// Position in the profile list (0 = highest priority) - #[arg(long)] - at: Option, - }, - /// Remove a saved network (config + NetworkManager) - Forget { ssid: String }, - /// Scan, pick, connect and save a network interactively - Scan { - /// Attach the saved network to this profile - #[arg(long)] - to: Option, - }, - /// List configured networks and profiles - List { - #[arg(long)] - show_passwords: bool, - }, - /// Open the config file in $EDITOR - Edit, - /// Quick connectivity / Tailscale diagnostics - Doctor { - /// Run the full diag.sh report from the config directory - #[arg(long)] - full: bool, - }, - /// Print the breadcrumbs config directory - Cd { - #[arg(long)] - shell: bool, - }, - /// Install + enable the systemd user watcher service - InstallService { - /// Install the unit but do not enable/start it - #[arg(long)] - no_enable: bool, - }, -} - -#[derive(Subcommand)] -enum ProfileCmd { - /// Print the active profile - Get, - /// Set the active profile (and apply it unless --no-apply) - Set { - name: String, - #[arg(long)] - no_apply: bool, - }, - /// List available profiles - List, -} +//! Thin binary entry point. All argument parsing and command logic lives in +//! the library crate (`breadcrumbs::app`) so it can also be exercised +//! in-process by the integration tests under `tests/`. fn main() { - let cli = Cli::parse(); - let code = match real_main(cli) { - Ok(c) => c, - Err(e) => { - eprintln!("{C_RED}error:{C_RESET} {e}"); - 1 - } - }; - std::process::exit(code); -} - -fn active_profile(cfg: &Config, override_p: &Option) -> String { - if let Some(p) = override_p { - return p.clone(); - } - State::load(&cfg.settings.default_profile).profile -} - -fn real_main(cli: Cli) -> Result { - let cmd = cli.cmd.unwrap_or(Cmd::Status); - - // `cd` and `install-service` don't need a parsed config first. - if let Cmd::Cd { shell } = &cmd { - return cmd_cd(*shell); - } - - let mut cfg = Config::load()?; - - match cmd { - Cmd::Status => cmd_status(&cfg, &cli.profile), - Cmd::Init => { - let p = active_profile(&cfg, &cli.profile); - let outcome = flow::run(&cfg, &p); - print_outcome(&p, &outcome); - Ok(if outcome.ok() { 0 } else { 1 }) - } - Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), - Cmd::Profile { action } => cmd_profile(&cfg, action), - Cmd::Detect { apply } => cmd_detect(&cfg, apply), - Cmd::Add { - ssid, - password, - hidden, - to, - at, - } => cmd_add(&mut cfg, ssid, password, hidden, to, at), - Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), - Cmd::Scan { to } => cmd_scan(&mut cfg, to), - Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), - Cmd::Edit => cmd_edit(), - Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full), - Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), - Cmd::Cd { .. } => unreachable!(), - } -} - -fn print_outcome(profile: &str, o: &flow::Outcome) { - match o { - flow::Outcome::Connected { ssid, note } => { - print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})"); - match note { - Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"), - None => println!(), - } - } - flow::Outcome::TailscaleError { ssid, health } => { - println!( - "{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}", - health.describe(), - ssid.clone().unwrap_or_else(|| "—".into()) - ); - } - flow::Outcome::NoInterface => { - println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue") - } - flow::Outcome::NoNetworks => { - println!("{C_RED}no known networks in range{C_RESET} (profile {profile})") - } - flow::Outcome::UnknownProfile(p) => { - println!("{C_RED}unknown profile{C_RESET}: {p}") - } - } -} - -fn cmd_status(cfg: &Config, override_p: &Option) -> Result { - let p = active_profile(cfg, override_p); - let s = status::gather(cfg, &p); - - let dot = |ok: bool| { - if ok { - format!("{C_GREEN}●{C_RESET}") - } else { - format!("{C_RED}●{C_RESET}") - } - }; - - println!("{C_BOLD}breadcrumbs{C_RESET}"); - println!(" profile {C_BOLD}{p}{C_RESET}"); - println!( - " adapter {}", - s.iface - .clone() - .unwrap_or_else(|| format!("{C_RED}none{C_RESET}")) - ); - println!( - " ssid {}", - s.ssid - .clone() - .unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) - ); - println!( - " ip {}", - s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) - ); - println!( - " internet {} {}", - dot(s.internet), - if s.internet { "ok" } else { "down" } - ); - - match (&s.tailscale, s.tailscale_required) { - (Some(h), req) => { - let ok = h.is_ok(); - println!( - " tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}", - dot(ok || !req), - h.describe(), - s.exit_node, - if req { "" } else { ", optional" } - ); - } - (None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), - } - - let healthy = s.internet - && s.iface.is_some() - && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)); - println!( - " state {}", - if healthy { - format!("{C_GREEN}healthy{C_RESET}") - } else { - format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`") - } - ); - Ok(if healthy { 0 } else { 1 }) -} - -fn cmd_profile(cfg: &Config, action: Option) -> Result { - match action.unwrap_or(ProfileCmd::Get) { - ProfileCmd::Get => { - println!("{}", State::load(&cfg.settings.default_profile).profile); - Ok(0) - } - ProfileCmd::List => { - let cur = State::load(&cfg.settings.default_profile).profile; - for name in cfg.profiles.keys() { - let mark = if *name == cur { "*" } else { " " }; - println!("{mark} {name}"); - } - Ok(0) - } - ProfileCmd::Set { name, no_apply } => { - if !cfg.profiles.contains_key(&name) { - let avail: Vec<&String> = cfg.profiles.keys().collect(); - return Err(format!("unknown profile '{name}'. Available: {avail:?}")); - } - let st = State { - profile: name.clone(), - updated: util::timestamp(), - }; - st.save()?; - notify::log(&format!("profile set -> {name}")); - println!("profile = {C_BOLD}{name}{C_RESET}"); - if no_apply { - return Ok(0); - } - let outcome = flow::run(cfg, &name); - print_outcome(&name, &outcome); - Ok(if outcome.ok() { 0 } else { 1 }) - } - } -} - -fn detect_profile(cfg: &Config) -> Option { - let iface = nm::wifi_interface()?; - nm::radio_on(); - nm::rescan(&iface, &[]); - let visible = nm::visible_ssids(&iface); - - // Profiles are stored in a BTreeMap so iteration order is deterministic - // (alphabetical). The caller can rely on that for tie-breaking. - for (name, profile) in &cfg.profiles { - if profile.detect_ssids.is_empty() { - continue; - } - if profile - .detect_ssids - .iter() - .any(|s| visible.contains(s.as_str())) - { - return Some(name.clone()); - } - } - - // Fall back to the default profile if no markers matched. - Some(cfg.settings.default_profile.clone()) -} - -fn cmd_detect(cfg: &Config, apply: bool) -> Result { - match detect_profile(cfg) { - Some(p) => { - println!("{p}"); - if apply { - State { - profile: p.clone(), - updated: util::timestamp(), - } - .save()?; - let outcome = flow::run(cfg, &p); - print_outcome(&p, &outcome); - return Ok(if outcome.ok() { 0 } else { 1 }); - } - Ok(0) - } - None => Err("could not detect a profile (no Wi-Fi adapter?)".into()), - } -} - -fn prompt_line(msg: &str) -> String { - print!("{msg}"); - let _ = std::io::stdout().flush(); - let mut s = String::new(); - let _ = std::io::stdin().lock().read_line(&mut s); - s.trim_end_matches(['\n', '\r']).to_string() -} - -fn prompt_secret(msg: &str) -> String { - // `util::run` redirects child stdin to /dev/null, so plain `stty -echo` - // would target the wrong fd and silently leave echo ON (leaking the - // password to the screen). `-F /dev/tty` makes stty act on the controlling - // terminal directly. If there is no tty we fall back to visible input. - let had_tty = run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success; - let val = prompt_line(msg); - if had_tty { - let _ = run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2)); - println!(); - } - val -} - -fn cmd_add( - cfg: &mut Config, - ssid: String, - password: Option, - hidden: bool, - to: Option, - at: Option, -) -> Result { - let password = match password { - Some(p) => p, - None => prompt_secret(&format!("Password for '{ssid}': ")), - }; - match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { - Some(n) => { - n.password = password; - n.hidden = hidden || n.hidden; - } - None => cfg.networks.push(NetworkDef { - ssid: ssid.clone(), - password, - hidden, - }), - } - if let Some(prof_name) = to { - let prof = cfg - .profiles - .get_mut(&prof_name) - .ok_or_else(|| format!("unknown profile '{prof_name}'"))?; - prof.networks.retain(|s| s != &ssid); - let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len()); - prof.networks.insert(idx, ssid.clone()); - } - cfg.save()?; - println!("{C_GREEN}saved{C_RESET} {ssid}"); - Ok(0) -} - -fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { - let before = cfg.networks.len(); - cfg.networks.retain(|n| n.ssid != ssid); - for p in cfg.profiles.values_mut() { - p.networks.retain(|s| s != ssid); - if p.bootstrap.as_deref() == Some(ssid) { - p.bootstrap = None; - } - } - cfg.save()?; - let removed = nm::delete_connections_for_ssid(ssid); - println!( - "{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})", - if cfg.networks.len() < before { - "removed" - } else { - "not present" - }, - if removed { "removed" } else { "not present" } - ); - Ok(0) -} - -fn cmd_scan(cfg: &mut Config, to: Option) -> Result { - let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?; - nm::radio_on(); - nm::rescan(&iface, &[]); - let entries = nm::scan_list(&iface); - if entries.is_empty() { - return Err("no networks found".into()); - } - for (i, e) in entries.iter().enumerate() { - println!( - "{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}", - i + 1, - if e.ssid.is_empty() { - "" - } else { - &e.ssid - }, - e.signal, - e.security - ); - } - let sel = prompt_line("Select number: "); - let idx: usize = sel - .parse::() - .ok() - .filter(|n| *n >= 1 && *n <= entries.len()) - .ok_or("invalid selection")?; - let ssid = entries[idx - 1].ssid.clone(); - if ssid.is_empty() { - return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into()); - } - let password = prompt_secret(&format!("Password for '{ssid}': ")); - let def = NetworkDef { - ssid: ssid.clone(), - password: password.clone(), - hidden: false, - }; - if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { - return Err(format!("failed to connect to {ssid}")); - } - match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { - Some(n) => n.password = password, - None => cfg.networks.push(def), - } - if let Some(prof_name) = to { - if let Some(prof) = cfg.profiles.get_mut(&prof_name) { - if !prof.networks.contains(&ssid) { - prof.networks.push(ssid.clone()); - } - } - } - cfg.save()?; - println!("{C_GREEN}connected + saved{C_RESET} {ssid}"); - Ok(0) -} - -fn mask(p: &str) -> String { - if p.len() <= 2 { - "••".into() - } else { - format!("{}{}", &p[..1], "•".repeat(p.len().saturating_sub(1))) - } -} - -fn cmd_list(cfg: &Config, show_pw: bool) -> Result { - println!("{C_BOLD}settings{C_RESET}"); - println!(" dns {}", cfg.settings.dns); - println!(" exit_node {}", cfg.settings.exit_node); - println!(" default {}", cfg.settings.default_profile); - println!(" watch every {}s", cfg.settings.watch_interval); - - println!("\n{C_BOLD}networks{C_RESET}"); - for n in &cfg.networks { - println!( - " {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}", - n.ssid, - if show_pw { - n.password.clone() - } else { - mask(&n.password) - }, - if n.hidden { " (hidden)" } else { "" } - ); - } - - println!("\n{C_BOLD}profiles{C_RESET}"); - let cur = State::load(&cfg.settings.default_profile).profile; - for (name, p) in &cfg.profiles { - let mark = if *name == cur { - format!("{C_GREEN}*{C_RESET}") - } else { - " ".into() - }; - println!("{mark} {C_BOLD}{name}{C_RESET}"); - if let Some(b) = &p.bootstrap { - println!(" bootstrap {b}"); - } - if p.tailscale { - println!( - " tailscale required (exit: {})", - p.exit_node - .clone() - .unwrap_or_else(|| cfg.settings.exit_node.clone()) - ); - } - let mut order: Vec = p.networks.clone(); - if p.include_all_known { - order.push("…all other known networks".into()); - } - println!(" priority {}", order.join(" > ")); - } - Ok(0) -} - -fn cmd_edit() -> Result { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); - let path = config::config_path(); - let status = Command::new(&editor) - .arg(&path) - .status() - .map_err(|e| format!("launching {editor}: {e}"))?; - if !status.success() { - return Err("editor exited with error".into()); - } - match Config::load() { - Ok(_) => { - println!("{C_GREEN}config OK{C_RESET}"); - Ok(0) - } - Err(e) => Err(format!("config is now invalid: {e}")), - } -} - -fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result { - if full { - let script = config::config_dir().join("diag.sh"); - if !script.exists() { - return Err(format!( - "diag.sh not found (expected at {})", - script.display() - )); - } - let st = Command::new("bash") - .arg(&script) - .status() - .map_err(|e| format!("running diag: {e}"))?; - return Ok(st.code().unwrap_or(1)); - } - - let p = active_profile(cfg, override_p); - let s = status::gather(cfg, &p); - println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); - println!( - " nmcli {}", - if command_exists("nmcli") { - "present" - } else { - "MISSING" - } - ); - println!( - " tailscale {}", - if command_exists("tailscale") { - "present" - } else { - "absent" - } - ); - println!( - " adapter {}", - s.iface.clone().unwrap_or_else(|| "none".into()) - ); - println!( - " ssid {}", - s.ssid.clone().unwrap_or_else(|| "—".into()) - ); - println!( - " ip {}", - s.ip.clone().unwrap_or_else(|| "—".into()) - ); - println!(" internet {}", if s.internet { "ok" } else { "DOWN" }); - if let Some(h) = &s.tailscale { - println!(" tailscale {} (exit {})", h.describe(), s.exit_node); - } - - if let Some(iface) = &s.iface { - let visible = nm::visible_ssids(iface); - let known: Vec<&str> = cfg - .networks - .iter() - .filter(|n| visible.contains(&n.ssid)) - .map(|n| n.ssid.as_str()) - .collect(); - println!( - " in range {}", - if known.is_empty() { - "none of your saved networks".into() - } else { - known.join(", ") - } - ); - } - println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}"); - Ok(0) -} - -fn cmd_cd(shell: bool) -> Result { - let dir = config::config_dir(); - if shell { - let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let err = exec_replace(&sh, &["-lc", &format!("cd {:?} && exec {sh}", dir)]); - return Err(err); - } - println!("{}", dir.display()); - Ok(0) -} - -fn exec_replace(prog: &str, args: &[&str]) -> String { - use std::os::unix::process::CommandExt; - let e = Command::new(prog).args(args).exec(); - format!("exec {prog} failed: {e}") -} - -fn cmd_install_service(enable: bool) -> Result { - let unit_dir = home_dir().join(".config/systemd/user"); - std::fs::create_dir_all(&unit_dir) - .map_err(|e| format!("creating {}: {e}", unit_dir.display()))?; - let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?; - // Ordering against graphical-session.target lets the watcher inherit the - // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale - // login browser-open actually work. PATH is pinned because systemd --user - // units do not get the login shell's PATH, and the watcher shells out to - // nmcli/tailscale/sudo/xdg-open by name. - let unit = format!( - "[Unit]\n\ - Description=breadcrumbs Wi-Fi state machine watcher\n\ - After=network.target NetworkManager.service graphical-session.target\n\ - Wants=network.target graphical-session.target\n\n\ - [Service]\n\ - Type=simple\n\ - Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\ - ExecStart={bin} watch\n\ - Restart=always\n\ - RestartSec=5\n\ - Nice=5\n\n\ - [Install]\n\ - WantedBy=default.target\n", - bin = bin.display() - ); - let unit_path = unit_dir.join("breadcrumbs.service"); - std::fs::write(&unit_path, unit) - .map_err(|e| format!("writing {}: {e}", unit_path.display()))?; - println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display()); - - let _ = run( - "systemctl", - &["--user", "daemon-reload"], - Duration::from_secs(10), - ); - if enable { - let o = run( - "systemctl", - &["--user", "enable", "--now", "breadcrumbs.service"], - Duration::from_secs(15), - ); - if o.success { - println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service"); - } else { - println!( - "{C_YELLOW}unit installed{C_RESET}; enable failed: {}", - o.stderr.trim() - ); - return Ok(1); - } - } else { - println!("Run: systemctl --user enable --now breadcrumbs.service"); - } - Ok(0) + std::process::exit(breadcrumbs::app::run()); } diff --git a/src/nm.rs b/src/nm.rs index d51a32b..1364251 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -22,8 +22,11 @@ fn unescape(s: &str) -> String { } /// Split one nmcli `-t` line into fields. Fields are ':'-separated but values -/// escape ':' as '\:' and '\' as '\\'. -fn parse_scan_line(line: &str) -> Vec { +/// escape ':' as '\:' and '\' as '\\' — a plain `splitn(2, ':')` mis-splits +/// any field (device name, connection name, SSID, …) that legitimately +/// contains a colon, so every terse-output parse in this module goes through +/// here rather than splitting on raw bytes. Fields are returned unescaped. +fn split_fields(line: &str) -> Vec { let mut fields: Vec = Vec::new(); let mut cur = String::new(); let mut chars = line.chars().peekable(); @@ -55,9 +58,9 @@ pub fn wifi_interface() -> Option { return None; } for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 && parts[1] == "wifi" { - return Some(unescape(parts[0])); + let fields = split_fields(line); + if fields.len() >= 2 && fields[1] == "wifi" { + return Some(fields[0].clone()); } } None @@ -132,7 +135,7 @@ pub fn scan_list(iface: &str) -> Vec { return out; } for line in o.stdout.lines() { - let fields = parse_scan_line(line); + let fields = split_fields(line); if fields.is_empty() { continue; } @@ -168,9 +171,9 @@ pub fn active_ssid(iface: &str) -> Option { return None; } for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 && parts[0] == "yes" { - let s = unescape(parts[1].trim()); + let fields = split_fields(line); + if fields.len() >= 2 && fields[0] == "yes" { + let s = fields[1].trim().to_string(); if !s.is_empty() { return Some(s); } @@ -189,9 +192,9 @@ pub fn device_connected(iface: &str) -> bool { return false; } for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 && unescape(parts[0]) == iface { - return parts[1].starts_with("connected"); + let fields = split_fields(line); + if fields.len() >= 2 && fields[0] == iface { + return fields[1].starts_with("connected"); } } false @@ -254,11 +257,11 @@ fn first_profile_for_ssid(ssid: &str) -> Option { } let mut fallback: Option = None; for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() < 2 || !parts[1].contains("wireless") { + let fields = split_fields(line); + if fields.len() < 2 || !fields[1].contains("wireless") { continue; } - let name = unescape(parts[0]); + let name = fields[0].clone(); if name == ssid { return Some(name); } @@ -286,12 +289,33 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { /// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to /// `nmcli device wifi connect` — which creates a new profile — only when no /// saved profile is found. +/// +/// `net.password` is only sent when `Some`: on the reuse path, `None` means +/// "leave the saved PSK alone" (either NetworkManager already durably owns +/// it, or the network is open); on the create path it means "no password +/// argument at all", which is also how a genuinely open (no-security) SSID +/// is connected. See the field doc on [`NetworkDef::password`] for how a +/// local secret transitions to `None` after its first successful use. +/// +/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's +/// passed to `nmcli` as a plain command-line argument +/// (`802-11-wireless-security.psk ` on the reuse path, `password ` +/// on the create path). For the lifetime of that `nmcli` child, the secret +/// is readable by other local users via `/proc//cmdline`. +/// `util::run_with_stdin` exists to feed secrets on stdin instead, but +/// wiring it up correctly needs either verified `nmcli --ask` piped-stdin +/// behavior or NetworkManager's D-Bus secret-agent API — neither of which +/// can be validated without a live NetworkManager connection — so this is +/// left as documented tech debt rather than a guess. In practice this +/// exposure window now only exists on a network's *first* connect: once +/// NetworkManager has the credential, breadcrumbs clears its local copy, so +/// there's nothing left to pass on argv for every subsequent connect. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { let wait_s = wait.to_string(); if let Some(profile) = first_profile_for_ssid(&net.ssid) { // Update the saved PSK and, for hidden networks, ensure the flag is set. - if !net.password.is_empty() { + if let Some(pw) = &net.password { let _ = run( "nmcli", &[ @@ -299,7 +323,7 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R "modify", &profile, "802-11-wireless-security.psk", - net.password.as_str(), + pw.as_str(), ], Duration::from_secs(6), ); @@ -338,20 +362,28 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R // No saved profile — create one via device wifi connect. let hidden = if net.hidden { "yes" } else { "no" }; - let args = [ + let mut args: Vec<&str> = vec![ "--wait", &wait_s, "device", "wifi", "connect", net.ssid.as_str(), - "password", - net.password.as_str(), - "hidden", - hidden, - "ifname", - iface, ]; + // Only pass `password` when we actually have one. An empty/missing PSK + // argument makes nmcli treat the network as open (no security), which is + // what we want both for genuinely open SSIDs and for a network whose + // secret NetworkManager should already hold — though the latter case + // only succeeds if a saved profile in fact exists, which is why we only + // reach this branch (no saved profile found) when that assumption held. + if let Some(pw) = &net.password { + args.push("password"); + args.push(pw.as_str()); + } + args.push("hidden"); + args.push(hidden); + args.push("ifname"); + args.push(iface); let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); if !o.success { let detail = o.stderr.trim().to_string(); @@ -380,12 +412,12 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool { } let mut removed = false; for line in list.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() < 2 { + let fields = split_fields(line); + if fields.len() < 2 { continue; } - let name = unescape(parts[0]); - let typ = parts[1]; + let name = fields[0].clone(); + let typ = &fields[1]; if !typ.contains("wireless") { continue; } @@ -421,17 +453,37 @@ mod tests { } #[test] - fn parse_scan_line_splits_and_unescapes() { + fn split_fields_splits_and_unescapes() { // SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID. - let f = parse_scan_line(r"My\:Net:72:WPA2"); + let f = split_fields(r"My\:Net:72:WPA2"); assert_eq!(f, vec!["My:Net", "72", "WPA2"]); // SSID with a space (common in real network names) - let f = parse_scan_line("My Network:88:WPA2"); + let f = split_fields("My Network:88:WPA2"); assert_eq!(f, vec!["My Network", "88", "WPA2"]); // Empty SSID (hidden) keeps the empty leading field. - let f = parse_scan_line(":40:WPA3"); + let f = split_fields(":40:WPA3"); assert_eq!(f, vec!["", "40", "WPA3"]); } + + #[test] + fn split_fields_two_column_with_colon_in_first_field() { + // A connection NAME or SSID containing a literal ':' must not be + // mis-split into TYPE — this is what a plain `splitn(2, ':')` gets + // wrong (e.g. `wifi_interface`/`first_profile_for_ssid` parsing). + let f = split_fields(r"Office\:5G:802-11-wireless"); + assert_eq!(f, vec!["Office:5G", "802-11-wireless"]); + } + + #[test] + fn split_fields_empty_line() { + assert_eq!(split_fields(""), vec![""]); + } + + #[test] + fn split_fields_trailing_backslash_in_field() { + let f = split_fields(r"trail\\:wifi"); + assert_eq!(f, vec![r"trail\", "wifi"]); + } } diff --git a/src/tailscale.rs b/src/tailscale.rs index a5aa49a..50b7f11 100644 --- a/src/tailscale.rs +++ b/src/tailscale.rs @@ -381,4 +381,77 @@ mod tests { }); assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false)); } + + #[test] + fn exit_node_lookup_is_case_insensitive() { + let v = json!({ + "Peer": { + "k1": { "HostName": "ExitNode", "DNSName": "ExitNode.ts.net.", + "Online": true, "ExitNode": true, "ExitNodeOption": true } + } + }); + assert_eq!(exit_node_state(&v, "EXITNODE"), (true, true, true)); + } + + #[test] + fn exit_node_state_with_no_peers_is_all_false() { + let v = json!({ "BackendState": "Running" }); + assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false)); + } + + #[test] + fn exit_node_state_empty_node_name_matches_nothing() { + let v = json!({ + "Peer": { + "k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.", + "Online": true, "ExitNode": true, "ExitNodeOption": true } + } + }); + assert_eq!(exit_node_state(&v, ""), (false, false, false)); + } + + #[test] + fn exit_node_status_online_only_applies_when_selected() { + // ExitNodeStatus.Online reflects the currently *active* exit node — + // it must not leak into the reported state of a peer that merely + // matches by name but isn't the one actually selected. + let v = json!({ + "ExitNodeStatus": { "Online": true }, + "Peer": { + "k1": { "HostName": "other", "DNSName": "other.ts.net.", + "Online": false, "ExitNode": false, "ExitNodeOption": true } + } + }); + assert_eq!(exit_node_state(&v, "other"), (true, false, false)); + } + + #[test] + fn ts_health_is_ok_only_for_ok_variant() { + assert!(TsHealth::Ok.is_ok()); + assert!(!TsHealth::NotInstalled.is_ok()); + assert!(!TsHealth::NeedsLogin.is_ok()); + assert!(!TsHealth::Stopped.is_ok()); + assert!(!TsHealth::ExitNodeMissing.is_ok()); + assert!(!TsHealth::ExitNodeOffline.is_ok()); + assert!(!TsHealth::Error("x".into()).is_ok()); + } + + #[test] + fn ts_health_describe_is_human_readable() { + assert_eq!(TsHealth::Ok.describe(), "ok"); + assert_eq!( + TsHealth::NeedsLogin.describe(), + "not logged in (run: tailscale up)" + ); + assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom"); + } + + #[test] + fn extract_url_finds_https_token_among_others() { + assert_eq!( + extract_url("To authenticate, visit: https://login.tailscale.com/abc"), + Some("https://login.tailscale.com/abc".to_string()) + ); + assert_eq!(extract_url("no url on this line"), None); + } } diff --git a/src/util.rs b/src/util.rs index 04f3740..f697f8f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::io::{Read, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -10,17 +11,6 @@ pub fn home_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from("/root")) } -pub fn command_exists(name: &str) -> bool { - if let Some(paths) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&paths) { - if dir.join(name).is_file() { - return true; - } - } - } - false -} - #[derive(Debug, Clone)] pub struct Output { pub success: bool, @@ -38,6 +28,65 @@ impl Output { } } +/// Everything breadcrumbs does to touch the outside world *other than* its +/// own file I/O and env var reads: spawning an external program, and +/// checking whether one is available at all. Every call site in this crate +/// (`nm.rs`, `tailscale.rs`, `status.rs`, `notify.rs`, `app.rs`) goes through +/// the free functions below (`run`/`run_with_stdin`/`run_ok`/ +/// `command_exists`), which are thin wrappers dispatching to whatever +/// `Runner` is currently installed in the thread-local slot — `RealRunner` by +/// default. +/// +/// Tests swap in a fake implementation via [`with_runner`] so real call +/// chains (`flow::run`, `watch::classify`, …) can be driven in-process +/// against canned output, with no subprocess ever spawned and full +/// visibility into exactly what *would* have been executed — the natural +/// mechanism for asserting things like "no password ever reaches nmcli's +/// argv on a repeat connect" (see the credential tests under `tests/`). +/// +/// A thread-local (rather than an explicit parameter threaded through every +/// function) was chosen so the large existing call surface in `nm.rs` et al. +/// didn't need every signature rewritten to carry a `&dyn Runner` — call +/// sites are unchanged, only `util`'s internals dispatch differently. It's +/// safe across `cargo test`'s parallel test threads because each thread gets +/// its own independent slot, defaulting to `RealRunner`, so tests that don't +/// install a fake are unaffected by ones that do. +pub trait Runner { + fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output; + fn command_exists(&self, name: &str) -> bool; +} + +struct RealRunner; + +impl Runner for RealRunner { + fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { + spawn_run(prog, args, stdin, timeout) + } + + fn command_exists(&self, name: &str) -> bool { + path_lookup_exists(name) + } +} + +fn path_lookup_exists(name: &str) -> bool { + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + if dir.join(name).is_file() { + return true; + } + } + } + false +} + +thread_local! { + static RUNNER: RefCell> = RefCell::new(Box::new(RealRunner)); +} + +pub fn command_exists(name: &str) -> bool { + RUNNER.with(|r| r.borrow().command_exists(name)) +} + /// Run a command with a hard timeout. The child is killed if it overruns so a /// hung nmcli/tailscale can never wedge the daemon. pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { @@ -48,6 +97,32 @@ pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { /// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv, /// where any local user could read them via `ps`. pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { + RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout)) +} + +pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { + run(prog, args, timeout).success +} + +/// Swap the thread-local [`Runner`] for `runner` for the duration of `f`, +/// restoring whatever was previously installed afterward — even if `f` +/// panics, so a failing assertion inside a test can't leak a fake runner +/// into whatever test happens to run next on this thread. This is the seam +/// integration tests use to drive real logic without spawning subprocesses. +pub fn with_runner(runner: R, f: impl FnOnce() -> T) -> T +where + R: Runner + 'static, +{ + let prev = RUNNER.with(|r| std::mem::replace(&mut *r.borrow_mut(), Box::new(runner))); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + RUNNER.with(|r| *r.borrow_mut() = prev); + match result { + Ok(v) => v, + Err(payload) => std::panic::resume_unwind(payload), + } +} + +fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { let stdin_cfg = if stdin.is_some() { Stdio::piped() } else { @@ -115,10 +190,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D } } -pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { - run(prog, args, timeout).success -} - /// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling /// back to a dependency-free UTC computation if it is unavailable. pub fn timestamp() -> String { @@ -176,4 +247,41 @@ mod tests { // Leap day 2024-02-29 12:00:00 UTC assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00"); } + + #[test] + fn fmt_epoch_pre_1970_is_handled() { + // The div_euclid/rem_euclid split must stay correct for negative + // epoch seconds (dates before 1970), not just the common positive case. + assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00"); + } + + #[test] + fn fmt_epoch_year_and_month_boundaries() { + assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59"); + assert_eq!(fmt_epoch(1_735_689_600), "2025-01-01 00:00:00"); + // Last second of October (non-leap-day month boundary). + assert_eq!(fmt_epoch(1_730_419_199), "2024-10-31 23:59:59"); + } + + #[test] + fn command_exists_false_for_bogus_binary() { + assert!(!command_exists("definitely-not-a-real-binary-xyz123")); + } + + #[test] + fn command_exists_true_for_a_real_binary() { + // `sh` is guaranteed present on any POSIX system this runs on. + assert!(command_exists("sh")); + } + + #[test] + fn run_on_missing_binary_fails_cleanly_instead_of_panicking() { + let o = run( + "definitely-not-a-real-binary-xyz123", + &[], + Duration::from_secs(1), + ); + assert!(!o.success); + assert_eq!(o.stdout, ""); + } } diff --git a/src/watch.rs b/src/watch.rs index b2d40f1..aacfe7b 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -11,16 +11,30 @@ use crate::state::State; use crate::status::{self}; use crate::tailscale::TsHealth; +/// Coarse health classification the watch loop reacts to each tick. `pub` +/// (and so is [`classify`]) purely so integration tests can drive the real +/// classification logic in-process against a faked [`crate::util::Runner`], +/// instead of only being able to observe it indirectly through the watch +/// loop's side effects. #[derive(PartialEq, Eq, Clone, Debug)] -enum Health { +pub enum Health { Up, DownNoNet, DownTailscaleManual, DownTailscaleOther, NoAdapter, + /// `profile` isn't defined in the config (e.g. state still points at a + /// custom profile the user deleted from breadcrumbs.toml). + UnknownProfile, } -fn classify(cfg: &Config, profile: &str) -> (Health, Option) { +pub fn classify(cfg: &Config, profile: &str) -> (Health, Option) { + // Checked before gather(): a profile missing from config would otherwise + // silently fall back to "tailscale not required" and read as healthy off + // of nothing but a bare internet check, never surfacing the misconfig. + if cfg.profile(profile).is_none() { + return (Health::UnknownProfile, None); + } let s = status::gather(cfg, profile); if s.iface.is_none() { return (Health::NoAdapter, None); @@ -43,6 +57,15 @@ fn classify(cfg: &Config, profile: &str) -> (Health, Option) { } } +/// Whether a debounced signal is allowed to fire. `None` (never fired) always +/// fires; otherwise it fires only once more than `gap` has elapsed since the +/// last fire. Pulled out as a pure helper so the debounce logic is testable and +/// so the "first event fires immediately" case is expressed without the +/// panic-prone `Instant::now() - gap` seed. +fn debounce_ready(last: Option, gap: Duration) -> bool { + last.map(|t| t.elapsed() > gap).unwrap_or(true) +} + /// Tail `nmcli monitor` and ping the channel on link-state churn so we react /// to drops within a second instead of waiting out the poll interval. fn spawn_nm_monitor(tx: mpsc::Sender<()>) { @@ -62,14 +85,21 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) { }; if let Some(out) = child.stdout.take() { let reader = BufReader::new(out); - let mut last = Instant::now() - Duration::from_secs(10); + // `None` means "haven't fired yet, so fire on the first interesting + // line". Storing an `Option` instead of seeding with + // `Instant::now() - 10s` avoids a panic: `Instant - Duration` + // underflows (and panics) when the monotonic clock is younger than + // the offset, which happens if `watch` starts within ~10s of boot — + // exactly when the systemd unit (ordered after graphical-session) + // tends to launch. + let mut last: Option = None; for line in reader.lines().map_while(Result::ok) { let l = line.to_lowercase(); let interesting = l.contains("disconnect") || l.contains("unavailable") || l.contains("failed"); - if interesting && last.elapsed() > Duration::from_millis(1500) { - last = Instant::now(); + if interesting && debounce_ready(last, Duration::from_millis(1500)) { + last = Some(Instant::now()); let _ = tx.send(()); } } @@ -116,7 +146,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { )); } else { log(&format!("watch: initial flow for profile={profile}")); - let _ = flow::run(&cfg, &profile); + let _ = flow::run(&mut cfg, &profile); } } @@ -128,6 +158,12 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { loop { // Reload config + state so edits and `profile set` take effect live. + // This always runs *before* `flow::run` below (never after, within + // the same tick), so a password `flow::run` clears-and-saves this + // iteration is durably on disk by the time the *next* iteration's + // reload runs — there's no window where a stale (still-has-password) + // reload could clobber the save, since the two never race on + // different threads: everything here is sequential on this loop. if let Ok(fresh) = Config::load() { cfg = fresh; } @@ -175,6 +211,15 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } fail_streak = fail_streak.saturating_add(1); } + Health::UnknownProfile => { + // flow::run() already notifies + logs the "unknown profile" + // critical error; re-running it here would just spam that on + // every tick, so only surface it once per transition. + if transition || profile_changed { + let _ = flow::run(&mut cfg, &profile); + } + fail_streak = fail_streak.saturating_add(1); + } Health::DownTailscaleManual => { // Can't be auto-fixed (login / not installed). Notify once. if transition { @@ -187,7 +232,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } // Re-run flow only on transition so we land on the bootstrap net. if transition || profile_changed { - let _ = flow::run(&cfg, &profile); + let _ = flow::run(&mut cfg, &profile); } fail_streak = fail_streak.saturating_add(1); } @@ -205,7 +250,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { "watch: down ({:?}) profile={profile} ssid={:?} — running flow", health, ssid )); - let outcome = flow::run(&cfg, &profile); + let outcome = flow::run(&mut cfg, &profile); log(&format!("watch: recovery outcome = {:?}", outcome)); last_flow_at = Some(Instant::now()); fail_streak = if outcome.ok() { @@ -230,3 +275,29 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { wait_for_tick(&rx, dur); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debounce_fires_immediately_when_never_fired() { + // Regression guard for the old `Instant::now() - Duration::from_secs(10)` + // seed, which panicked near boot. `None` must fire without any + // subtraction on the clock. + assert!(debounce_ready(None, Duration::from_millis(1500))); + } + + #[test] + fn debounce_suppresses_immediately_after_firing() { + let just_now = Instant::now(); + assert!(!debounce_ready(Some(just_now), Duration::from_secs(3600))); + } + + #[test] + fn debounce_fires_again_after_gap_elapses() { + // A zero gap is always already-elapsed, so a prior fire doesn't block. + let earlier = Instant::now(); + assert!(debounce_ready(Some(earlier), Duration::from_millis(0))); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 297173a..ac0ae0b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -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"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..dca7888 --- /dev/null +++ b/tests/common/mod.rs @@ -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, + pub stdin: Option, +} + +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 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, + calls: Rc>>, +} + +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>> { + 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> = 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)>, +} + +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)> = 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); + } +} diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs new file mode 100644 index 0000000..110d950 --- /dev/null +++ b/tests/flow_watch.rs @@ -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 ...` 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); +}