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:
@@ -9,3 +9,11 @@ lanparty-proto = { path = "../lanparty-proto" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -23,24 +23,49 @@ pub enum ControlCodecError {
|
||||
InvalidMessage(#[from] ControlError),
|
||||
}
|
||||
|
||||
/// Encodes a control message as a length-prefixed JSON frame.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ControlCodecError::InvalidMessage`] if `message` does not pass its
|
||||
/// own validation, [`ControlCodecError::Json`] if serialization fails, or
|
||||
/// [`ControlCodecError::MessageTooLarge`] if the payload exceeds
|
||||
/// [`MAX_CONTROL_MESSAGE_LEN`].
|
||||
pub fn encode_control_message(message: &ControlMessage) -> Result<Vec<u8>, ControlCodecError> {
|
||||
message.validate()?;
|
||||
|
||||
let payload = serde_json::to_vec(message)?;
|
||||
let payload_len = payload.len();
|
||||
if payload_len > MAX_CONTROL_MESSAGE_LEN {
|
||||
// The wire prefix is a u32, and MAX_CONTROL_MESSAGE_LEN keeps it in range,
|
||||
// so both conditions collapse into the same "too large" error.
|
||||
let Some(prefix) = u32::try_from(payload_len)
|
||||
.ok()
|
||||
.filter(|_| payload_len <= MAX_CONTROL_MESSAGE_LEN)
|
||||
else {
|
||||
return Err(ControlCodecError::MessageTooLarge {
|
||||
len: payload_len,
|
||||
max: MAX_CONTROL_MESSAGE_LEN,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut frame = Vec::with_capacity(CONTROL_LENGTH_PREFIX_LEN + payload_len);
|
||||
frame.extend_from_slice(&(payload_len as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&prefix.to_be_bytes());
|
||||
frame.extend_from_slice(&payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Decodes exactly one control frame, which must contain one whole message and
|
||||
/// nothing else.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ControlCodecError::FrameTooShort`] or
|
||||
/// [`ControlCodecError::IncompletePayload`] if `frame` does not hold a complete
|
||||
/// message, [`ControlCodecError::TrailingBytes`] if it holds more than one,
|
||||
/// [`ControlCodecError::MessageTooLarge`] if the declared length is over the
|
||||
/// limit, [`ControlCodecError::Json`] if the payload is not valid JSON, or
|
||||
/// [`ControlCodecError::InvalidMessage`] if the decoded message fails
|
||||
/// validation.
|
||||
pub fn decode_control_frame(frame: &[u8]) -> Result<ControlMessage, ControlCodecError> {
|
||||
let Some(total_len) = complete_control_frame_len(frame)? else {
|
||||
return Err(incomplete_frame_error(frame));
|
||||
@@ -59,6 +84,14 @@ pub fn decode_control_frame(frame: &[u8]) -> Result<ControlMessage, ControlCodec
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Returns the total length of the first complete frame in `buffer`, or `None`
|
||||
/// if more bytes are still needed. Stream readers use this to decide when a
|
||||
/// frame can be handed to [`decode_control_frame`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ControlCodecError::MessageTooLarge`] if the declared payload
|
||||
/// length exceeds [`MAX_CONTROL_MESSAGE_LEN`].
|
||||
pub fn complete_control_frame_len(buffer: &[u8]) -> Result<Option<usize>, ControlCodecError> {
|
||||
if buffer.len() < CONTROL_LENGTH_PREFIX_LEN {
|
||||
return Ok(None);
|
||||
@@ -81,18 +114,14 @@ pub fn complete_control_frame_len(buffer: &[u8]) -> Result<Option<usize>, Contro
|
||||
}
|
||||
|
||||
fn declared_payload_len(buffer: &[u8]) -> Result<usize, ControlCodecError> {
|
||||
if buffer.len() < CONTROL_LENGTH_PREFIX_LEN {
|
||||
let Some(prefix) = buffer.first_chunk::<CONTROL_LENGTH_PREFIX_LEN>() else {
|
||||
return Err(ControlCodecError::FrameTooShort {
|
||||
actual: buffer.len(),
|
||||
minimum: CONTROL_LENGTH_PREFIX_LEN,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(u32::from_be_bytes(
|
||||
buffer[0..CONTROL_LENGTH_PREFIX_LEN]
|
||||
.try_into()
|
||||
.expect("length prefix slice has exact size"),
|
||||
) as usize)
|
||||
Ok(u32::from_be_bytes(*prefix) as usize)
|
||||
}
|
||||
|
||||
fn incomplete_frame_error(frame: &[u8]) -> ControlCodecError {
|
||||
@@ -174,7 +203,8 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_oversized_declared_length() {
|
||||
let mut frame = [0; CONTROL_LENGTH_PREFIX_LEN];
|
||||
frame.copy_from_slice(&((MAX_CONTROL_MESSAGE_LEN as u32) + 1).to_be_bytes());
|
||||
let oversized = u32::try_from(MAX_CONTROL_MESSAGE_LEN).unwrap() + 1;
|
||||
frame.copy_from_slice(&oversized.to_be_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
complete_control_frame_len(&frame).unwrap_err(),
|
||||
@@ -185,11 +215,10 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_decoded_messages() {
|
||||
let json = format!(
|
||||
r#"{{"type":"welcome","payload":{{"protocol_version":1,"room_id":1,"peer_id":0,"effective_tap_mtu":{}}}}}"#,
|
||||
MIN_USEFUL_TAP_MTU
|
||||
r#"{{"type":"welcome","payload":{{"protocol_version":1,"room_id":1,"peer_id":0,"effective_tap_mtu":{MIN_USEFUL_TAP_MTU}}}}}"#
|
||||
);
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&(json.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&u32::try_from(json.len()).unwrap().to_be_bytes());
|
||||
frame.extend_from_slice(json.as_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! QUIC streams carry these messages as length-prefixed JSON frames. The crate
|
||||
//! defines the typed handshake/status model and the small framing layer needed
|
||||
//! by client, relay, and gateway stream handlers.
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used))]
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
@@ -54,6 +55,12 @@ pub enum ControlError {
|
||||
pub struct RoomCode(String);
|
||||
|
||||
impl RoomCode {
|
||||
/// Validates and wraps a room code.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a [`ControlError`] variant describing which room-code rule the
|
||||
/// value breaks (length or allowed characters).
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ControlError> {
|
||||
let value = value.into();
|
||||
validate_room_code(&value)?;
|
||||
@@ -135,6 +142,13 @@ pub struct EndpointHello {
|
||||
}
|
||||
|
||||
impl EndpointHello {
|
||||
/// Builds the hello a tunnel client sends.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `announced_mac` is not a locally administered
|
||||
/// unicast address or `max_datagram_size` is too small to carry a useful
|
||||
/// TAP MTU.
|
||||
pub fn client(
|
||||
room: RoomCode,
|
||||
announced_mac: MacAddr,
|
||||
@@ -152,6 +166,13 @@ impl EndpointHello {
|
||||
Ok(hello)
|
||||
}
|
||||
|
||||
/// Builds the hello the LAN gateway sends. A gateway announces no MAC
|
||||
/// because it bridges the whole LAN rather than one identity.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `max_datagram_size` is too small to carry a useful
|
||||
/// TAP MTU.
|
||||
pub fn gateway(room: RoomCode, max_datagram_size: u16) -> Result<Self, ControlError> {
|
||||
let hello = Self {
|
||||
protocol_version: CONTROL_PROTOCOL_VERSION,
|
||||
@@ -165,6 +186,14 @@ impl EndpointHello {
|
||||
Ok(hello)
|
||||
}
|
||||
|
||||
/// Re-checks a decoded [`EndpointHello`], which serde may have built
|
||||
/// without going through the constructors.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the protocol version is not supported, the datagram
|
||||
/// size cannot carry a useful TAP MTU, or the role and announced MAC do not
|
||||
/// match.
|
||||
pub fn validate(&self) -> Result<(), ControlError> {
|
||||
if self.protocol_version != CONTROL_PROTOCOL_VERSION {
|
||||
return Err(ControlError::UnsupportedVersion {
|
||||
@@ -225,6 +254,13 @@ pub struct ServerWelcome {
|
||||
}
|
||||
|
||||
impl ServerWelcome {
|
||||
/// Builds the welcome the relay sends once a peer is admitted to a room.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ControlError::InvalidPeerId`] if `peer_id` is 0, or
|
||||
/// [`ControlError::EffectiveMtuTooSmall`] if `effective_tap_mtu` is below
|
||||
/// [`MIN_USEFUL_TAP_MTU`].
|
||||
pub fn new(room_id: u64, peer_id: u32, effective_tap_mtu: u16) -> Result<Self, ControlError> {
|
||||
if peer_id == 0 {
|
||||
return Err(ControlError::InvalidPeerId);
|
||||
@@ -272,6 +308,13 @@ impl ServerWelcome {
|
||||
self
|
||||
}
|
||||
|
||||
/// Re-checks a decoded [`ServerWelcome`], which serde may have built
|
||||
/// without going through [`ServerWelcome::new`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the protocol version is not supported, a peer id is
|
||||
/// 0, or the effective TAP MTU is below [`MIN_USEFUL_TAP_MTU`].
|
||||
pub fn validate(&self) -> Result<(), ControlError> {
|
||||
if self.protocol_version != CONTROL_PROTOCOL_VERSION {
|
||||
return Err(ControlError::UnsupportedVersion {
|
||||
@@ -342,6 +385,12 @@ pub struct PeerInfo {
|
||||
}
|
||||
|
||||
impl PeerInfo {
|
||||
/// Describes one peer of a room.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `peer_id` is 0, a client has no or an unusable MAC,
|
||||
/// or a gateway announces one.
|
||||
pub fn new(peer_id: u32, role: Role, mac: Option<MacAddr>) -> Result<Self, ControlError> {
|
||||
if peer_id == 0 {
|
||||
return Err(ControlError::InvalidPeerId);
|
||||
@@ -364,6 +413,12 @@ impl PeerInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-checks a decoded [`PeerInfo`], which serde may have built without
|
||||
/// going through [`PeerInfo::new`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the peer id is 0 or the role and MAC do not match.
|
||||
pub fn validate(&self) -> Result<(), ControlError> {
|
||||
if self.peer_id == 0 {
|
||||
return Err(ControlError::InvalidPeerId);
|
||||
@@ -463,14 +518,21 @@ pub enum ControlMessage {
|
||||
}
|
||||
|
||||
impl ControlMessage {
|
||||
/// Validates a decoded control message by delegating to the payload type.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the [`ControlError`] reported by the contained payload, or
|
||||
/// [`ControlError::InvalidPeerId`] for a peer-left notice about peer 0.
|
||||
pub fn validate(&self) -> Result<(), ControlError> {
|
||||
match self {
|
||||
Self::Hello(hello) => hello.validate(),
|
||||
Self::Welcome(welcome) => welcome.validate(),
|
||||
Self::Reject(_) | Self::Stats(_) | Self::Disconnect { .. } => Ok(()),
|
||||
Self::PeerJoined(peer) => peer.validate(),
|
||||
Self::PeerLeft { peer_id, .. } if *peer_id == 0 => Err(ControlError::InvalidPeerId),
|
||||
Self::PeerLeft { .. } => Ok(()),
|
||||
Self::Reject(_) | Self::Stats(_) | Self::Disconnect { .. } | Self::PeerLeft { .. } => {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,15 +621,15 @@ mod tests {
|
||||
#[test]
|
||||
fn server_welcome_rejects_reserved_peer_id_and_tiny_mtu() {
|
||||
assert_eq!(
|
||||
ServerWelcome::new(1, 0, MIN_USEFUL_TAP_MTU as u16).unwrap_err(),
|
||||
ServerWelcome::new(1, 0, u16::try_from(MIN_USEFUL_TAP_MTU).unwrap()).unwrap_err(),
|
||||
ControlError::InvalidPeerId
|
||||
);
|
||||
assert!(matches!(
|
||||
ServerWelcome::new(1, 2, (MIN_USEFUL_TAP_MTU - 1) as u16).unwrap_err(),
|
||||
ServerWelcome::new(1, 2, u16::try_from(MIN_USEFUL_TAP_MTU - 1).unwrap()).unwrap_err(),
|
||||
ControlError::EffectiveMtuTooSmall { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16)
|
||||
ServerWelcome::new(1, 2, u16::try_from(MIN_USEFUL_TAP_MTU).unwrap())
|
||||
.unwrap()
|
||||
.with_gateway_peer_id(Some(0))
|
||||
.validate()
|
||||
@@ -578,7 +640,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn server_welcome_reports_gateway_presence() {
|
||||
let welcome = ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16).unwrap();
|
||||
let welcome = ServerWelcome::new(1, 2, u16::try_from(MIN_USEFUL_TAP_MTU).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(welcome.mode(), ConnectionMode::Relay);
|
||||
assert!(!welcome.gateway_connected());
|
||||
@@ -595,7 +657,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn server_welcome_reports_connection_mode() {
|
||||
let welcome = ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16)
|
||||
let welcome = ServerWelcome::new(1, 2, u16::try_from(MIN_USEFUL_TAP_MTU).unwrap())
|
||||
.unwrap()
|
||||
.with_mode(ConnectionMode::DirectFailedRelayFallback);
|
||||
|
||||
@@ -619,8 +681,7 @@ mod tests {
|
||||
#[test]
|
||||
fn server_welcome_defaults_missing_mode_to_relay() {
|
||||
let json = format!(
|
||||
r#"{{"protocol_version":1,"room_id":1,"peer_id":2,"effective_tap_mtu":{}}}"#,
|
||||
MIN_USEFUL_TAP_MTU
|
||||
r#"{{"protocol_version":1,"room_id":1,"peer_id":2,"effective_tap_mtu":{MIN_USEFUL_TAP_MTU}}}"#
|
||||
);
|
||||
let welcome: ServerWelcome = serde_json::from_str(&json).unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user