Fix bugs from a full audit: Tailscale recovery, SSID verification, captive portals, config races
The watch loop could spin forever on a stopped Tailscale daemon (the
auto-start path was unreachable) while re-notifying every retry, report
"connected" when NM autoconnect won a race onto a different SSID, and
classify captive portals as healthy (200/302 accepted as internet). The
bread-bus subscription thread also read/wrote config and state files
concurrently with the watch loop. Fix all of those plus: EDITOR values
with arguments, scan --to silently ignoring unknown profiles, deleted
core profiles being resurrected, inline-network migration data loss,
login never retried, XDG-unaware install-service, detect persisting a
stale default profile, password length leaking through the mask, a
stdin/stdout pipe deadlock, and several minor UI/robustness issues.
Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
parent
02e96126e0
commit
c02360a873
12 changed files with 538 additions and 189 deletions
|
|
@ -317,7 +317,10 @@ fn install_service_no_enable_writes_valid_unit_file() {
|
|||
let o = sb.cmd(&["install-service", "--no-enable"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
|
||||
let unit_path = sb.root.join(".config/systemd/user/breadcrumbs.service");
|
||||
// The sandbox sets XDG_CONFIG_HOME=$root/config, so the unit lands
|
||||
// under $XDG_CONFIG_HOME/systemd/user — the whole point of the fix is
|
||||
// honoring XDG rather than hardcoding ~/.config.
|
||||
let unit_path = sb.root.join("config/systemd/user/breadcrumbs.service");
|
||||
assert!(unit_path.exists());
|
||||
let text = fs::read_to_string(unit_path).unwrap();
|
||||
assert!(text.contains("ExecStart="));
|
||||
|
|
@ -423,6 +426,8 @@ case "$args" in
|
|||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "TestNet" ;;
|
||||
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||
echo "yes:TestNet" ;;
|
||||
"-t -f NAME,TYPE connection show")
|
||||
if [ -f "$marker" ]; then
|
||||
echo "TestNet:802-11-wireless"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ impl RecordedCall {
|
|||
}
|
||||
|
||||
type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>;
|
||||
type DynamicRule = (Matcher, Box<dyn Fn(&str, &[&str]) -> Output>);
|
||||
|
||||
/// A canned, rule-based [`Runner`]. Rules are tried in registration order;
|
||||
/// the first whose matcher returns `true` supplies the response. No rule
|
||||
|
|
@ -58,6 +59,7 @@ type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>;
|
|||
/// (a wrong exit code) rather than silently returning success.
|
||||
pub struct FakeRunner {
|
||||
rules: Vec<(Matcher, Output)>,
|
||||
dynamic_rules: Vec<DynamicRule>,
|
||||
commands: HashSet<String>,
|
||||
calls: Rc<RefCell<Vec<RecordedCall>>>,
|
||||
}
|
||||
|
|
@ -66,6 +68,7 @@ impl FakeRunner {
|
|||
pub fn new() -> Self {
|
||||
FakeRunner {
|
||||
rules: Vec::new(),
|
||||
dynamic_rules: Vec::new(),
|
||||
commands: HashSet::new(),
|
||||
calls: Rc::new(RefCell::new(Vec::new())),
|
||||
}
|
||||
|
|
@ -99,6 +102,20 @@ impl FakeRunner {
|
|||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// Register a rule whose *response* is computed at call time (rather
|
||||
/// than canned), enabling stateful fakes — e.g. answering "which SSID is
|
||||
/// active?" with the SSID of the most recently dialed connection.
|
||||
/// Dynamic rules are tried after the static ones.
|
||||
pub fn on_dynamic(
|
||||
mut self,
|
||||
matcher: impl Fn(&str, &[&str]) -> bool + 'static,
|
||||
out: impl Fn(&str, &[&str]) -> Output + 'static,
|
||||
) -> Self {
|
||||
self.dynamic_rules
|
||||
.push((Box::new(matcher), Box::new(out)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FakeRunner {
|
||||
|
|
@ -119,6 +136,11 @@ impl Runner for FakeRunner {
|
|||
return out.clone();
|
||||
}
|
||||
}
|
||||
for (matcher, out) in &self.dynamic_rules {
|
||||
if matcher(prog, args) {
|
||||
return out(prog, args);
|
||||
}
|
||||
}
|
||||
Output::failed()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,16 +52,53 @@ fn base_config() -> Config {
|
|||
/// the device reports connected after any successful connect attempt.
|
||||
fn base_nm(visible_ssids: &[&str]) -> FakeRunner {
|
||||
let visible = visible_ssids.join("\n");
|
||||
FakeRunner::new()
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
.on_contains("nmcli", "-f SSID device wifi list", ok(&visible))
|
||||
// Exact match: `-f ACTIVE,SSID` queries (which contain the substring
|
||||
// "SSID device wifi list") must NOT be answered with the visible
|
||||
// list — they go to the stateful rule below.
|
||||
.on(
|
||||
move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok(&visible),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"));
|
||||
let calls = runner.calls_handle();
|
||||
runner.on_dynamic(
|
||||
move |_prog, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0",
|
||||
move |_prog, _args| {
|
||||
// Stateful: answer with the SSID of the most recently dialed
|
||||
// connection, so connect_and_verify's post-connect SSID check
|
||||
// sees the network that was just activated (bootstrap first,
|
||||
// then the target).
|
||||
let rec = calls.borrow();
|
||||
let ssid = rec.iter().rev().find_map(|call| {
|
||||
let j = call.args.join(" ");
|
||||
if j.contains("connect") {
|
||||
call.args
|
||||
.iter()
|
||||
.position(|a| a == "connect")
|
||||
.map(|i| call.args[i + 1].clone())
|
||||
} else if j.contains("connection up") {
|
||||
call.args
|
||||
.iter()
|
||||
.position(|a| a == "up")
|
||||
.map(|i| call.args[i + 1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
match ssid {
|
||||
Some(s) => ok(&format!("yes:{s}")),
|
||||
None => ok(""),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A successful `device wifi connect <ssid> ...` for every ssid in `ssids`.
|
||||
|
|
@ -494,12 +531,17 @@ fn set_profile_command_persists_even_with_no_daemon_reachable() {
|
|||
state::set_profile(&cfg, "away").unwrap();
|
||||
assert_eq!(State::load("away").profile, "away");
|
||||
|
||||
let acted = bread_events::handle_command(&command_event(
|
||||
// handle_command only parses/validates (no file I/O on the subscription
|
||||
// thread); the loop thread then applies the action.
|
||||
let action = bread_events::handle_command(&command_event(
|
||||
"bread.command.crumbs.set_profile",
|
||||
serde_json::json!({ "profile": "home" }),
|
||||
));
|
||||
|
||||
assert!(acted, "known profile must persist");
|
||||
assert!(
|
||||
matches!(action, bread_events::CommandAction::SetProfile(n) if n == "home"),
|
||||
"a known profile must yield a SetProfile action"
|
||||
);
|
||||
bread_events::apply_set_profile("home");
|
||||
assert_eq!(State::load("away").profile, "home");
|
||||
}
|
||||
|
||||
|
|
@ -509,12 +551,14 @@ fn set_profile_command_rejects_unknown_profile() {
|
|||
let cfg = Config::load().expect("fresh config");
|
||||
state::set_profile(&cfg, "away").unwrap();
|
||||
|
||||
let acted = bread_events::handle_command(&command_event(
|
||||
let action = bread_events::handle_command(&command_event(
|
||||
"bread.command.crumbs.set_profile",
|
||||
serde_json::json!({ "profile": "bogus" }),
|
||||
));
|
||||
|
||||
assert!(!acted);
|
||||
assert!(matches!(action, bread_events::CommandAction::SetProfile(n) if n == "bogus"));
|
||||
// The rejection happens when the loop thread applies it: state is
|
||||
// untouched and the failure event is emitted (a no-op without breadd).
|
||||
bread_events::apply_set_profile("bogus");
|
||||
assert_eq!(
|
||||
State::load("away").profile,
|
||||
"away",
|
||||
|
|
@ -528,12 +572,11 @@ fn set_profile_command_rejects_missing_profile_field() {
|
|||
let cfg = Config::load().expect("fresh config");
|
||||
state::set_profile(&cfg, "away").unwrap();
|
||||
|
||||
let acted = bread_events::handle_command(&command_event(
|
||||
let action = bread_events::handle_command(&command_event(
|
||||
"bread.command.crumbs.set_profile",
|
||||
serde_json::json!({}),
|
||||
));
|
||||
|
||||
assert!(!acted);
|
||||
assert!(matches!(action, bread_events::CommandAction::Ignore));
|
||||
assert_eq!(State::load("away").profile, "away");
|
||||
}
|
||||
|
||||
|
|
@ -543,12 +586,11 @@ fn handle_command_ignores_unrecognized_verb() {
|
|||
let cfg = Config::load().expect("fresh config");
|
||||
state::set_profile(&cfg, "away").unwrap();
|
||||
|
||||
let acted = bread_events::handle_command(&command_event(
|
||||
let action = bread_events::handle_command(&command_event(
|
||||
"bread.command.crumbs.pin",
|
||||
serde_json::json!({}),
|
||||
));
|
||||
|
||||
assert!(!acted);
|
||||
assert!(matches!(action, bread_events::CommandAction::Ignore));
|
||||
assert_eq!(
|
||||
State::load("away").profile,
|
||||
"away",
|
||||
|
|
@ -562,14 +604,20 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() {
|
|||
let cfg = Config::load().expect("fresh config");
|
||||
state::set_profile(&cfg, "away").unwrap();
|
||||
|
||||
assert!(!bread_events::handle_command(&command_event(
|
||||
"bread.command.clip.clear",
|
||||
serde_json::json!({}),
|
||||
)));
|
||||
assert!(!bread_events::handle_command(&command_event(
|
||||
"bread.crumbs.profile.changed",
|
||||
serde_json::json!({ "from": "away", "to": "home" }),
|
||||
)));
|
||||
assert!(matches!(
|
||||
bread_events::handle_command(&command_event(
|
||||
"bread.command.clip.clear",
|
||||
serde_json::json!({}),
|
||||
)),
|
||||
bread_events::CommandAction::Ignore
|
||||
));
|
||||
assert!(matches!(
|
||||
bread_events::handle_command(&command_event(
|
||||
"bread.crumbs.profile.changed",
|
||||
serde_json::json!({ "from": "away", "to": "home" }),
|
||||
)),
|
||||
bread_events::CommandAction::Ignore
|
||||
));
|
||||
assert_eq!(State::load("away").profile, "away");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue