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

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

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

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

View file

@ -381,4 +381,77 @@ mod tests {
});
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
}
#[test]
fn exit_node_lookup_is_case_insensitive() {
let v = json!({
"Peer": {
"k1": { "HostName": "ExitNode", "DNSName": "ExitNode.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "EXITNODE"), (true, true, true));
}
#[test]
fn exit_node_state_with_no_peers_is_all_false() {
let v = json!({ "BackendState": "Running" });
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
}
#[test]
fn exit_node_state_empty_node_name_matches_nothing() {
let v = json!({
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, ""), (false, false, false));
}
#[test]
fn exit_node_status_online_only_applies_when_selected() {
// ExitNodeStatus.Online reflects the currently *active* exit node —
// it must not leak into the reported state of a peer that merely
// matches by name but isn't the one actually selected.
let v = json!({
"ExitNodeStatus": { "Online": true },
"Peer": {
"k1": { "HostName": "other", "DNSName": "other.ts.net.",
"Online": false, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "other"), (true, false, false));
}
#[test]
fn ts_health_is_ok_only_for_ok_variant() {
assert!(TsHealth::Ok.is_ok());
assert!(!TsHealth::NotInstalled.is_ok());
assert!(!TsHealth::NeedsLogin.is_ok());
assert!(!TsHealth::Stopped.is_ok());
assert!(!TsHealth::ExitNodeMissing.is_ok());
assert!(!TsHealth::ExitNodeOffline.is_ok());
assert!(!TsHealth::Error("x".into()).is_ok());
}
#[test]
fn ts_health_describe_is_human_readable() {
assert_eq!(TsHealth::Ok.describe(), "ok");
assert_eq!(
TsHealth::NeedsLogin.describe(),
"not logged in (run: tailscale up)"
);
assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom");
}
#[test]
fn extract_url_finds_https_token_among_others() {
assert_eq!(
extract_url("To authenticate, visit: https://login.tailscale.com/abc"),
Some("https://login.tailscale.com/abc".to_string())
);
assert_eq!(extract_url("no url on this line"), None);
}
}