refactor(core): split quic.rs (3.2k lines) into src/quic/ — pure move

Networking-audit deferred plan §3. One file per concern, zero logic edits:

  quic/mod.rs      MAGIC/CTL_MAGIC + re-exports (every crate::quic::X path
                   compiles unchanged across host + all clients)
  quic/msgs.rs     Hello/Welcome/Start, typed control msgs + type bytes,
                   resolve_codec, ColorInfo, window_loss_ppm, pairing msgs
  quic/pake.rs     the SPAKE2 pairing exchange
  quic/datagram.rs 0xC9–0xCF plane codecs (audio/rumble/mic/rich-input/
                   hidout/HdrMeta/HostTiming)
  quic/io.rs       length-prefixed stream IO
  quic/clock.rs    clock_offset_ns estimator, clock_sync, ClockResync
  quic/endpoint.rs quinn config, ALPN, pinning verifiers, keep-alive
  quic/tests.rs    the cross-cutting test module, unchanged

Mechanical deltas only: the nested `pub mod` wrappers became files (one
dedent), submodules import what they previously inherited from the parent
scope, and the three RichInput kind tags are pub(super) for the tests
(same-module before). Verified line-multiset-identical after normalizing
indentation. cargo check --workspace, core tests (quic), clippy, and
cargo ndk check all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:51:15 +02:00
parent d4467a44e2
commit e9b2eacf87
9 changed files with 3249 additions and 3227 deletions
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
//! Wall-clock skew: the connect-time handshake ([`clock_sync`]), the NTP-style offset
//! estimator ([`clock_offset_ns`]), and the mid-stream re-sync state machine
//! ([`ClockResync`]).
use super::{io, ClockEcho, ClockProbe};
/// Estimate the host↔client clock offset (**host minus client**, ns) and RTT (ns) from skew-handshake
/// samples `(t1, t2, t3, t4)` — NTP's formula, taking the **minimum-RTT** sample (least queuing
/// noise; also discards the first round's host-setup latency). Offset is positive when the host
/// clock is ahead of the client's; add it to a client timestamp to express it in the host clock.
/// Returns `None` for an empty sample set.
pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
samples
.iter()
.map(|&(t1, t2, t3, t4)| {
let rtt = ((t4 as i128 - t1 as i128) - (t3 as i128 - t2 as i128)).max(0) as u64;
let offset = (((t2 as i128 - t1 as i128) + (t3 as i128 - t4 as i128)) / 2) as i64;
(offset, rtt)
})
.min_by_key(|&(_, rtt)| rtt)
}
/// One wall-clock skew-handshake outcome (see [`clock_sync`]).
pub struct ClockSkew {
/// Host clock minus client clock, ns: add it to a client timestamp to express it in host time.
pub offset_ns: i64,
/// Round-trip time of the minimum-RTT sample, ns.
pub rtt_ns: u64,
/// How many probe rounds the host answered.
pub rounds: usize,
}
/// Run the wall-clock skew handshake from the client side over the (already-open) control stream:
/// `ROUNDS` [`ClockProbe`]/[`ClockEcho`] round-trips, returning the host↔client offset from the
/// minimum-RTT sample. `None` if the host never answers (an old host) — the caller then assumes a
/// shared clock. Each read is bounded so a silent host can't wedge session start. Shared by the
/// reference client and the embeddable connector; uses the realtime clock the host stamps `pts_ns`
/// with, so the offset aligns a client receive instant to the host's capture clock.
pub async fn clock_sync(
send: &mut quinn::SendStream,
recv: &mut quinn::RecvStream,
) -> Option<ClockSkew> {
use std::time::Duration;
const ROUNDS: usize = 8;
let read_timeout = Duration::from_secs(2);
let mut samples: Vec<(u64, u64, u64, u64)> = Vec::with_capacity(ROUNDS);
for _ in 0..ROUNDS {
let t1 = wall_clock_ns();
let probe = ClockProbe { t1_ns: t1 }.encode();
if io::write_msg(send, &probe).await.is_err() {
break;
}
let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
let echo = match read {
Ok(Ok(b)) => match ClockEcho::decode(&b) {
Ok(e) => e,
Err(_) => break,
},
_ => break, // timeout or stream error -> old host / no skew support
};
samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, wall_clock_ns()));
}
clock_offset_ns(&samples).map(|(offset_ns, rtt_ns)| ClockSkew {
offset_ns,
rtt_ns,
rounds: samples.len(),
})
}
/// Wall-clock now (ns since the Unix epoch) — the clock the skew handshake stamps and the host
/// stamps AU `pts_ns` with (CLOCK_REALTIME basis, deliberately NOT monotonic: steps/slew are
/// exactly what the handshake measures across machines).
pub fn wall_clock_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
/// What [`ClockResync::on_echo`] asks the driver to do next.
#[derive(Debug, PartialEq, Eq)]
pub enum ResyncStep {
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
Idle,
/// Send this next-round probe and keep feeding echoes.
Probe(ClockProbe),
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
Done { offset_ns: i64, rtt_ns: u64 },
}
/// Mid-stream wall-clock re-sync (networking-audit deferred plan §2): the same 8-round
/// probe/echo estimate as the connect-time [`clock_sync`], restructured as a state machine so
/// the client's control task can drive it from its `select!` loop without blocking the stream —
/// echoes interleave with other control traffic; rounds are matched by the echoed `t1`.
///
/// A step or slow drift of either wall clock after connect silently corrupts the clock-based
/// jump-to-live signal, the ABR one-way-delay signal, and every latency stat. Re-syncing
/// restores them; the disarm heuristic stays as the final backstop.
pub struct ClockResync {
/// `t1_ns` of the probe in flight; `None` = no batch active. An echo whose `t1` doesn't
/// match is stale (an abandoned batch) and ignored.
pending_t1: Option<u64>,
samples: Vec<(u64, u64, u64, u64)>,
}
impl ClockResync {
/// Rounds per batch — matches the connect-time [`clock_sync`].
pub const ROUNDS: usize = 8;
pub fn new() -> ClockResync {
ClockResync {
pending_t1: None,
samples: Vec::with_capacity(Self::ROUNDS),
}
}
/// Start a (new) batch, abandoning any batch still in flight — its late echoes won't match
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
self.samples.clear();
self.pending_t1 = Some(now_ns);
ClockProbe { t1_ns: now_ns }
}
/// Feed an inbound [`ClockEcho`] received at `now_ns` (the round's `t4`).
pub fn on_echo(&mut self, echo: &ClockEcho, now_ns: u64) -> ResyncStep {
if self.pending_t1 != Some(echo.t1_ns) {
return ResyncStep::Idle; // stale (abandoned batch) or unsolicited
}
self.samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
if self.samples.len() < Self::ROUNDS {
self.pending_t1 = Some(now_ns);
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
}
self.pending_t1 = None;
match clock_offset_ns(&self.samples) {
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
}
}
}
impl Default for ClockResync {
fn default() -> Self {
Self::new()
}
}
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
/// biases the offset by its queueing delay, and frames already read late exactly then; better
/// to keep the old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
}
+418
View File
@@ -0,0 +1,418 @@
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xCF):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
/// (0xCE, host→client).
pub const AUDIO_MAGIC: u8 = 0xC9;
pub const RUMBLE_MAGIC: u8 = 0xCA;
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
/// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
pub const MIC_MAGIC: u8 = 0xCB;
/// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
/// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
/// kind-tagged (see [`RichInput`]).
pub const RICH_INPUT_MAGIC: u8 = 0xCC;
/// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
/// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
/// [`HidOutput`].
pub const HIDOUT_MAGIC: u8 = 0xCD;
/// Audio datagram, host → client: `[0xC9][u32 seq LE][u64 pts_ns LE][opus payload]`.
/// One Opus frame per datagram (5 ms — well under any MTU); QUIC already encrypts.
pub fn encode_audio_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(13 + opus.len());
b.push(AUDIO_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse an audio datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
if b.len() < 13 || b[0] != AUDIO_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
Some((seq, pts_ns, &b[13..]))
}
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop).
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
let mut b = [0u8; 7];
b[0] = RUMBLE_MAGIC;
b[1..3].copy_from_slice(&pad.to_le_bytes());
b[3..5].copy_from_slice(&low.to_le_bytes());
b[5..7].copy_from_slice(&high.to_le_bytes());
b
}
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length.
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
if b.len() < 7 || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
Some((u16at(1), u16at(3), u16at(5)))
}
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(13 + opus.len());
b.push(MIC_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse a mic datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
if b.len() < 13 || b[0] != MIC_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
Some((seq, pts_ns, &b[13..]))
}
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
pub(super) const RICH_MOTION: u8 = 0x02;
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
/// `[0xCC][kind][fields…]` — variable-length and kind-tagged (forward-compatible: an unknown
/// kind decodes to `None` and is dropped).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RichInput {
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
/// the finger.
Touchpad {
pad: u8,
finger: u8,
active: bool,
x: u16,
y: u16,
},
/// Motion sensors: `gyro` (pitch/yaw/roll) + `accel`, raw signed-16 in the sensor's own
/// units — passed straight into the DualSense report.
Motion {
pad: u8,
gyro: [i16; 3],
accel: [i16; 3],
},
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
/// `TouchpadEx` (`0x03`) indefinitely.
TouchpadEx {
pad: u8,
surface: u8,
finger: u8,
touch: bool,
click: bool,
x: i16,
y: i16,
pressure: u16,
},
}
impl RichInput {
pub fn encode(&self) -> Vec<u8> {
let mut out = vec![RICH_INPUT_MAGIC];
match *self {
RichInput::Touchpad {
pad,
finger,
active,
x,
y,
} => {
out.extend_from_slice(&[RICH_TOUCHPAD, pad, finger, active as u8]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
}
RichInput::Motion { pad, gyro, accel } => {
out.extend_from_slice(&[RICH_MOTION, pad]);
for v in gyro.iter().chain(accel.iter()) {
out.extend_from_slice(&v.to_le_bytes());
}
}
RichInput::TouchpadEx {
pad,
surface,
finger,
touch,
click,
x,
y,
pressure,
} => {
let state = (touch as u8) | ((click as u8) << 1);
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
out.extend_from_slice(&pressure.to_le_bytes());
}
}
out
}
pub fn decode(b: &[u8]) -> Option<RichInput> {
if b.first() != Some(&RICH_INPUT_MAGIC) {
return None;
}
match *b.get(1)? {
RICH_TOUCHPAD if b.len() >= 9 => Some(RichInput::Touchpad {
pad: b[2],
finger: b[3],
active: b[4] != 0,
x: u16::from_le_bytes([b[5], b[6]]),
y: u16::from_le_bytes([b[7], b[8]]),
}),
RICH_MOTION if b.len() >= 15 => {
let i16at = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]);
Some(RichInput::Motion {
pad: b[2],
gyro: [i16at(3), i16at(5), i16at(7)],
accel: [i16at(9), i16at(11), i16at(13)],
})
}
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
pad: b[2],
surface: b[3],
finger: b[4],
touch: b[5] & 0x01 != 0,
click: b[5] & 0x02 != 0,
x: i16::from_le_bytes([b[6], b[7]]),
y: i16::from_le_bytes([b[8], b[9]]),
pressure: u16::from_le_bytes([b[10], b[11]]),
}),
_ => None,
}
}
}
const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
/// rumble itself stays on [`RUMBLE_MAGIC`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HidOutput {
/// Lightbar RGB.
Led { pad: u8, r: u8, g: u8, b: u8 },
/// Player-indicator LEDs (low 5 bits).
PlayerLeds { pad: u8, bits: u8 },
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
/// trigger parameter block (mode + params) for the client to replay on a real controller.
Trigger { pad: u8, which: u8, effect: Vec<u8> },
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
TrackpadHaptic {
pad: u8,
side: u8,
amplitude: u16,
period: u16,
count: u16,
},
}
impl HidOutput {
pub fn encode(&self) -> Vec<u8> {
let mut out = vec![HIDOUT_MAGIC];
match self {
HidOutput::Led { pad, r, g, b } => {
out.extend_from_slice(&[HIDOUT_LED, *pad, *r, *g, *b])
}
HidOutput::PlayerLeds { pad, bits } => {
out.extend_from_slice(&[HIDOUT_PLAYER_LEDS, *pad, *bits])
}
HidOutput::Trigger { pad, which, effect } => {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect);
}
HidOutput::TrackpadHaptic {
pad,
side,
amplitude,
period,
count,
} => {
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
out.extend_from_slice(&amplitude.to_le_bytes());
out.extend_from_slice(&period.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
}
}
out
}
pub fn decode(b: &[u8]) -> Option<HidOutput> {
if b.first() != Some(&HIDOUT_MAGIC) {
return None;
}
match *b.get(1)? {
HIDOUT_LED if b.len() >= 6 => Some(HidOutput::Led {
pad: b[2],
r: b[3],
g: b[4],
b: b[5],
}),
HIDOUT_PLAYER_LEDS if b.len() >= 4 => Some(HidOutput::PlayerLeds {
pad: b[2],
bits: b[3],
}),
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
pad: b[2],
which: b[3],
effect: b[4..].to_vec(),
}),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
side: b[3],
amplitude: u16::from_le_bytes([b[4], b[5]]),
period: u16::from_le_bytes([b[6], b[7]]),
count: u16::from_le_bytes([b[8], b[9]]),
}),
_ => None,
}
}
}
/// Static HDR metadata, host → client: SMPTE ST.2086 mastering display colour volume + CEA-861.3
/// content light level. Tag [`HDR_META_MAGIC`]. Carried on a datagram (not [`Welcome`]) because it
/// is larger and can change mid-stream when the source's mastering intent changes; the host
/// re-sends it on keyframes so a client that dropped the best-effort datagram converges. Omitted
/// for HLG (scene-referred — no mastering metadata).
///
/// All fields use the standard HDR10 SEI fixed-point units, so they pass straight to
/// `DXGI_HDR_METADATA_HDR10` / Android `KEY_HDR_STATIC_INFO` / Apple `CAEDRMetadata` — the
/// libavcodec `AVMasteringDisplayMetadata` side needs an `AVRational` conversion.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct HdrMeta {
/// Display primaries G, B, R as (x, y) chromaticity in 1/50000 units (the ST.2086 RGB order
/// is G, B, R).
pub display_primaries: [[u16; 2]; 3],
/// White point (x, y) in 1/50000 units.
pub white_point: [u16; 2],
/// Max display mastering luminance, 0.0001 cd/m² units.
pub max_display_mastering_luminance: u32,
/// Min display mastering luminance, 0.0001 cd/m² units.
pub min_display_mastering_luminance: u32,
/// Maximum content light level (MaxCLL), nits. `0` = unknown.
pub max_cll: u16,
/// Maximum frame-average light level (MaxFALL), nits. `0` = unknown.
pub max_fall: u16,
}
/// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
pub const HDR_META_MAGIC: u8 = 0xCE;
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32
/// luminance + 2×u16 CLL/FALL = 29 bytes.
const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4;
/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram.
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> {
let mut b = Vec::with_capacity(HDR_META_LEN);
b.push(HDR_META_MAGIC);
for p in m.display_primaries.iter() {
b.extend_from_slice(&p[0].to_le_bytes());
b.extend_from_slice(&p[1].to_le_bytes());
}
b.extend_from_slice(&m.white_point[0].to_le_bytes());
b.extend_from_slice(&m.white_point[1].to_le_bytes());
b.extend_from_slice(&m.max_display_mastering_luminance.to_le_bytes());
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes());
b.extend_from_slice(&m.max_cll.to_le_bytes());
b.extend_from_slice(&m.max_fall.to_le_bytes());
b
}
/// Parse a [`HDR_META_MAGIC`] datagram → [`HdrMeta`]. `None` on bad tag or a short/truncated buffer
/// (every attacker-controlled field is bounds-checked by the fixed length before any read).
pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
Some(HdrMeta {
display_primaries: [
[u16at(1), u16at(3)],
[u16at(5), u16at(7)],
[u16at(9), u16at(11)],
],
white_point: [u16at(13), u16at(15)],
max_display_mastering_luminance: u32at(17),
min_display_mastering_luminance: u32at(21),
max_cll: u16at(25),
max_fall: u16at(27),
})
}
/// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
/// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
/// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
pub const HOST_TIMING_MAGIC: u8 = 0xCF;
/// One access unit's host-side processing time: capture → fully sent (the whole host pipeline —
/// capture read/convert, encode, FEC+seal, paced send). The client correlates it to the AU by
/// `pts_ns` (the AU's capture stamp, unique per frame) and derives
/// `network = (received + clock_offset pts_ns) host_us`, so the unified-stats equation's
/// `host+network` stage splits into two per-frame-tiling terms. Best-effort like every side-plane
/// datagram: a lost 0xCF just means that frame contributes no host/network sample.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HostTiming {
/// The AU's capture stamp (host capture clock — matches the AU's `pts_ns` exactly).
pub pts_ns: u64,
/// Host capture→sent duration, µs (saturated at `u32::MAX` ≈ 71 min — far past the 10 s
/// client-side sanity clamp anyway).
pub host_us: u32,
}
/// Wire length of a [`HOST_TIMING_MAGIC`] datagram: tag + u64 pts + u32 µs = 13 bytes.
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram.
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
let mut b = Vec::with_capacity(HOST_TIMING_LEN);
b.push(HOST_TIMING_MAGIC);
b.extend_from_slice(&t.pts_ns.to_le_bytes());
b.extend_from_slice(&t.host_us.to_le_bytes());
b
}
/// Parse a [`HOST_TIMING_MAGIC`] datagram → [`HostTiming`]. `None` on bad tag or a short buffer
/// (the fixed length bounds every read before it happens).
pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
if b.len() < HOST_TIMING_LEN || b[0] != HOST_TIMING_MAGIC {
return None;
}
Some(HostTiming {
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
})
}
+370
View File
@@ -0,0 +1,370 @@
use std::sync::{Arc, Mutex};
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
/// idle timer run out and quinn closes the session — surfacing to the embedder as
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
fn stream_transport() -> Arc<quinn::TransportConfig> {
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
}
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
/// the keep-alive holds it open through quiet control periods.
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
use std::time::Duration;
let idle = idle.max(Duration::from_secs(1));
let keep_alive = (idle / 2).min(Duration::from_secs(4));
let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some(
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
));
t.keep_alive_interval(Some(keep_alive));
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
// standing delay that never drains while video stays live. Capping the buffer makes the
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
// surround bitrates), so sustained congestion costs concealable drops, never lag.
t.datagram_send_buffer_size(4 * 1024);
Arc::new(t)
}
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
/// persist an identity and use [`server_with_identity`] so clients can pin it).
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
}
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
pub fn server_with_identity(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
) -> anyhow_result::Result<quinn::Endpoint> {
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
}
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
/// keep-alive scales with it so a live session never false-closes.
pub fn server_with_identity_idle(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
use rustls::pki_types::pem::PemObject;
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
server_from_der(cert_der, key_der, addr, idle)
}
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
/// together (acceptable while the protocol/ABI is still evolving).
const QUIC_ALPN: &[u8] = b"pkf1";
fn server_from_der(
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
// one that presents none still connects (and is rejected at the app layer when
// pairing is required).
let mut rustls_cfg = rustls::ServerConfig::builder()
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
.with_single_cert(vec![cert_der], key_der)
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
Ok(quinn::Endpoint::server(server_config, addr)?)
}
/// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a
/// client persists once and presents on every connect so hosts can recognize it.
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
}
/// Fingerprint of the client certificate a connection presented (host side), if any.
pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
let identity = conn.peer_identity()?;
let certs = identity
.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>()
.ok()?;
certs.first().map(|c| cert_fingerprint(c.as_ref()))
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(cert_der).into()
}
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
/// must match what the client's verifier computes from the DER on the wire).
pub fn fingerprint_of_pem(cert_pem: &str) -> anyhow_result::Result<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
let der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
Ok(cert_fingerprint(der.as_ref()))
}
/// Client endpoint that skips certificate verification (TOFU bootstrap — read the
/// observed fingerprint off the slot and pin it on the next connect).
pub fn client_insecure() -> anyhow_result::Result<quinn::Endpoint> {
client_pinned(None).0
}
/// What [`client_pinned`] returns: the endpoint plus the slot the verifier writes the
/// observed host fingerprint into during the handshake.
pub type PinnedClient = (
anyhow_result::Result<quinn::Endpoint>,
Arc<Mutex<Option<[u8; 32]>>>,
);
/// Client endpoint that verifies the host by certificate fingerprint.
///
/// `pin = Some(sha256)` rejects any host whose leaf cert doesn't hash to `sha256`;
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
client_pinned_with_identity(pin, None)
}
/// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8
/// key) via TLS client auth — how a paired client identifies itself to the host.
pub fn client_pinned_with_identity(
pin: Option<[u8; 32]>,
identity: Option<(&str, &str)>,
) -> PinnedClient {
let observed = Arc::new(Mutex::new(None));
let ep = (|| {
let _ = rustls::crypto::ring::default_provider().install_default();
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify {
pin,
observed: observed.clone(),
}));
let mut rustls_cfg = match identity {
None => builder.with_no_client_auth(),
Some((cert_pem, key_pem)) => {
use rustls::pki_types::pem::PemObject;
let cert =
rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| {
anyhow_result::Error::msg(format!("client cert pem: {e}"))
})?;
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?;
builder
.with_client_auth_cert(vec![cert], key)
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
}
};
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
ep.set_default_client_config(client_cfg);
Ok(ep)
})();
(ep, observed)
}
/// Minimal error plumbing without pulling anyhow into punktfunk-core's public API.
pub mod anyhow_result {
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl Error {
pub fn msg(s: String) -> Self {
Error(s)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error(e.to_string())
}
}
}
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
/// verify the handshake signature for real — possession of the presented cert's key is
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
/// Authorization (is this fingerprint paired?) happens at the application layer.
#[derive(Debug)]
struct AcceptAnyClientCert;
impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
&[]
}
fn client_auth_mandatory(&self) -> bool {
false // unpaired/legacy clients still connect; gating is per-feature
}
fn verify_client_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error>
{
Ok(rustls::server::danger::ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
{
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
{
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
observed: Arc<Mutex<Option<[u8; 32]>>>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error>
{
let fp = cert_fingerprint(end_entity.as_ref());
*self.observed.lock().unwrap() = Some(fp);
if let Some(expected) = self.pin {
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
// The handshake signatures MUST be verified for real even though we pin the cert:
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
// skip it and an active MITM can replay the host's (public) certificate, match the
// pin, and complete the handshake with its own key.
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
{
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
{
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 2];
recv.read_exact(&mut len)
.await
.map_err(std::io::Error::other)?;
let n = u16::from_le_bytes(len) as usize;
let mut buf = vec![0u8; n];
recv.read_exact(&mut buf)
.await
.map_err(std::io::Error::other)?;
Ok(buf)
}
/// Write one framed message.
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
send.write_all(&super::frame(payload))
.await
.map_err(std::io::Error::other)
}
+64
View File
@@ -0,0 +1,64 @@
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
//!
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
//! length-prefixed binary handshake on one bidirectional stream:
//!
//! ```text
//! client → host Hello { abi_version }
//! host → client Welcome { abi_version, session: full data-plane Config + mode + UDP port }
//! client → host Start { client_udp_port }
//! ```
//!
//! after which both sides bring up a [`crate::session::Session`] over a plain
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
//! plane is exactly the hardened core `Session`.
//!
//! Transport security: the host presents a long-lived self-signed certificate
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
//! ([`endpoint::client_pinned`]; no pin = trust-on-first-use, with the observed fingerprint
//! reported back for persisting). The data plane adds AES-GCM on top.
//! All integers little-endian; every message is `u16 length || payload`.
//!
//! Split by concern (networking-audit deferred plan §3 — a pure move): [`msgs`] the
//! handshake + typed control messages, [`pake`] the pairing SPAKE2, [`datagram`] the
//! 0xC90xCF plane codecs, [`io`] framed stream IO, [`clock`] skew estimation + mid-stream
//! re-sync, [`endpoint`] the quinn constructors. Every item is re-exported here, so all
//! existing `crate::quic::X` paths compile unchanged.
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
pub const MAGIC: &[u8; 4] = b"PKF1";
/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the
/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version
/// byte sits where a type byte would) can never be misparsed as a control message, and
/// vice-versa, regardless of field values.
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
mod clock;
mod datagram;
mod msgs;
/// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via
/// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via
/// [`endpoint::client_pinned`] ([`endpoint::client_insecure`] is the no-pin special case).
pub mod endpoint;
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
pub mod io;
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
/// the identities are ordered; each side binds **both** certificate fingerprints as the
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
/// *and* saw the same two certificates (a MITM, presenting different certs to each leg,
/// cannot reach a shared key).
pub mod pake;
pub use clock::*;
pub use datagram::*;
pub use msgs::*;
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
use crate::error::{PunktfunkError, Result};
use hmac::{Hmac, Mac};
use spake2::{Ed25519Group, Identity, Password, Spake2};
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
pub struct PairingPake {
state: Spake2<Ed25519Group>,
transcript: Vec<u8>,
}
/// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the
/// client passes what it observed via TOFU; the host passes its own + the client's
/// presented cert). Returns the state and this side's outbound SPAKE2 message.
pub fn start(
is_client: bool,
pin: &str,
client_fp: &[u8; 32],
host_fp: &[u8; 32],
) -> (PairingPake, Vec<u8>) {
let pw = Password::new(pin.as_bytes());
let id_client = Identity::new(client_fp);
let id_host = Identity::new(host_fp);
let (state, msg) = if is_client {
Spake2::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
} else {
Spake2::<Ed25519Group>::start_b(&pw, &id_client, &id_host)
};
let mut transcript = Vec::with_capacity(64);
transcript.extend_from_slice(client_fp);
transcript.extend_from_slice(host_fp);
(PairingPake { state, transcript }, msg)
}
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
let mut mac =
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
mac.update(label);
mac.update(transcript);
mac.finalize().into_bytes().into()
}
/// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare
/// our recomputed tag the same way.
fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
/// Confirmation tags both sides expect, given the agreed SPAKE2 key.
pub struct Confirmations {
/// MAC the host sends (client verifies).
pub host: [u8; 32],
/// MAC the client sends (host verifies).
pub client: [u8; 32],
}
impl PairingPake {
/// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if
/// the peer's message is malformed (a wrong PIN does NOT error here — it yields a
/// *different* key, so the confirmation MACs simply won't match).
pub fn finish(self, peer_msg: &[u8]) -> Result<Confirmations> {
let key = self
.state
.finish(peer_msg)
.map_err(|_| PunktfunkError::Crypto)?;
Ok(Confirmations {
host: confirm(&key, b"punktfunk-pair-host", &self.transcript),
client: confirm(&key, b"punktfunk-pair-client", &self.transcript),
})
}
}
/// Constant-time tag comparison for the confirmation step.
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
ct_eq(expected, got)
}
+955
View File
@@ -0,0 +1,955 @@
use super::*;
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode};
#[test]
fn welcome_roundtrip() {
let w = Welcome {
abi_version: 1,
udp_port: 9999,
mode: Mode {
width: 2560,
height: 1440,
refresh_hz: 240,
},
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 4096,
},
shard_payload: 1200,
encrypt: true,
key: [7u8; 16],
salt: [1, 2, 3, 4],
frames: 600,
compositor: CompositorPref::Gamescope,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 50_000,
bit_depth: 10,
color: ColorInfo::HDR10_BT2020_PQ,
chroma_format: CHROMA_IDC_444,
audio_channels: 2,
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
host_caps: HOST_CAP_GAMEPAD_STATE,
};
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
}
#[test]
fn codec_negotiation_and_back_compat() {
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1, 0),
Some(CODEC_HEVC)
);
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264, 0),
Some(CODEC_AV1)
);
assert_eq!(resolve_codec(CODEC_H264, CODEC_H264, 0), Some(CODEC_H264));
// A software host (H.264 only) + an HEVC-only client share nothing → refuse.
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, 0), None);
// An older client (0 = no codec byte) is treated as HEVC-only.
assert_eq!(
resolve_codec(0, CODEC_HEVC | CODEC_H264, 0),
Some(CODEC_HEVC)
);
assert_eq!(resolve_codec(0, CODEC_H264, 0), None);
// Soft preference: honored when the host can also emit it, overriding precedence...
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_H264 | CODEC_HEVC, CODEC_H264),
Some(CODEC_H264)
);
assert_eq!(
resolve_codec(CODEC_HEVC | CODEC_AV1, CODEC_HEVC | CODEC_AV1, CODEC_AV1),
Some(CODEC_AV1)
);
// ...but falls back to precedence when the preferred codec isn't in the shared set.
assert_eq!(
resolve_codec(CODEC_HEVC | CODEC_H264, CODEC_HEVC | CODEC_H264, CODEC_AV1),
Some(CODEC_HEVC)
);
// A preference the host can't emit still can't rescue a no-shared-codec case.
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
// a build that ignores the trailing byte (back-compat: extra bytes are skipped).
let h = Hello {
abi_version: 2,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
video_codecs: CODEC_H264 | CODEC_HEVC,
preferred_codec: CODEC_H264,
};
let enc = h.encode();
let dec = Hello::decode(&enc).unwrap();
assert_eq!(dec.video_codecs, CODEC_H264 | CODEC_HEVC);
assert_eq!(dec.preferred_codec, CODEC_H264);
// Drop the preferred_codec byte → still decodes, video_codecs intact, preference gone.
let no_pref = &enc[..enc.len() - 1];
assert_eq!(
Hello::decode(no_pref).unwrap().video_codecs,
CODEC_H264 | CODEC_HEVC
);
assert_eq!(Hello::decode(no_pref).unwrap().preferred_codec, 0);
// A pre-codec Hello (no video_codecs/preferred bytes) decodes to 0 → HEVC-only.
let legacy = &enc[..enc.len() - 2];
assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0);
assert_eq!(Hello::decode(legacy).unwrap().preferred_codec, 0);
// A pre-codec Welcome (no codec byte) decodes to HEVC.
let mut w = Welcome::decode(
&Welcome {
abi_version: 2,
udp_port: 1,
mode: h.mode,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0,
max_data_per_block: 1024,
},
shard_payload: 1024,
encrypt: false,
key: [0; 16],
salt: [0; 4],
frames: 0,
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
bit_depth: 8,
color: ColorInfo::SDR_BT709,
chroma_format: CHROMA_IDC_420,
audio_channels: 2,
codec: CODEC_H264,
host_caps: 0,
}
.encode(),
)
.unwrap();
assert_eq!(w.codec, CODEC_H264);
w.codec = CODEC_HEVC;
let wenc = w.encode();
assert_eq!(
Welcome::decode(&wenc[..wenc.len() - 1]).unwrap().codec,
CODEC_HEVC
);
}
#[test]
fn hdr_meta_datagram_roundtrip_and_truncation() {
let m = HdrMeta {
// BT.2020 display primaries in 1/50000 units (the DXGI/ST.2086 reference values).
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
white_point: [15635, 16450], // D65
max_display_mastering_luminance: 10_000_000, // 1000 nits in 0.0001 cd/m²
min_display_mastering_luminance: 1, // 0.0001 nits
max_cll: 1000,
max_fall: 400,
};
let d = encode_hdr_meta_datagram(&m);
assert_eq!(d[0], HDR_META_MAGIC);
assert_eq!(decode_hdr_meta_datagram(&d), Some(m));
// Truncated buffers and a wrong tag are rejected (never partially read).
for n in 0..d.len() {
assert_eq!(decode_hdr_meta_datagram(&d[..n]), None);
}
let mut bad = d.clone();
bad[0] = HIDOUT_MAGIC;
assert_eq!(decode_hdr_meta_datagram(&bad), None);
}
#[test]
fn host_timing_datagram_roundtrip_and_truncation() {
let t = HostTiming {
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
host_us: 4_321,
};
let d = encode_host_timing_datagram(&t);
assert_eq!(d[0], HOST_TIMING_MAGIC);
assert_eq!(d.len(), 13);
assert_eq!(decode_host_timing_datagram(&d), Some(t));
// Truncated buffers and a wrong tag are rejected (never partially read).
for n in 0..d.len() {
assert_eq!(decode_host_timing_datagram(&d[..n]), None);
}
let mut bad = d.clone();
bad[0] = HDR_META_MAGIC;
assert_eq!(decode_host_timing_datagram(&bad), None);
}
#[test]
fn hello_start_roundtrip() {
let h = Hello {
abi_version: 1,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 120,
},
compositor: CompositorPref::Kwin,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 25_000,
name: Some("Test Device".into()),
launch: Some("steam:570".into()),
video_caps: VIDEO_CAP_10BIT,
audio_channels: 2,
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
preferred_codec: CODEC_HEVC,
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
client_udp_port: 1234,
};
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
}
#[test]
fn compositor_pref_wire_and_names() {
for p in [
CompositorPref::Auto,
CompositorPref::Kwin,
CompositorPref::Wlroots,
CompositorPref::Mutter,
CompositorPref::Gamescope,
] {
assert_eq!(CompositorPref::from_u8(p.to_u8()), p);
assert_eq!(CompositorPref::from_name(p.as_str()), Some(p));
}
// Aliases + unknowns.
assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin));
assert_eq!(
CompositorPref::from_name("sway"),
Some(CompositorPref::Wlroots)
);
assert_eq!(CompositorPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
}
#[test]
fn gamepad_pref_wire_and_names() {
for p in [
GamepadPref::Auto,
GamepadPref::Xbox360,
GamepadPref::DualSense,
GamepadPref::XboxOne,
GamepadPref::DualShock4,
] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
}
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
// Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
assert_eq!(
GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne)
);
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
assert_eq!(GamepadPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
}
#[test]
fn hello_welcome_compositor_back_compat() {
// Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's
// shorter message still decodes (missing fields = Auto), and a legacy peer reading a
// new message ignores the trailing bytes. Simulate both directions by truncation.
let h = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Mutter,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 80_000,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
// Legacy (20-byte) Hello → both Auto, no bitrate, mode intact.
let legacy = Hello::decode(&enc[..20]).unwrap();
assert_eq!(legacy.compositor, CompositorPref::Auto);
assert_eq!(legacy.gamepad, GamepadPref::Auto);
assert_eq!(legacy.bitrate_kbps, 0);
assert_eq!(legacy.mode, h.mode);
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
let mid = Hello::decode(&enc[..21]).unwrap();
assert_eq!(mid.compositor, CompositorPref::Mutter);
assert_eq!(mid.gamepad, GamepadPref::Auto);
// Gamepad-era (22-byte) Hello → compositor + gamepad intact, bitrate 0 (host default).
let pre_bitrate = Hello::decode(&enc[..22]).unwrap();
assert_eq!(pre_bitrate.gamepad, GamepadPref::DualSense);
assert_eq!(pre_bitrate.bitrate_kbps, 0);
// Full message → bitrate intact.
assert_eq!(Hello::decode(&enc).unwrap().bitrate_kbps, 80_000);
let w = Welcome {
abi_version: 2,
udp_port: 7000,
mode: h.mode,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 4096,
},
shard_payload: 1200,
encrypt: true,
key: [3u8; 16],
salt: [9, 8, 7, 6],
frames: 0,
compositor: CompositorPref::Kwin,
gamepad: GamepadPref::Xbox360,
bitrate_kbps: 120_000,
bit_depth: 10,
color: ColorInfo::HDR10_BT2020_PQ,
chroma_format: CHROMA_IDC_444,
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE,
};
let wenc = w.encode();
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
assert_eq!(legacy_w.bitrate_kbps, 0);
assert_eq!(legacy_w.frames, 0);
assert_eq!(legacy_w.key, w.key);
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
// Gamepad-era (55-byte) Welcome → gamepad intact, bitrate 0 (unknown).
let pre_bitrate_w = Welcome::decode(&wenc[..55]).unwrap();
assert_eq!(pre_bitrate_w.gamepad, GamepadPref::Xbox360);
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
assert_eq!(pre_bitrate_w.bit_depth, 8); // older host (no trailing byte) → 8-bit assumed
assert_eq!(legacy_w.bit_depth, 8);
// A pre-colour (60-byte) Welcome → SDR BT.709 (the only colour those hosts produced).
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
assert_eq!(pre_color_w.bit_depth, 10);
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
assert_eq!(pre_audio_w.audio_channels, 2);
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
assert_eq!(
Welcome::decode(&wenc).unwrap().color,
ColorInfo::HDR10_BT2020_PQ
);
assert_eq!(
Welcome::decode(&wenc).unwrap().chroma_format,
CHROMA_IDC_444
); // full form carries 4:4:4
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
assert_eq!(
Welcome::decode(&wenc).unwrap().host_caps,
HOST_CAP_GAMEPAD_STATE
);
}
#[test]
fn hello_name_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: Some("Enrico's MacBook".into()),
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
};
let enc = base.encode();
assert_eq!(
Hello::decode(&enc).unwrap().name.as_deref(),
Some("Enrico's MacBook")
);
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
// host reading a bitrate-era Hello decodes name = None.
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
let unnamed = Hello {
name: None,
..base.clone()
};
assert_eq!(unnamed.encode().len(), 26);
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
let long = Hello {
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
..base.clone()
};
let dec = Hello::decode(&long.encode()).unwrap();
let n = dec.name.expect("truncated name still present");
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
let mut bad_len = unnamed.encode();
bad_len.push(40); // claims 40 name bytes, none follow
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
let mut bad_utf8 = unnamed.encode();
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
}
#[test]
fn hello_launch_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
};
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello {
launch: Some("steam:570".into()),
..base.clone()
};
assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch);
// launch + name together.
let both = Hello {
name: Some("Enrico's Mac".into()),
launch: Some("custom:abc123".into()),
..base.clone()
};
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
// name but no launch (a name-era client): launch decodes None.
let name_only = Hello {
name: Some("Enrico's Mac".into()),
..base.clone()
};
assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None);
// Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted).
assert_eq!(base.encode().len(), 26);
assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None);
// A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it.
assert_eq!(
Hello::decode(&with_launch.encode()[..26]).unwrap().launch,
None
);
// Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX.
let long = Hello {
launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))),
..base.clone()
};
let dec = Hello::decode(&long.encode())
.unwrap()
.launch
.expect("present");
assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x'));
}
#[test]
fn reconfigure_roundtrip() {
let rq = Reconfigure {
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 144,
},
};
assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq);
for accepted in [true, false] {
let rs = Reconfigured {
accepted,
mode: rq.mode,
};
assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs);
}
// The type byte separates the post-handshake messages from each other.
assert!(Reconfigure::decode(
&Reconfigured {
accepted: true,
mode: rq.mode
}
.encode()
)
.is_err());
}
#[test]
fn request_keyframe_roundtrip() {
let bytes = RequestKeyframe.encode();
assert!(RequestKeyframe::decode(&bytes).is_ok());
// Distinct from the other control messages — its type byte must not collide.
let mode = Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
assert!(Reconfigure::decode(&bytes).is_err());
// Length is exact (no trailing bytes accepted).
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
}
#[test]
fn loss_report_roundtrip() {
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
let r = LossReport { loss_ppm };
assert_eq!(LossReport::decode(&r.encode()).unwrap(), r);
}
// Disjoint from the other control messages (type byte + length).
assert!(LossReport::decode(&RequestKeyframe.encode()).is_err());
assert!(RequestKeyframe::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
assert!(LossReport::decode(
&[LossReport { loss_ppm: 0 }.encode().as_slice(), &[0]].concat()
)
.is_err());
}
#[test]
fn window_loss_ppm_estimates_and_caps() {
// No traffic → 0. A clean window (nothing recovered) → 0.
assert_eq!(window_loss_ppm(0, 0, 0), 0);
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
}
#[test]
fn bitrate_messages_roundtrip() {
let req = SetBitrate {
bitrate_kbps: 14_000,
};
assert_eq!(SetBitrate::decode(&req.encode()).unwrap(), req);
let ack = BitrateChanged {
bitrate_kbps: 14_000,
};
assert_eq!(BitrateChanged::decode(&ack.encode()).unwrap(), ack);
// Same payload shape as LossReport — the type byte alone must keep them disjoint.
assert!(LossReport::decode(&req.encode()).is_err());
assert!(SetBitrate::decode(&ack.encode()).is_err());
assert!(BitrateChanged::decode(&req.encode()).is_err());
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
}
#[test]
fn probe_messages_roundtrip() {
let req = ProbeRequest {
target_kbps: 250_000,
duration_ms: 2000,
};
assert_eq!(ProbeRequest::decode(&req.encode()).unwrap(), req);
let res = ProbeResult {
bytes_sent: 62_500_000,
packets_sent: 480,
duration_ms: 2003,
wire_packets_sent: 41_000,
send_dropped: 1_200,
};
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
assert_eq!(res.encode().len(), 29);
// A pre-wire-stats host's 21-byte ProbeResult still decodes, with the new fields zeroed.
let legacy = {
let full = res.encode();
full[..21].to_vec()
};
let decoded = ProbeResult::decode(&legacy).unwrap();
assert_eq!(decoded.wire_packets_sent, 0);
assert_eq!(decoded.send_dropped, 0);
assert_eq!(decoded.bytes_sent, res.bytes_sent);
// Type bytes keep the control messages disjoint from each other.
assert!(ProbeRequest::decode(&res.encode()).is_err());
assert!(Reconfigure::decode(&req.encode()).is_err());
assert!(ProbeResult::decode(&req.encode()).is_err());
}
#[test]
fn clock_messages_roundtrip() {
let probe = ClockProbe {
t1_ns: 1_700_000_000_123,
};
assert_eq!(ClockProbe::decode(&probe.encode()).unwrap(), probe);
let echo = ClockEcho {
t1_ns: 1_700_000_000_123,
t2_ns: 1_700_000_050_456,
t3_ns: 1_700_000_050_789,
};
assert_eq!(ClockEcho::decode(&echo.encode()).unwrap(), echo);
// Disjoint from the other control messages (distinct type bytes).
assert!(ClockProbe::decode(&echo.encode()).is_err());
assert!(ProbeRequest::decode(&probe.encode()).is_err());
assert!(ClockEcho::decode(&probe.encode()).is_err());
}
#[test]
fn clock_offset_picks_min_rtt_and_recovers_offset() {
// Host clock is +1_000_000 ns ahead of the client. Construct samples where a symmetric
// round-trip recovers exactly that offset, and a noisy (asymmetric, high-RTT) sample is
// present but must be ignored by the min-RTT selection.
const OFF: i64 = 1_000_000;
// Clean sample: client t1=0, one-way=200µs each way → t2 = t1 + 200_000 + OFF (host clock),
// t3 = t2 + 50_000 (host processing), t4 = t3 - OFF + 200_000 (back in client clock).
let t1 = 0u64;
let t2 = (t1 as i64 + 200_000 + OFF) as u64;
let t3 = t2 + 50_000;
let t4 = (t3 as i64 - OFF + 200_000) as u64;
// Noisy sample: same offset but a fat, asymmetric RTT (slow return path) — higher RTT.
let n1 = 1_000_000u64;
let n2 = (n1 as i64 + 200_000 + OFF) as u64;
let n3 = n2 + 50_000;
let n4 = (n3 as i64 - OFF + 5_000_000) as u64; // 5 ms return → big RTT
let (offset, rtt) =
clock_offset_ns(&[(n1, n2, n3, n4), (t1, t2, t3, t4)]).expect("non-empty");
// The min-RTT sample recovers the offset exactly; its RTT is 2x200us, and the noisy
// (asymmetric, 5 ms return) sample is ignored by the min-RTT selection.
assert_eq!(offset, OFF);
assert_eq!(rtt, 400_000);
assert!(clock_offset_ns(&[]).is_none());
}
/// The mid-stream re-sync state machine: 8 rounds collected via matched echoes, stale
/// echoes ignored, a restarted batch abandons the old one, and the batch result is the
/// min-RTT estimate — the exact behavior the connect-time `clock_sync` loop has.
#[test]
fn clock_resync_collects_rounds_and_ignores_stale_echoes() {
// Host clock +1 ms ahead; symmetric 100 µs one-way paths except one congested round.
const OFF: i64 = 1_000_000;
let echo_for = |t1: u64, one_way: u64| ClockEcho {
t1_ns: t1,
t2_ns: (t1 as i64 + one_way as i64 + OFF) as u64,
t3_ns: (t1 as i64 + one_way as i64 + OFF) as u64 + 10_000,
};
let t4_for = |e: &ClockEcho, one_way: u64| (e.t3_ns as i64 - OFF + one_way as i64) as u64;
let mut rs = ClockResync::new();
// An unsolicited echo before any batch is ignored.
assert_eq!(rs.on_echo(&echo_for(42, 100_000), 500_000), ResyncStep::Idle);
let mut probe = rs.begin(1_000_000);
// A stale echo (wrong t1: the abandoned pre-begin probe) is ignored mid-batch.
assert_eq!(rs.on_echo(&echo_for(42, 100_000), 500_000), ResyncStep::Idle);
for round in 0..ClockResync::ROUNDS {
// Round 3 is congested (5 ms one-way) — it must lose the min-RTT selection.
let one_way = if round == 3 { 5_000_000 } else { 100_000 };
let echo = echo_for(probe.t1_ns, one_way);
let t4 = t4_for(&echo, one_way);
match rs.on_echo(&echo, t4) {
ResyncStep::Probe(p) => {
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
probe = p;
}
ResyncStep::Done { offset_ns, rtt_ns } => {
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
assert_eq!(offset_ns, OFF, "min-RTT round recovers the offset exactly");
assert_eq!(rtt_ns, 200_000); // 2×100 µs; host processing (t3t2) excluded
}
ResyncStep::Idle => panic!("matched echo must advance the batch"),
}
}
// The batch is done: even a matching-t1 replay no longer advances anything.
assert_eq!(
rs.on_echo(&echo_for(probe.t1_ns, 100_000), probe.t1_ns + 300_000),
ResyncStep::Idle
);
// begin() mid-batch abandons the in-flight batch: its echo is stale afterwards.
let old = rs.begin(2_000_000);
let fresh = rs.begin(3_000_000);
assert_eq!(
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
ResyncStep::Idle
);
assert!(matches!(
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
ResyncStep::Probe(_)
));
}
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
/// replace the offset — its queueing delay biases the estimate exactly when frames
/// already read late. Floor of 2 ms so a near-zero connect RTT (same-host/LAN) doesn't
/// reject every later batch over normal jitter.
#[test]
fn clock_resync_acceptance_guard() {
// Generous connect RTT (10 ms): accept up to 1.5×.
assert!(accept_resync(14_000_000, 10_000_000));
assert!(!accept_resync(16_000_000, 10_000_000));
// Tiny connect RTT (200 µs, wired LAN): the 2 ms floor governs.
assert!(accept_resync(1_900_000, 200_000));
assert!(!accept_resync(2_100_000, 200_000));
// Boundary: exactly at the bound is accepted.
assert!(accept_resync(2_000_000, 0));
assert!(accept_resync(15_000_000, 10_000_000));
}
#[test]
fn control_messages_disjoint_from_hello() {
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
// any abi_version — can be misparsed as a control message, and vice-versa.
for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] {
let h = Hello {
abi_version: abi,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
assert!(Reconfigure::decode(&h).is_err());
}
// And a PairRequest never parses as a Hello.
let pr = PairRequest {
name: "x".into(),
spake_a: vec![0u8; 33],
}
.encode();
assert!(Hello::decode(&pr).is_err());
}
#[test]
fn pair_messages_roundtrip() {
let pr = PairRequest {
name: "Enrico's Mac".into(),
spake_a: vec![1, 2, 3, 4, 5],
};
assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr);
let pc = PairChallenge {
spake_b: vec![9; 33],
confirm: [7u8; 32],
};
assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc);
let pp = PairProof { confirm: [3u8; 32] };
assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp);
for ok in [true, false] {
assert_eq!(
PairResult::decode(&PairResult { ok }.encode()).unwrap().ok,
ok
);
}
// Length-exact: a truncated/padded PairProof is rejected.
let mut bad = pp.encode();
bad.push(0);
assert!(PairProof::decode(&bad).is_err());
}
#[test]
fn spake2_pairing_agrees_only_on_matching_pin_and_certs() {
let cfp = [0x11u8; 32];
let hfp = [0x22u8; 32];
// Right PIN, same fingerprint views on both sides → both confirmations agree.
let (ca, ma) = pake::start(true, "4321", &cfp, &hfp);
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
let a = ca.finish(&mb).unwrap();
let b = cb.finish(&ma).unwrap();
assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client));
// Wrong PIN → different keys → confirmations DON'T match (one online guess wasted).
let (ca, ma) = pake::start(true, "0000", &cfp, &hfp);
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
let a = ca.finish(&mb).unwrap();
let b = cb.finish(&ma).unwrap();
assert!(!pake::verify(&a.client, &b.client));
// MITM: the two legs saw different host certs → no agreement even with the right PIN.
let attacker_hfp = [0x33u8; 32];
let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp);
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
let a = ca.finish(&mb).unwrap();
let b = cb.finish(&ma).unwrap();
assert!(!pake::verify(&a.client, &b.client));
}
#[test]
fn audio_datagram_roundtrip() {
let opus = [0x42u8; 97];
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
assert_eq!(d[0], AUDIO_MAGIC);
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
assert_eq!((seq, pts), (7, 1_000_000_123));
assert_eq!(payload, opus);
assert!(decode_audio_datagram(&d[..12]).is_none()); // truncated header
assert!(decode_audio_datagram(&[0u8; 13]).is_none()); // bad magic
// Empty payload is legal (DTX) — header-only datagram.
let header_only = encode_audio_datagram(0, 0, &[]);
let (_, _, empty) = decode_audio_datagram(&header_only).unwrap();
assert!(empty.is_empty());
}
#[test]
fn rumble_datagram_roundtrip() {
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
assert_eq!(d[0], RUMBLE_MAGIC);
assert_eq!(decode_rumble_datagram(&d), Some((1, 0x1234, 0xFFFF)));
assert!(decode_rumble_datagram(&d[..6]).is_none());
}
#[test]
fn mic_datagram_roundtrip_and_disjoint_from_audio() {
let opus = [0x5Au8; 80];
let d = encode_mic_datagram(42, 9_999, &opus);
assert_eq!(d[0], MIC_MAGIC);
let (seq, pts, payload) = decode_mic_datagram(&d).unwrap();
assert_eq!((seq, pts), (42, 9_999));
assert_eq!(payload, opus);
assert!(decode_mic_datagram(&d[..12]).is_none()); // truncated
// Tag separation: a mic datagram is not an audio datagram and vice-versa.
assert!(decode_audio_datagram(&d).is_none());
assert!(decode_mic_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
// Empty payload (DTX) is legal.
assert!(decode_mic_datagram(&encode_mic_datagram(0, 0, &[]))
.unwrap()
.2
.is_empty());
}
#[test]
fn rich_input_roundtrip() {
for ev in [
RichInput::Touchpad {
pad: 1,
finger: 0,
active: true,
x: 40000,
y: 12345,
},
RichInput::Motion {
pad: 0,
gyro: [-100, 200, -300],
accel: [16384, -8192, 1],
},
RichInput::TouchpadEx {
pad: 2,
surface: 1,
finger: 1,
touch: true,
click: false,
x: -12345,
y: 30000,
pressure: 4000,
},
] {
let d = ev.encode();
assert_eq!(d[0], RICH_INPUT_MAGIC);
assert_eq!(RichInput::decode(&d), Some(ev));
}
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
// short
}
#[test]
fn hid_output_roundtrip() {
let cases = [
HidOutput::Led {
pad: 2,
r: 0xAA,
g: 0xBB,
b: 0xCC,
},
HidOutput::PlayerLeds {
pad: 0,
bits: 0b10101,
},
HidOutput::Trigger {
pad: 1,
which: 1,
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
},
HidOutput::TrackpadHaptic {
pad: 0,
side: 1,
amplitude: 0x1234,
period: 0x5678,
count: 9,
},
];
for ev in &cases {
let d = ev.encode();
assert_eq!(d[0], HIDOUT_MAGIC);
assert_eq!(HidOutput::decode(&d).as_ref(), Some(ev));
}
assert!(HidOutput::decode(&[HIDOUT_MAGIC, 0x7F]).is_none()); // unknown kind
// A rich-input datagram is not a HID-output datagram.
assert!(HidOutput::decode(
&RichInput::Motion {
pad: 0,
gyro: [0; 3],
accel: [0; 3]
}
.encode()
)
.is_none());
}
#[test]
fn fingerprint_is_sha256_of_der() {
// Stable across calls, distinct for distinct certs.
let a = endpoint::cert_fingerprint(b"cert-a");
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
}