Compare commits

...

1 commit

Author SHA1 Message Date
Breadway
9b38504240 Release v2.1.0: backend test seam, captive-portal detection, JSON status, robustness
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
Build and publish package / package (push) Successful in 1m31s
Features:
- Introduce a Backend trait + System impl so flow/status/watch can be unit
  tested against a fake; add 11 connect-state-machine tests.
- Captive-portal detection: status::connectivity returns Online/Portal/Offline;
  surfaced in status, JSON, connect notes, and a dedicated watch state.
- `status --json` for bars/scripts; `profile add`/`profile remove`; detect now
  scores by number of in-range markers.

Robustness:
- Pin LC_ALL=C/LANG=C on child processes for locale-independent parsing.
- Atomic config/state writes (temp + rename); 0600 config never world-readable.
- Transient PSK file written to $XDG_RUNTIME_DIR when available.

Fixes (from prior audit):
- Feed Wi-Fi PSK to nmcli via stdin/passwd-file, never argv.
- mask() no longer panics on multi-byte passwords.
- Connectivity check requires HTTP 204 (no captive-portal false positives).
- nmcli NAME,TYPE parsing handles escaped colons.
- Strip CIDR suffix from displayed IP; PKGBUILD/Cargo version aligned (2.1.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iGKg2EEqRuw6HyWd4tnmL
2026-06-23 12:13:34 +08:00
17 changed files with 1662 additions and 217 deletions

View file

@ -17,9 +17,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: install build deps
run: sudo apt-get install -y libnm-dev libdbus-1-dev pkg-config 2>/dev/null || true
- name: build - name: build
run: cargo build --release --locked run: cargo build --release --locked

2
Cargo.lock generated
View file

@ -54,7 +54,7 @@ dependencies = [
[[package]] [[package]]
name = "breadcrumbs" name = "breadcrumbs"
version = "2.0.1" version = "2.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"serde", "serde",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadcrumbs" name = "breadcrumbs"
version = "2.0.1" version = "2.1.0"
edition = "2021" edition = "2021"
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon" description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
license = "MIT" license = "MIT"

View file

@ -9,8 +9,10 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
- **Profile-based connection management** — define ordered network priority lists per location - **Profile-based connection management** — define ordered network priority lists per location
- **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network - **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` - **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 - **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers (picks the profile with the most markers in range)
- **Secure credential handling** — passwords fed to `nmcli` via stdin (never in argv/`ps`), config stored at 0600 - **Captive-portal detection** — distinguishes a real connection from a sign-in page and surfaces the portal URL instead of falsely reporting "online"
- **Secure credential handling** — passwords fed to `nmcli` out-of-band (via stdin with `--ask`, or a 0600 `passwd-file`), never in argv/`ps`; config stored at 0600
- **Machine-readable status**`breadcrumbs status --json` for bars/scripts
- **Desktop notifications** via `notify-send` (optional) - **Desktop notifications** via `notify-send` (optional)
- **systemd user service** generation via `breadcrumbs install-service` - **systemd user service** generation via `breadcrumbs install-service`
@ -25,7 +27,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
## Installation ## Installation
```bash ```bash
git clone https://github.com/breadway/breadcrumbs git clone https://github.com/Breadway/breadcrumbs
cd breadcrumbs cd breadcrumbs
cargo build --release cargo build --release
# Copy to somewhere on your PATH: # Copy to somewhere on your PATH:
@ -95,12 +97,14 @@ breadcrumbs [--profile <name>] <command>
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `status` | Show current Wi-Fi / Tailscale health (default) | | `status [--json]` | Show current Wi-Fi / Tailscale health (default); `--json` for scripts |
| `init` | Run the full connect sequence for the active profile | | `init` | Run the full connect sequence for the active profile |
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops | | `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
| `profile get` | Print the active profile | | `profile get` | Print the active profile |
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) | | `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
| `profile list` | List all profiles | | `profile list` | List all profiles |
| `profile add <name> [--detect <ssid>]…` | Create a new (empty) profile, optionally with detection markers |
| `profile remove <name>` | Delete a profile (core `home`/`work`/`away` are protected) |
| `detect [--apply]` | Guess profile from visible networks; optionally apply it | | `detect [--apply]` | Guess profile from visible networks; optionally apply it |
| `add <ssid> [password]` | Add or update a saved network | | `add <ssid> [password]` | Add or update a saved network |
| `forget <ssid>` | Remove a network from config and NetworkManager | | `forget <ssid>` | Remove a network from config and NetworkManager |

View file

@ -11,6 +11,8 @@ nmcli_wait = 8
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
default_profile = "away" default_profile = "away"
watch_interval = 12 watch_interval = 12
# Must be a "generate_204"-style endpoint: only an empty HTTP 204 counts as
# online, so a captive portal (200 login page / 30x redirect) is detected.
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
ping_host = "1.1.1.1" ping_host = "1.1.1.1"

View file

@ -1,7 +1,7 @@
# Maintainer: Breadway <rileyhorsham@gmail.com> # Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=breadcrumbs pkgname=breadcrumbs
pkgver=0.1.0 pkgver=2.1.0
pkgrel=1 pkgrel=1
pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration" pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration"
arch=('x86_64') arch=('x86_64')

80
src/backend.rs Normal file
View file

@ -0,0 +1,80 @@
//! The seam between breadcrumbs' decision logic and the outside world.
//!
//! Every interaction with NetworkManager, Tailscale, connectivity probes,
//! notifications and logging goes through the [`Backend`] trait. Production code
//! uses [`System`], which delegates to the `nm`/`tailscale`/`status`/`notify`
//! modules that shell out. Tests inject a fake so the connect state machine
//! (`flow`) and watch classifier can be exercised without touching the host.
use std::collections::HashSet;
use crate::config::{Config, NetworkDef};
use crate::nm;
use crate::notify::{self, Urgency};
use crate::status::{self, Connectivity};
use crate::tailscale::{self, TsHealth};
pub trait Backend {
fn wifi_interface(&self) -> Option<String>;
fn radio_on(&self);
fn rescan(&self, iface: &str, ssids: &[String]);
fn visible_ssids(&self, iface: &str) -> HashSet<String>;
fn active_ssid(&self, iface: &str) -> Option<String>;
fn ipv4(&self, iface: &str) -> Option<String>;
fn device_connected(&self, iface: &str) -> bool;
fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String>;
fn tailscale_installed(&self) -> bool;
fn ensure_exit_node(&self, node: &str) -> TsHealth;
fn tailscale_check(&self, node: &str) -> TsHealth;
fn connectivity(&self, cfg: &Config) -> Connectivity;
fn notify(&self, summary: &str, body: &str, urgency: Urgency);
fn log(&self, line: &str);
}
/// The real backend: every method delegates to the system-facing modules.
pub struct System;
impl Backend for System {
fn wifi_interface(&self) -> Option<String> {
nm::wifi_interface()
}
fn radio_on(&self) {
nm::radio_on()
}
fn rescan(&self, iface: &str, ssids: &[String]) {
nm::rescan(iface, ssids)
}
fn visible_ssids(&self, iface: &str) -> HashSet<String> {
nm::visible_ssids(iface)
}
fn active_ssid(&self, iface: &str) -> Option<String> {
nm::active_ssid(iface)
}
fn ipv4(&self, iface: &str) -> Option<String> {
status::ipv4(iface)
}
fn device_connected(&self, iface: &str) -> bool {
nm::device_connected(iface)
}
fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
nm::connect_verbose(iface, net, wait, dns)
}
fn tailscale_installed(&self) -> bool {
tailscale::installed()
}
fn ensure_exit_node(&self, node: &str) -> TsHealth {
tailscale::ensure_exit_node(node)
}
fn tailscale_check(&self, node: &str) -> TsHealth {
tailscale::check(node)
}
fn connectivity(&self, cfg: &Config) -> Connectivity {
status::connectivity(cfg)
}
fn notify(&self, summary: &str, body: &str, urgency: Urgency) {
notify::notify(summary, body, urgency)
}
fn log(&self, line: &str) {
notify::log(line)
}
}

View file

@ -158,13 +158,10 @@ impl Config {
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; 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 text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
let path = config_path(); let path = config_path();
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?; // Plaintext Wi-Fi passwords live here: write atomically and owner-only,
// Plaintext Wi-Fi passwords live here — keep it owner-only. // so there's no torn read and no world-readable window.
#[cfg(unix)] crate::util::write_atomic(&path, &text, 0o600)
{ .map_err(|e| format!("writing {}: {e}", path.display()))?;
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(()) Ok(())
} }
} }
@ -274,4 +271,124 @@ mod tests {
assert!(cfg.profile("work").is_some()); assert!(cfg.profile("work").is_some());
assert!(cfg.profile("away").is_some()); assert!(cfg.profile("away").is_some());
} }
#[test]
fn ensure_core_profiles_does_not_overwrite_existing() {
let mut cfg = Config {
settings: Settings::default(),
networks: vec![],
profiles: BTreeMap::new(),
};
cfg.profiles.insert(
"home".to_string(),
Profile {
tailscale: true,
exit_node: Some("mynode".into()),
..Default::default()
},
);
ensure_core_profiles(&mut cfg);
let home = cfg.profile("home").unwrap();
assert!(home.tailscale, "existing field should be preserved");
assert_eq!(home.exit_node.as_deref(), Some("mynode"));
}
#[test]
fn network_lookup_found_and_not_found() {
let mut cfg = build_initial_config();
cfg.networks.push(NetworkDef {
ssid: "TestNet".into(),
password: "secret".into(),
hidden: false,
});
let found = cfg.network("TestNet");
assert!(found.is_some());
assert_eq!(found.unwrap().password, "secret");
assert!(cfg.network("NoSuchSSID").is_none());
}
#[test]
fn profile_lookup_found_and_not_found() {
let cfg = build_initial_config();
assert!(cfg.profile("home").is_some());
assert!(cfg.profile("nonexistent").is_none());
}
#[test]
fn settings_default_values() {
let s = Settings::default();
assert_eq!(s.dns, "1.1.1.1");
assert_eq!(s.nmcli_wait, 8);
assert!(s.exit_node.is_empty());
assert_eq!(s.default_profile, "away");
assert_eq!(s.watch_interval, 12);
assert!(!s.connectivity_url.is_empty());
assert!(!s.ping_host.is_empty());
}
#[test]
fn config_toml_roundtrip_with_hidden_network() {
let mut cfg = build_initial_config();
cfg.networks.push(NetworkDef {
ssid: "HiddenNet".into(),
password: "pw".into(),
hidden: true,
});
cfg.networks.push(NetworkDef {
ssid: "VisibleNet".into(),
password: "pw2".into(),
hidden: false,
});
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(back.networks.len(), 2);
let hidden = back.network("HiddenNet").unwrap();
assert!(hidden.hidden);
let visible = back.network("VisibleNet").unwrap();
assert!(!visible.hidden);
}
#[test]
fn config_toml_roundtrip_with_full_profile_fields() {
let mut cfg = build_initial_config();
let work = cfg.profiles.get_mut("work").unwrap();
work.tailscale = true;
work.exit_node = Some("myexit".into());
work.bootstrap = Some("BootstrapSSID".into());
work.detect_ssids = vec!["WorkWifi".into(), "CorpGuest".into()];
work.networks = vec!["WorkWifi".into()];
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
let w = back.profile("work").unwrap();
assert!(w.tailscale);
assert_eq!(w.exit_node.as_deref(), Some("myexit"));
assert_eq!(w.bootstrap.as_deref(), Some("BootstrapSSID"));
assert_eq!(w.detect_ssids, vec!["WorkWifi", "CorpGuest"]);
assert_eq!(w.networks, vec!["WorkWifi"]);
}
#[test]
fn config_deserialization_applies_settings_defaults_for_missing_fields() {
let toml_str = r#"
[settings]
dns = "8.8.8.8"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.settings.dns, "8.8.8.8");
// Fields not specified should get their defaults.
assert_eq!(cfg.settings.nmcli_wait, 8);
assert_eq!(cfg.settings.default_profile, "away");
assert_eq!(cfg.settings.watch_interval, 12);
}
#[test]
fn network_def_hidden_defaults_to_false() {
let toml_str = r#"
[[networks]]
ssid = "MyNet"
password = "pass"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert!(!cfg.networks[0].hidden);
}
} }

View file

@ -1,8 +1,8 @@
use crate::backend::Backend;
use crate::config::{Config, NetworkDef}; use crate::config::{Config, NetworkDef};
use crate::nm; use crate::notify::Urgency;
use crate::notify::{log, notify, Urgency}; use crate::status::Connectivity;
use crate::status::internet_ok; use crate::tailscale::TsHealth;
use crate::tailscale::{self, TsHealth};
#[derive(Debug)] #[derive(Debug)]
pub enum Outcome { pub enum Outcome {
@ -48,20 +48,35 @@ fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'
/// Try to connect + confirm it actually carries traffic. /// Try to connect + confirm it actually carries traffic.
/// Returns Ok(()) on success, Err(reason) on failure. /// Returns Ok(()) on success, Err(reason) on failure.
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { fn connect_and_verify(
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?; be: &dyn Backend,
if !nm::device_connected(iface) { iface: &str,
def: &NetworkDef,
cfg: &Config,
) -> Result<(), String> {
be.connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
if !be.device_connected(iface) {
return Err("device not connected after nmcli success".into()); return Err("device not connected after nmcli success".into());
} }
Ok(()) Ok(())
} }
/// Describe post-association connectivity as an optional caveat note.
fn connectivity_note(be: &dyn Backend, cfg: &Config) -> Option<String> {
match be.connectivity(cfg) {
Connectivity::Online => None,
Connectivity::Portal(Some(url)) => Some(format!("captive portal — sign in at {url}")),
Connectivity::Portal(None) => Some("captive portal — sign in required".into()),
Connectivity::Offline => Some("associated but no internet yet".into()),
}
}
/// Run the connection state machine for `profile_name`. /// Run the connection state machine for `profile_name`.
pub fn run(cfg: &Config, profile_name: &str) -> Outcome { pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
let profile = match cfg.profile(profile_name) { let profile = match cfg.profile(profile_name) {
Some(p) => p.clone(), Some(p) => p.clone(),
None => { None => {
notify( be.notify(
"breadcrumbs: unknown profile", "breadcrumbs: unknown profile",
&format!("'{profile_name}' is not defined in breadcrumbs.toml"), &format!("'{profile_name}' is not defined in breadcrumbs.toml"),
Urgency::Critical, Urgency::Critical,
@ -70,10 +85,10 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
} }
}; };
let iface = match nm::wifi_interface() { let iface = match be.wifi_interface() {
Some(i) => i, Some(i) => i,
None => { None => {
notify( be.notify(
"breadcrumbs: no Wi-Fi adapter", "breadcrumbs: no Wi-Fi adapter",
"Hardware issue — Wi-Fi device not found. Manual check needed.", "Hardware issue — Wi-Fi device not found. Manual check needed.",
Urgency::Critical, Urgency::Critical,
@ -81,7 +96,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
return Outcome::NoInterface; return Outcome::NoInterface;
} }
}; };
nm::radio_on(); be.radio_on();
let exit_node = profile let exit_node = profile
.exit_node .exit_node
@ -89,7 +104,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
.unwrap_or_else(|| cfg.settings.exit_node.clone()); .unwrap_or_else(|| cfg.settings.exit_node.clone());
let candidates = resolve_candidates(cfg, &profile); let candidates = resolve_candidates(cfg, &profile);
log(&format!( be.log(&format!(
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]", "flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
profile.tailscale, profile.tailscale,
candidates candidates
@ -104,8 +119,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
if let Some(bs) = &profile.bootstrap { if let Some(bs) = &profile.bootstrap {
scan_targets.push(bs.clone()); scan_targets.push(bs.clone());
} }
nm::rescan(&iface, &scan_targets); be.rescan(&iface, &scan_targets);
let visible = nm::visible_ssids(&iface); let visible = be.visible_ssids(&iface);
// ---- Tailscale-gated profiles (e.g. school) ------------------------- // ---- Tailscale-gated profiles (e.g. school) -------------------------
let mut on_bootstrap = false; let mut on_bootstrap = false;
@ -114,29 +129,29 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
match cfg.network(&bs_ssid) { match cfg.network(&bs_ssid) {
Some(bdef) => { Some(bdef) => {
if visible.contains(&bdef.ssid) || bdef.hidden { if visible.contains(&bdef.ssid) || bdef.hidden {
match connect_and_verify(&iface, bdef, cfg) { match connect_and_verify(be, &iface, bdef, cfg) {
Ok(()) => { Ok(()) => {
on_bootstrap = true; on_bootstrap = true;
log(&format!("bootstrap connected: {}", bdef.ssid)); be.log(&format!("bootstrap connected: {}", bdef.ssid));
} }
Err(e) => { Err(e) => {
log(&format!("bootstrap connect failed: {}{e}", bdef.ssid)) be.log(&format!("bootstrap connect failed: {}{e}", bdef.ssid))
} }
} }
} else { } else {
log(&format!("bootstrap not in range: {}", bdef.ssid)); be.log(&format!("bootstrap not in range: {}", bdef.ssid));
} }
} }
None => log(&format!( None => be.log(&format!(
"bootstrap SSID '{bs_ssid}' has no credentials in config" "bootstrap SSID '{bs_ssid}' has no credentials in config"
)), )),
} }
} }
let ts = tailscale::ensure_exit_node(&exit_node); let ts = be.ensure_exit_node(&exit_node);
if !ts.is_ok() { if !ts.is_ok() {
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone());
notify( be.notify(
"Tailscale Error", "Tailscale Error",
&format!( &format!(
"{} — staying on {}", "{} — staying on {}",
@ -147,12 +162,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
); );
return Outcome::TailscaleError { ssid, health: ts }; return Outcome::TailscaleError { ssid, health: ts };
} }
log(&format!("tailscale healthy via exit node {exit_node}")); be.log(&format!("tailscale healthy via exit node {exit_node}"));
// Refresh visibility before moving to the target network. // Refresh visibility before moving to the target network.
nm::rescan(&iface, &scan_targets); be.rescan(&iface, &scan_targets);
} }
let visible = nm::visible_ssids(&iface); let visible = be.visible_ssids(&iface);
// ---- Connect to the priority list ---------------------------------- // ---- Connect to the priority list ----------------------------------
// Pass 1: visible networks in priority order. // Pass 1: visible networks in priority order.
@ -160,20 +175,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
for def in &candidates { for def in &candidates {
if visible.contains(&def.ssid) { if visible.contains(&def.ssid) {
any_attempted = true; any_attempted = true;
match connect_and_verify(&iface, def, cfg) { match connect_and_verify(be, &iface, def, cfg) {
Ok(()) => { Ok(()) => {
let note = if internet_ok(cfg) { let note = connectivity_note(be, cfg);
None finish_connected(be, &def.ssid, profile_name, &note);
} else {
Some("associated but no internet yet".to_string())
};
finish_connected(&def.ssid, profile_name, &note);
return Outcome::Connected { return Outcome::Connected {
ssid: def.ssid.clone(), ssid: def.ssid.clone(),
note, note,
}; };
} }
Err(e) => log(&format!("connect failed (visible): {}{e}", def.ssid)), Err(e) => be.log(&format!("connect failed (visible): {}{e}", def.ssid)),
} }
} }
} }
@ -181,20 +192,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
for def in &candidates { for def in &candidates {
if def.hidden && !visible.contains(&def.ssid) { if def.hidden && !visible.contains(&def.ssid) {
any_attempted = true; any_attempted = true;
match connect_and_verify(&iface, def, cfg) { match connect_and_verify(be, &iface, def, cfg) {
Ok(()) => { Ok(()) => {
let note = if internet_ok(cfg) { let note = connectivity_note(be, cfg);
None finish_connected(be, &def.ssid, profile_name, &note);
} else {
Some("associated but no internet yet".to_string())
};
finish_connected(&def.ssid, profile_name, &note);
return Outcome::Connected { return Outcome::Connected {
ssid: def.ssid.clone(), ssid: def.ssid.clone(),
note, note,
}; };
} }
Err(e) => log(&format!("connect failed (hidden): {}{e}", def.ssid)), Err(e) => be.log(&format!("connect failed (hidden): {}{e}", def.ssid)),
} }
} }
} }
@ -207,12 +214,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
.bootstrap .bootstrap
.clone() .clone()
.unwrap_or_else(|| "bootstrap".into()); .unwrap_or_else(|| "bootstrap".into());
if !nm::device_connected(&iface) { if !be.device_connected(&iface) {
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) { if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
match connect_and_verify(&iface, bdef, cfg) { match connect_and_verify(be, &iface, bdef, cfg) {
Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)), Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)),
Err(e) => { Err(e) => {
log(&format!("bootstrap reconnect failed: {}{e}", bdef.ssid)); be.log(&format!("bootstrap reconnect failed: {}{e}", bdef.ssid));
on_bootstrap = false; on_bootstrap = false;
} }
} }
@ -224,8 +231,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
} else { } else {
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)") format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
}; };
notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
return Outcome::Connected { return Outcome::Connected {
ssid: bs_ssid, ssid: bs_ssid,
note: Some(reason), note: Some(reason),
@ -238,34 +245,34 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
.map(|c| c.ssid.as_str()) .map(|c| c.ssid.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
notify( be.notify(
"breadcrumbs: no known networks", "breadcrumbs: no known networks",
&format!("profile '{profile_name}': none of [{names}] are in range"), &format!("profile '{profile_name}': none of [{names}] are in range"),
Urgency::Critical, Urgency::Critical,
); );
log(&format!( be.log(&format!(
"flow end: no networks connected (profile={profile_name})" "flow end: no networks connected (profile={profile_name})"
)); ));
Outcome::NoNetworks Outcome::NoNetworks
} }
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) { fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option<String>) {
match note { match note {
None => { None => {
notify( be.notify(
"breadcrumbs: connected", "breadcrumbs: connected",
&format!("{ssid} ({profile})"), &format!("{ssid} ({profile})"),
Urgency::Low, Urgency::Low,
); );
log(&format!("flow end: connected {ssid} (profile={profile})")); be.log(&format!("flow end: connected {ssid} (profile={profile})"));
} }
Some(n) => { Some(n) => {
notify( be.notify(
"breadcrumbs: connected (degraded)", "breadcrumbs: connected (degraded)",
&format!("{ssid} ({profile}) — {n}"), &format!("{ssid} ({profile}) — {n}"),
Urgency::Normal, Urgency::Normal,
); );
log(&format!( be.log(&format!(
"flow end: connected {ssid} (profile={profile}) note={n}" "flow end: connected {ssid} (profile={profile}) note={n}"
)); ));
} }
@ -276,6 +283,7 @@ fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{Profile, Settings}; use crate::config::{Profile, Settings};
use crate::tailscale::TsHealth;
use std::collections::BTreeMap; use std::collections::BTreeMap;
fn net(ssid: &str) -> NetworkDef { fn net(ssid: &str) -> NetworkDef {
@ -299,6 +307,341 @@ mod tests {
} }
} }
// --- a scriptable in-memory Backend for testing the state machine ---
use std::cell::RefCell;
use std::collections::HashSet;
struct Fake {
iface: Option<String>,
visible: HashSet<String>,
connectable: HashSet<String>,
connected: RefCell<Option<String>>,
ts: TsHealth,
conn: Connectivity,
notes: RefCell<Vec<String>>,
}
impl Fake {
fn new() -> Fake {
Fake {
iface: Some("wlan0".into()),
visible: HashSet::new(),
connectable: HashSet::new(),
connected: RefCell::new(None),
ts: TsHealth::Ok,
conn: Connectivity::Online,
notes: RefCell::new(Vec::new()),
}
}
fn set(ssids: &[&str]) -> HashSet<String> {
ssids.iter().map(|s| s.to_string()).collect()
}
fn visible(mut self, ssids: &[&str]) -> Self {
self.visible = Fake::set(ssids);
self
}
fn connectable(mut self, ssids: &[&str]) -> Self {
self.connectable = Fake::set(ssids);
self
}
fn ts(mut self, h: TsHealth) -> Self {
self.ts = h;
self
}
fn conn(mut self, c: Connectivity) -> Self {
self.conn = c;
self
}
fn no_iface(mut self) -> Self {
self.iface = None;
self
}
fn notified(&self, needle: &str) -> bool {
self.notes.borrow().iter().any(|n| n.contains(needle))
}
}
impl Backend for Fake {
fn wifi_interface(&self) -> Option<String> {
self.iface.clone()
}
fn radio_on(&self) {}
fn rescan(&self, _: &str, _: &[String]) {}
fn visible_ssids(&self, _: &str) -> HashSet<String> {
self.visible.clone()
}
fn active_ssid(&self, _: &str) -> Option<String> {
self.connected.borrow().clone()
}
fn ipv4(&self, _: &str) -> Option<String> {
self.connected.borrow().as_ref().map(|_| "10.0.0.2".into())
}
fn device_connected(&self, _: &str) -> bool {
self.connected.borrow().is_some()
}
fn connect(&self, _: &str, net: &NetworkDef, _: u32, _: &str) -> Result<(), String> {
if self.connectable.contains(&net.ssid) {
*self.connected.borrow_mut() = Some(net.ssid.clone());
Ok(())
} else {
// A failed association drops the current link, like nmcli.
*self.connected.borrow_mut() = None;
Err(format!("cannot connect to {}", net.ssid))
}
}
fn tailscale_installed(&self) -> bool {
true
}
fn ensure_exit_node(&self, _: &str) -> TsHealth {
self.ts.clone()
}
fn tailscale_check(&self, _: &str) -> TsHealth {
self.ts.clone()
}
fn connectivity(&self, _: &Config) -> Connectivity {
self.conn.clone()
}
fn notify(&self, summary: &str, _: &str, _: Urgency) {
self.notes.borrow_mut().push(summary.to_string());
}
fn log(&self, _: &str) {}
}
fn with_profile(name: &str, p: Profile) -> Config {
let mut c = cfg();
c.profiles.insert(name.to_string(), p);
c
}
fn connected(o: &Outcome) -> (&str, &Option<String>) {
match o {
Outcome::Connected { ssid, note } => (ssid.as_str(), note),
other => panic!("expected Connected, got {other:?}"),
}
}
// --- flow::run state machine ---
#[test]
fn run_unknown_profile_returns_unknown_and_notifies() {
let be = Fake::new();
let o = run(&be, &cfg(), "ghost");
assert!(matches!(o, Outcome::UnknownProfile(p) if p == "ghost"));
assert!(be.notified("unknown profile"));
}
#[test]
fn run_no_interface_returns_no_interface() {
let be = Fake::new().no_iface();
let c = with_profile(
"home",
Profile {
networks: vec!["HomeWifi".into()],
..Default::default()
},
);
assert!(matches!(run(&be, &c, "home"), Outcome::NoInterface));
}
#[test]
fn run_connects_to_visible_priority_network() {
let c = with_profile(
"home",
Profile {
networks: vec!["HomeWifi".into()],
..Default::default()
},
);
let be = Fake::new()
.visible(&["HomeWifi", "CafeWifi"])
.connectable(&["HomeWifi"]);
let o = run(&be, &c, "home");
let (ssid, note) = connected(&o);
assert_eq!(ssid, "HomeWifi");
assert!(note.is_none());
}
#[test]
fn run_follows_priority_order() {
let c = with_profile(
"home",
Profile {
networks: vec!["WorkNet".into(), "HomeWifi".into()],
..Default::default()
},
);
let be = Fake::new()
.visible(&["WorkNet", "HomeWifi"])
.connectable(&["WorkNet", "HomeWifi"]);
assert_eq!(connected(&run(&be, &c, "home")).0, "WorkNet");
}
#[test]
fn run_skips_failing_network_and_tries_next() {
let c = with_profile(
"home",
Profile {
networks: vec!["WorkNet".into(), "HomeWifi".into()],
..Default::default()
},
);
let be = Fake::new()
.visible(&["WorkNet", "HomeWifi"])
.connectable(&["HomeWifi"]); // WorkNet fails
assert_eq!(connected(&run(&be, &c, "home")).0, "HomeWifi");
}
#[test]
fn run_no_networks_in_range_returns_no_networks() {
let c = with_profile(
"home",
Profile {
networks: vec!["HomeWifi".into()],
..Default::default()
},
);
let be = Fake::new().visible(&["SomeoneElse"]);
assert!(matches!(run(&be, &c, "home"), Outcome::NoNetworks));
assert!(be.notified("no known networks"));
}
#[test]
fn run_captive_portal_surfaces_as_note() {
let c = with_profile(
"home",
Profile {
networks: vec!["HomeWifi".into()],
..Default::default()
},
);
let be = Fake::new()
.visible(&["HomeWifi"])
.connectable(&["HomeWifi"])
.conn(Connectivity::Portal(Some("http://login.test".into())));
let o = run(&be, &c, "home");
let (_, note) = connected(&o);
assert!(note.as_ref().unwrap().contains("captive portal"));
assert!(note.as_ref().unwrap().contains("http://login.test"));
}
#[test]
fn run_offline_after_associate_is_degraded_note() {
let c = with_profile(
"home",
Profile {
networks: vec!["HomeWifi".into()],
..Default::default()
},
);
let be = Fake::new()
.visible(&["HomeWifi"])
.connectable(&["HomeWifi"])
.conn(Connectivity::Offline);
let o = run(&be, &c, "home");
let (_, note) = connected(&o);
assert!(note.as_ref().unwrap().contains("no internet"));
}
#[test]
fn run_tailscale_gated_moves_to_target_when_healthy() {
let c = with_profile(
"work",
Profile {
bootstrap: Some("CafeWifi".into()),
networks: vec!["WorkNet".into()],
tailscale: true,
..Default::default()
},
);
let be = Fake::new()
.visible(&["CafeWifi", "WorkNet"])
.connectable(&["CafeWifi", "WorkNet"])
.ts(TsHealth::Ok);
assert_eq!(connected(&run(&be, &c, "work")).0, "WorkNet");
}
#[test]
fn run_tailscale_unhealthy_stays_on_bootstrap() {
let c = with_profile(
"work",
Profile {
bootstrap: Some("CafeWifi".into()),
networks: vec!["WorkNet".into()],
tailscale: true,
..Default::default()
},
);
let be = Fake::new()
.visible(&["CafeWifi", "WorkNet"])
.connectable(&["CafeWifi", "WorkNet"])
.ts(TsHealth::NeedsLogin);
match run(&be, &c, "work") {
Outcome::TailscaleError { ssid, health } => {
assert_eq!(ssid.as_deref(), Some("CafeWifi"));
assert_eq!(health, TsHealth::NeedsLogin);
}
other => panic!("expected TailscaleError, got {other:?}"),
}
assert!(be.notified("Tailscale"));
}
#[test]
fn run_tailscale_ok_but_target_out_of_range_keeps_bootstrap() {
let c = with_profile(
"work",
Profile {
bootstrap: Some("CafeWifi".into()),
networks: vec!["WorkNet".into()],
tailscale: true,
..Default::default()
},
);
let be = Fake::new()
.visible(&["CafeWifi"]) // WorkNet not in range
.connectable(&["CafeWifi"])
.ts(TsHealth::Ok);
let o = run(&be, &c, "work");
let (ssid, note) = connected(&o);
assert_eq!(ssid, "CafeWifi");
assert!(note.as_ref().unwrap().contains("not in range"));
}
// --- Outcome::ok ---
#[test]
fn outcome_ok_true_for_connected_with_and_without_note() {
assert!(Outcome::Connected {
ssid: "x".into(),
note: None
}
.ok());
assert!(Outcome::Connected {
ssid: "x".into(),
note: Some("associated but no internet yet".into()),
}
.ok());
}
#[test]
fn outcome_ok_false_for_all_error_variants() {
assert!(!Outcome::NoInterface.ok());
assert!(!Outcome::NoNetworks.ok());
assert!(!Outcome::UnknownProfile("p".into()).ok());
assert!(!Outcome::TailscaleError {
ssid: None,
health: TsHealth::NeedsLogin,
}
.ok());
assert!(!Outcome::TailscaleError {
ssid: Some("boot".into()),
health: TsHealth::ExitNodeOffline,
}
.ok());
}
// --- resolve_candidates ---
#[test] #[test]
fn candidates_follow_priority_order() { fn candidates_follow_priority_order() {
let c = cfg(); let c = cfg();
@ -345,4 +688,50 @@ mod tests {
.collect(); .collect();
assert_eq!(got, vec!["WorkNet"]); assert_eq!(got, vec!["WorkNet"]);
} }
#[test]
fn candidates_empty_when_profile_has_no_networks() {
let c = cfg();
let p = Profile::default();
assert!(resolve_candidates(&c, &p).is_empty());
}
#[test]
fn candidates_include_all_known_only_no_explicit_networks() {
let c = cfg();
let p = Profile {
include_all_known: true,
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got.len(), 4);
assert_eq!(got[0], "HomeWifi");
}
#[test]
fn candidates_deduplicates_repeated_ssid_in_explicit_list() {
let c = cfg();
let p = Profile {
networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()],
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got, vec!["HomeWifi", "WorkNet"]);
}
#[test]
fn candidates_all_unknown_ssids_returns_empty() {
let c = cfg();
let p = Profile {
networks: vec!["Ghost1".into(), "Ghost2".into()],
..Default::default()
};
assert!(resolve_candidates(&c, &p).is_empty());
}
} }

View file

@ -1,3 +1,4 @@
mod backend;
mod config; mod config;
mod flow; mod flow;
mod nm; mod nm;
@ -14,6 +15,7 @@ use std::time::Duration;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use backend::Backend;
use config::{Config, NetworkDef}; use config::{Config, NetworkDef};
use state::State; use state::State;
use util::{command_exists, home_dir, run}; use util::{command_exists, home_dir, run};
@ -44,7 +46,11 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Show current Wi-Fi / profile / Tailscale status (default) /// Show current Wi-Fi / profile / Tailscale status (default)
Status, Status {
/// Emit machine-readable JSON instead of the human summary
#[arg(long)]
json: bool,
},
/// Run the full connect sequence for the active profile /// Run the full connect sequence for the active profile
#[command(visible_aliases = ["up", "connect", "i"])] #[command(visible_aliases = ["up", "connect", "i"])]
Init, Init,
@ -126,6 +132,15 @@ enum ProfileCmd {
}, },
/// List available profiles /// List available profiles
List, List,
/// Create a new (empty) profile
Add {
name: String,
/// SSID whose presence marks this location (repeatable, for `detect`)
#[arg(long = "detect")]
detect: Vec<String>,
},
/// Delete a profile (core profiles home/work/away cannot be removed)
Remove { name: String },
} }
fn main() { fn main() {
@ -148,7 +163,7 @@ fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
} }
fn real_main(cli: Cli) -> Result<i32, String> { fn real_main(cli: Cli) -> Result<i32, String> {
let cmd = cli.cmd.unwrap_or(Cmd::Status); let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false });
// `cd` and `install-service` don't need a parsed config first. // `cd` and `install-service` don't need a parsed config first.
if let Cmd::Cd { shell } = &cmd { if let Cmd::Cd { shell } = &cmd {
@ -156,18 +171,19 @@ fn real_main(cli: Cli) -> Result<i32, String> {
} }
let mut cfg = Config::load()?; let mut cfg = Config::load()?;
let be = backend::System;
match cmd { match cmd {
Cmd::Status => cmd_status(&cfg, &cli.profile), Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json),
Cmd::Init => { Cmd::Init => {
let p = active_profile(&cfg, &cli.profile); let p = active_profile(&cfg, &cli.profile);
let outcome = flow::run(&cfg, &p); let outcome = flow::run(&be, &cfg, &p);
print_outcome(&p, &outcome); print_outcome(&p, &outcome);
Ok(if outcome.ok() { 0 } else { 1 }) Ok(if outcome.ok() { 0 } else { 1 })
} }
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
Cmd::Profile { action } => cmd_profile(&cfg, action), Cmd::Profile { action } => cmd_profile(&be, &mut cfg, action),
Cmd::Detect { apply } => cmd_detect(&cfg, apply), Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply),
Cmd::Add { Cmd::Add {
ssid, ssid,
password, password,
@ -179,7 +195,7 @@ fn real_main(cli: Cli) -> Result<i32, String> {
Cmd::Scan { to } => cmd_scan(&mut cfg, to), Cmd::Scan { to } => cmd_scan(&mut cfg, to),
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
Cmd::Edit => cmd_edit(), Cmd::Edit => cmd_edit(),
Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full), Cmd::Doctor { full } => cmd_doctor(&be, &cfg, &cli.profile, full),
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
Cmd::Cd { .. } => unreachable!(), Cmd::Cd { .. } => unreachable!(),
} }
@ -213,9 +229,45 @@ fn print_outcome(profile: &str, o: &flow::Outcome) {
} }
} }
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> { fn status_healthy(s: &status::Status) -> bool {
s.internet
&& s.iface.is_some()
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false))
}
fn cmd_status(
be: &dyn Backend,
cfg: &Config,
override_p: &Option<String>,
json: bool,
) -> Result<i32, String> {
let p = active_profile(cfg, override_p); let p = active_profile(cfg, override_p);
let s = status::gather(cfg, &p); let s = status::gather(be, cfg, &p);
let healthy = status_healthy(&s);
if json {
let v = serde_json::json!({
"profile": p,
"adapter": s.iface,
"ssid": s.ssid,
"ip": s.ip,
"internet": s.internet,
"captive_portal": s.portal,
"tailscale": {
"required": s.tailscale_required,
"installed": s.tailscale.is_some(),
"ok": s.tailscale.as_ref().map(|h| h.is_ok()),
"health": s.tailscale.as_ref().map(|h| h.describe()),
"exit_node": s.exit_node,
},
"healthy": healthy,
});
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into())
);
return Ok(if healthy { 0 } else { 1 });
}
let dot = |ok: bool| { let dot = |ok: bool| {
if ok { if ok {
@ -248,6 +300,14 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
dot(s.internet), dot(s.internet),
if s.internet { "ok" } else { "down" } if s.internet { "ok" } else { "down" }
); );
if let Some(portal) = &s.portal {
let detail = if portal.is_empty() {
"sign-in required".to_string()
} else {
portal.clone()
};
println!(" portal {C_YELLOW}captive portal{C_RESET} {C_DIM}{detail}{C_RESET}");
}
match (&s.tailscale, s.tailscale_required) { match (&s.tailscale, s.tailscale_required) {
(Some(h), req) => { (Some(h), req) => {
@ -263,9 +323,6 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), (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!( println!(
" state {}", " state {}",
if healthy { if healthy {
@ -277,7 +334,13 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
Ok(if healthy { 0 } else { 1 }) Ok(if healthy { 0 } else { 1 })
} }
fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String> { const CORE_PROFILES: [&str; 3] = ["home", "work", "away"];
fn cmd_profile(
be: &dyn Backend,
cfg: &mut Config,
action: Option<ProfileCmd>,
) -> Result<i32, String> {
match action.unwrap_or(ProfileCmd::Get) { match action.unwrap_or(ProfileCmd::Get) {
ProfileCmd::Get => { ProfileCmd::Get => {
println!("{}", State::load(&cfg.settings.default_profile).profile); println!("{}", State::load(&cfg.settings.default_profile).profile);
@ -291,6 +354,34 @@ fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String>
} }
Ok(0) Ok(0)
} }
ProfileCmd::Add { name, detect } => {
if cfg.profiles.contains_key(&name) {
return Err(format!("profile '{name}' already exists"));
}
cfg.profiles.insert(
name.clone(),
config::Profile {
detect_ssids: detect,
..Default::default()
},
);
cfg.save()?;
println!("{C_GREEN}added{C_RESET} profile {name}");
Ok(0)
}
ProfileCmd::Remove { name } => {
if CORE_PROFILES.contains(&name.as_str()) {
return Err(format!(
"'{name}' is a core profile and is always recreated; clear its networks instead"
));
}
if cfg.profiles.remove(&name).is_none() {
return Err(format!("unknown profile '{name}'"));
}
cfg.save()?;
println!("{C_GREEN}removed{C_RESET} profile {name}");
Ok(0)
}
ProfileCmd::Set { name, no_apply } => { ProfileCmd::Set { name, no_apply } => {
if !cfg.profiles.contains_key(&name) { if !cfg.profiles.contains_key(&name) {
let avail: Vec<&String> = cfg.profiles.keys().collect(); let avail: Vec<&String> = cfg.profiles.keys().collect();
@ -306,40 +397,46 @@ fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String>
if no_apply { if no_apply {
return Ok(0); return Ok(0);
} }
let outcome = flow::run(cfg, &name); let outcome = flow::run(be, cfg, &name);
print_outcome(&name, &outcome); print_outcome(&name, &outcome);
Ok(if outcome.ok() { 0 } else { 1 }) Ok(if outcome.ok() { 0 } else { 1 })
} }
} }
} }
fn detect_profile(cfg: &Config) -> Option<String> { fn detect_profile(be: &dyn Backend, cfg: &Config) -> Option<String> {
let iface = nm::wifi_interface()?; let iface = be.wifi_interface()?;
nm::radio_on(); be.radio_on();
nm::rescan(&iface, &[]); be.rescan(&iface, &[]);
let visible = nm::visible_ssids(&iface); let visible = be.visible_ssids(&iface);
// Profiles are stored in a BTreeMap so iteration order is deterministic // Pick the profile with the most marker SSIDs in range, so overlapping
// (alphabetical). The caller can rely on that for tie-breaking. // locations disambiguate by strength of evidence. Profiles iterate in
// BTreeMap (alphabetical) order, which deterministically breaks ties.
let mut best: Option<(usize, String)> = None;
for (name, profile) in &cfg.profiles { for (name, profile) in &cfg.profiles {
if profile.detect_ssids.is_empty() { let score = profile
continue;
}
if profile
.detect_ssids .detect_ssids
.iter() .iter()
.any(|s| visible.contains(s.as_str())) .filter(|s| visible.contains(s.as_str()))
{ .count();
return Some(name.clone()); if score == 0 {
continue;
}
if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) {
best = Some((score, name.clone()));
} }
} }
// Fall back to the default profile if no markers matched. // Fall back to the default profile if no markers matched.
Some(cfg.settings.default_profile.clone()) Some(
best.map(|(_, name)| name)
.unwrap_or_else(|| cfg.settings.default_profile.clone()),
)
} }
fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> { fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result<i32, String> {
match detect_profile(cfg) { match detect_profile(be, cfg) {
Some(p) => { Some(p) => {
println!("{p}"); println!("{p}");
if apply { if apply {
@ -348,7 +445,7 @@ fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
updated: util::timestamp(), updated: util::timestamp(),
} }
.save()?; .save()?;
let outcome = flow::run(cfg, &p); let outcome = flow::run(be, cfg, &p);
print_outcome(&p, &outcome); print_outcome(&p, &outcome);
return Ok(if outcome.ok() { 0 } else { 1 }); return Ok(if outcome.ok() { 0 } else { 1 });
} }
@ -497,10 +594,14 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
} }
fn mask(p: &str) -> String { fn mask(p: &str) -> String {
if p.len() <= 2 { // Count by characters, not bytes: slicing &p[..1] would panic on a
// multi-byte first character (valid in WPA passphrases).
let count = p.chars().count();
if count <= 2 {
"••".into() "••".into()
} else { } else {
format!("{}{}", &p[..1], "".repeat(p.len().saturating_sub(1))) let first: String = p.chars().take(1).collect();
format!("{}{}", first, "".repeat(count - 1))
} }
} }
@ -573,7 +674,12 @@ fn cmd_edit() -> Result<i32, String> {
} }
} }
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> { fn cmd_doctor(
be: &dyn Backend,
cfg: &Config,
override_p: &Option<String>,
full: bool,
) -> Result<i32, String> {
if full { if full {
let script = config::config_dir().join("diag.sh"); let script = config::config_dir().join("diag.sh");
if !script.exists() { if !script.exists() {
@ -590,7 +696,7 @@ fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i
} }
let p = active_profile(cfg, override_p); let p = active_profile(cfg, override_p);
let s = status::gather(cfg, &p); let s = status::gather(be, cfg, &p);
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
println!( println!(
" nmcli {}", " nmcli {}",

247
src/nm.rs
View file

@ -1,8 +1,9 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use crate::config::NetworkDef; use crate::config::{state_dir, NetworkDef};
use crate::util::{run, run_ok}; use crate::util::{run, run_ok, run_with_stdin};
/// nmcli `-t` escapes `:` and `\` in field values; undo that. /// nmcli `-t` escapes `:` and `\` in field values; undo that.
fn unescape(s: &str) -> String { fn unescape(s: &str) -> String {
@ -240,6 +241,19 @@ fn enforce_dns(uuid: &str, iface: &str, dns: &str) {
} }
} }
/// Return true if `name` is NetworkManager's numbered-duplicate convention for
/// `ssid`: exactly `ssid` followed by a space and one or more decimal digits
/// (e.g. "MyNet 1", "MyNet 2"). A name like "MyNet1" (no space) is a distinct
/// SSID and must not match.
fn is_numbered_nm_duplicate(name: &str, ssid: &str) -> bool {
if let Some(suffix) = name.strip_prefix(ssid) {
if let Some(digits) = suffix.strip_prefix(' ') {
return !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit());
}
}
false
}
/// Return the name of the first saved NM connection profile whose name is /// Return the name of the first saved NM connection profile whose name is
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention). /// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
/// Returns `None` if no such profile exists. /// Returns `None` if no such profile exists.
@ -254,26 +268,58 @@ fn first_profile_for_ssid(ssid: &str) -> Option<String> {
} }
let mut fallback: Option<String> = None; let mut fallback: Option<String> = None;
for line in o.stdout.lines() { for line in o.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect(); // NAME may itself contain an (escaped) ':', so a plain splitn would
if parts.len() < 2 || !parts[1].contains("wireless") { // mis-split it — parse the line the same way nmcli escapes it.
let fields = parse_scan_line(line);
if fields.len() < 2 || !fields[1].contains("wireless") {
continue; continue;
} }
let name = unescape(parts[0]); let name = fields[0].clone();
if name == ssid { if name == ssid {
return Some(name); return Some(name);
} }
if fallback.is_none() { if fallback.is_none() && is_numbered_nm_duplicate(&name, ssid) {
if let Some(suffix) = name.strip_prefix(ssid) { fallback = Some(name);
let s = suffix.trim();
if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
fallback = Some(name);
}
}
} }
} }
fallback fallback
} }
/// Write the PSK to a 0600 file in the `setting.property:value` format nmcli's
/// `passwd-file` expects, so the secret reaches NetworkManager without ever
/// appearing in argv (where any local user could read it via `ps`). Returns the
/// path; the caller is responsible for removing it.
fn write_psk_file(password: &str) -> Option<PathBuf> {
use std::io::Write;
// Prefer $XDG_RUNTIME_DIR (per-user tmpfs, mode 0700, wiped on logout) for a
// transient secret; fall back to the on-disk state dir only if it's unset.
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(state_dir);
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(format!("breadcrumbs.psk.{}", std::process::id()));
let mut f = {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.ok()?
}
#[cfg(not(unix))]
{
std::fs::File::create(&path).ok()?
}
};
f.write_all(format!("802-11-wireless-security.psk:{password}\n").as_bytes())
.ok()?;
Some(path)
}
/// Connect to a network and pin DNS. Returns true only if associated. /// Connect to a network and pin DNS. Returns true only if associated.
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
connect_verbose(iface, net, wait, dns).is_ok() connect_verbose(iface, net, wait, dns).is_ok()
@ -290,20 +336,8 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
let wait_s = wait.to_string(); let wait_s = wait.to_string();
if let Some(profile) = first_profile_for_ssid(&net.ssid) { if let Some(profile) = first_profile_for_ssid(&net.ssid) {
// Update the saved PSK and, for hidden networks, ensure the flag is set. // Ensure the hidden flag is set — this carries no secret, so passing it
if !net.password.is_empty() { // in argv is safe.
let _ = run(
"nmcli",
&[
"connection",
"modify",
&profile,
"802-11-wireless-security.psk",
net.password.as_str(),
],
Duration::from_secs(6),
);
}
if net.hidden { if net.hidden {
let _ = run( let _ = run(
"nmcli", "nmcli",
@ -317,50 +351,64 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
Duration::from_secs(6), Duration::from_secs(6),
); );
} }
let o = run( // Supply the PSK to the activation via a 0600 passwd-file rather than
"nmcli", // argv. Harmless if NM already has a matching secret stored.
&[ let psk_file = if net.password.is_empty() {
"--wait", None
&wait_s, } else {
"connection", write_psk_file(&net.password)
"up", };
&profile, let psk_path = psk_file.as_ref().map(|p| p.display().to_string());
"ifname", let mut args: Vec<&str> = vec![
iface, "--wait",
], &wait_s,
Duration::from_secs(wait as u64 + 15), "connection",
); "up",
if !o.success { &profile,
let detail = o.stderr.trim().to_string(); "ifname",
return Err(if detail.is_empty() { iface,
o.stdout.trim().to_string() ];
} else { if let Some(ref p) = psk_path {
detail args.push("passwd-file");
}); args.push(p.as_str());
} }
if let Some(uuid) = active_uuid(iface) { let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
enforce_dns(&uuid, iface, dns); if let Some(p) = psk_file {
let _ = std::fs::remove_file(p);
} }
return Ok(()); if o.success {
if let Some(uuid) = active_uuid(iface) {
enforce_dns(&uuid, iface, dns);
}
return Ok(());
}
// Activation failed (e.g. the saved secret is stale). Fall through to a
// fresh connect below, which re-supplies the PSK over stdin.
} }
// No saved profile — create one via device wifi connect. // No saved profile (or reuse failed) — create/refresh via device wifi
// connect. `--ask` makes nmcli read the PSK from stdin instead of argv.
let hidden = if net.hidden { "yes" } else { "no" }; let hidden = if net.hidden { "yes" } else { "no" };
let args = [ let args = [
"--ask",
"--wait", "--wait",
&wait_s, &wait_s,
"device", "device",
"wifi", "wifi",
"connect", "connect",
net.ssid.as_str(), net.ssid.as_str(),
"password",
net.password.as_str(),
"hidden", "hidden",
hidden, hidden,
"ifname", "ifname",
iface, iface,
]; ];
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); let stdin = format!("{}\n", net.password);
let o = run_with_stdin(
"nmcli",
&args,
Some(&stdin),
Duration::from_secs(wait as u64 + 15),
);
if !o.success { if !o.success {
let detail = o.stderr.trim().to_string(); let detail = o.stderr.trim().to_string();
return Err(if detail.is_empty() { return Err(if detail.is_empty() {
@ -388,15 +436,12 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool {
} }
let mut removed = false; let mut removed = false;
for line in list.stdout.lines() { for line in list.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect(); // NAME may contain an escaped ':' — parse rather than naive-split.
if parts.len() < 2 { let fields = parse_scan_line(line);
continue; if fields.len() < 2 || !fields[1].contains("wireless") {
}
let name = unescape(parts[0]);
let typ = parts[1];
if !typ.contains("wireless") {
continue; continue;
} }
let name = fields[0].clone();
let conn_ssid = run( let conn_ssid = run(
"nmcli", "nmcli",
&["-g", "802-11-wireless.ssid", "connection", "show", &name], &["-g", "802-11-wireless.ssid", "connection", "show", &name],
@ -428,6 +473,21 @@ mod tests {
assert_eq!(unescape("trailing\\"), "trailing\\"); assert_eq!(unescape("trailing\\"), "trailing\\");
} }
#[test]
fn unescape_empty_string() {
assert_eq!(unescape(""), "");
}
#[test]
fn unescape_multiple_consecutive_escapes() {
assert_eq!(unescape(r"a\:b\:c"), "a:b:c");
}
#[test]
fn unescape_double_backslash_produces_single() {
assert_eq!(unescape(r"a\\b"), r"a\b");
}
#[test] #[test]
fn parse_scan_line_splits_and_unescapes() { fn parse_scan_line_splits_and_unescapes() {
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID. // SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
@ -442,4 +502,71 @@ mod tests {
let f = parse_scan_line(":40:WPA3"); let f = parse_scan_line(":40:WPA3");
assert_eq!(f, vec!["", "40", "WPA3"]); assert_eq!(f, vec!["", "40", "WPA3"]);
} }
#[test]
fn parse_scan_line_single_field_no_separators() {
let f = parse_scan_line("OnlySSID");
assert_eq!(f, vec!["OnlySSID"]);
}
#[test]
fn parse_scan_line_empty_input_yields_one_empty_field() {
let f = parse_scan_line("");
assert_eq!(f, vec![""]);
}
#[test]
fn parse_scan_line_all_empty_fields() {
// Three colons → four empty fields.
let f = parse_scan_line(":::");
assert_eq!(f, vec!["", "", "", ""]);
}
#[test]
fn parse_scan_line_multiple_escaped_colons_in_ssid() {
let f = parse_scan_line(r"a\:b\:c:80:WPA3");
assert_eq!(f, vec!["a:b:c", "80", "WPA3"]);
}
#[test]
fn parse_scan_line_backslash_escape_then_colon_separator() {
// "abc\:60:WPA2" — \: is an escaped colon inside the SSID, not a separator.
let f = parse_scan_line(r"abc\:60:WPA2");
assert_eq!(f, vec!["abc:60", "WPA2"]);
}
#[test]
fn is_numbered_nm_duplicate_exact_match_is_not_duplicate() {
assert!(!is_numbered_nm_duplicate("Net", "Net"));
}
#[test]
fn is_numbered_nm_duplicate_space_digits_matches() {
assert!(is_numbered_nm_duplicate("Net 1", "Net"));
assert!(is_numbered_nm_duplicate("My Network 12", "My Network"));
}
#[test]
fn is_numbered_nm_duplicate_no_space_does_not_match() {
// "Net1" is a distinct SSID, not a numbered duplicate of "Net".
assert!(!is_numbered_nm_duplicate("Net1", "Net"));
assert!(!is_numbered_nm_duplicate("HomeWifi2", "HomeWifi"));
}
#[test]
fn is_numbered_nm_duplicate_non_numeric_suffix_does_not_match() {
assert!(!is_numbered_nm_duplicate("Net foo", "Net"));
assert!(!is_numbered_nm_duplicate("Net 1x", "Net"));
}
#[test]
fn is_numbered_nm_duplicate_empty_digits_does_not_match() {
// "Net " (trailing space only, no digits) must not match.
assert!(!is_numbered_nm_duplicate("Net ", "Net"));
}
#[test]
fn is_numbered_nm_duplicate_unrelated_name_does_not_match() {
assert!(!is_numbered_nm_duplicate("OtherNet 1", "Net"));
}
} }

View file

@ -29,6 +29,7 @@ impl State {
pub fn save(&self) -> Result<(), String> { pub fn save(&self) -> Result<(), String> {
fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?; fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?;
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?; let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?;
fs::write(state_path(), text).map_err(|e| format!("writing state: {e}")) crate::util::write_atomic(&state_path(), &text, 0o644)
.map_err(|e| format!("writing state: {e}"))
} }
} }

View file

@ -1,11 +1,31 @@
use std::time::Duration; use std::time::Duration;
use crate::backend::Backend;
use crate::config::Config; use crate::config::Config;
use crate::nm; use crate::tailscale::TsHealth;
use crate::tailscale::{self, TsHealth};
use crate::util::{command_exists, run}; use crate::util::{command_exists, run};
pub fn internet_ok(cfg: &Config) -> bool { /// Result of a connectivity probe. `Portal` distinguishes a captive portal
/// (associated, but traffic is being intercepted) from real internet or a hard
/// outage — the optional string is the portal's sign-in URL when known.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Connectivity {
Online,
Portal(Option<String>),
Offline,
}
impl Connectivity {
pub fn online(&self) -> bool {
matches!(self, Connectivity::Online)
}
}
/// Probe connectivity. Only an empty HTTP 204 from the generate_204-style
/// endpoint counts as online; a 200/redirect means a captive portal is
/// intercepting traffic. If the HTTP probe is inconclusive (timeout/5xx) we fall
/// back to ICMP, which reaching the host treats as online.
pub fn connectivity(cfg: &Config) -> Connectivity {
if command_exists("curl") { if command_exists("curl") {
let o = run( let o = run(
"curl", "curl",
@ -14,28 +34,45 @@ pub fn internet_ok(cfg: &Config) -> bool {
"-o", "-o",
"/dev/null", "/dev/null",
"-w", "-w",
"%{http_code}", "%{http_code} %{redirect_url}",
"--max-time", "--max-time",
"4", "4",
&cfg.settings.connectivity_url, &cfg.settings.connectivity_url,
], ],
Duration::from_secs(6), Duration::from_secs(6),
); );
let code = o.stdout.trim(); let mut parts = o.stdout.split_whitespace();
if code == "204" || code == "200" || code == "301" || code == "302" { let code = parts.next().unwrap_or("");
return true; let redirect = parts.next().unwrap_or("").trim();
match code {
"204" => return Connectivity::Online,
"200" | "301" | "302" | "303" | "307" | "308" => {
let url = if redirect.is_empty() {
None
} else {
Some(redirect.to_string())
};
return Connectivity::Portal(url);
}
// 000/timeout/5xx → inconclusive, try ICMP below.
_ => {}
} }
} }
// Fallback: ICMP to the configured host. let ping = run(
run(
"ping", "ping",
&["-c", "1", "-W", "2", &cfg.settings.ping_host], &["-c", "1", "-W", "2", &cfg.settings.ping_host],
Duration::from_secs(4), Duration::from_secs(4),
) )
.success .success;
if ping {
Connectivity::Online
} else {
Connectivity::Offline
}
} }
fn ipv4(iface: &str) -> Option<String> { /// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped.
pub fn ipv4(iface: &str) -> Option<String> {
let o = run( let o = run(
"nmcli", "nmcli",
&["-g", "IP4.ADDRESS", "device", "show", iface], &["-g", "IP4.ADDRESS", "device", "show", iface],
@ -48,7 +85,9 @@ fn ipv4(iface: &str) -> Option<String> {
if s.is_empty() { if s.is_empty() {
None None
} else { } else {
Some(s.lines().next().unwrap_or(s).trim().to_string()) // nmcli reports "192.168.1.5/24"; drop the prefix length for display.
let first = s.lines().next().unwrap_or(s).trim();
Some(first.split('/').next().unwrap_or(first).to_string())
} }
} }
@ -57,16 +96,24 @@ pub struct Status {
pub ssid: Option<String>, pub ssid: Option<String>,
pub ip: Option<String>, pub ip: Option<String>,
pub internet: bool, pub internet: bool,
/// Set when a captive portal was detected; inner string is its URL if known.
pub portal: Option<String>,
pub tailscale_required: bool, pub tailscale_required: bool,
pub tailscale: Option<TsHealth>, pub tailscale: Option<TsHealth>,
pub exit_node: String, pub exit_node: String,
} }
pub fn gather(cfg: &Config, profile_name: &str) -> Status { pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status {
let iface = nm::wifi_interface(); let iface = be.wifi_interface();
let ssid = iface.as_deref().and_then(nm::active_ssid); let ssid = iface.as_deref().and_then(|i| be.active_ssid(i));
let ip = iface.as_deref().and_then(ipv4); let ip = iface.as_deref().and_then(|i| be.ipv4(i));
let internet = internet_ok(cfg);
let conn = be.connectivity(cfg);
let internet = conn.online();
let portal = match conn {
Connectivity::Portal(url) => Some(url.unwrap_or_default()),
_ => None,
};
let prof = cfg.profile(profile_name); let prof = cfg.profile(profile_name);
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
@ -74,8 +121,8 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
.and_then(|p| p.exit_node.clone()) .and_then(|p| p.exit_node.clone())
.unwrap_or_else(|| cfg.settings.exit_node.clone()); .unwrap_or_else(|| cfg.settings.exit_node.clone());
let tailscale = if tailscale::installed() { let tailscale = if be.tailscale_installed() {
Some(tailscale::check(&exit_node)) Some(be.tailscale_check(&exit_node))
} else { } else {
None None
}; };
@ -85,6 +132,7 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
ssid, ssid,
ip, ip,
internet, internet,
portal,
tailscale_required: ts_required, tailscale_required: ts_required,
tailscale, tailscale,
exit_node, exit_node,

View file

@ -313,6 +313,54 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
#[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_covers_all_variants() {
assert_eq!(TsHealth::Ok.describe(), "ok");
assert!(TsHealth::NotInstalled.describe().contains("not installed"));
assert!(TsHealth::NeedsLogin.describe().contains("not logged in"));
assert!(TsHealth::Stopped.describe().contains("stopped"));
assert!(TsHealth::ExitNodeMissing.describe().contains("not found"));
assert!(TsHealth::ExitNodeOffline.describe().contains("offline"));
let msg = TsHealth::Error("boom".into()).describe();
assert!(msg.contains("boom"));
}
#[test]
fn extract_url_finds_https_url() {
assert_eq!(
extract_url("To authenticate, visit https://login.tailscale.com/a/xxx"),
Some("https://login.tailscale.com/a/xxx".into())
);
}
#[test]
fn extract_url_returns_none_when_no_url() {
assert_eq!(extract_url("Waiting for login..."), None);
assert_eq!(extract_url(""), None);
}
#[test]
fn extract_url_picks_first_https_token() {
let line = "Try https://first.example.com https://second.example.com";
assert_eq!(extract_url(line), Some("https://first.example.com".into()));
}
#[test]
fn extract_url_does_not_match_plain_http() {
assert_eq!(extract_url("see http://example.com for info"), None);
}
#[test] #[test]
fn backend_state_extraction() { fn backend_state_extraction() {
assert_eq!( assert_eq!(
@ -322,6 +370,13 @@ mod tests {
assert_eq!(backend_state(&json!({})), ""); assert_eq!(backend_state(&json!({})), "");
} }
#[test]
fn backend_state_all_known_values() {
for state in ["Running", "NeedsLogin", "NoState", "Stopped"] {
assert_eq!(backend_state(&json!({"BackendState": state})), state);
}
}
#[test] #[test]
fn exit_node_healthy_and_selected() { fn exit_node_healthy_and_selected() {
let v = json!({ let v = json!({
@ -381,4 +436,59 @@ mod tests {
}); });
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false)); assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
} }
#[test]
fn exit_node_state_empty_peer_map() {
let v = json!({ "BackendState": "Running", "Peer": {} });
assert_eq!(exit_node_state(&v, "anynode"), (false, false, false));
}
#[test]
fn exit_node_state_no_peer_field() {
let v = json!({ "BackendState": "Running" });
assert_eq!(exit_node_state(&v, "anynode"), (false, false, false));
}
#[test]
fn exit_node_state_case_insensitive_hostname() {
let v = json!({
"Peer": {
"k1": { "HostName": "MYNODE", "DNSName": "mynode.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
let (exists, online, selected) = exit_node_state(&v, "mynode");
assert!(exists && online && selected);
}
#[test]
fn exit_node_status_overrides_peer_online_when_selected() {
// ExitNodeStatus.Online=false should override the peer's Online=true
// when the peer is the currently-selected exit node.
let v = json!({
"ExitNodeStatus": { "Online": false },
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
let (exists, online, selected) = exit_node_state(&v, "exitnode");
assert!(exists);
assert!(
!online,
"ExitNodeStatus.Online=false should override peer Online"
);
assert!(selected);
}
#[test]
fn exit_node_state_wrong_node_name_not_matched() {
let v = json!({
"Peer": {
"k1": { "HostName": "othernode", "DNSName": "othernode.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
}
} }

View file

@ -1,5 +1,6 @@
use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::thread; use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@ -10,6 +11,42 @@ pub fn home_dir() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("/root")) .unwrap_or_else(|| PathBuf::from("/root"))
} }
/// Atomically replace `path` with `contents`: write a sibling temp file (created
/// with `mode` on unix) and `rename` it over the target. Avoids torn reads by a
/// concurrent reader (the watch daemon reloads config every tick) and never
/// leaves a half-written file behind on crash. Because the temp file is created
/// with `mode` up front, secrets never exist world-readable even briefly.
pub fn write_atomic(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(dir)?;
let stem = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("breadcrumbs");
let tmp = dir.join(format!(".{stem}.tmp.{}", std::process::id()));
let mut open = fs::OpenOptions::new();
open.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open.mode(mode);
}
#[cfg(not(unix))]
let _ = mode;
let res = (|| {
let mut f = open.open(&tmp)?;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
fs::rename(&tmp, path)
})();
if res.is_err() {
let _ = fs::remove_file(&tmp);
}
res
}
pub fn command_exists(name: &str) -> bool { pub fn command_exists(name: &str) -> bool {
if let Some(paths) = std::env::var_os("PATH") { if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
@ -55,6 +92,11 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
}; };
let mut child = match Command::new(prog) let mut child = match Command::new(prog)
.args(args) .args(args)
// Pin the C locale so message text we parse (nmcli states, monitor
// lines) is stable English regardless of the user's LANG. SSID/value
// bytes are unaffected.
.env("LC_ALL", "C")
.env("LANG", "C")
.stdin(stdin_cfg) .stdin(stdin_cfg)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
@ -64,13 +106,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
Err(_) => return Output::failed(), Err(_) => return Output::failed(),
}; };
if let Some(data) = stdin {
if let Some(mut sink) = child.stdin.take() {
let _ = sink.write_all(data.as_bytes());
// Drop closes the pipe so the child's read sees EOF.
}
}
let mut stdout_pipe = child.stdout.take(); let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take(); let mut stderr_pipe = child.stderr.take();
@ -89,6 +124,16 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
buf buf
}); });
// Feed stdin only after the reader threads are draining stdout/stderr, so a
// child that writes more than a pipe buffer before consuming stdin can't
// deadlock against our blocking write.
if let Some(data) = stdin {
if let Some(mut sink) = child.stdin.take() {
let _ = sink.write_all(data.as_bytes());
// Drop closes the pipe so the child's read sees EOF.
}
}
let start = Instant::now(); let start = Instant::now();
let status = loop { let status = loop {
match child.try_wait() { match child.try_wait() {
@ -176,4 +221,58 @@ mod tests {
// Leap day 2024-02-29 12:00:00 UTC // Leap day 2024-02-29 12:00:00 UTC
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00"); assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
} }
#[test]
fn fmt_epoch_year_2000_century_divisible_by_400_leap() {
// 2000-01-01 00:00:00 UTC — divisible by 400, so it IS a leap year.
assert_eq!(fmt_epoch(946_684_800), "2000-01-01 00:00:00");
}
#[test]
fn fmt_epoch_end_of_year_boundary() {
// 2023-12-31 23:59:59 UTC
assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59");
}
#[test]
fn fmt_epoch_negative_before_unix_epoch() {
// 1969-12-31 23:59:59 UTC
assert_eq!(fmt_epoch(-1), "1969-12-31 23:59:59");
// 1969-12-31 00:00:00 UTC
assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00");
}
#[test]
fn fmt_epoch_february_non_leap_year_boundary() {
// 2023-02-28 00:00:00 UTC (2023 is not a leap year)
assert_eq!(fmt_epoch(1_677_542_400), "2023-02-28 00:00:00");
// 2023-03-01 00:00:00 UTC — next day after Feb 28 in non-leap year
assert_eq!(fmt_epoch(1_677_628_800), "2023-03-01 00:00:00");
}
#[test]
fn fmt_epoch_century_non_leap_year_1900_equivalent() {
// 1900 is NOT a leap year (div by 100 but not 400).
// 1900-03-01 00:00:00 UTC: days from epoch = (1900-1970)*365.25 ≈ use known anchor.
// 2100-02-28 00:00:00 UTC = epoch 4107456000; next day is Mar 1 (not Feb 29).
// We verify via the leap day boundary: 2100-02-28 + 86400 must be 2100-03-01.
assert_eq!(fmt_epoch(4_107_456_000), "2100-02-28 00:00:00");
assert_eq!(fmt_epoch(4_107_456_000 + 86_400), "2100-03-01 00:00:00");
}
#[test]
fn fmt_epoch_midnight_vs_end_of_day() {
// 2022-06-15 00:00:00 UTC
assert_eq!(fmt_epoch(1_655_251_200), "2022-06-15 00:00:00");
// 2022-06-15 23:59:59 UTC
assert_eq!(fmt_epoch(1_655_337_599), "2022-06-15 23:59:59");
}
#[test]
fn fmt_epoch_time_of_day_components() {
// 1970-01-01 01:02:03 UTC
assert_eq!(fmt_epoch(3723), "1970-01-01 01:02:03");
// 1970-01-01 23:59:59 UTC
assert_eq!(fmt_epoch(86_399), "1970-01-01 23:59:59");
}
} }

View file

@ -4,6 +4,7 @@ use std::sync::mpsc::{self, Receiver};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::backend::{Backend, System};
use crate::config::Config; use crate::config::Config;
use crate::flow; use crate::flow;
use crate::notify::{log, notify, Urgency}; use crate::notify::{log, notify, Urgency};
@ -15,32 +16,34 @@ use crate::tailscale::TsHealth;
enum Health { enum Health {
Up, Up,
DownNoNet, DownNoNet,
/// Associated, but a captive portal is intercepting traffic (manual login).
CaptivePortal,
DownTailscaleManual, DownTailscaleManual,
DownTailscaleOther, DownTailscaleOther,
NoAdapter, NoAdapter,
} }
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) { fn classify(be: &dyn Backend, cfg: &Config, profile: &str) -> (Health, status::Status) {
let s = status::gather(cfg, profile); let s = status::gather(be, cfg, profile);
if s.iface.is_none() { let health = if s.iface.is_none() {
return (Health::NoAdapter, None); Health::NoAdapter
} } else if s.portal.is_some() {
let ssid = s.ssid.clone(); Health::CaptivePortal
if !s.internet { } else if !s.internet {
return (Health::DownNoNet, ssid); Health::DownNoNet
} } else if s.tailscale_required {
if s.tailscale_required {
match s.tailscale { match s.tailscale {
Some(TsHealth::Ok) => (Health::Up, ssid), Some(TsHealth::Ok) => Health::Up,
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => { Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
(Health::DownTailscaleManual, ssid) Health::DownTailscaleManual
} }
Some(_) => (Health::DownTailscaleOther, ssid), Some(_) => Health::DownTailscaleOther,
None => (Health::DownTailscaleManual, ssid), None => Health::DownTailscaleManual,
} }
} else { } else {
(Health::Up, ssid) Health::Up
} };
(health, s)
} }
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react /// Tail `nmcli monitor` and ping the channel on link-state churn so we react
@ -105,17 +108,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
let (tx, rx) = mpsc::channel::<()>(); let (tx, rx) = mpsc::channel::<()>();
spawn_nm_monitor(tx); spawn_nm_monitor(tx);
let be = System;
let mut profile = State::load(&cfg.settings.default_profile).profile; let mut profile = State::load(&cfg.settings.default_profile).profile;
if run_initial { if run_initial {
// Don't churn an already-working connection on (re)start. // Don't churn an already-working connection on (re)start.
let (h, _) = classify(&cfg, &profile); let (h, _) = classify(&be, &cfg, &profile);
if h == Health::Up { if h == Health::Up {
log(&format!( log(&format!(
"watch: already healthy on start (profile={profile}); skipping initial flow" "watch: already healthy on start (profile={profile}); skipping initial flow"
)); ));
} else { } else {
log(&format!("watch: initial flow for profile={profile}")); log(&format!("watch: initial flow for profile={profile}"));
let _ = flow::run(&cfg, &profile); let _ = flow::run(&be, &cfg, &profile);
} }
} }
@ -147,7 +151,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
last_flow_at = None; // allow immediate recovery on profile change last_flow_at = None; // allow immediate recovery on profile change
} }
let (health, ssid) = classify(&cfg, &profile); let (health, s) = classify(&be, &cfg, &profile);
let ssid = s.ssid.clone();
let transition = prev_health.as_ref() != Some(&health); let transition = prev_health.as_ref() != Some(&health);
match &health { match &health {
@ -164,6 +169,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
} }
fail_streak = 0; fail_streak = 0;
} }
Health::CaptivePortal => {
// Associated but gated behind a sign-in page we can't automate;
// notify once and don't hammer flow (reconnecting won't help).
if transition {
let body = match s.portal.as_deref().filter(|u| !u.is_empty()) {
Some(url) => format!("Sign in to continue: {url}"),
None => format!("Sign in to continue ({profile})."),
};
notify("breadcrumbs: captive portal", &body, Urgency::Normal);
}
fail_streak = 0;
}
Health::NoAdapter => { Health::NoAdapter => {
if transition { if transition {
notify( notify(
@ -186,7 +203,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
} }
// Re-run flow only on transition so we land on the bootstrap net. // Re-run flow only on transition so we land on the bootstrap net.
if transition || profile_changed { if transition || profile_changed {
let _ = flow::run(&cfg, &profile); let _ = flow::run(&be, &cfg, &profile);
last_flow_at = Some(Instant::now());
} }
fail_streak = fail_streak.saturating_add(1); fail_streak = fail_streak.saturating_add(1);
} }
@ -206,7 +224,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
"watch: down ({:?}) profile={profile} ssid={:?} — running flow", "watch: down ({:?}) profile={profile} ssid={:?} — running flow",
health, ssid health, ssid
)); ));
let outcome = flow::run(&cfg, &profile); let outcome = flow::run(&be, &cfg, &profile);
log(&format!("watch: recovery outcome = {:?}", outcome)); log(&format!("watch: recovery outcome = {:?}", outcome));
last_flow_at = Some(Instant::now()); last_flow_at = Some(Instant::now());
fail_streak = if outcome.ok() { fail_streak = if outcome.ok() {

View file

@ -142,3 +142,350 @@ fn unknown_profile_override_is_reported() {
let o = sb.cmd(&["--profile", "nope", "init"]); let o = sb.cmd(&["--profile", "nope", "init"]);
assert!(!o.status.success()); assert!(!o.status.success());
} }
#[test]
fn profile_list_marks_active_profile() {
let sb = Sandbox::new();
// Default profile is "away".
let o = sb.cmd(&["profile", "list"]);
assert!(o.status.success());
let out = stdout(&o);
// The active profile line starts with "* ".
assert!(
out.lines().any(|l| l.starts_with("* away")),
"active profile marker missing: {out}"
);
// Inactive profiles start with " ".
assert!(
out.lines().any(|l| l.starts_with(" home")),
"inactive profile format wrong: {out}"
);
// After switching to "work" the marker should move.
sb.cmd(&["profile", "set", "work", "--no-apply"]);
let o = sb.cmd(&["profile", "list"]);
let out = stdout(&o);
assert!(out.lines().any(|l| l.starts_with("* work")));
assert!(out.lines().any(|l| l.starts_with(" away")));
}
#[test]
fn add_saves_network_to_config() {
let sb = Sandbox::new();
let o = sb.cmd(&["add", "CafeWifi", "mypassword"]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
assert!(stdout(&o).contains("saved"));
// The network should now appear in the config file.
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(text.contains("CafeWifi"), "SSID missing from config");
assert!(text.contains("mypassword"), "password missing from config");
}
#[test]
fn add_attaches_ssid_to_profile_at_position() {
let sb = Sandbox::new();
// First add without attaching, then add again with --to.
sb.cmd(&["add", "HomeWifi", "pw1"]);
let o = sb.cmd(&["add", "HomeWifi", "pw1", "--to", "home"]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
let text = fs::read_to_string(sb.config_file()).unwrap();
// After attaching, the home profile's networks list should contain HomeWifi.
assert!(
text.contains("HomeWifi"),
"SSID not found in config after --to: {text}"
);
}
#[test]
fn add_to_unknown_profile_fails() {
let sb = Sandbox::new();
let o = sb.cmd(&["add", "SomeNet", "pw", "--to", "nonexistent"]);
assert!(!o.status.success());
}
#[test]
fn add_updates_existing_network_password() {
let sb = Sandbox::new();
sb.cmd(&["add", "MyNet", "oldpass"]);
let o = sb.cmd(&["add", "MyNet", "newpass"]);
assert!(o.status.success());
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(text.contains("newpass"), "updated password missing");
// Old password must be gone.
assert!(!text.contains("oldpass"), "old password still present");
// Only one entry for MyNet.
assert_eq!(
text.matches("MyNet").count(),
1,
"duplicate network entries"
);
}
#[test]
fn forget_removes_network_from_config() {
let sb = Sandbox::new();
sb.cmd(&["add", "ToDelete", "pw"]);
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(text.contains("ToDelete"));
let o = sb.cmd(&["forget", "ToDelete"]);
assert!(o.status.success());
assert!(stdout(&o).contains("forgot"));
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(
!text.contains("ToDelete"),
"network still in config after forget"
);
}
#[test]
fn forget_nonexistent_network_is_graceful() {
let sb = Sandbox::new();
// Should succeed (idempotent) even if the SSID was never saved.
let o = sb.cmd(&["forget", "NeverSaved"]);
assert!(o.status.success());
}
#[test]
fn forget_removes_ssid_from_profile_networks_list() {
let sb = Sandbox::new();
// Add "WorkNet" and attach it to the "work" profile.
sb.cmd(&["add", "WorkNet", "pw", "--to", "work"]);
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(text.contains("WorkNet"));
sb.cmd(&["forget", "WorkNet"]);
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(
!text.contains("WorkNet"),
"SSID still in profile after forget"
);
}
#[test]
fn list_masks_passwords_by_default() {
let sb = Sandbox::new();
sb.cmd(&["add", "SecretNet", "hunter2"]);
let o = sb.cmd(&["list"]);
assert!(o.status.success());
let out = stdout(&o);
assert!(
!out.contains("hunter2"),
"plain-text password exposed in list"
);
// A masking bullet should appear.
assert!(out.contains('•'), "no masking character in list output");
}
#[test]
fn list_masks_multibyte_password_without_panicking() {
let sb = Sandbox::new();
// A password whose first character is multi-byte UTF-8 used to panic the
// byte-slicing mask(); list must mask it cleanly instead.
sb.cmd(&["add", "UnicodeNet", "ñoño-café-🔐"]);
let o = sb.cmd(&["list"]);
assert!(
o.status.success(),
"list crashed on multibyte password: {}",
String::from_utf8_lossy(&o.stderr)
);
let out = stdout(&o);
assert!(
!out.contains("ñoño-café-🔐"),
"password leaked in masked list"
);
assert!(out.contains('•'), "no masking character in list output");
}
#[test]
fn list_show_passwords_reveals_password() {
let sb = Sandbox::new();
sb.cmd(&["add", "SecretNet", "hunter2"]);
let o = sb.cmd(&["list", "--show-passwords"]);
assert!(o.status.success());
assert!(
stdout(&o).contains("hunter2"),
"password not shown with --show-passwords"
);
}
#[test]
fn cd_prints_config_directory() {
let sb = Sandbox::new();
// Trigger config creation first.
sb.cmd(&["list"]);
let o = sb.cmd(&["cd"]);
assert!(o.status.success());
let out = stdout(&o).trim().to_string();
assert!(!out.is_empty(), "cd produced no output");
// The printed path must end with "breadcrumbs" (the config subdirectory).
assert!(out.ends_with("breadcrumbs"), "unexpected config dir: {out}");
}
#[test]
fn doctor_runs_without_crashing() {
let sb = Sandbox::new();
// With an empty PATH, nmcli/tailscale are absent. Doctor should report
// "MISSING"/"absent" but still exit successfully (it's a diag tool).
let o = sb.cmd(&["doctor"]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
let out = stdout(&o);
assert!(out.contains("nmcli"), "doctor missing nmcli line");
assert!(out.contains("tailscale"), "doctor missing tailscale line");
}
#[test]
fn status_exits_nonzero_when_unhealthy() {
let sb = Sandbox::new();
// No nmcli/tailscale → internet check fails → unhealthy → exit code 1.
let o = sb.cmd(&["status"]);
assert!(
!o.status.success(),
"expected non-zero exit for unhealthy status"
);
let out = stdout(&o);
assert!(
out.contains("breadcrumbs"),
"missing header in status output"
);
assert!(
out.contains("profile"),
"missing profile line in status output"
);
}
#[test]
fn profile_override_flag_does_not_persist() {
let sb = Sandbox::new();
// Set profile to "home" persistently.
sb.cmd(&["profile", "set", "home", "--no-apply"]);
// Use --profile flag to override for a single run (status).
let o = sb.cmd(&["--profile", "work", "status"]);
// Status exits non-zero (no network), but it should show the overridden profile.
let out = stdout(&o);
assert!(out.contains("work"), "override profile not shown in status");
// The persistent profile must still be "home".
let o = sb.cmd(&["profile", "get"]);
assert_eq!(stdout(&o).trim(), "home");
}
#[test]
fn add_hidden_flag_is_persisted() {
let sb = Sandbox::new();
let o = sb.cmd(&["add", "HiddenNet", "pw", "--hidden"]);
assert!(o.status.success());
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(text.contains("hidden = true"), "hidden flag not persisted");
}
#[test]
fn status_json_is_valid_and_machine_readable() {
let sb = Sandbox::new();
// No nmcli/tailscale → unhealthy, exit 1, but JSON must still be valid.
let o = sb.cmd(&["status", "--json"]);
assert!(
!o.status.success(),
"expected non-zero exit for unhealthy status"
);
let v: serde_json::Value =
serde_json::from_str(&stdout(&o)).expect("status --json did not emit valid JSON");
assert_eq!(v["profile"], "away");
assert_eq!(v["internet"], false);
assert_eq!(v["healthy"], false);
assert!(v["tailscale"].is_object());
}
#[test]
fn profile_add_persists_detect_ssids_and_remove_deletes() {
let sb = Sandbox::new();
let o = sb.cmd(&[
"profile", "add", "lab", "--detect", "LabWifi", "--detect", "LabGuest",
]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(
text.contains("[profiles.lab]"),
"profile not written: {text}"
);
assert!(
text.contains("LabWifi") && text.contains("LabGuest"),
"detect ssids missing"
);
// Adding the same profile again is rejected.
assert!(!sb.cmd(&["profile", "add", "lab"]).status.success());
// Remove it.
let o = sb.cmd(&["profile", "remove", "lab"]);
assert!(o.status.success());
let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(
!text.contains("[profiles.lab]"),
"profile still present after remove"
);
}
#[test]
fn profile_remove_core_is_rejected() {
let sb = Sandbox::new();
let o = sb.cmd(&["profile", "remove", "home"]);
assert!(!o.status.success(), "removing a core profile should fail");
}
#[test]
fn install_service_writes_unit_file() {
let sb = Sandbox::new();
// Install but don't enable (systemctl is absent in the empty PATH, but
// writing the unit file should succeed regardless).
let o = sb.cmd(&["install-service", "--no-enable"]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
let unit = sb.root.join(".config/systemd/user/breadcrumbs.service");
assert!(unit.exists(), "unit file not written at {}", unit.display());
let content = fs::read_to_string(&unit).unwrap();
assert!(content.contains("ExecStart="), "unit missing ExecStart");
assert!(
content.contains("breadcrumbs watch"),
"unit missing watch subcommand"
);
}