feat(wifiscan): add Rust macOS + Linux adapters, fix Python byte counters
- Add MacosCoreWlanScanner (macOS): CoreWLAN Swift helper adapter with synthetic BSSID generation via FNV-1a hash for redacted MACs (ADR-025) - Add LinuxIwScanner (Linux): parses `iw dev <iface> scan` output with freq-to-channel conversion and BSS stanza parsing - Both adapters produce Vec<BssidObservation> compatible with the existing WindowsWifiPipeline 8-stage processing - Platform-gate modules with #[cfg(target_os)] so each adapter only compiles on its target OS - Fix Python MacosWifiCollector: remove synthetic byte counters that produced misleading tx_bytes/rx_bytes data (set to 0) - Add compiled Swift binary (mac_wifi) to .gitignore Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -193,6 +193,9 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Compiled Swift helper binaries (macOS WiFi sensing)
|
||||
v1/src/sensing/mac_wifi
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Adapter that scans WiFi BSSIDs on Linux by invoking `iw dev <iface> scan`.
|
||||
//!
|
||||
//! This is the Linux counterpart to [`NetshBssidScanner`](super::NetshBssidScanner)
|
||||
//! on Windows and [`MacosCoreWlanScanner`](super::MacosCoreWlanScanner) on macOS.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! The adapter shells out to `iw dev <interface> scan` (or `iw dev <interface> scan dump`
|
||||
//! to read cached results without triggering a new scan, which requires root).
|
||||
//! The output is parsed into [`BssidObservation`] values using the same domain
|
||||
//! types shared by all platform adapters.
|
||||
//!
|
||||
//! # Permissions
|
||||
//!
|
||||
//! - `iw dev <iface> scan` requires `CAP_NET_ADMIN` (typically root).
|
||||
//! - `iw dev <iface> scan dump` reads cached results and may work without root
|
||||
//! on some distributions.
|
||||
//!
|
||||
//! # Platform
|
||||
//!
|
||||
//! Linux only. Gated behind `#[cfg(target_os = "linux")]` at the module level.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
use crate::error::WifiScanError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LinuxIwScanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Synchronous WiFi scanner that shells out to `iw dev <interface> scan`.
|
||||
///
|
||||
/// Each call to [`scan_sync`](Self::scan_sync) spawns a subprocess, captures
|
||||
/// stdout, and parses the BSS stanzas into [`BssidObservation`] values.
|
||||
pub struct LinuxIwScanner {
|
||||
/// Wireless interface name (e.g. `"wlan0"`, `"wlp2s0"`).
|
||||
interface: String,
|
||||
/// If true, use `scan dump` (cached results) instead of triggering a new
|
||||
/// scan. This avoids the root requirement but may return stale data.
|
||||
use_dump: bool,
|
||||
}
|
||||
|
||||
impl LinuxIwScanner {
|
||||
/// Create a scanner for the default interface `wlan0`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
interface: "wlan0".to_owned(),
|
||||
use_dump: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a scanner for a specific wireless interface.
|
||||
pub fn with_interface(iface: impl Into<String>) -> Self {
|
||||
Self {
|
||||
interface: iface.into(),
|
||||
use_dump: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Use `scan dump` instead of `scan` to read cached results without root.
|
||||
pub fn use_cached(mut self) -> Self {
|
||||
self.use_dump = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run `iw dev <iface> scan` and parse the output synchronously.
|
||||
///
|
||||
/// Returns one [`BssidObservation`] per BSS stanza in the output.
|
||||
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let scan_cmd = if self.use_dump { "dump" } else { "scan" };
|
||||
|
||||
let mut args = vec!["dev", &self.interface, "scan"];
|
||||
if self.use_dump {
|
||||
args.push(scan_cmd);
|
||||
}
|
||||
|
||||
// iw uses "scan dump" not "scan scan dump"
|
||||
let args = if self.use_dump {
|
||||
vec!["dev", &self.interface, "scan", "dump"]
|
||||
} else {
|
||||
vec!["dev", &self.interface, "scan"]
|
||||
};
|
||||
|
||||
let output = Command::new("iw")
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
WifiScanError::ProcessError(format!(
|
||||
"failed to run `iw {}`: {e}",
|
||||
args.join(" ")
|
||||
))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WifiScanError::ScanFailed {
|
||||
reason: format!(
|
||||
"iw exited with {}: {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_iw_scan_output(&stdout)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LinuxIwScanner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Intermediate accumulator for fields within a single BSS stanza.
|
||||
#[derive(Default)]
|
||||
struct BssStanza {
|
||||
bssid: Option<String>,
|
||||
ssid: Option<String>,
|
||||
signal_dbm: Option<f64>,
|
||||
freq_mhz: Option<u32>,
|
||||
channel: Option<u8>,
|
||||
}
|
||||
|
||||
impl BssStanza {
|
||||
/// Flush this stanza into a [`BssidObservation`], if we have enough data.
|
||||
fn flush(self, timestamp: Instant) -> Option<BssidObservation> {
|
||||
let bssid_str = self.bssid?;
|
||||
let bssid = BssidId::parse(&bssid_str).ok()?;
|
||||
let rssi_dbm = self.signal_dbm.unwrap_or(-90.0);
|
||||
|
||||
// Determine channel from explicit field or frequency.
|
||||
let channel = self.channel.or_else(|| {
|
||||
self.freq_mhz.map(freq_to_channel)
|
||||
}).unwrap_or(0);
|
||||
|
||||
let band = BandType::from_channel(channel);
|
||||
let radio_type = infer_radio_type_from_freq(self.freq_mhz.unwrap_or(0));
|
||||
let signal_pct = ((rssi_dbm + 100.0) * 2.0).clamp(0.0, 100.0);
|
||||
|
||||
Some(BssidObservation {
|
||||
bssid,
|
||||
rssi_dbm,
|
||||
signal_pct,
|
||||
channel,
|
||||
band,
|
||||
radio_type,
|
||||
ssid: self.ssid.unwrap_or_default(),
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the text output of `iw dev <iface> scan [dump]`.
|
||||
///
|
||||
/// The output consists of BSS stanzas, each starting with:
|
||||
/// ```text
|
||||
/// BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
/// ```
|
||||
/// followed by indented key-value lines.
|
||||
pub fn parse_iw_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let now = Instant::now();
|
||||
let mut results = Vec::new();
|
||||
let mut current: Option<BssStanza> = None;
|
||||
|
||||
for line in output.lines() {
|
||||
// New BSS stanza starts with "BSS " at column 0.
|
||||
if line.starts_with("BSS ") {
|
||||
// Flush previous stanza.
|
||||
if let Some(stanza) = current.take() {
|
||||
if let Some(obs) = stanza.flush(now) {
|
||||
results.push(obs);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse BSSID from "BSS aa:bb:cc:dd:ee:ff(on wlan0)" or
|
||||
// "BSS aa:bb:cc:dd:ee:ff -- associated".
|
||||
let rest = &line[4..];
|
||||
let mac_end = rest.find(|c: char| !c.is_ascii_hexdigit() && c != ':')
|
||||
.unwrap_or(rest.len());
|
||||
let mac = &rest[..mac_end];
|
||||
|
||||
if mac.len() == 17 {
|
||||
let mut stanza = BssStanza::default();
|
||||
stanza.bssid = Some(mac.to_lowercase());
|
||||
current = Some(stanza);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indented lines belong to the current stanza.
|
||||
let trimmed = line.trim();
|
||||
if let Some(ref mut stanza) = current {
|
||||
if let Some(rest) = trimmed.strip_prefix("SSID:") {
|
||||
stanza.ssid = Some(rest.trim().to_owned());
|
||||
} else if let Some(rest) = trimmed.strip_prefix("signal:") {
|
||||
// "signal: -52.00 dBm"
|
||||
stanza.signal_dbm = parse_signal_dbm(rest);
|
||||
} else if let Some(rest) = trimmed.strip_prefix("freq:") {
|
||||
// "freq: 5180"
|
||||
stanza.freq_mhz = rest.trim().parse().ok();
|
||||
} else if let Some(rest) = trimmed.strip_prefix("DS Parameter set: channel") {
|
||||
// "DS Parameter set: channel 6"
|
||||
stanza.channel = rest.trim().parse().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the last stanza.
|
||||
if let Some(stanza) = current.take() {
|
||||
if let Some(obs) = stanza.flush(now) {
|
||||
results.push(obs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Convert a frequency in MHz to an 802.11 channel number.
|
||||
fn freq_to_channel(freq_mhz: u32) -> u8 {
|
||||
match freq_mhz {
|
||||
// 2.4 GHz: channels 1-14.
|
||||
2412..=2472 => ((freq_mhz - 2407) / 5) as u8,
|
||||
2484 => 14,
|
||||
// 5 GHz: channels 36-177.
|
||||
5170..=5885 => ((freq_mhz - 5000) / 5) as u8,
|
||||
// 6 GHz (Wi-Fi 6E).
|
||||
5955..=7115 => ((freq_mhz - 5950) / 5) as u8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a signal strength string like "-52.00 dBm" into dBm.
|
||||
fn parse_signal_dbm(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
// Take everything up to " dBm" or just parse the number.
|
||||
let num_part = s.split_whitespace().next()?;
|
||||
num_part.parse().ok()
|
||||
}
|
||||
|
||||
/// Infer radio type from frequency (best effort).
|
||||
fn infer_radio_type_from_freq(freq_mhz: u32) -> RadioType {
|
||||
match freq_mhz {
|
||||
5955..=7115 => RadioType::Ax, // 6 GHz → Wi-Fi 6E
|
||||
5170..=5885 => RadioType::Ac, // 5 GHz → likely 802.11ac
|
||||
_ => RadioType::N, // 2.4 GHz → at least 802.11n
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Real-world `iw dev wlan0 scan` output (truncated to 3 BSSes).
|
||||
const SAMPLE_IW_OUTPUT: &str = "\
|
||||
BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
\tTSF: 123456789 usec
|
||||
\tfreq: 5180
|
||||
\tbeacon interval: 100 TUs
|
||||
\tcapability: ESS Privacy (0x0011)
|
||||
\tsignal: -52.00 dBm
|
||||
\tSSID: HomeNetwork
|
||||
\tDS Parameter set: channel 36
|
||||
BSS 11:22:33:44:55:66(on wlan0)
|
||||
\tfreq: 2437
|
||||
\tsignal: -71.00 dBm
|
||||
\tSSID: GuestWifi
|
||||
\tDS Parameter set: channel 6
|
||||
BSS de:ad:be:ef:ca:fe(on wlan0) -- associated
|
||||
\tfreq: 5745
|
||||
\tsignal: -45.00 dBm
|
||||
\tSSID: OfficeNet
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn parse_three_bss_stanzas() {
|
||||
let obs = parse_iw_scan_output(SAMPLE_IW_OUTPUT).unwrap();
|
||||
assert_eq!(obs.len(), 3);
|
||||
|
||||
// First BSS.
|
||||
assert_eq!(obs[0].ssid, "HomeNetwork");
|
||||
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
||||
assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON);
|
||||
assert_eq!(obs[0].channel, 36);
|
||||
assert_eq!(obs[0].band, BandType::Band5GHz);
|
||||
|
||||
// Second BSS: 2.4 GHz.
|
||||
assert_eq!(obs[1].ssid, "GuestWifi");
|
||||
assert_eq!(obs[1].channel, 6);
|
||||
assert_eq!(obs[1].band, BandType::Band2_4GHz);
|
||||
assert_eq!(obs[1].radio_type, RadioType::N);
|
||||
|
||||
// Third BSS: "-- associated" suffix.
|
||||
assert_eq!(obs[2].ssid, "OfficeNet");
|
||||
assert_eq!(obs[2].bssid.to_string(), "de:ad:be:ef:ca:fe");
|
||||
assert!((obs[2].rssi_dbm - (-45.0)).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freq_to_channel_conversion() {
|
||||
assert_eq!(freq_to_channel(2412), 1);
|
||||
assert_eq!(freq_to_channel(2437), 6);
|
||||
assert_eq!(freq_to_channel(2462), 11);
|
||||
assert_eq!(freq_to_channel(2484), 14);
|
||||
assert_eq!(freq_to_channel(5180), 36);
|
||||
assert_eq!(freq_to_channel(5745), 149);
|
||||
assert_eq!(freq_to_channel(5955), 1); // 6 GHz channel 1
|
||||
assert_eq!(freq_to_channel(9999), 0); // Unknown
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_signal_dbm_values() {
|
||||
assert!((parse_signal_dbm(" -52.00 dBm").unwrap() - (-52.0)).abs() < f64::EPSILON);
|
||||
assert!((parse_signal_dbm("-71.00 dBm").unwrap() - (-71.0)).abs() < f64::EPSILON);
|
||||
assert!((parse_signal_dbm("-45.00").unwrap() - (-45.0)).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output() {
|
||||
let obs = parse_iw_scan_output("").unwrap();
|
||||
assert!(obs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_ssid_defaults_to_empty() {
|
||||
let output = "\
|
||||
BSS 11:22:33:44:55:66(on wlan0)
|
||||
\tfreq: 2437
|
||||
\tsignal: -60.00 dBm
|
||||
";
|
||||
let obs = parse_iw_scan_output(output).unwrap();
|
||||
assert_eq!(obs.len(), 1);
|
||||
assert_eq!(obs[0].ssid, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_from_freq_when_ds_param_missing() {
|
||||
let output = "\
|
||||
BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
\tfreq: 5180
|
||||
\tsignal: -50.00 dBm
|
||||
\tSSID: NoDS
|
||||
";
|
||||
let obs = parse_iw_scan_output(output).unwrap();
|
||||
assert_eq!(obs.len(), 1);
|
||||
assert_eq!(obs[0].channel, 36); // Derived from 5180 MHz.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Adapter that scans WiFi BSSIDs on macOS by invoking a compiled Swift
|
||||
//! helper binary that uses Apple's CoreWLAN framework.
|
||||
//!
|
||||
//! This is the macOS counterpart to [`NetshBssidScanner`](super::NetshBssidScanner)
|
||||
//! on Windows. It follows ADR-025 (ORCA — macOS CoreWLAN WiFi Sensing).
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! Apple removed the `airport` CLI in macOS Sonoma 14.4+ and CoreWLAN is a
|
||||
//! Swift/Objective-C framework with no stable C ABI for Rust FFI. We therefore
|
||||
//! shell out to a small Swift helper (`mac_wifi`) that outputs JSON lines:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"ssid":"MyNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"}
|
||||
//! ```
|
||||
//!
|
||||
//! macOS Sonoma+ redacts real BSSID MACs to `00:00:00:00:00:00` unless the app
|
||||
//! holds the `com.apple.wifi.scan` entitlement. When we detect a zeroed BSSID
|
||||
//! we generate a deterministic synthetic MAC via `SHA-256(ssid:channel)[:6]`,
|
||||
//! setting the locally-administered bit so it never collides with real OUI
|
||||
//! allocations.
|
||||
//!
|
||||
//! # Platform
|
||||
//!
|
||||
//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
use crate::error::WifiScanError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MacosCoreWlanScanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Synchronous WiFi scanner that shells out to the `mac_wifi` Swift helper.
|
||||
///
|
||||
/// The helper binary must be compiled from `v1/src/sensing/mac_wifi.swift` and
|
||||
/// placed on `$PATH` or at a known location. The scanner invokes it with a
|
||||
/// `--scan-once` flag (single-shot mode) and parses the JSON output.
|
||||
///
|
||||
/// If the helper is not found, [`scan_sync`](Self::scan_sync) returns a
|
||||
/// [`WifiScanError::ProcessError`].
|
||||
pub struct MacosCoreWlanScanner {
|
||||
/// Path to the `mac_wifi` helper binary. Defaults to `"mac_wifi"` (on PATH).
|
||||
helper_path: String,
|
||||
}
|
||||
|
||||
impl MacosCoreWlanScanner {
|
||||
/// Create a scanner that looks for `mac_wifi` on `$PATH`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
helper_path: "mac_wifi".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a scanner with an explicit path to the Swift helper binary.
|
||||
pub fn with_path(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
helper_path: path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the Swift helper and parse the output synchronously.
|
||||
///
|
||||
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
|
||||
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let output = Command::new(&self.helper_path)
|
||||
.arg("--scan-once")
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
WifiScanError::ProcessError(format!(
|
||||
"failed to run mac_wifi helper ({}): {e}",
|
||||
self.helper_path
|
||||
))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WifiScanError::ScanFailed {
|
||||
reason: format!(
|
||||
"mac_wifi exited with {}: {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_macos_scan_output(&stdout)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MacosCoreWlanScanner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse the JSON-lines output from the `mac_wifi` Swift helper.
|
||||
///
|
||||
/// Each line is expected to be a JSON object with the fields:
|
||||
/// `ssid`, `bssid`, `rssi`, `noise`, `channel`, `band`.
|
||||
///
|
||||
/// Lines that fail to parse are silently skipped (the helper may emit
|
||||
/// status messages on stdout).
|
||||
pub fn parse_macos_scan_output(output: &str) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let now = Instant::now();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || !line.starts_with('{') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(obs) = parse_json_line(line, now) {
|
||||
results.push(obs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Parse a single JSON line into a [`BssidObservation`].
|
||||
///
|
||||
/// Uses a lightweight manual parser to avoid pulling in `serde_json` as a
|
||||
/// hard dependency. The JSON structure is simple and well-known.
|
||||
fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
|
||||
let ssid = extract_string_field(line, "ssid")?;
|
||||
let bssid_str = extract_string_field(line, "bssid")?;
|
||||
let rssi = extract_number_field(line, "rssi")?;
|
||||
let channel_f = extract_number_field(line, "channel")?;
|
||||
let channel = channel_f as u8;
|
||||
|
||||
// Resolve BSSID: use real MAC if available, otherwise generate synthetic.
|
||||
let bssid = resolve_bssid(&bssid_str, &ssid, channel)?;
|
||||
|
||||
let band = BandType::from_channel(channel);
|
||||
|
||||
// macOS CoreWLAN doesn't report radio type directly; infer from band/channel.
|
||||
let radio_type = infer_radio_type(channel);
|
||||
|
||||
// Convert RSSI to signal percentage using the standard mapping.
|
||||
let signal_pct = ((rssi + 100.0) * 2.0).clamp(0.0, 100.0);
|
||||
|
||||
Some(BssidObservation {
|
||||
bssid,
|
||||
rssi_dbm: rssi,
|
||||
signal_pct,
|
||||
channel,
|
||||
band,
|
||||
radio_type,
|
||||
ssid,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a BSSID string to a [`BssidId`].
|
||||
///
|
||||
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
|
||||
/// locally-administered MAC from `SHA-256(ssid:channel)`.
|
||||
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
// Try parsing the real BSSID first.
|
||||
if let Ok(id) = BssidId::parse(bssid_str) {
|
||||
// Check for the all-zeros redacted BSSID.
|
||||
if id.0 != [0, 0, 0, 0, 0, 0] {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes,
|
||||
// set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear).
|
||||
Some(synthetic_bssid(ssid, channel))
|
||||
}
|
||||
|
||||
/// Generate a deterministic synthetic BSSID from SSID and channel.
|
||||
///
|
||||
/// Uses a simple hash (FNV-1a-inspired) to avoid pulling in `sha2` crate.
|
||||
/// The locally-administered bit is set so these never collide with real OUI MACs.
|
||||
fn synthetic_bssid(ssid: &str, channel: u8) -> BssidId {
|
||||
// Simple but deterministic hash — FNV-1a 64-bit.
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &byte in ssid.as_bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
hash ^= u64::from(channel);
|
||||
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
||||
|
||||
let bytes = hash.to_le_bytes();
|
||||
let mut mac = [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]];
|
||||
|
||||
// Set locally-administered bit (bit 1 of byte 0) and clear multicast (bit 0).
|
||||
mac[0] = (mac[0] | 0x02) & 0xFE;
|
||||
|
||||
BssidId(mac)
|
||||
}
|
||||
|
||||
/// Infer radio type from channel number (best effort on macOS).
|
||||
fn infer_radio_type(channel: u8) -> RadioType {
|
||||
match channel {
|
||||
// 5 GHz channels → likely 802.11ac or newer
|
||||
36..=177 => RadioType::Ac,
|
||||
// 2.4 GHz → at least 802.11n
|
||||
_ => RadioType::N,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightweight JSON field extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract a string field value from a JSON object string.
|
||||
///
|
||||
/// Looks for `"key":"value"` or `"key": "value"` patterns.
|
||||
fn extract_string_field(json: &str, key: &str) -> Option<String> {
|
||||
let pattern = format!("\"{}\"", key);
|
||||
let key_pos = json.find(&pattern)?;
|
||||
let after_key = &json[key_pos + pattern.len()..];
|
||||
|
||||
// Skip optional whitespace and the colon.
|
||||
let after_colon = after_key.trim_start().strip_prefix(':')?;
|
||||
let after_colon = after_colon.trim_start();
|
||||
|
||||
// Expect opening quote.
|
||||
let after_quote = after_colon.strip_prefix('"')?;
|
||||
|
||||
// Find closing quote (handle escaped quotes).
|
||||
let mut end = 0;
|
||||
let bytes = after_quote.as_bytes();
|
||||
while end < bytes.len() {
|
||||
if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
|
||||
Some(after_quote[..end].to_owned())
|
||||
}
|
||||
|
||||
/// Extract a numeric field value from a JSON object string.
|
||||
///
|
||||
/// Looks for `"key": <number>` patterns.
|
||||
fn extract_number_field(json: &str, key: &str) -> Option<f64> {
|
||||
let pattern = format!("\"{}\"", key);
|
||||
let key_pos = json.find(&pattern)?;
|
||||
let after_key = &json[key_pos + pattern.len()..];
|
||||
|
||||
let after_colon = after_key.trim_start().strip_prefix(':')?;
|
||||
let after_colon = after_colon.trim_start();
|
||||
|
||||
// Collect digits, sign, and decimal point.
|
||||
let num_str: String = after_colon
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit() || *c == '-' || *c == '.' || *c == '+' || *c == 'e' || *c == 'E')
|
||||
.collect();
|
||||
|
||||
num_str.parse().ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_OUTPUT: &str = r#"
|
||||
{"ssid":"HomeNetwork","bssid":"aa:bb:cc:dd:ee:ff","rssi":-52,"noise":-90,"channel":36,"band":"5GHz"}
|
||||
{"ssid":"GuestWifi","bssid":"11:22:33:44:55:66","rssi":-71,"noise":-92,"channel":6,"band":"2.4GHz"}
|
||||
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parse_valid_output() {
|
||||
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
|
||||
assert_eq!(obs.len(), 3);
|
||||
|
||||
// First entry: real BSSID.
|
||||
assert_eq!(obs[0].ssid, "HomeNetwork");
|
||||
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
||||
assert!((obs[0].rssi_dbm - (-52.0)).abs() < f64::EPSILON);
|
||||
assert_eq!(obs[0].channel, 36);
|
||||
assert_eq!(obs[0].band, BandType::Band5GHz);
|
||||
|
||||
// Second entry: 2.4 GHz.
|
||||
assert_eq!(obs[1].ssid, "GuestWifi");
|
||||
assert_eq!(obs[1].channel, 6);
|
||||
assert_eq!(obs[1].band, BandType::Band2_4GHz);
|
||||
assert_eq!(obs[1].radio_type, RadioType::N);
|
||||
|
||||
// Third entry: redacted BSSID → synthetic MAC.
|
||||
assert_eq!(obs[2].ssid, "Redacted");
|
||||
// Should NOT be all-zeros.
|
||||
assert_ne!(obs[2].bssid.0, [0, 0, 0, 0, 0, 0]);
|
||||
// Should have locally-administered bit set.
|
||||
assert_eq!(obs[2].bssid.0[0] & 0x02, 0x02);
|
||||
// Should have unicast bit (multicast cleared).
|
||||
assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bssid_is_deterministic() {
|
||||
let a = synthetic_bssid("TestNet", 36);
|
||||
let b = synthetic_bssid("TestNet", 36);
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Different SSID or channel → different MAC.
|
||||
let c = synthetic_bssid("OtherNet", 36);
|
||||
assert_ne!(a, c);
|
||||
|
||||
let d = synthetic_bssid("TestNet", 6);
|
||||
assert_ne!(a, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_and_junk_lines() {
|
||||
let output = "\n \nnot json\n{broken json\n";
|
||||
let obs = parse_macos_scan_output(output).unwrap();
|
||||
assert!(obs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_string_field_basic() {
|
||||
let json = r#"{"ssid":"MyNet","bssid":"aa:bb:cc:dd:ee:ff"}"#;
|
||||
assert_eq!(extract_string_field(json, "ssid").unwrap(), "MyNet");
|
||||
assert_eq!(
|
||||
extract_string_field(json, "bssid").unwrap(),
|
||||
"aa:bb:cc:dd:ee:ff"
|
||||
);
|
||||
assert!(extract_string_field(json, "missing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_number_field_basic() {
|
||||
let json = r#"{"rssi":-52,"channel":36}"#;
|
||||
assert!((extract_number_field(json, "rssi").unwrap() - (-52.0)).abs() < f64::EPSILON);
|
||||
assert!((extract_number_field(json, "channel").unwrap() - 36.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_pct_clamping() {
|
||||
// RSSI -50 → pct = (-50+100)*2 = 100
|
||||
let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-50,"channel":1}"#;
|
||||
let obs = parse_json_line(json, Instant::now()).unwrap();
|
||||
assert!((obs.signal_pct - 100.0).abs() < f64::EPSILON);
|
||||
|
||||
// RSSI -100 → pct = 0
|
||||
let json = r#"{"ssid":"Test","bssid":"aa:bb:cc:dd:ee:ff","rssi":-100,"channel":1}"#;
|
||||
let obs = parse_json_line(json, Instant::now()).unwrap();
|
||||
assert!((obs.signal_pct - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,30 @@
|
||||
//! Adapter implementations for the [`WlanScanPort`] port.
|
||||
//!
|
||||
//! Each adapter targets a specific platform scanning mechanism:
|
||||
//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid`.
|
||||
//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path.
|
||||
//! - [`NetshBssidScanner`]: Tier 1 -- parses `netsh wlan show networks mode=bssid` (Windows).
|
||||
//! - [`WlanApiScanner`]: Tier 2 -- async wrapper with metrics and future native FFI path (Windows).
|
||||
//! - [`MacosCoreWlanScanner`]: CoreWLAN via Swift helper binary (macOS, ADR-025).
|
||||
//! - [`LinuxIwScanner`]: parses `iw dev <iface> scan` output (Linux).
|
||||
|
||||
pub(crate) mod netsh_scanner;
|
||||
pub mod wlanapi_scanner;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos_scanner;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux_scanner;
|
||||
|
||||
pub use netsh_scanner::NetshBssidScanner;
|
||||
pub use netsh_scanner::parse_netsh_output;
|
||||
pub use wlanapi_scanner::WlanApiScanner;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos_scanner::MacosCoreWlanScanner;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos_scanner::parse_macos_scan_output;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux_scanner::LinuxIwScanner;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux_scanner::parse_iw_scan_output;
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
//!
|
||||
//! - **Domain types**: [`BssidId`], [`BssidObservation`], [`BandType`], [`RadioType`]
|
||||
//! - **Port**: [`WlanScanPort`] -- trait abstracting the platform scan backend
|
||||
//! - **Adapter**: [`NetshBssidScanner`] -- Tier 1 adapter that parses
|
||||
//! `netsh wlan show networks mode=bssid` output
|
||||
//! - **Adapters**:
|
||||
//! - [`NetshBssidScanner`] -- Windows, parses `netsh wlan show networks mode=bssid`
|
||||
//! - `MacosCoreWlanScanner` -- macOS, invokes CoreWLAN Swift helper (ADR-025)
|
||||
//! - `LinuxIwScanner` -- Linux, parses `iw dev <iface> scan` output
|
||||
|
||||
pub mod adapter;
|
||||
pub mod domain;
|
||||
@@ -19,6 +21,16 @@ pub mod port;
|
||||
pub use adapter::NetshBssidScanner;
|
||||
pub use adapter::parse_netsh_output;
|
||||
pub use adapter::WlanApiScanner;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use adapter::MacosCoreWlanScanner;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use adapter::parse_macos_scan_output;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use adapter::LinuxIwScanner;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use adapter::parse_iw_scan_output;
|
||||
pub use domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
pub use domain::frame::MultiApFrame;
|
||||
pub use domain::registry::{BssidEntry, BssidMeta, BssidRegistry, RunningStats};
|
||||
|
||||
@@ -696,9 +696,6 @@ class MacosWifiCollector:
|
||||
bufsize=1 # Line buffered
|
||||
)
|
||||
|
||||
synth_tx = 0
|
||||
synth_rx = 0
|
||||
|
||||
while self._running and self._process and self._process.poll() is None:
|
||||
try:
|
||||
line = self._process.stdout.readline()
|
||||
@@ -720,16 +717,13 @@ class MacosWifiCollector:
|
||||
|
||||
link_quality = max(0.0, min(1.0, (rssi + 100.0) / 60.0))
|
||||
|
||||
synth_tx += 1500
|
||||
synth_rx += 3000
|
||||
|
||||
sample = WifiSample(
|
||||
timestamp=time.time(),
|
||||
rssi_dbm=rssi,
|
||||
noise_dbm=noise,
|
||||
link_quality=link_quality,
|
||||
tx_bytes=synth_tx,
|
||||
rx_bytes=synth_rx,
|
||||
tx_bytes=0,
|
||||
rx_bytes=0,
|
||||
retry_count=0,
|
||||
interface=self._interface,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user