Compare commits

...

1 commit
v2.1.8 ... main

Author SHA1 Message Date
Breadway
02e96126e0 Feed Wi-Fi PSK to nmcli --ask on stdin, never argv
All checks were successful
check / check (push) Successful in 1m7s
dev release / build (push) Successful in 1m28s
First connect (and reuse-with-password) no longer puts the secret on
nmcli's command line, so it is not visible in /proc/<pid>/cmdline.
networks.toml stays 0600; the local copy is still cleared after first
success.
2026-08-23 14:44:23 +08:00
8 changed files with 246 additions and 97 deletions

View file

@ -10,7 +10,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
- **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network - **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
- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Note: on that first connect, the PSK is still passed to `nmcli` as a command argument, so it's briefly visible to other local users via `/proc/<pid>/cmdline` for the lifetime of that `nmcli` child — a known limitation (see the note in `src/nm.rs`); a `nmcli --ask`/D-Bus secret-agent path that avoids argv exposure entirely is not yet wired up. In practice this window now only exists once per network, not on every connect. - **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). On that first connect the PSK is fed to `nmcli --ask` on stdin, never as a command argument, so it does not appear in `/proc/<pid>/cmdline`.
- **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`

View file

@ -357,9 +357,8 @@ fn prompt_line(msg: &str) -> String {
/// response) means "this network has no password" (open Wi-Fi) — normalize /// 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 /// 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 /// genuinely absent/cleared password does. Without this, `Some("")` would
/// make `nm::connect_verbose` send an empty PSK argument, which nmcli treats /// make `nm::connect_verbose` treat it as a (blank) secret rather than an
/// as "secured with a blank password" rather than "open", and the connect /// open network, and the connect fails against a real open SSID.
/// fails against a real open SSID.
fn non_empty(s: String) -> Option<String> { fn non_empty(s: String) -> Option<String> {
if s.is_empty() { if s.is_empty() {
None None

View file

@ -70,7 +70,8 @@ pub struct NetworkDef {
/// rather than writing a plaintext copy that's no longer needed. `None` /// rather than writing a plaintext copy that's no longer needed. `None`
/// means either "NetworkManager already owns this secret" or "this is /// means either "NetworkManager already owns this secret" or "this is
/// an open (unsecured) network" — both cases behave the same way on /// an open (unsecured) network" — both cases behave the same way on
/// connect: no password argument is ever sent. /// connect: no PSK is sent to nmcli at all. When `Some`, the PSK is
/// fed to `nmcli --ask` on stdin, never as an argv element.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>, pub password: Option<String>,
#[serde(default)] #[serde(default)]

View file

@ -54,12 +54,11 @@ fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec<NetworkDe
/// A network was just connected to using a local password, and NetworkManager /// A network was just connected to using a local password, and NetworkManager
/// now durably holds that secret — either in a freshly created connection /// now durably holds that secret — either in a freshly created connection
/// profile (`device wifi connect`) or an existing one whose PSK we just /// profile (`device wifi connect --ask`) or an existing one whose PSK we just
/// updated (`connection modify`). Either way breadcrumbs no longer needs its /// supplied via `--ask connection up`. Either way breadcrumbs no longer needs
/// own plaintext copy: clear it and persist immediately, so it can't be /// its own plaintext copy: clear it and persist immediately so it doesn't sit
/// re-sent as an argv argument on the next connect and doesn't sit on disk /// on disk any longer than necessary. A no-op (no save) if the network has no
/// any longer than necessary. A no-op (no save) if the network has no local /// local password to begin with.
/// password to begin with.
fn clear_password_if_used(cfg: &mut Config, ssid: &str) { fn clear_password_if_used(cfg: &mut Config, ssid: &str) {
let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else { let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else {
return; return;
@ -157,7 +156,9 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
log(&format!("bootstrap connected: {}", bdef.ssid)); log(&format!("bootstrap connected: {}", bdef.ssid));
clear_password_if_used(cfg, &bdef.ssid); clear_password_if_used(cfg, &bdef.ssid);
} }
Err(e) => log(&format!("bootstrap connect failed: {}{e}", bdef.ssid)), Err(e) => {
log(&format!("bootstrap connect failed: {}{e}", bdef.ssid))
}
} }
} else { } else {
log(&format!("bootstrap not in range: {}", bdef.ssid)); log(&format!("bootstrap not in range: {}", bdef.ssid));
@ -248,7 +249,10 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
if !nm::device_connected(&iface) { if !nm::device_connected(&iface) {
// Owned clone (not a borrow of `cfg`) so a successful reconnect // Owned clone (not a borrow of `cfg`) so a successful reconnect
// is free to mutate `cfg` to clear the used password. // 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()) if let Some(bdef) = profile
.bootstrap
.as_deref()
.and_then(|s| cfg.network(s).cloned())
{ {
match connect_and_verify(&iface, &bdef, cfg) { match connect_and_verify(&iface, &bdef, cfg) {
Ok(()) => { Ok(()) => {

162
src/nm.rs
View file

@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use crate::config::NetworkDef; use crate::config::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 {
@ -284,8 +284,8 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
/// Connect to a network and pin DNS. Returns the nmcli error on failure. /// Connect to a network and pin DNS. Returns the nmcli error on failure.
/// ///
/// Reuses an existing saved profile for the SSID when one exists (updating its /// Reuses an existing saved profile for the SSID when one exists so that
/// PSK) so that repeated connections do not accumulate numbered duplicates in /// repeated connections do not accumulate numbered duplicates in
/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to /// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to
/// `nmcli device wifi connect` — which creates a new profile — only when no /// `nmcli device wifi connect` — which creates a new profile — only when no
/// saved profile is found. /// saved profile is found.
@ -293,41 +293,18 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
/// `net.password` is only sent when `Some`: on the reuse path, `None` means /// `net.password` is only sent when `Some`: on the reuse path, `None` means
/// "leave the saved PSK alone" (either NetworkManager already durably owns /// "leave the saved PSK alone" (either NetworkManager already durably owns
/// it, or the network is open); on the create path it means "no password /// 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 /// at all", which is also how a genuinely open (no-security) SSID is
/// is connected. See the field doc on [`NetworkDef::password`] for how a /// connected. See the field doc on [`NetworkDef::password`] for how a local
/// local secret transitions to `None` after its first successful use. /// secret transitions to `None` after its first successful use.
/// ///
/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's /// When a password *is* sent it goes to `nmcli --ask` on stdin, never on
/// passed to `nmcli` as a plain command-line argument /// argv — `/proc/<pid>/cmdline` is world-readable. After the first success
/// (`802-11-wireless-security.psk <pw>` on the reuse path, `password <pw>` /// breadcrumbs clears its local copy, so subsequent connects pass nothing.
/// 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> { pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
let wait_s = wait.to_string(); let wait_s = wait.to_string();
let timeout = Duration::from_secs(wait as u64 + 15);
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.
if let Some(pw) = &net.password {
let _ = run(
"nmcli",
&[
"connection",
"modify",
&profile,
"802-11-wireless-security.psk",
pw.as_str(),
],
Duration::from_secs(6),
);
}
if net.hidden { if net.hidden {
let _ = run( let _ = run(
"nmcli", "nmcli",
@ -341,11 +318,52 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
Duration::from_secs(6), Duration::from_secs(6),
); );
} }
let o = run( let o = if let Some(pw) = &net.password {
"nmcli", // WHY: a stored PSK makes NM skip the secret agent, so --ask
&["--wait", &wait_s, "connection", "up", &profile, "ifname", iface], // would never read stdin. Resetting the property (empty value,
Duration::from_secs(wait as u64 + 15), // not a secret) forces a request; the new PSK arrives on stdin.
); let _ = run(
"nmcli",
&[
"connection",
"modify",
&profile,
"802-11-wireless-security.psk",
"",
],
Duration::from_secs(6),
);
let stdin = format!("{pw}\n");
run_with_stdin(
"nmcli",
&[
"--ask",
"--wait",
&wait_s,
"connection",
"up",
&profile,
"ifname",
iface,
],
Some(&stdin),
timeout,
)
} else {
run(
"nmcli",
&[
"--wait",
&wait_s,
"connection",
"up",
&profile,
"ifname",
iface,
],
timeout,
)
};
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() {
@ -362,29 +380,51 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
// No saved profile — create one via device wifi connect. // No saved profile — create one via device wifi connect.
let hidden = if net.hidden { "yes" } else { "no" }; let hidden = if net.hidden { "yes" } else { "no" };
let mut args: Vec<&str> = vec![ let o = if let Some(pw) = &net.password {
"--wait", // WHY: never put the PSK on argv — /proc/<pid>/cmdline is
&wait_s, // world-readable. `nmcli --ask` registers as a secret agent and
"device", // nmc_readline reads the PSK from stdin (one line). Open networks
"wifi", // stay on the no-ask path so we don't hang on a prompt.
"connect", let stdin = format!("{pw}\n");
net.ssid.as_str(), run_with_stdin(
]; "nmcli",
// Only pass `password` when we actually have one. An empty/missing PSK &[
// argument makes nmcli treat the network as open (no security), which is "--ask",
// what we want both for genuinely open SSIDs and for a network whose "--wait",
// secret NetworkManager should already hold — though the latter case &wait_s,
// only succeeds if a saved profile in fact exists, which is why we only "device",
// reach this branch (no saved profile found) when that assumption held. "wifi",
if let Some(pw) = &net.password { "connect",
args.push("password"); net.ssid.as_str(),
args.push(pw.as_str()); "hidden",
} hidden,
args.push("hidden"); "ifname",
args.push(hidden); iface,
args.push("ifname"); ],
args.push(iface); Some(&stdin),
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); timeout,
)
} else {
// No local PSK: either the SSID is open, or NM should already hold
// the secret — the latter only succeeds if a saved profile exists,
// which is why we only reach this branch when that assumption held.
run(
"nmcli",
&[
"--wait",
&wait_s,
"device",
"wifi",
"connect",
net.ssid.as_str(),
"hidden",
hidden,
"ifname",
iface,
],
timeout,
)
};
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() {

View file

@ -113,9 +113,8 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
let mut last: Option<Instant> = None; let mut last: Option<Instant> = None;
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
let l = line.to_lowercase(); let l = line.to_lowercase();
let interesting = l.contains("disconnect") let interesting =
|| l.contains("unavailable") l.contains("disconnect") || l.contains("unavailable") || l.contains("failed");
|| l.contains("failed");
if interesting && debounce_ready(last, Duration::from_millis(1500)) { if interesting && debounce_ready(last, Duration::from_millis(1500)) {
last = Some(Instant::now()); last = Some(Instant::now());
let _ = tx.send(()); let _ = tx.send(());
@ -281,7 +280,9 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
Urgency::Normal, Urgency::Normal,
); );
} }
let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX); let elapsed = last_flow_at
.map(|t| t.elapsed().as_secs())
.unwrap_or(u64::MAX);
if elapsed >= FLOW_COOLDOWN { if elapsed >= FLOW_COOLDOWN {
log(&format!( log(&format!(
"watch: down ({:?}) profile={profile} ssid={:?} — running flow", "watch: down ({:?}) profile={profile} ssid={:?} — running flow",

View file

@ -194,7 +194,9 @@ fn profile_list_marks_exactly_the_current_profile() {
let out = stdout(&o); let out = stdout(&o);
assert!(out.contains("* home"), "out: {out}"); assert!(out.contains("* home"), "out: {out}");
assert_eq!( assert_eq!(
out.lines().filter(|l| l.trim_start().starts_with('*')).count(), out.lines()
.filter(|l| l.trim_start().starts_with('*'))
.count(),
1, 1,
"expected exactly one marked profile, got: {out}" "expected exactly one marked profile, got: {out}"
); );
@ -262,7 +264,10 @@ fn forget_removes_network_from_config() {
); );
// ...and it should never have been in breadcrumbs.toml to begin with. // ...and it should never have been in breadcrumbs.toml to begin with.
let text = fs::read_to_string(sb.config_file()).unwrap(); let text = fs::read_to_string(sb.config_file()).unwrap();
assert!(!text.contains("CafeWifi"), "network leaked into config: {text}"); assert!(
!text.contains("CafeWifi"),
"network leaked into config: {text}"
);
} }
#[test] #[test]
@ -270,7 +275,11 @@ fn detect_without_wifi_adapter_errors() {
let sb = Sandbox::new(); let sb = Sandbox::new();
let o = sb.cmd(&["detect"]); let o = sb.cmd(&["detect"]);
assert!(!o.status.success()); assert!(!o.status.success());
assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o)); assert!(
stderr(&o).contains("could not detect"),
"stderr: {}",
stderr(&o)
);
} }
#[test] #[test]
@ -364,7 +373,11 @@ fn networks_are_stored_separately_from_settings_and_profiles() {
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777; let mode = fs::metadata(sb.networks_file())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "networks.toml should be owner-only"); assert_eq!(mode, 0o600, "networks.toml should be owner-only");
} }
} }
@ -374,8 +387,8 @@ fn add_with_empty_password_is_stored_as_no_password() {
// An explicitly empty password (e.g. `add SSID ""`, or a blank response // An explicitly empty password (e.g. `add SSID ""`, or a blank response
// at the interactive prompt) means "this is an open network" — it must // at the interactive prompt) means "this is an open network" — it must
// round-trip as an absent `password` key, the same as a cleared one, // round-trip as an absent `password` key, the same as a cleared one,
// not as `password = ""` (which `nm::connect_verbose` would send as a // not as `password = ""` (which `nm::connect_verbose` would treat as a
// literal empty PSK and fail against a real open SSID). // blank secret rather than an open network).
let sb = Sandbox::new(); let sb = Sandbox::new();
let o = sb.cmd(&["add", "OpenCafe", ""]); let o = sb.cmd(&["add", "OpenCafe", ""]);
assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(o.status.success(), "stderr: {}", stderr(&o));
@ -446,14 +459,19 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() {
let record = sb.root.join(".nmcli-calls"); let record = sb.root.join(".nmcli-calls");
// First connect: no saved NM profile yet, so breadcrumbs creates one via // First connect: no saved NM profile yet, so breadcrumbs creates one via
// `device wifi connect ... password hunter2 ...`. // `nmcli --ask device wifi connect ...` with the PSK on stdin, not argv.
let first = sb.cmd(&["init"]); let first = sb.cmd(&["init"]);
assert!(first.status.success(), "stderr: {}", stderr(&first)); assert!(first.status.success(), "stderr: {}", stderr(&first));
let first_calls = fs::read_to_string(&record).unwrap_or_default(); let first_calls = fs::read_to_string(&record).unwrap_or_default();
assert!( assert!(
first_calls.contains("device wifi connect TestNet") && first_calls.contains("hunter2"), first_calls.contains("device wifi connect TestNet") && first_calls.contains("--ask"),
"first connect should create a new NM profile with the password: {first_calls}" "first connect should create a new NM profile via --ask: {first_calls}"
); );
assert!(
!first_calls.contains("hunter2"),
"PSK must not appear on nmcli argv: {first_calls}"
);
// stdin payload is asserted in-process via FakeRunner (see flow_watch).
// The local copy is gone from disk immediately after. // The local copy is gone from disk immediately after.
let networks = fs::read_to_string(sb.networks_file()).unwrap(); let networks = fs::read_to_string(sb.networks_file()).unwrap();
@ -461,7 +479,10 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() {
!networks.contains("hunter2"), !networks.contains("hunter2"),
"password should have been cleared from networks.toml: {networks}" "password should have been cleared from networks.toml: {networks}"
); );
assert!(networks.contains("TestNet"), "network entry itself should remain"); assert!(
networks.contains("TestNet"),
"network entry itself should remain"
);
// Reset the recording so the second run's argv can be checked in isolation. // Reset the recording so the second run's argv can be checked in isolation.
fs::write(&record, "").unwrap(); fs::write(&record, "").unwrap();
@ -524,7 +545,10 @@ fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() {
let o = sb.cmd(&["doctor"]); let o = sb.cmd(&["doctor"]);
assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o); let out = stdout(&o);
assert!(out.contains("nmcli") && out.contains("present"), "out: {out}"); assert!(
out.contains("nmcli") && out.contains("present"),
"out: {out}"
);
assert!(!out.contains("MISSING"), "out: {out}"); assert!(!out.contains("MISSING"), "out: {out}");
} }

View file

@ -14,6 +14,7 @@ use bread_utils::bread_client::BreadEvent;
use breadcrumbs::bread_events; use breadcrumbs::bread_events;
use breadcrumbs::config::{Config, NetworkDef, Profile, Settings}; use breadcrumbs::config::{Config, NetworkDef, Profile, Settings};
use breadcrumbs::flow; use breadcrumbs::flow;
use breadcrumbs::nm;
use breadcrumbs::state::{self, State}; use breadcrumbs::state::{self, State};
use breadcrumbs::util::with_runner; use breadcrumbs::util::with_runner;
use breadcrumbs::watch::{classify, Health}; use breadcrumbs::watch::{classify, Health};
@ -83,10 +84,7 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
let _env = EnvSandbox::new(); let _env = EnvSandbox::new();
let mut cfg = base_config(); let mut cfg = base_config();
cfg.networks = vec![ cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))];
net("First", Some("pw1")),
net("Second", Some("pw2")),
];
cfg.profiles.insert( cfg.profiles.insert(
"home".into(), "home".into(),
Profile { Profile {
@ -112,10 +110,11 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
// Priority order actually mattered: "Second" was never dialed even // Priority order actually mattered: "Second" was never dialed even
// though it was visible and would have succeeded too. // though it was visible and would have succeeded too.
let dialed_second = calls let dialed_second = calls.borrow().iter().any(|c| {
.borrow() c.prog == "nmcli"
.iter() && c.args.contains(&"connect".to_string())
.any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second")); && c.args.iter().any(|a| a == "Second")
});
assert!(!dialed_second, "connected to Second when First should win"); assert!(!dialed_second, "connected to Second when First should win");
// The password used for the winning connect is now NM's problem, not // The password used for the winning connect is now NM's problem, not
@ -573,3 +572,84 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() {
))); )));
assert_eq!(State::load("away").profile, "away"); assert_eq!(State::load("away").profile, "away");
} }
// ---------------------------------------------------------------------
// PSK never on argv (first connect feeds nmcli --ask on stdin)
// ---------------------------------------------------------------------
fn assert_psk_not_on_argv(calls: &[common::RecordedCall], psk: &str) {
for c in calls {
if c.prog != "nmcli" {
continue;
}
assert!(
!c.args.iter().any(|a| a == psk),
"PSK leaked onto nmcli argv: {:?}",
c.args
);
}
}
#[test]
fn connect_verbose_create_feeds_psk_on_stdin_never_argv() {
let runner = FakeRunner::new()
.on_contains("nmcli", "NAME,TYPE", ok(""))
.on_contains("nmcli", "connect", ok(""))
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
.on_contains("nmcli", "device reapply", ok(""));
let calls = runner.calls_handle();
let net = net("Cafe", Some("super-secret-psk"));
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
assert!(result.is_ok(), "{result:?}");
let calls = calls.borrow();
assert_psk_not_on_argv(&calls, "super-secret-psk");
let connect = calls
.iter()
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "connect"))
.expect("expected device wifi connect");
assert!(
connect.args.iter().any(|a| a == "--ask"),
"create path must use --ask: {:?}",
connect.args
);
assert_eq!(connect.stdin.as_deref(), Some("super-secret-psk\n"));
}
#[test]
fn connect_verbose_reuse_feeds_psk_on_stdin_never_argv() {
let runner = FakeRunner::new()
.on_contains("nmcli", "NAME,TYPE", ok("Cafe:802-11-wireless"))
.on_contains("nmcli", "connection modify", ok(""))
.on_contains("nmcli", "connection up", ok(""))
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
.on_contains("nmcli", "device reapply", ok(""));
let calls = runner.calls_handle();
let net = net("Cafe", Some("super-secret-psk"));
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
assert!(result.is_ok(), "{result:?}");
let calls = calls.borrow();
assert_psk_not_on_argv(&calls, "super-secret-psk");
let up = calls
.iter()
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "up"))
.expect("expected connection up");
assert!(
up.args.iter().any(|a| a == "--ask"),
"reuse path must use --ask: {:?}",
up.args
);
assert_eq!(up.stdin.as_deref(), Some("super-secret-psk\n"));
// Clearing the stored PSK uses an empty argv value, never the secret.
let cleared = calls.iter().any(|c| {
c.prog == "nmcli"
&& c.args.iter().any(|a| a == "802-11-wireless-security.psk")
&& c.args.last().is_some_and(|a| a.is_empty())
});
assert!(cleared, "reuse+password should reset stored PSK: {calls:?}");
}