Files
peerspeak/src/protocol.rs
T
molluskandClaude Opus 4.8 961705ffa9 feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background
Steps 5-6 of game detection. BREAKING wire change — bump everyone.

Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
  Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
  different topics + signature domains, so a coordinated redeploy is
  required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
  control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
  128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
  48KB avatar is ~49KB, so this bounds allocation abuse with headroom.

Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
  select. Detection runs continuously (for the local background); the
  broadcast is gated by game_presence_enabled (opt-in, default OFF).
  New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
  SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.

Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
  per-game override (config.game_backgrounds[id]) or falls back to the
  W16 default; reuses the existing cached-handle path (no redraw flicker).

397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:31:09 -04:00

100 lines
4.8 KiB
Rust

//! Single source of truth for PeerSpeak's on-wire protocol versions and the
//! per-plane ALPNs / gossip constants derived from them.
//!
//! See `VERSIONING.md`. The rule: each transport plane is versioned independently
//! (audio rarely changes, gossip changes often), and incompatible peers must fail
//! fast — never as a silent decode/signature error. iroh refuses a mismatched
//! ALPN at the QUIC handshake, so the audio/friends planes are self-isolating;
//! gossip can't use a custom ALPN (it rides iroh-gossip's `GOSSIP_ALPN`), so its
//! version is bound into the subscribed topic ([`versioned_topic`]) and the
//! signature domain ([`GOSSIP_SIG_DOMAIN`]).
//!
//! **Never hand-write an ALPN literal elsewhere — derive it here.** Bumping a
//! plane's protocol version is a breaking wire change → also bump `Cargo.toml`
//! MINOR (see `VERSIONING.md`).
/// Audio datagram plane version (Opus framing / sequencing). Bump on any audio
/// wire change. Mirrored in [`AUDIO_ALPN`].
pub const AUDIO_PROTO: u32 = 1;
/// Friends/presence plane version (`ControlMsg` ping-pong shape). Bump on any
/// change. Mirrored in [`FRIENDS_ALPN`].
pub const FRIENDS_PROTO: u32 = 1;
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
/// into [`versioned_topic`].
///
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work.
///
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
/// strictly required for decoding — but per the versioning discipline a wire-shape
/// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
pub const GOSSIP_PROTO: u32 = 3;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_PROTO: u32 = 1;
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
/// ALPN for the file-transfer plane: `peerspeak/files/<FILES_PROTO>`. Carries
/// chat attachment bytes via direct QUIC streams (not gossip).
pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
/// never share a swarm — the gossip analog of a versioned ALPN. The room's raw
/// `topic_id` (random 32 bytes, carried in the ticket) is the room identity and
/// is unchanged; only the *subscribed* topic is namespaced.
///
/// Deterministic and dependency-free; bijective for a fixed version, so distinct
/// rooms stay distinct after namespacing. This transform is for *isolation*, not
/// security — cryptographic separation between versions comes from
/// [`GOSSIP_SIG_DOMAIN`].
pub fn versioned_topic(topic_id: [u8; 32]) -> [u8; 32] {
let v = GOSSIP_PROTO.to_le_bytes();
let mut out = topic_id;
for (i, b) in out.iter_mut().enumerate() {
*b ^= v[i % v.len()];
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The ALPN/domain strings must stay in lock-step with the integer versions
/// so a version bump can't silently forget to update the wire string.
#[test]
fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes());
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
}
#[test]
fn versioned_topic_is_deterministic_and_room_distinct() {
let a = [9u8; 32];
let mut b = a;
b[5] = 10;
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
}
#[test]
fn versioned_topic_actually_namespaces_for_current_version() {
// Guards against a no-op transform: GOSSIP_PROTO=1 must change the topic.
assert_ne!(versioned_topic([0u8; 32]), [0u8; 32]);
}
}