chore(lints): enforce per-crate lint policy and add build profiles

Give every member crate its own `[lints.clippy]` (`pedantic`, `todo`,
`unwrap_used`) and `[lints.rust]` block, add explicit `release` and
`production` profiles to the workspace manifest, and fix the code so that
`just clippy` -- `cargo clippy --workspace --all-targets --all-features
-- -D warnings` -- passes with zero warnings.

Declaring the lints is the easy half and on its own it was actively
harmful: `unsafe_code = "forbid"` cannot be overridden from source, so it
broke `lanparty-gateway`, `lanparty-client-tap`, and
`lanparty-client-route`, which all need OS FFI, and the workspace no
longer compiled on Linux at all. The rest of the workspace produced
roughly 120 denied lints. A lint policy nobody can satisfy is worse than
no policy, so this commit makes the tree actually clean.

Unsafe policy: the three FFI crates use `unsafe_code = "deny"`, and the
one module in each that holds the FFI (`gateway::packet`,
`client_tap::windows`, `client_route::windows`) opts back in with a
documented `#![allow(unsafe_code)]`. Every other crate keeps `"forbid"`.
`deny` was chosen over dropping the lint so that `unsafe` outside those
modules is still a hard error, and over per-block allows because the FFI
is dense enough that per-block attributes would drown the code.

Build profiles: `release` stays optimized but debuggable (debug info,
debug assertions, overflow checks, no LTO, incremental) so that a bug
reproduced at close-to-real speed still panics loudly and gives a usable
backtrace. `production` inherits from it and turns all of that off, adding
fat LTO and a single codegen unit, and is what ships. Note the
consequence: `cargo build --release` binaries are now slower than before
this commit and must not be shipped; use `just build-production`.

Code changes made to satisfy the lints, grouped by kind:

- `# Errors` sections on every public fallible function, and `# Panics`
  on `RoomRegistry::new`. The Windows-only implementations and their
  non-Windows `bail!` stubs are documented in parallel so the crates stay
  clean when built for Windows too, which CI on Linux cannot check.
- Panicking paths removed rather than documented where the panic was only
  an unreachable invariant: `OverlayHeader::decode` and
  `declared_payload_len` take a fixed-size prefix via `first_chunk`
  instead of `try_into().expect(...)`, and `RoomRegistry::join` returns an
  `InternalError` reject instead of `expect`ing the room it just inserted.
- Tests keep using `unwrap`/`unwrap_err`: each crate root carries
  `#![cfg_attr(test, allow(clippy::unwrap_used))]`. The lint is about
  production code; an `expect` message per assertion buys nothing.
- Lossy casts replaced with `try_from` where a real conversion was
  happening (datagram-size negotiation, control-frame length prefix,
  MTU clamping). In `gateway::packet` the cast lints are allowed
  module-wide instead: those casts move values between libc's C types
  and ours after the value is already known to fit, so fallible
  conversions would only add unreachable branches.
- Signature changes, all on private or crate-internal items except one:
  `RoomRegistry::join` now takes `&EndpointHello` (public, it never
  consumed the hello), `Room::join` and `reject_control_error` likewise.
  `Room`'s `room_id` field is now `id`.
- `#[allow(clippy::too_many_lines)]` with a reason on three end-to-end
  test scenarios and on `bridge_until_shutdown`, whose `select!` loop
  mutates state shared by every arm; splitting it would hide that.
- Mechanical fixes from `cargo clippy --fix`: `map_or_else`, `let...else`,
  inline format args, backticks in doc comments, `Duration::from_mins`.

No runtime behavior changes: the only observable differences are the
error text when a relay room lookup fails immediately after insertion
(previously a panic) and the profile rename described above.

Also documents the lint policy and the profile table in README.md, whose
build section now points at the `just` recipes it should have used all
along.

Test Plan:
- `just clippy` -- passes, zero warnings
- `just test` -- 186 tests pass, 0 failed
- `cargo check --profile production --workspace --all-features` -- passes
- `just fmt` -- clean
- Not verified: the Windows-only code paths and their doc comments, which
  need a Windows target to compile.

Refs: https://doc.rust-lang.org/cargo/reference/profiles.html
Refs: https://rust-lang.github.io/rust-clippy/master/index.html#missing_errors_doc
Refs: https://doc.rust-lang.org/reference/attributes/diagnostics.html#lint-check-attributes
This commit is contained in:
2026-08-16 19:00:54 +02:00
parent 2bed62e9ec
commit e62f584377
35 changed files with 1024 additions and 227 deletions
+10
View File
@@ -18,3 +18,13 @@ tokio.workspace = true
[dev-dependencies]
rcgen.workspace = true
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
# `deny` rather than `forbid`: this crate needs OS FFI, so single
# modules opt back in with `#![allow(unsafe_code)]`.
unsafe_code = "deny"
+79 -30
View File
@@ -1,7 +1,8 @@
//! Linux LAN gateway control-plane connection.
//!
//! This crate owns the gateway binary's relay connection and Linux AF_PACKET
//! This crate owns the gateway binary's relay connection and Linux `AF_PACKET`
//! bridge loop that moves Ethernet frames between the relay and wired LAN.
#![cfg_attr(test, allow(clippy::unwrap_used))]
#[cfg(target_os = "linux")]
mod packet;
@@ -62,7 +63,7 @@ use tokio::io::unix::AsyncFd;
const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;
const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
#[cfg(target_os = "linux")]
const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
const CAM_REFRESH_INTERVAL: Duration = Duration::from_mins(1);
#[cfg(target_os = "linux")]
const GATEWAY_STATS_INTERVAL: Duration = Duration::from_secs(10);
#[cfg(target_os = "linux")]
@@ -102,7 +103,7 @@ pub struct GatewayArgs {
#[arg(long)]
room: RoomCode,
/// Wired LAN interface that will later be opened with AF_PACKET.
/// Wired LAN interface that will later be opened with `AF_PACKET`.
#[arg(long, visible_alias = "iface")]
interface: String,
@@ -112,6 +113,13 @@ pub struct GatewayArgs {
}
impl GatewayArgs {
/// Resolves the parsed arguments into a validated [`GatewayConfig`],
/// reading the relay CA certificate from disk.
///
/// # Errors
///
/// Returns an error if the CA certificate cannot be read, the relay
/// endpoint cannot be resolved, or the resulting configuration is invalid.
pub fn into_config(self) -> Result<GatewayConfig> {
let relay_ca_cert = fs::read(&self.relay_ca_cert).with_context(|| {
format!(
@@ -147,6 +155,12 @@ pub struct GatewayConfig {
}
impl GatewayConfig {
/// Assembles everything the gateway needs to bridge one LAN into one room.
///
/// # Errors
///
/// Returns an error if the server name, CA certificate, or interface name is
/// empty, or if `max_datagram_size` cannot carry a useful TAP MTU.
pub fn new(
relay_addr: SocketAddr,
server_name: impl Into<String>,
@@ -270,6 +284,13 @@ impl GatewayConnection {
self.quic_max_datagram_size
}
/// Forwards one LAN Ethernet frame to the relay.
///
/// # Errors
///
/// Returns an error if the frame is malformed, filtered by the LAN safety
/// rules, exceeds the negotiated datagram budget, or the datagram send
/// fails.
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
match send_gateway_ethernet(
&self.connection,
@@ -288,10 +309,21 @@ impl GatewayConnection {
}
}
/// Waits for the next Ethernet frame a remote client sent into the room.
///
/// # Errors
///
/// Returns an error if the QUIC connection fails while reading.
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
recv_gateway_ethernet(&self.connection, &self.welcome, &self.stats).await
}
/// Waits for the next control-plane message from the relay.
///
/// # Errors
///
/// Returns an error if the control stream fails or carries a frame that
/// cannot be decoded.
pub async fn recv_control_event(&self) -> Result<ControlMessage> {
recv_gateway_control_event(&self.connection).await
}
@@ -301,11 +333,26 @@ impl GatewayConnection {
self.stats.snapshot()
}
/// Reports the current tunnel counters to the relay.
///
/// # Errors
///
/// Returns an error if the control stream write fails.
pub async fn send_stats_snapshot(&self) -> Result<()> {
send_gateway_stats(&self.connection, self.stats.snapshot()).await
}
/// Runs the LAN-to-relay bridge until the connection closes or the process
/// is asked to shut down.
///
/// # Errors
///
/// Returns an error if the LAN socket or the relay connection fails in a
/// way the bridge cannot recover from.
#[cfg(target_os = "linux")]
// The bridge is one `select!` loop over every event source; splitting arms
// into helpers would hide the shared state they all mutate.
#[allow(clippy::too_many_lines)]
pub async fn bridge_until_shutdown(self, packet_socket: PacketSocket) -> Result<()> {
let mut remote_clients = RemoteClientTable::new(packet_socket.interface_mac());
let mut cam_refresh_tick = tokio::time::interval_at(
@@ -596,12 +643,9 @@ async fn recv_gateway_ethernet_outcome(
stats.record_dropped_frame();
continue;
}
let ethernet_frame = match EthernetFrame::parse(packet.payload()) {
Ok(frame) => frame,
Err(_) => {
stats.record_malformed_frame();
continue;
}
let Ok(ethernet_frame) = EthernetFrame::parse(packet.payload()) else {
stats.record_malformed_frame();
continue;
};
stats.record_ethernet_rx(ethernet_frame);
@@ -757,8 +801,7 @@ fn format_gateway_control_event(event: &ControlMessage) -> String {
ControlMessage::PeerJoined(peer) if peer.role() == Role::Client => {
let mac = peer
.mac()
.map(|mac| mac.to_string())
.unwrap_or_else(|| "unknown".to_string());
.map_or_else(|| "unknown".to_string(), |mac| mac.to_string());
format!(
"gateway control event: client peer {} joined with MAC {}",
peer.peer_id(),
@@ -796,24 +839,19 @@ fn gateway_frame_log_line(
};
let source_mac = log
.source_mac()
.map(|mac| mac.to_string())
.unwrap_or_else(|| "-".to_owned());
.map_or_else(|| "-".to_owned(), |mac| mac.to_string());
let destination_mac = log
.destination_mac()
.map(|mac| mac.to_string())
.unwrap_or_else(|| "-".to_owned());
.map_or_else(|| "-".to_owned(), |mac| mac.to_string());
let ethertype_or_len = log
.ethertype_or_len()
.map(|value| format!("0x{value:04x}"))
.unwrap_or_else(|| "-".to_owned());
.map_or_else(|| "-".to_owned(), |value| format!("0x{value:04x}"));
let peer_id = log
.peer_id()
.map(|peer_id| peer_id.to_string())
.unwrap_or_else(|| "-".to_owned());
.map_or_else(|| "-".to_owned(), |peer_id| peer_id.to_string());
let drop_reason = log
.drop_reason()
.map(|reason| format!("{reason:?}"))
.unwrap_or_else(|| "-".to_owned());
.map_or_else(|| "-".to_owned(), |reason| format!("{reason:?}"));
format!(
"gateway frame interface={} direction={:?} peer_id={} src={} dst={} ethertype_or_len={} len={} action={:?} drop_reason={}",
@@ -853,7 +891,7 @@ async fn read_lan_ethernet(packet_socket: &AsyncFd<PacketSocket>) -> Result<Byte
return Ok(Bytes::from(buffer));
}
Ok(Err(error)) => return Err(error).context("failed to read LAN Ethernet frame"),
Err(_would_block) => continue,
Err(_would_block) => {}
}
}
}
@@ -870,7 +908,7 @@ async fn write_lan_ethernet(packet_socket: &AsyncFd<PacketSocket>, frame: &[u8])
Ok(Ok(sent)) if sent == frame.len() => return Ok(()),
Ok(Ok(sent)) => bail!("partial LAN Ethernet frame write: {sent}/{}", frame.len()),
Ok(Err(error)) => return Err(error).context("failed to write LAN Ethernet frame"),
Err(_would_block) => continue,
Err(_would_block) => {}
}
}
}
@@ -996,6 +1034,14 @@ fn cam_refresh_frame(source: MacAddr, destination: MacAddr) -> Vec<u8> {
frame
}
/// Connects to the relay, announces the gateway hello, and returns the
/// connection once the relay answers with a welcome.
///
/// # Errors
///
/// Returns an error if the QUIC endpoint cannot be created or connected, the
/// relay does not negotiate DATAGRAM support, the control handshake fails, or
/// the relay rejects the hello.
pub async fn connect_gateway(config: GatewayConfig) -> Result<GatewayConnection> {
let client_config = relay_client_config(config.relay_ca_cert_der())?;
let mut endpoint = Endpoint::client(client_bind_addr(config.relay_addr()))
@@ -1009,9 +1055,9 @@ pub async fn connect_gateway(config: GatewayConfig) -> Result<GatewayConnection>
let peer_datagram_size = connection
.max_datagram_size()
.context("relay did not negotiate QUIC DATAGRAM support")?;
let hello_datagram_size = usize::from(config.max_datagram_size())
.min(peer_datagram_size)
.min(usize::from(u16::MAX)) as u16;
let hello_datagram_size = u16::try_from(peer_datagram_size)
.unwrap_or(u16::MAX)
.min(config.max_datagram_size());
let hello = EndpointHello::gateway(config.room().clone(), hello_datagram_size)
.context("failed to build gateway hello")?;
let response = request_control_message(&connection, ControlMessage::Hello(hello)).await?;
@@ -1167,6 +1213,9 @@ mod tests {
}
#[tokio::test]
// One end-to-end handshake: a scripted relay plus the gateway side of the
// exchange only make sense read together.
#[allow(clippy::too_many_lines)]
async fn connects_to_relay_control_stream_as_gateway() {
let (server_config, certificate) = test_server_config();
let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
@@ -1339,10 +1388,10 @@ mod tests {
#[test]
fn snapshots_gateway_broadcast_stats() {
let stats = GatewayTunnelStats::default();
let broadcast_tx_bytes = broadcast_ethernet_frame(b"broadcast tx");
let broadcast_rx_bytes = broadcast_ethernet_frame(b"broadcast rx");
let broadcast_tx = EthernetFrame::parse(&broadcast_tx_bytes).unwrap();
let broadcast_rx = EthernetFrame::parse(&broadcast_rx_bytes).unwrap();
let sent_frame_bytes = broadcast_ethernet_frame(b"broadcast tx");
let received_frame_bytes = broadcast_ethernet_frame(b"broadcast rx");
let broadcast_tx = EthernetFrame::parse(&sent_frame_bytes).unwrap();
let broadcast_rx = EthernetFrame::parse(&received_frame_bytes).unwrap();
stats.record_ethernet_tx(broadcast_tx);
stats.record_datagram_rx();
+1
View File
@@ -1,3 +1,4 @@
#![cfg_attr(test, allow(clippy::unwrap_used))]
use clap::Parser;
use lanparty_gateway::GatewayArgs;
#[cfg(target_os = "linux")]
+49 -4
View File
@@ -1,3 +1,20 @@
//! Raw `AF_PACKET` socket plumbing for the Linux gateway.
//!
//! Everything that talks to the kernel through libc lives here, which is why
//! this is the one gateway module that opts back into `unsafe`.
#![allow(unsafe_code)]
// The libc bindings type kernel ABI fields as whatever C type the header uses
// (`c_char`, `c_int`, `c_ushort`, ...). Moving values between those and the Rust
// types used here is reinterpretation of a value that is already known to fit -
// interface indices, `ETH_P_ALL`, MAC octets, and the byte counts returned by
// send/recv after their error check - so a fallible conversion would only add
// unreachable branches.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
use std::{
ffi::CString,
fs,
@@ -22,6 +39,14 @@ pub struct PacketSocket {
}
impl PacketSocket {
/// Opens a promiscuous `AF_PACKET` socket bound to `interface`.
///
/// # Errors
///
/// Returns an error if the interface name is unusable or unknown, the
/// interface is wireless or has no carrier, the interface is not Ethernet,
/// or any of the socket, bind, promiscuous-membership, or MAC-lookup calls
/// fails - typically for lack of `CAP_NET_RAW`.
pub fn open(interface: &str) -> io::Result<Self> {
let interface_index = interface_index(interface)?;
reject_wireless_interface(interface)?;
@@ -58,7 +83,7 @@ impl PacketSocket {
// matches that struct. fd remains owned by this function across the call.
libc::bind(
fd.as_raw_fd(),
(&address as *const libc::sockaddr_ll).cast::<libc::sockaddr>(),
(&raw const address).cast::<libc::sockaddr>(),
std::mem::size_of::<libc::sockaddr_ll>() as libc::socklen_t,
)
};
@@ -91,6 +116,13 @@ impl PacketSocket {
self.interface_mac
}
/// Sends one Ethernet frame on the LAN, returning the number of bytes the
/// kernel accepted.
///
/// # Errors
///
/// Returns the OS error if the send fails, including `WouldBlock` on this
/// non-blocking socket.
pub fn send_frame(&self, frame: &[u8]) -> io::Result<usize> {
let sent = unsafe {
// SAFETY: frame.as_ptr() is valid for frame.len() bytes for the duration of send,
@@ -109,6 +141,13 @@ impl PacketSocket {
Ok(sent as usize)
}
/// Receives one inbound Ethernet frame into `buffer`, skipping the copies
/// of our own outgoing frames that `AF_PACKET` also delivers.
///
/// # Errors
///
/// Returns the OS error if the receive fails, including `WouldBlock` on this
/// non-blocking socket.
pub fn recv_frame(&self, buffer: &mut [u8]) -> io::Result<usize> {
loop {
let mut address = unsafe {
@@ -126,8 +165,8 @@ impl PacketSocket {
buffer.as_mut_ptr().cast::<libc::c_void>(),
buffer.len(),
0,
(&mut address as *mut libc::sockaddr_ll).cast::<libc::sockaddr>(),
&mut address_len,
(&raw mut address).cast::<libc::sockaddr>(),
&raw mut address_len,
)
};
if received < 0 {
@@ -146,6 +185,12 @@ impl AsRawFd for PacketSocket {
}
}
/// Resolves a network interface name to its kernel index.
///
/// # Errors
///
/// Returns an error if the name is empty, contains a path separator or NUL, is
/// longer than `IFNAMSIZ`, or does not name an existing interface.
pub fn interface_index(interface: &str) -> io::Result<u32> {
let name = interface_name(interface)?;
@@ -170,7 +215,7 @@ fn enable_promiscuous_membership(fd: RawFd, interface_index: u32) -> io::Result<
fd,
libc::SOL_PACKET,
libc::PACKET_ADD_MEMBERSHIP,
(&membership as *const libc::packet_mreq).cast::<libc::c_void>(),
(&raw const membership).cast::<libc::c_void>(),
std::mem::size_of::<libc::packet_mreq>() as libc::socklen_t,
)
};