Release v2.1.0: backend test seam, captive-portal detection, JSON status, robustness
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).
This commit is contained in:
parent
8aceab7857
commit
d3c1e19ba3
17 changed files with 1662 additions and 217 deletions
131
src/config.rs
131
src/config.rs
|
|
@ -158,13 +158,10 @@ impl Config {
|
|||
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
||||
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
|
||||
let path = config_path();
|
||||
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||
// Plaintext Wi-Fi passwords live here — keep it owner-only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
// Plaintext Wi-Fi passwords live here: write atomically and owner-only,
|
||||
// so there's no torn read and no world-readable window.
|
||||
crate::util::write_atomic(&path, &text, 0o600)
|
||||
.map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -274,4 +271,124 @@ mod tests {
|
|||
assert!(cfg.profile("work").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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue