use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::atomic::write_atomic;
use crate::{
derive_pinned_mac, is_known_stable_efuse, is_quirk_randomizer, AdapterSource, LearnerRecord,
MacAddr, MacPinsState, UsbId,
};
pub const NETWORKD_DIR: &str = "/etc/systemd/network";
pub const MACHINE_ID_PATHS: [&str; 2] = ["/etc/machine-id", "/var/lib/dbus/machine-id"];
pub const LEARN_THRESHOLD: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinLayout {
pub state_path: PathBuf,
pub networkd_dir: PathBuf,
pub link_prefix: String,
pub owner_label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetAdapter {
pub name: String,
pub usb_id: Option<UsbId>,
pub usb_path: String,
pub mac: Option<MacAddr>,
}
#[derive(Debug, Clone)]
pub struct ReconcileConfig {
pub enabled: bool,
pub apply_live_allowed: bool,
pub overrides: HashMap<String, String>,
pub layout: PinLayout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Stable,
Disabled,
Deferred(String),
Pin {
mac: MacAddr,
source: AdapterSource,
},
Candidate {
proposed: Option<MacAddr>,
},
Observe,
}
pub fn adapter_key(vidpid: &str, usb_path: &str) -> String {
format!("{vidpid}@{usb_path}")
}
pub fn link_file_name(layout: &PinLayout, key: &str) -> String {
let safe: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '@' | '-') {
c
} else {
'-'
}
})
.collect();
format!("{}{safe}.link", layout.link_prefix)
}
pub fn adapter_key_for_iface(state_path: &Path, iface: &str) -> Option<String> {
let doc = read_state(state_path)?;
doc.get("adapters")?.as_array()?.iter().find_map(|a| {
if a.get("name")?.as_str()? != iface {
return None;
}
let vidpid = a.get("vidpid")?.as_str()?;
let usb_path = a.get("usb_path")?.as_str()?;
(!vidpid.is_empty() && !usb_path.is_empty()).then(|| adapter_key(vidpid, usb_path))
})
}
pub fn stale_pin_links(
layout: &PinLayout,
existing: &[String],
present: &std::collections::HashSet<String>,
pinned: &std::collections::HashSet<String>,
) -> Vec<String> {
existing
.iter()
.filter(|name| is_pin_link_file(layout, name) && !pinned.contains(*name))
.filter(|name| present.contains(*name) || !name.contains('@'))
.cloned()
.collect()
}
pub fn is_pin_link_file(layout: &PinLayout, name: &str) -> bool {
name.starts_with(&layout.link_prefix) && name.ends_with(".link")
}
pub fn render_link_file(layout: &PinLayout, match_block: &str, mac: &MacAddr) -> String {
format!(
"# Pin a stable MAC on an onboard adapter with no efuse MAC (it would\n\
# otherwise randomize each boot and churn the DHCP lease). Managed by the\n\
# {}; remove this file to revert. NamePolicy=kernel keeps the kernel\n\
# interface name so the wpa_supplicant binding survives. The [Match] block\n\
# names exactly one adapter: MACAddress= below is unconditional, so a glob\n\
# here would give every interface on this box the same address.\n\
[Match]\n\
{}\n\
\n\
[Link]\n\
NamePolicy=kernel\n\
MACAddress={}\n",
layout.owner_label,
match_block.trim_end(),
mac
)
}
const IDENTITY_MATCH_KEYS: [&str; 4] =
["Path", "OriginalName", "PermanentMACAddress", "MACAddress"];
pub fn match_block_is_specific(match_block: &str) -> bool {
match_block.lines().any(|raw| {
let line = raw.trim();
let Some((key, value)) = line.split_once('=') else {
return false;
};
let key = key.trim();
let value = value.trim();
IDENTITY_MATCH_KEYS
.iter()
.any(|k| k.eq_ignore_ascii_case(key))
&& !value.is_empty()
&& !value.contains(['*', '?', '['])
&& !value.starts_with('!')
})
}
pub fn parse_match_block(link_body: &str) -> Option<String> {
let mut in_match = false;
let mut lines: Vec<&str> = Vec::new();
for raw in link_body.lines() {
let line = raw.trim();
if line.starts_with('[') && line.ends_with(']') {
if line.eq_ignore_ascii_case("[Match]") {
in_match = true;
continue;
}
if in_match {
break; }
continue;
}
if in_match && !line.is_empty() && !line.starts_with('#') {
lines.push(line);
}
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
pub fn parse_udev_id_path(props: &str) -> Option<String> {
for line in props.lines() {
let line = line.trim();
if let Some(value) = line.strip_prefix("ID_PATH=") {
let value = value.trim().trim_matches('"');
if !value.is_empty() && !value.contains(['*', '?', '[']) {
return Some(value.to_string());
}
}
}
None
}
#[allow(clippy::too_many_arguments)]
pub fn classify_adapter(
usb_id: UsbId,
key: &str,
vidpid: &str,
machine_id: Option<&str>,
salt: &str,
config: &ReconcileConfig,
link_mechanism: bool,
learner: Option<&LearnerRecord>,
with_learner: bool,
) -> Decision {
if let Some(raw) = config
.overrides
.get(key)
.or_else(|| config.overrides.get(vidpid))
{
return match (config.enabled, link_mechanism, MacAddr::parse(raw)) {
(false, _, _) => Decision::Disabled,
(true, false, _) => Decision::Deferred("no systemd-udev link mechanism".into()),
(true, true, Some(mac)) => Decision::Pin {
mac,
source: AdapterSource::Override,
},
(true, true, None) => Decision::Deferred("override MAC is malformed".into()),
};
}
if is_known_stable_efuse(usb_id) {
return Decision::Stable;
}
if is_quirk_randomizer(usb_id).is_some() {
if !config.enabled {
return Decision::Disabled;
}
if !link_mechanism {
return Decision::Deferred("no systemd-udev link mechanism".into());
}
return match machine_id.and_then(|m| derive_pinned_mac(m, salt)) {
Some(mac) => Decision::Pin {
mac,
source: AdapterSource::Quirk,
},
None => Decision::Deferred("no machine-id to derive a stable MAC".into()),
};
}
if !with_learner {
return Decision::Observe;
}
match learner {
Some(rec) if rec.mac_change_count >= LEARN_THRESHOLD => {
let proposed = machine_id.and_then(|m| derive_pinned_mac(m, salt));
Decision::Candidate { proposed }
}
_ => Decision::Observe,
}
}
pub fn load_state_from(path: &Path) -> MacPinsState {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_state_to(path: &Path, state: &MacPinsState) -> std::io::Result<()> {
let body = serde_json::to_vec_pretty(state).map_err(std::io::Error::other)?;
write_atomic(path, &body, 0o644)
}
mod projection;
pub use projection::{adapter_to_camel, adapters_camel_from, read_state, state_json_to_camel};
pub fn read_machine_id() -> Option<String> {
for p in MACHINE_ID_PATHS {
if let Ok(s) = std::fs::read_to_string(p) {
let t = s.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const UDEV_RELOAD_ARGV: &[&[&str]] = &[&["control", "--reload"]];
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const IP_CMD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const UDEV_CMD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use crate::{AdapterState, AdapterVerdict};
use std::path::PathBuf;
use std::process::Command;
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn enumerate_net_adapters() -> Vec<NetAdapter> {
let mut out = Vec::new();
let read = match std::fs::read_dir("/sys/class/net") {
Ok(r) => r,
Err(_) => return out,
};
for entry in read.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == "lo" {
continue;
}
let base = entry.path();
let device = base.join("device");
if !device.exists() {
continue;
}
let (usb_id, usb_path) = resolve_usb_identity(&device);
let mac = std::fs::read_to_string(base.join("address"))
.ok()
.and_then(|s| MacAddr::parse(s.trim()));
out.push(NetAdapter {
name,
usb_id,
usb_path,
mac,
});
}
out
}
fn resolve_usb_identity(device_link: &Path) -> (Option<UsbId>, String) {
let mut cur = match std::fs::canonicalize(device_link) {
Ok(p) => p,
Err(_) => return (None, String::new()),
};
for _ in 0..6 {
let vid = std::fs::read_to_string(cur.join("idVendor")).ok();
let pid = std::fs::read_to_string(cur.join("idProduct")).ok();
if let (Some(v), Some(p)) = (vid, pid) {
let vid = u16::from_str_radix(v.trim(), 16).ok();
let pid = u16::from_str_radix(p.trim(), 16).ok();
let path = cur
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let (Some(vid), Some(pid)) = (vid, pid) {
return (Some(UsbId { vid, pid }), path);
}
}
match cur.parent() {
Some(p) => cur = p.to_path_buf(),
None => break,
}
}
(None, String::new())
}
pub fn link_mechanism_available() -> bool {
Path::new("/run/udev").exists() || Path::new("/run/systemd/netif").exists()
}
fn udev_id_path(iface: &str) -> Option<String> {
let target = format!("/sys/class/net/{iface}");
let out = Command::new("udevadm")
.args(["info", "--query=property", "--path", &target])
.output()
.ok()?;
parse_udev_id_path(&String::from_utf8_lossy(&out.stdout))
}
pub fn resolve_match_block(iface: &str) -> Option<String> {
udev_id_path(iface).map(|p| format!("Path={p}"))
}
pub fn write_pin_link(
layout: &PinLayout,
key: &str,
match_block: &str,
mac: &MacAddr,
) -> std::io::Result<PathBuf> {
if !match_block_is_specific(match_block) {
return Err(std::io::Error::other(format!(
"refusing to pin {key}: [Match] block {match_block:?} does not name one adapter"
)));
}
let path = layout.networkd_dir.join(link_file_name(layout, key));
let body = render_link_file(layout, match_block, mac);
let unchanged = std::fs::read_to_string(&path)
.map(|cur| cur == body)
.unwrap_or(false);
if !unchanged {
write_atomic(&path, body.as_bytes(), 0o644)?;
reload_udev();
}
Ok(path)
}
pub async fn remove_pin_link(layout: &PinLayout, key: &str) -> std::io::Result<bool> {
let path = layout.networkd_dir.join(link_file_name(layout, key));
if path.exists() {
std::fs::remove_file(&path)?;
reload_udev_async().await;
Ok(true)
} else {
Ok(false)
}
}
fn sweep_pin_links(
layout: &PinLayout,
present: &std::collections::HashSet<String>,
pinned: &std::collections::HashSet<String>,
) -> usize {
let dir = layout.networkd_dir.as_path();
let existing: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
let mut removed = 0;
for name in stale_pin_links(layout, &existing, present, pinned) {
match std::fs::remove_file(dir.join(&name)) {
Ok(()) => {
removed += 1;
tracing::info!(file = %name, "removed a stale MAC pin link");
}
Err(e) => {
tracing::warn!(file = %name, error = %e, "stale MAC pin link not removed")
}
}
}
if removed > 0 {
reload_udev();
}
removed
}
pub async fn apply_live(iface: &str, mac: &MacAddr) -> std::io::Result<()> {
let mac = mac.to_string();
run_ip(&["link", "set", "dev", iface, "down"]).await?;
run_ip(&["link", "set", "dev", iface, "address", &mac]).await?;
run_ip(&["link", "set", "dev", iface, "up"]).await?;
Ok(())
}
async fn run_ip(args: &[&str]) -> std::io::Result<()> {
let status = tokio::time::timeout(
IP_CMD_TIMEOUT,
tokio::process::Command::new("ip")
.args(args)
.kill_on_drop(true)
.status(),
)
.await
.map_err(|_| std::io::Error::other(format!("ip {args:?} timed out")))??;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!("ip {args:?} failed")))
}
}
fn reload_udev() {
for argv in UDEV_RELOAD_ARGV {
let _ = Command::new("udevadm").args(*argv).status();
}
}
async fn reload_udev_async() {
for argv in UDEV_RELOAD_ARGV {
let _ = tokio::time::timeout(
UDEV_CMD_TIMEOUT,
tokio::process::Command::new("udevadm")
.args(*argv)
.kill_on_drop(true)
.status(),
)
.await;
}
}
pub fn reconcile(config: &ReconcileConfig, with_learner: bool) -> MacPinsState {
let layout = &config.layout;
let mut state = load_state_from(&layout.state_path);
let adapters = enumerate_net_adapters();
let machine_id = read_machine_id();
let link_mechanism = link_mechanism_available();
let now = now_unix();
let mut verdicts = Vec::new();
let mut present = std::collections::HashSet::new();
let mut pinned = std::collections::HashSet::new();
for a in &adapters {
let usb_id = match a.usb_id {
Some(id) => id,
None => continue, };
let vidpid = format!("{:04x}:{:04x}", usb_id.vid, usb_id.pid);
let key = adapter_key(&vidpid, &a.usb_path);
present.insert(link_file_name(layout, &key));
let salt = a.usb_path.as_str();
let is_unknown = !config.overrides.contains_key(&vidpid)
&& !config.overrides.contains_key(&key)
&& !is_known_stable_efuse(usb_id)
&& is_quirk_randomizer(usb_id).is_none();
if is_unknown {
if let Some(mac) = a.mac {
fold_learner(&mut state.learner, &vidpid, &a.usb_path, mac, now);
}
}
let learner_rec = state
.learner
.iter()
.find(|r| r.vidpid == vidpid && r.usb_path == a.usb_path)
.cloned();
let decision = classify_adapter(
usb_id,
&key,
&vidpid,
machine_id.as_deref(),
salt,
config,
link_mechanism,
learner_rec.as_ref(),
with_learner,
);
let verdict = realize(layout, decision, a, &vidpid, &key);
if verdict.state == AdapterState::Pinned {
pinned.insert(link_file_name(layout, &key));
}
verdicts.push(verdict);
}
sweep_pin_links(layout, &present, &pinned);
state.adapters = verdicts;
state.updated_at = now;
let _ = save_state_to(&layout.state_path, &state);
state
}
fn realize(
layout: &PinLayout,
decision: Decision,
a: &NetAdapter,
vidpid: &str,
key: &str,
) -> AdapterVerdict {
let mut v = AdapterVerdict {
name: a.name.clone(),
vidpid: vidpid.to_string(),
usb_path: a.usb_path.clone(),
state: AdapterState::Stable,
source: None,
pinned_mac: None,
last_seen_mac: a.mac,
applied_live: false,
link_file: None,
deferred_reason: None,
};
match decision {
Decision::Stable | Decision::Observe => {}
Decision::Disabled => v.state = AdapterState::Disabled,
Decision::Deferred(reason) => {
v.state = AdapterState::Deferred;
v.deferred_reason = Some(reason);
}
Decision::Candidate { proposed } => {
v.state = AdapterState::Candidate;
v.source = Some(AdapterSource::Learned);
v.pinned_mac = proposed;
}
Decision::Pin { mac, source } => {
let Some(match_block) = resolve_match_block(&a.name) else {
v.state = AdapterState::Deferred;
v.deferred_reason =
Some("no stable device path (udev ID_PATH) to match on".into());
tracing::warn!(iface = %a.name, "MAC pin deferred: no stable device path");
return v;
};
match write_pin_link(layout, key, &match_block, &mac) {
Ok(path) => {
v.state = AdapterState::Pinned;
v.source = Some(source);
v.pinned_mac = Some(mac);
v.link_file = Some(path.to_string_lossy().to_string());
tracing::info!(
iface = %a.name, key, mac = %mac,
"pinned a stable MAC on a no-efuse adapter (next boot)"
);
}
Err(e) => {
v.state = AdapterState::Deferred;
v.deferred_reason = Some(format!("link write failed: {e}"));
tracing::warn!(iface = %a.name, error = %e, "MAC pin link write failed");
}
}
}
}
v
}
}
#[cfg(target_os = "linux")]
pub use linux::{
apply_live, enumerate_net_adapters, link_mechanism_available, reconcile, remove_pin_link,
resolve_match_block, write_pin_link,
};
#[cfg(not(target_os = "linux"))]
pub fn enumerate_net_adapters() -> Vec<NetAdapter> {
Vec::new()
}
#[cfg(not(target_os = "linux"))]
pub fn link_mechanism_available() -> bool {
false
}
#[cfg(not(target_os = "linux"))]
pub fn reconcile(_config: &ReconcileConfig, _with_learner: bool) -> MacPinsState {
MacPinsState::default()
}
pub fn fold_learner(
learner: &mut Vec<LearnerRecord>,
vidpid: &str,
usb_path: &str,
current: MacAddr,
now: u64,
) {
if let Some(rec) = learner
.iter_mut()
.find(|r| r.vidpid == vidpid && r.usb_path == usb_path)
{
rec.boot_count = rec.boot_count.saturating_add(1);
if rec.last_mac != current {
rec.mac_change_count = rec.mac_change_count.saturating_add(1);
rec.last_mac = current;
}
} else {
learner.push(LearnerRecord {
vidpid: vidpid.to_string(),
usb_path: usb_path.to_string(),
last_mac: current,
first_seen: now,
boot_count: 1,
mac_change_count: 0,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn layout() -> PinLayout {
PinLayout {
state_path: PathBuf::from("/tmp/test-mac-pins.state"),
networkd_dir: PathBuf::from(NETWORKD_DIR),
link_prefix: "10-test-mac-".into(),
owner_label: "test suite".into(),
}
}
fn cfg(enabled: bool) -> ReconcileConfig {
ReconcileConfig {
enabled,
apply_live_allowed: false,
overrides: HashMap::new(),
layout: layout(),
}
}
#[test]
fn link_filename_sorts_before_stock_and_names_the_adapter() {
let l = layout();
let name = link_file_name(&l, &adapter_key("a69c:8d81", "5-1.3"));
assert_eq!(name, format!("10-test-mac-{}.link", "a69c-8d81@5-1.3"));
assert!(is_pin_link_file(&l, &name));
assert!(name.as_str() < "50-board-wifi.link");
}
#[test]
fn an_override_follows_the_adapter_not_the_interface_name() {
let mut c = cfg(true);
c.overrides
.insert(adapter_key("1234:5678", "1-2"), "02:11:22:33:44:55".into());
c.overrides
.insert("wlan0".into(), "02:aa:aa:aa:aa:aa".into());
let id = UsbId {
vid: 0x1234,
pid: 0x5678,
};
let pin = |key: &str| {
classify_adapter(id, key, "1234:5678", Some("m"), "", &c, true, None, false)
};
assert_eq!(
pin(&adapter_key("1234:5678", "1-2")),
Decision::Pin {
mac: MacAddr::parse("02:11:22:33:44:55").unwrap(),
source: AdapterSource::Override
}
);
assert_eq!(pin(&adapter_key("1234:5678", "1-3")), Decision::Observe);
}
#[test]
fn stale_pin_links_are_swept_but_an_unplugged_adapter_keeps_its_pin() {
let set = |names: &[&str]| -> std::collections::HashSet<String> {
names.iter().map(|s| s.to_string()).collect()
};
let l = layout();
let kept = link_file_name(&l, &adapter_key("a69c:8d81", "5-1.3"));
let unpinned = link_file_name(&l, &adapter_key("a69c:8d81", "5-1.4"));
let unplugged = link_file_name(&l, &adapter_key("a69c:8d81", "3-1"));
let existing = vec![
kept.clone(),
unpinned.clone(),
unplugged.clone(),
"10-test-mac-wlan0.link".to_string(),
"50-board-wifi.link".to_string(),
];
let mut stale = stale_pin_links(
&l,
&existing,
&set(&[kept.as_str(), unpinned.as_str()]),
&set(&[kept.as_str()]),
);
stale.sort();
assert_eq!(stale, vec![unpinned, "10-test-mac-wlan0.link".to_string()]);
}
#[test]
fn reload_udev_never_triggers_the_live_net_link() {
for argv in UDEV_RELOAD_ARGV {
assert!(
!argv.contains(&"trigger"),
"reload must not re-trigger devices: {argv:?}"
);
assert!(
!argv.iter().any(|a| a.contains("subsystem-match=net")),
"reload must never target the net subsystem: {argv:?}"
);
}
assert!(UDEV_RELOAD_ARGV
.iter()
.any(|argv| argv.contains(&"control") && argv.contains(&"--reload")));
}
#[test]
fn render_carries_match_namepolicy_and_mac() {
let mac = MacAddr::parse("02:c6:75:83:1a:3e").unwrap();
let body = render_link_file(&layout(), "Path=platform-xhci-hcd.0-usb-0:1.3:1.0", &mac);
assert!(body.contains("Managed by the\n# test suite; remove"));
assert!(body.contains("[Match]\nPath=platform-xhci-hcd.0-usb-0:1.3:1.0"));
assert!(body.contains("NamePolicy=kernel"));
assert!(body.contains("MACAddress=02:c6:75:83:1a:3e"));
}
#[test]
fn parse_match_block_extracts_only_match_lines() {
let body =
"# comment\n[Match]\nOriginalName=wlan*\nDriver=usb\n\n[Link]\nNamePolicy=kernel\n";
assert_eq!(
parse_match_block(body).as_deref(),
Some("OriginalName=wlan*\nDriver=usb")
);
let mac = MacAddr::parse("02:00:00:00:00:01").unwrap();
let rendered = render_link_file(&layout(), "OriginalName=wlan0", &mac);
assert_eq!(
parse_match_block(&rendered).as_deref(),
Some("OriginalName=wlan0")
);
}
#[test]
fn a_wildcard_match_is_not_specific_enough_to_pin() {
assert!(!match_block_is_specific("OriginalName=*"));
assert!(!match_block_is_specific("OriginalName=wlan*"));
assert!(!match_block_is_specific("Driver=usb\nType=wlan"));
assert!(!match_block_is_specific("OriginalName=!eth0"));
assert!(!match_block_is_specific(""));
assert!(match_block_is_specific("OriginalName=wlan0"));
assert!(match_block_is_specific(
"Path=platform-xhci-hcd.0-usb-0:1.3:1.0"
));
assert!(match_block_is_specific(
"Driver=usb\nPermanentMACAddress=00:c0:ca:b1:0d:a2"
));
assert!(match_block_is_specific(
"OriginalName=wlan*\nPath=pci-0000:01:00.0"
));
}
#[test]
fn id_path_is_read_from_udev_properties() {
let props = "ID_BUS=usb\nID_PATH=platform-xhci-hcd.0-usb-0:1.3:1.0\nID_PATH_TAG=platform_xhci_hcd_0_usb_0_1_3_1_0\n";
assert_eq!(
parse_udev_id_path(props).as_deref(),
Some("platform-xhci-hcd.0-usb-0:1.3:1.0")
);
assert_eq!(parse_udev_id_path("ID_BUS=usb\n"), None);
assert_eq!(parse_udev_id_path("ID_PATH=usb-*\n"), None);
assert_eq!(parse_udev_id_path("ID_PATH=\n"), None);
}
#[test]
fn quirk_adapter_pins_when_armed() {
let d = classify_adapter(
UsbId {
vid: 0xa69c,
pid: 0x8d81,
},
"wlan0",
"a69c:8d81",
Some("03851cd61fc642d781d3f93a00e624cd"),
"",
&cfg(true),
true,
None,
false,
);
assert_eq!(
d,
Decision::Pin {
mac: MacAddr::parse("02:c6:75:83:1a:3e").unwrap(),
source: AdapterSource::Quirk
}
);
}
#[test]
fn two_quirk_adapters_salted_by_usb_path_get_distinct_macs() {
let machine_id = "03851cd61fc642d781d3f93a00e624cd";
let id = UsbId {
vid: 0xa69c,
pid: 0x8d81,
};
let single = classify_adapter(
id,
"wlan0",
"a69c:8d81",
Some(machine_id),
"",
&cfg(true),
true,
None,
false,
);
assert_eq!(
single,
Decision::Pin {
mac: MacAddr::parse("02:c6:75:83:1a:3e").unwrap(),
source: AdapterSource::Quirk
}
);
let a = classify_adapter(
id,
"wlan0",
"a69c:8d81",
Some(machine_id),
"5-1.3",
&cfg(true),
true,
None,
false,
);
let b = classify_adapter(
id,
"wlan1",
"a69c:8d81",
Some(machine_id),
"5-1.4",
&cfg(true),
true,
None,
false,
);
let (Decision::Pin { mac: mac_a, .. }, Decision::Pin { mac: mac_b, .. }) = (a, b) else {
panic!("both quirk adapters should pin");
};
assert_ne!(mac_a, mac_b);
assert_ne!(mac_a, MacAddr::parse("02:c6:75:83:1a:3e").unwrap());
}
#[test]
fn quirk_adapter_deferred_or_disabled_when_blocked() {
assert_eq!(
classify_adapter(
UsbId {
vid: 0xa69c,
pid: 1
},
"wlan0",
"a69c:0001",
Some("m"),
"",
&cfg(false),
true,
None,
false
),
Decision::Disabled
);
assert!(matches!(
classify_adapter(
UsbId {
vid: 0xa69c,
pid: 1
},
"wlan0",
"a69c:0001",
Some("m"),
"",
&cfg(true),
false,
None,
false
),
Decision::Deferred(_)
));
assert!(matches!(
classify_adapter(
UsbId {
vid: 0xa69c,
pid: 1
},
"wlan0",
"a69c:0001",
None,
"",
&cfg(true),
true,
None,
false
),
Decision::Deferred(_)
));
}
#[test]
fn efuse_radio_is_left_stable() {
let d = classify_adapter(
UsbId {
vid: 0x0bda,
pid: 0xa81a,
},
"wlxabc",
"0bda:a81a",
Some("m"),
"",
&cfg(true),
true,
None,
true,
);
assert_eq!(d, Decision::Stable);
}
#[test]
fn override_pins_an_unknown_adapter() {
let mut c = cfg(true);
c.overrides
.insert("1234:5678".into(), "02:11:22:33:44:55".into());
let d = classify_adapter(
UsbId {
vid: 0x1234,
pid: 0x5678,
},
"wlan1",
"1234:5678",
Some("m"),
"",
&c,
true,
None,
true,
);
assert_eq!(
d,
Decision::Pin {
mac: MacAddr::parse("02:11:22:33:44:55").unwrap(),
source: AdapterSource::Override
}
);
}
#[test]
fn unknown_adapter_only_candidate_after_threshold() {
let id = UsbId {
vid: 0x1234,
pid: 0x5678,
};
let rec = LearnerRecord {
vidpid: "1234:5678".into(),
usb_path: "1-1".into(),
last_mac: MacAddr([0; 6]),
first_seen: 0,
boot_count: 3,
mac_change_count: 1,
};
assert_eq!(
classify_adapter(
id,
"wlan1",
"1234:5678",
Some("m"),
"",
&cfg(true),
true,
Some(&rec),
true
),
Decision::Observe
);
let rec2 = LearnerRecord {
mac_change_count: LEARN_THRESHOLD,
..rec.clone()
};
assert!(matches!(
classify_adapter(
id,
"wlan1",
"1234:5678",
Some("m"),
"",
&cfg(true),
true,
Some(&rec2),
true
),
Decision::Candidate { proposed: Some(_) }
));
assert_eq!(
classify_adapter(
id,
"wlan1",
"1234:5678",
Some("m"),
"",
&cfg(true),
true,
Some(&rec2),
false
),
Decision::Observe
);
}
#[test]
fn fold_learner_tracks_changes() {
let mut l = Vec::new();
let a = MacAddr([1, 2, 3, 4, 5, 6]);
let b = MacAddr([9, 8, 7, 6, 5, 4]);
fold_learner(&mut l, "v:p", "1-1", a, 100); assert_eq!(l[0].boot_count, 1);
assert_eq!(l[0].mac_change_count, 0);
fold_learner(&mut l, "v:p", "1-1", b, 200); assert_eq!(l[0].boot_count, 2);
assert_eq!(l[0].mac_change_count, 1);
fold_learner(&mut l, "v:p", "1-1", b, 300); assert_eq!(l[0].boot_count, 3);
assert_eq!(l[0].mac_change_count, 1);
}
#[test]
fn state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mac-pins.state");
let mut st = MacPinsState::default();
st.learner.push(LearnerRecord {
vidpid: "a:b".into(),
usb_path: "1-1".into(),
last_mac: MacAddr([0; 6]),
first_seen: 1,
boot_count: 1,
mac_change_count: 0,
});
save_state_to(&path, &st).unwrap();
assert_eq!(load_state_from(&path), st);
assert_eq!(
load_state_from(&dir.path().join("nope")),
MacPinsState::default()
);
}
}