Gate the punch and ACL permission tests to the platforms that can run them

GitHub CI runs unit tests on Windows and macOS; the local gate is Linux
only and saw neither failure.

The five punch tests distinguish a spoofer from a planned target by source
IP, so each needs its own loopback address. Only Linux treats all of 127/8
as local, so the bind panicked elsewhere. Collapsing them onto 127.0.0.1
would make every source rank RemappedPort and the tests would stop testing
what they exist for, so they are gated to Linux instead. The ranking
decision itself stays covered everywhere by the rank_punch_source unit
tests, which take no sockets.

The three ACL permission-fault tests make a file unreadable through the
unix mode bits, which Windows has no equivalent for: a read-only NTFS file
is still readable, so the fault cannot be produced there.

Both coverage gaps are recorded at the site rather than treated as
discharged.
This commit is contained in:
Johnathan Corgan
2026-08-23 13:58:14 +01:00
parent e0fb8d363d
commit 97d64f5d56
2 changed files with 32 additions and 0 deletions
+19
View File
@@ -1375,9 +1375,21 @@ async fn signal_events_use_current_timestamps() {
assert!(created_at <= after);
}
/// These punch tests distinguish a spoofer from a planned target by source
/// **IP**, so each needs its own loopback address. Only Linux treats the whole
/// of 127/8 as local; macOS and Windows bind 127.0.0.1 alone unless an alias is
/// added, so the bind panics there. They are gated to Linux rather than
/// rewritten onto one address, because collapsing them onto 127.0.0.1 would
/// make every source rank `RemappedPort` and the tests would stop testing what
/// they are for.
///
/// **Coverage gap**: on macOS and Windows nothing exercises `run_punch_attempt`
/// end to end. The ranking decision itself is covered on every platform by the
/// `rank_punch_source_*` unit tests above, which take no sockets.
/// A loopback socket bound on `host`, non-blocking as both production call
/// sites leave it, since `run_punch_attempt` hands it straight to
/// `UdpSocket::from_std`.
#[cfg(target_os = "linux")]
fn punch_socket(host: &str) -> std::net::UdpSocket {
let socket = std::net::UdpSocket::bind(format!("{host}:0")).expect("bind a loopback socket");
socket
@@ -1398,12 +1410,14 @@ fn immediate_punch_hint(duration_ms: u64) -> PunchHint {
/// Send one well-formed probe carrying `session_id`'s hash from `from` to
/// `to`, which is what a replay of captured punch bytes looks like.
#[cfg(target_os = "linux")]
fn send_probe(from: &std::net::UdpSocket, to: SocketAddr, session_id: &str) {
let packet = build_punch_packet(PunchPacketKind::Probe, 1, session_id);
from.send_to(&packet, to).expect("probe should send");
}
/// Whether anything readable on `socket` is a punch ack.
#[cfg(target_os = "linux")]
fn received_an_ack(socket: &std::net::UdpSocket) -> bool {
let mut buf = [0u8; 2048];
while let Ok((len, _)) = socket.recv_from(&mut buf) {
@@ -1448,6 +1462,7 @@ fn rank_punch_source_rejects_an_address_we_never_planned_to_probe() {
/// every probe, so anyone who has seen one can replay it. Acceptance is now
/// constrained to the targets this node planned; the spoofer is neither
/// adopted nor acked, and an ack would be a reflection we control.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn a_matching_punch_packet_from_an_unplanned_source_is_neither_adopted_nor_acked() {
let victim = punch_socket("127.0.0.3");
@@ -1477,6 +1492,7 @@ async fn a_matching_punch_packet_from_an_unplanned_source_is_neither_adopted_nor
}
/// The spoofer wins the race on arrival order and still loses on address.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn a_planned_source_is_adopted_even_when_a_spoofer_replies_first() {
let victim = punch_socket("127.0.0.3");
@@ -1505,6 +1521,7 @@ async fn a_planned_source_is_adopted_even_when_a_spoofer_replies_first() {
/// The healthy path, which is the check that the source constraint does not
/// red a legitimately clean run: one probe from the single planned target is
/// adopted immediately and acked.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn the_ordinary_probe_from_a_planned_target_is_still_adopted_and_acked() {
let victim = punch_socket("127.0.0.3");
@@ -1534,6 +1551,7 @@ async fn the_ordinary_probe_from_a_planned_target_is_still_adopted_and_acked() {
/// shares a planned target's IP. Adopting it is the main class of NAT pairing
/// punching exists to rescue, and this test reds if the rule is ever tightened
/// to exact matching without that being reopened deliberately.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn a_planned_targets_remapped_port_is_adopted_when_that_is_all_that_arrives() {
let victim = punch_socket("127.0.0.3");
@@ -1562,6 +1580,7 @@ async fn a_planned_targets_remapped_port_is_adopted_when_that_is_all_that_arrive
/// An exact match inside the settle window supersedes a remapped one that
/// arrived first, which is what the window is for.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn an_exact_target_supersedes_a_remapped_port_inside_the_settle_window() {
let victim = punch_socket("127.0.0.3");
+13
View File
@@ -1027,21 +1027,32 @@ mod tests {
);
}
/// The three permission-fault tests below make a file unreadable through
/// the unix mode bits, which Windows has no equivalent for: a read-only
/// NTFS file is still readable, so the fault they need cannot be produced.
/// They are gated to unix rather than made to pass vacuously elsewhere.
///
/// **Coverage gap**: on Windows nothing exercises the reloader's
/// unreadable-input path, so the fail-open defect this fix closes is
/// unverified there.
/// Make a file unreadable, returning false if the effective uid can read
/// it anyway. Root bypasses the mode bits, so the permission-fault tests
/// cannot run there and skip instead of passing vacuously; that leaves
/// the EACCES path unexercised in any root CI job.
#[cfg(unix)]
fn make_unreadable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).unwrap();
std::fs::read_to_string(path).is_err()
}
#[cfg(unix)]
fn make_readable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn test_acl_reload_holds_last_good_snapshot_when_deny_file_unreadable() {
let dir = tempfile::tempdir().unwrap();
@@ -1075,6 +1086,7 @@ mod tests {
make_readable(&deny);
}
#[cfg(unix)]
#[tokio::test]
async fn test_acl_reload_retries_after_a_transient_read_error() {
let dir = tempfile::tempdir().unwrap();
@@ -1101,6 +1113,7 @@ mod tests {
assert!(!reloader.status().stale);
}
#[cfg(unix)]
#[tokio::test]
async fn test_acl_reload_holds_last_good_when_the_hosts_file_becomes_unreadable() {
let dir = tempfile::tempdir().unwrap();