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:
ddidderr committed 2026-08-16 19:00:54 +02:00
1 parent 2bed62e9ec
commit e62f584377
35 files changed
+1024 -227

No files matched your search

+8
View File
@@ -18,3 +18,11 @@ serde_json.workspace = true
[dev-dependencies]
rcgen.workspace = true
tokio.workspace = true
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
+115 -17
View File
@@ -4,6 +4,7 @@
//! crate owns the shared relay-facing state machine: connect to the relay,
//! announce the client's virtual MAC, and exchange Ethernet frames as QUIC
//! datagrams after the control-plane welcome.
#![cfg_attr(test, allow(clippy::unwrap_used))]
use std::{
fs,
@@ -57,6 +58,12 @@ pub struct ClientIdentity {
}
impl ClientIdentity {
/// Adopts `virtual_mac` as this client's identity on the virtual LAN.
///
/// # Errors
///
/// Returns an error unless `virtual_mac` is a locally administered unicast
/// address, which is what the relay requires of a client.
pub fn new(virtual_mac: MacAddr) -> Result<Self> {
if !virtual_mac.is_valid_client_identity() {
bail!("client virtual MAC must be locally administered unicast");
@@ -65,6 +72,11 @@ impl ClientIdentity {
Ok(Self { virtual_mac })
}
/// Generates a fresh random locally administered unicast identity.
///
/// # Errors
///
/// Returns an error if the OS random source is unavailable.
pub fn generate() -> Result<Self> {
let mut octets = [0_u8; 6];
getrandom::fill(&mut octets).context("failed to generate client virtual MAC")?;
@@ -85,6 +97,11 @@ pub struct ClientIdentityStore {
}
impl ClientIdentityStore {
/// Points the store at the file that persists the client identity.
///
/// # Errors
///
/// Returns an error if `path` is empty.
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
if path.as_os_str().is_empty() {
@@ -99,6 +116,12 @@ impl ClientIdentityStore {
&self.path
}
/// Loads the stored identity, generating and persisting one on first run.
///
/// # Errors
///
/// Returns an error if the file exists but cannot be read or parsed, or if
/// a newly generated identity cannot be written.
pub fn load_or_create(&self) -> Result<ClientIdentity> {
match fs::read(&self.path) {
Ok(bytes) => read_identity(&bytes)
@@ -169,6 +192,12 @@ pub struct ClientSessionConfig {
}
impl ClientSessionConfig {
/// Assembles everything the client needs to open a relay session.
///
/// # Errors
///
/// Returns an error if the server name or CA certificate is empty, or if
/// `virtual_mac` and `max_datagram_size` would not produce a valid hello.
pub fn new(
relay_addr: SocketAddr,
server_name: impl Into<String>,
@@ -327,26 +356,61 @@ impl ClientSession {
)
}
/// Sends one Ethernet frame from the TAP device to the relay.
///
/// # Errors
///
/// Returns an error if the frame is rejected or the datagram cannot be
/// sent; see [`ClientRelayIo::send_ethernet`].
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
self.relay_io().send_ethernet(frame)
}
/// Sends one Ethernet frame, reporting whether it was dropped instead of
/// failing on frames the client itself filters.
///
/// # Errors
///
/// Returns an error only if the QUIC datagram send fails.
pub fn send_ethernet_with_outcome(&self, frame: &[u8]) -> Result<ClientSendOutcome> {
self.relay_io().send_ethernet_with_outcome(frame)
}
/// Waits for the next Ethernet frame the client accepts, skipping filtered
/// ones.
///
/// # Errors
///
/// Returns an error if the QUIC connection fails while reading.
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
self.relay_io().recv_ethernet().await
}
/// Waits for the next Ethernet frame, also reporting filtered ones so
/// callers can account for them.
///
/// # Errors
///
/// Returns an error if the QUIC connection fails while reading.
pub async fn recv_ethernet_outcome(&self) -> Result<ClientReceiveOutcome> {
self.relay_io().recv_ethernet_outcome().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_control_event(&self.connection).await
}
/// 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<()> {
self.relay_io().send_stats_snapshot().await
}
@@ -408,6 +472,13 @@ impl ClientRelayIo {
self.virtual_mac
}
/// Sends one Ethernet frame, turning a drop into an error.
///
/// # Errors
///
/// Returns an error if the frame is malformed, has an unusable or foreign
/// source MAC, exceeds the negotiated datagram budget, is filtered by the
/// LAN safety rules, or the datagram send fails.
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
match self.send_ethernet_with_outcome(frame)? {
ClientSendOutcome::Sent => Ok(()),
@@ -423,13 +494,16 @@ impl ClientRelayIo {
}
}
/// Sends one Ethernet frame, returning the drop reason instead of an error
/// for frames the client filters itself.
///
/// # Errors
///
/// Returns an error only if the QUIC datagram send fails.
pub fn send_ethernet_with_outcome(&self, frame: &[u8]) -> Result<ClientSendOutcome> {
let ethernet_frame = match EthernetFrame::parse(frame) {
Ok(frame) => frame,
Err(_) => {
self.stats.record_malformed_frame();
return Ok(ClientSendOutcome::Dropped(DropReason::Malformed));
}
let Ok(ethernet_frame) = EthernetFrame::parse(frame) else {
self.stats.record_malformed_frame();
return Ok(ClientSendOutcome::Dropped(DropReason::Malformed));
};
if !ethernet_frame.source().is_valid_unicast() {
self.stats.record_dropped_frame();
@@ -476,6 +550,11 @@ impl ClientRelayIo {
Ok(ClientSendOutcome::Sent)
}
/// Waits for the next accepted Ethernet frame, discarding filtered ones.
///
/// # Errors
///
/// Returns an error if the QUIC connection fails while reading.
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
loop {
match self.recv_ethernet_outcome().await? {
@@ -485,6 +564,12 @@ impl ClientRelayIo {
}
}
/// Waits for the next Ethernet frame and reports whether it was accepted or
/// filtered.
///
/// # Errors
///
/// Returns an error if the QUIC connection fails while reading.
pub async fn recv_ethernet_outcome(&self) -> Result<ClientReceiveOutcome> {
loop {
let datagram = self.connection.read_datagram().await?;
@@ -501,12 +586,9 @@ impl ClientRelayIo {
self.stats.record_dropped_frame();
continue;
}
let ethernet_frame = match EthernetFrame::parse(packet.payload()) {
Ok(frame) => frame,
Err(_) => {
self.stats.record_malformed_frame();
continue;
}
let Ok(ethernet_frame) = EthernetFrame::parse(packet.payload()) else {
self.stats.record_malformed_frame();
continue;
};
self.stats.record_ethernet_rx(ethernet_frame);
@@ -550,6 +632,11 @@ impl ClientRelayIo {
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<()> {
let stats = self.stats.snapshot();
send_control_event(&self.connection, ControlMessage::Stats(stats)).await
@@ -618,6 +705,14 @@ impl ClientTunnelStats {
}
}
/// Connects to the relay, announces the client hello, and returns the session
/// 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_client(config: ClientSessionConfig) -> Result<ClientSession> {
let client_config = relay_client_config(config.relay_ca_cert_der())?;
let mut endpoint = Endpoint::client(client_bind_addr(config.relay_addr()))
@@ -661,7 +756,7 @@ pub async fn connect_client(config: ClientSessionConfig) -> Result<ClientSession
#[must_use]
fn negotiated_quic_datagram_size(configured: u16, peer: usize) -> u16 {
usize::from(configured).min(peer).min(usize::from(u16::MAX)) as u16
u16::try_from(peer).unwrap_or(u16::MAX).min(configured)
}
fn relay_client_config(relay_ca_cert_der: &[u8]) -> Result<ClientConfig> {
@@ -867,6 +962,9 @@ mod tests {
}
#[tokio::test]
// One end-to-end handshake: a scripted relay plus the client side of the
// exchange only make sense read together.
#[allow(clippy::too_many_lines)]
async fn connects_to_relay_control_stream_as_client() {
let (server_config, certificate) = test_server_config();
let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
@@ -1179,10 +1277,10 @@ mod tests {
#[test]
fn snapshots_client_tunnel_stats() {
let stats = ClientTunnelStats::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();