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:
@@ -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"
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used))]
|
||||
use clap::Parser;
|
||||
use lanparty_gateway::GatewayArgs;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user