Harden breadcrumbs: fix real bugs, restructure as lib, stop storing PSKs twice

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

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

Test count: 20 -> 89 (52 unit, 24 CLI integration, 13 in-process
state-machine tests). Full clean run: cargo build/build --release/
test/clippy --all-targets, verified from a `cargo clean` rebuild.
This commit is contained in:
Breadway 2026-07-22 06:58:47 +08:00
parent d177cc8d82
commit 037c6e54c9
16 changed files with 2688 additions and 834 deletions

796
src/app.rs Normal file
View file

@ -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<String>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[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<ProfileCmd>,
},
/// 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<String>,
/// 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<String>,
/// Position in the profile list (0 = highest priority)
#[arg(long)]
at: Option<usize>,
},
/// 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<String>,
},
/// 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>) -> String {
if let Some(p) = override_p {
return p.clone();
}
State::load(&cfg.settings.default_profile).profile
}
fn real_main(cli: Cli) -> Result<i32, String> {
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<String>) -> Result<i32, String> {
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<ProfileCmd>) -> Result<i32, String> {
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<String> {
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<i32, String> {
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<String> {
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<String>,
hidden: bool,
to: Option<String>,
at: Option<usize>,
) -> Result<i32, String> {
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<i32, String> {
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<String>) -> Result<i32, String> {
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() {
"<hidden>"
} else {
&e.ssid
},
e.signal,
e.security
);
}
let sel = prompt_line("Select number: ");
let idx: usize = sel
.parse::<usize>()
.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<i32, String> {
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<String> = 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<i32, String> {
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<String>, full: bool) -> Result<i32, String> {
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<i32, String> {
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<i32, String> {
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());
}
}

View file

@ -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<String>,
#[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<NetworkDef>,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
/// 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<NetworkDef>,
}
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::<Config>(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());
}
}

View file

@ -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<NetworkDef> {
let mut out: Vec<NetworkDef> = 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());
}
}

19
src/lib.rs Normal file
View file

@ -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;

View file

@ -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<String>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[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<ProfileCmd>,
},
/// 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<String>,
/// 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<String>,
/// Position in the profile list (0 = highest priority)
#[arg(long)]
at: Option<usize>,
},
/// 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<String>,
},
/// 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>) -> String {
if let Some(p) = override_p {
return p.clone();
}
State::load(&cfg.settings.default_profile).profile
}
fn real_main(cli: Cli) -> Result<i32, String> {
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<String>) -> Result<i32, String> {
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<ProfileCmd>) -> Result<i32, String> {
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<String> {
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<i32, String> {
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<String>,
hidden: bool,
to: Option<String>,
at: Option<usize>,
) -> Result<i32, String> {
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<i32, String> {
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<String>) -> Result<i32, String> {
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() {
"<hidden>"
} else {
&e.ssid
},
e.signal,
e.security
);
}
let sel = prompt_line("Select number: ");
let idx: usize = sel
.parse::<usize>()
.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<i32, String> {
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<String> = 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<i32, String> {
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<String>, full: bool) -> Result<i32, String> {
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<i32, String> {
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<i32, String> {
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());
}

116
src/nm.rs
View file

@ -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<String> {
/// 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<String> {
let mut fields: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
@ -55,9 +58,9 @@ pub fn wifi_interface() -> Option<String> {
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<ScanEntry> {
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<String> {
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<String> {
}
let mut fallback: Option<String> = 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 <pw>` on the reuse path, `password <pw>`
/// on the create path). For the lifetime of that `nmcli` child, the secret
/// is readable by other local users via `/proc/<pid>/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"]);
}
}

View file

@ -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);
}
}

View file

@ -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<Box<dyn Runner>> = 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<R, T>(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, "");
}
}

View file

@ -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<String>) {
pub fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
// 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<String>) {
}
}
/// 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<Instant>, 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<Instant> = 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)));
}
}