From 02cb46550ec310999603d9746275028007d3b007 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 20 Jun 2026 23:00:51 -0400 Subject: [PATCH] Chat file attachments, stage 1: protocol + data model + pure seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of in-chat file/photo sharing (dedicated file plane, images inline + file chips, session-only). This stage adds the wire types and the pure, unit-tested logic; no transport or UI yet. - protocol: new FILES_ALPN / FILES_PROTO (peerspeak/files/1) for the dedicated file-transfer plane. Bump GOSSIP_PROTO 1->2 + sig domain v2 (Chat gained an attachment field, so cross-version peers fail fast rather than half-work) and Cargo 0.2.0 -> 0.3.0 per VERSIONING.md. BREAKING wire change: all peers must run >= 0.3.0. - new src/files.rs: ChatAttachment descriptor (name/size/kind/id; bytes travel off-gossip), AttachmentKind, plus pure seams — sanitize_filename (path-traversal/control-char/length-safe), size_within_cap, image magic-byte sniffing + defensive limited decode (decode-bomb guard), 32-byte request parsing, human_size. 13 unit tests. - GossipMessage::Chat and RoomEvent::ChatMessage carry an optional ChatAttachment; send_chat takes Option. Untrusted inbound descriptors are filename-sanitized + size-validated on ingest. serde(default) keeps the field forward-compatible at the JSON layer; +round-trip and pre-v2 back-compat tests. The attachment id is a random 32-byte handle (rand, already a dep), not a content hash — the fetch is authenticated + encrypted + member-gated, so no crypto-hash dep is needed. 349 lib tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 --- .agents/AGENTS.md | 21 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- packaging/PKGBUILD | 2 +- src/core/mod.rs | 2 +- src/files.rs | 295 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/network/gossip.rs | 87 +++++++++++-- src/network/mod.rs | 19 ++- src/protocol.rs | 15 ++- 10 files changed, 424 insertions(+), 22 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 src/files.rs diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..aefad8b --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,21 @@ +# PeerSpeak Codebase Layout and Architecture Rules + +When working in the PeerSpeak repository, adhere to the following architectural boundaries and layout: + +## Code Layout +- `src/main.rs`: The application entry point (initializes Tokio and the Iced GUI). +- `src/app/`: The UI layer (Iced). Handles themes, views (Home, Room, Settings), and visual state. Must communicate with the core via message passing (`UiEvent`/`CoreCommand`), not direct function calls. +- `src/core/`: The central orchestrator. + - `mod.rs`: Manages the session lifecycle, ties together network and UI, and manages the async mixer tasks. + - `jitter.rs`: Houses the adaptive playout delay JitterBuffer and Packet Loss Concealment (PLC) logic. +- `src/network/`: The "Dual-Plane" transport layer. + - `gossip.rs` (Control Plane): Built on `iroh-gossip`. Manages room rosters, verified membership, presence, and chat via cryptographically signed envelopes. + - `iroh_impl.rs` (Data Plane): Manages raw QUIC endpoints and peer connections. Forwards UDP voice datagrams directly to peers for minimum latency. +- `src/audio/`: Hardware audio backends. + - Interfaces heavily with `cpal_impl.rs` (Windows/WASAPI) and `pipewire_impl.rs` (Linux). + - **CRITICAL RULE**: The RT audio callbacks are strictly lock-free. They communicate with the async core exclusively via Single-Producer Single-Consumer (SPSC) ring buffers (`HeapRb`). Never allocate memory, log to stdout, or lock Mutexes on the RT threads. +- `src/codec/`: Audio compression abstractions, standardizing on Opus at 48kHz mono (`opus_impl.rs`). + +## General Directives +- **Security**: Audio admission is strictly derived from the verified gossip roster (S8). Never trust raw UDP sender IDs without validating against gossip. +- **Latency**: Preserve the deterministic dialer vs acceptor logic in the QUIC layer to prevent connection loops. diff --git a/Cargo.lock b/Cargo.lock index c7e880b..c454f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4742,7 +4742,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "peerspeak" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index f9bfde3..ac142b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peerspeak" -version = "0.2.0" +version = "0.3.0" edition = "2024" # Application crate, not a crates.io library — refuse `cargo publish` and let # cargo-deny's [licenses.private] skip the missing-license check. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 932feea..f52815c 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: mollusk pkgname=peerspeak-git _pkgname=peerspeak -pkgver=0.1.0 +pkgver=0.2.0.r218.gcbba4b6 pkgrel=1 pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)" arch=('x86_64') diff --git a/src/core/mod.rs b/src/core/mod.rs index 5b443b4..becde25 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2208,7 +2208,7 @@ async fn run_core_loop( CoreCommand::SendChat(text) => { if let Some(session) = &active_session - && let Err(e) = session.room_state.send_chat(text).await + && let Err(e) = session.room_state.send_chat(text, None).await { crate::log_msg(&format!("Failed to send chat: {e}")); } diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..a5cba4e --- /dev/null +++ b/src/files.rs @@ -0,0 +1,295 @@ +//! Chat file attachments: the compact descriptor that rides a gossip chat +//! message, plus the pure validation/sanitization seams for the file-transfer +//! plane. +//! +//! Attachment **bytes do not travel over gossip** — gossip is a small-frame +//! broadcast plane (see `avatar` for why image bytes there are hard-capped to +//! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor* +//! (name, size, kind, id); the sender serves the actual bytes over the dedicated +//! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients +//! fetch them point-to-point. Everything in this module is dependency-light and +//! pure so it can be unit-tested away from the network and the GUI. + +use serde::{Deserialize, Serialize}; + +/// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can +/// make us hold (when fetching) or serve, and the time a transfer can take. +/// 25 MiB comfortably covers phone photos and ordinary documents. +pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; + +/// Max decoded pixels per side for an inline image preview. Defends against a +/// decode-bomb (a small file that expands to an enormous bitmap), independent of +/// the byte cap. Applied via `image::Limits` when validating/decoding. +pub const MAX_IMAGE_PX: u32 = 4096; + +/// Longest filename we keep and display. Keeps the gossip descriptor compact and +/// the UI tidy; the real bytes are unaffected. +pub const MAX_FILENAME_LEN: usize = 96; + +/// A 32-byte opaque id identifying one attachment for the fetch request. Minted +/// randomly per attachment by the sender (see core); the transfer itself is +/// authenticated + encrypted + room-member gated, so the id only needs to be a +/// hard-to-guess handle into the sender's serve store, not a content hash. +pub type AttachmentId = [u8; 32]; + +/// How the receiver should present an attachment. A *hint* derived from the +/// sender's content sniff — never trusted for a safety decision. The receiver +/// re-validates image bytes itself before decoding, and falls back to a file +/// chip if an "Image" doesn't actually decode. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum AttachmentKind { + Image, + File, +} + +/// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it +/// holds no file bytes, only what the UI needs to render a placeholder/chip and +/// what a fetch needs to pull the bytes. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct ChatAttachment { + /// Sanitized display filename (already path-stripped — see + /// [`sanitize_filename`]). Never used as a filesystem path on receipt without + /// the user choosing a save location. + pub name: String, + /// Byte length of the file. Bounds the fetch read; must be + /// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]). + pub size: u64, + /// Presentation hint (image vs. generic file). + pub kind: AttachmentKind, + /// Opaque handle the receiver writes on the file plane to request the bytes. + pub id: AttachmentId, +} + +/// Sanitize an arbitrary (possibly hostile) filename for display and as a +/// save-dialog default. Strips any directory component (both `/` and `\`), +/// removes control characters, collapses whitespace, trims, caps the length +/// while trying to preserve a short extension, and rejects the `.`/`..` traps. +/// Always returns a non-empty, path-component-free name (falls back to `file`). +pub fn sanitize_filename(raw: &str) -> String { + // Take only the final *non-empty* path component, defeating + // `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes + // (`a/b/c/` → `c`). + let base = raw + .rsplit(['/', '\\']) + .find(|s| !s.trim().is_empty()) + .unwrap_or("") + .trim(); + + // Drop control chars; turn other whitespace into single spaces later. + let cleaned: String = base + .chars() + .filter(|c| !c.is_control()) + .collect(); + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + let collapsed = collapsed.trim_matches('.').trim(); + + if collapsed.is_empty() { + return "file".to_string(); + } + if collapsed.chars().count() <= MAX_FILENAME_LEN { + return collapsed.to_string(); + } + + // Too long: keep the extension (if short + sane) and truncate the stem. + if let Some((stem, ext)) = collapsed.rsplit_once('.') + && !ext.is_empty() + && ext.chars().count() <= 8 + && ext.chars().all(|c| c.is_ascii_alphanumeric()) + { + let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1); + let truncated: String = stem.chars().take(keep).collect(); + return format!("{truncated}.{ext}"); + } + collapsed.chars().take(MAX_FILENAME_LEN).collect() +} + +/// Whether a declared/observed size is within the transfer cap and non-zero. +/// Used both when sending (reject before serving) and when fetching (reject a +/// descriptor before opening a stream). +pub fn size_within_cap(size: u64) -> bool { + size > 0 && size <= MAX_ATTACHMENT_BYTES +} + +/// Sniff the leading bytes for a known image container, to set the attachment +/// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a +/// presentation hint only — actual inline rendering still depends on the bytes +/// decoding (we only build image features for PNG/JPEG), with a file-chip +/// fallback otherwise. +pub fn is_probably_image(bytes: &[u8]) -> bool { + let b = bytes; + let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]); + let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]); + let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a"); + let bmp = b.starts_with(b"BM"); + let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP"; + png || jpeg || gif || bmp || webp +} + +/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it +/// sniffs as an image container, else [`AttachmentKind::File`]. +pub fn classify(bytes: &[u8]) -> AttachmentKind { + if is_probably_image(bytes) { + AttachmentKind::Image + } else { + AttachmentKind::File + } +} + +/// Defensively decode image bytes under strict pixel limits to confirm they're a +/// real, sane image before we hand them to the renderer. Returns the decoded +/// dimensions on success. Guards against decode-bombs (small file → huge bitmap) +/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our +/// `image` feature set; anything else returns `None` and the caller shows a chip. +pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_PX); + limits.max_image_height = Some(MAX_IMAGE_PX); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + let mut reader = reader; + reader.limits(limits); + let img = reader.decode().ok()?; + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX { + return None; + } + Some((w, h)) +} + +/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32 +/// bytes). Anything else is rejected so a peer can't send a malformed/oversized +/// request frame. Pure half of the serve handler. +pub fn parse_request(bytes: &[u8]) -> Option { + if bytes.len() != 32 { + return None; + } + let mut id = [0u8; 32]; + id.copy_from_slice(bytes); + Some(id) +} + +/// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip. +pub fn human_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.0} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_strips_directory_traversal() { + assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); + assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png"); + assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc"); + assert_eq!(sanitize_filename("a/b/c/"), "c"); + } + + #[test] + fn sanitize_rejects_dot_traps_and_empty() { + assert_eq!(sanitize_filename(""), "file"); + assert_eq!(sanitize_filename("."), "file"); + assert_eq!(sanitize_filename(".."), "file"); + assert_eq!(sanitize_filename(" "), "file"); + assert_eq!(sanitize_filename("/"), "file"); + } + + #[test] + fn sanitize_removes_control_chars_and_collapses_ws() { + // Control chars (incl. tab/newline) are stripped entirely. + assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png"); + assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt"); + // Real spaces are collapsed but preserved. + assert_eq!(sanitize_filename("my photo .png"), "my photo .png"); + } + + #[test] + fn sanitize_caps_length_preserving_extension() { + let long_stem = "x".repeat(200); + let name = format!("{long_stem}.png"); + let out = sanitize_filename(&name); + assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count()); + assert!(out.ends_with(".png"), "extension preserved: {out}"); + } + + #[test] + fn size_cap_bounds() { + assert!(!size_within_cap(0)); + assert!(size_within_cap(1)); + assert!(size_within_cap(MAX_ATTACHMENT_BYTES)); + assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1)); + } + + #[test] + fn image_sniffing_recognizes_containers() { + assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0])); + assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0])); + assert!(is_probably_image(b"GIF89a....")); + let mut webp = b"RIFF".to_vec(); + webp.extend_from_slice(&[0, 0, 0, 0]); + webp.extend_from_slice(b"WEBP"); + assert!(is_probably_image(&webp)); + assert!(!is_probably_image(b"%PDF-1.7")); + assert!(!is_probably_image(b"")); + } + + #[test] + fn classify_maps_sniff_to_kind() { + assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image); + assert_eq!(classify(b"plain text"), AttachmentKind::File); + } + + #[test] + fn parse_request_requires_exact_32_bytes() { + assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32])); + assert_eq!(parse_request(&[7u8; 31]), None); + assert_eq!(parse_request(&[7u8; 33]), None); + assert_eq!(parse_request(&[]), None); + } + + #[test] + fn validate_image_rejects_garbage() { + assert_eq!(validate_image_bytes(b"not an image"), None); + assert_eq!(validate_image_bytes(&[]), None); + } + + #[test] + fn validate_image_accepts_a_real_png() { + // Encode a tiny PNG in-memory, then validate it. + let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30])); + let mut buf = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut buf, image::ImageFormat::Png) + .unwrap(); + assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3))); + } + + #[test] + fn human_size_units() { + assert_eq!(human_size(40), "40 B"); + assert_eq!(human_size(2048), "2 KB"); + assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB"); + } + + #[test] + fn attachment_descriptor_round_trips_json() { + let a = ChatAttachment { + name: "photo.png".to_string(), + size: 12345, + kind: AttachmentKind::Image, + id: [9u8; 32], + }; + let bytes = serde_json::to_vec(&a).unwrap(); + let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(a, back); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1dd59ab..3b9b55d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod background; pub mod recents; pub mod discovery; pub mod hotkeys; +pub mod files; use std::fs::File; use std::path::{Path, PathBuf}; diff --git a/src/network/gossip.rs b/src/network/gossip.rs index 242c1b3..07de35a 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -184,9 +184,16 @@ fn compute_bootstrap( pub enum GossipMessage { Announce(PeerState), Leave, - /// A room text-chat message: the author's display name, the text, and a - /// sender-stamped millisecond timestamp. - Chat { name: String, text: String, ts: u64 }, + /// A room text-chat message: the author's display name, the text, a + /// sender-stamped millisecond timestamp, and an optional file attachment + /// descriptor (the bytes are fetched off-gossip on the file plane). + Chat { + name: String, + text: String, + ts: u64, + #[serde(default)] + attachment: Option, + }, } pub struct IrohGossipState { @@ -438,13 +445,24 @@ impl RoomState for IrohGossipState { let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await; } } - GossipMessage::Chat { name, text, ts } => { + GossipMessage::Chat { name, text, ts, attachment } => { crate::log_msg(&format!("Gossip chat from author={:?}", payload.author)); + // Defensively normalize an untrusted attachment + // descriptor: sanitize the filename and drop it + // entirely if it declares an out-of-cap size. + let attachment = attachment.and_then(|mut a| { + if !crate::files::size_within_cap(a.size) { + return None; + } + a.name = crate::files::sanitize_filename(&a.name); + Some(a) + }); let _ = event_tx.send(RoomEvent::ChatMessage { from: payload.author, name, text, ts, + attachment, }).await; } } @@ -565,7 +583,11 @@ impl RoomState for IrohGossipState { } } - async fn send_chat(&self, text: String) -> Result<(), NetError> { + async fn send_chat( + &self, + text: String, + attachment: Option, + ) -> Result<(), NetError> { let name = { let guard = self.self_state.lock().unwrap(); match guard.as_ref() { @@ -582,7 +604,7 @@ impl RoomState for IrohGossipState { &self.secret_key, &topic, ts, - GossipMessage::Chat { name, text, ts }, + GossipMessage::Chat { name, text, ts, attachment }, ); if let Ok(bytes) = serde_json::to_vec(&payload) { sender.broadcast(bytes.into()).await @@ -744,13 +766,15 @@ mod tests { name: "Alice".to_string(), text: "Hello".to_string(), ts: 123456789, + attachment: None, }; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); - if let GossipMessage::Chat { name, text, ts } = deserialized { + if let GossipMessage::Chat { name, text, ts, attachment } = deserialized { assert_eq!(name, "Alice"); assert_eq!(text, "Hello"); assert_eq!(ts, 123456789); + assert_eq!(attachment, None); } else { panic!("Expected GossipMessage::Chat"); } @@ -760,10 +784,11 @@ mod tests { name: "".to_string(), text: "".to_string(), ts: u64::MAX, + attachment: None, }; let serialized_empty = serde_json::to_string(&original_empty).unwrap(); let deserialized_empty: GossipMessage = serde_json::from_str(&serialized_empty).unwrap(); - if let GossipMessage::Chat { name, text, ts } = deserialized_empty { + if let GossipMessage::Chat { name, text, ts, .. } = deserialized_empty { assert_eq!(name, ""); assert_eq!(text, ""); assert_eq!(ts, u64::MAX); @@ -772,6 +797,40 @@ mod tests { } } + #[test] + fn test_gossip_chat_attachment_round_trip_and_back_compat() { + let att = crate::files::ChatAttachment { + name: "photo.png".to_string(), + size: 4096, + kind: crate::files::AttachmentKind::Image, + id: [42u8; 32], + }; + let original = GossipMessage::Chat { + name: "Alice".to_string(), + text: "look at this".to_string(), + ts: 1, + attachment: Some(att.clone()), + }; + let serialized = serde_json::to_string(&original).unwrap(); + let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); + if let GossipMessage::Chat { attachment, .. } = deserialized { + assert_eq!(attachment, Some(att)); + } else { + panic!("Expected GossipMessage::Chat"); + } + + // A pre-v2 chat payload (no `attachment` field) must still deserialize, + // defaulting the attachment to None (serde(default)). + let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#; + let parsed: GossipMessage = serde_json::from_str(legacy).unwrap(); + if let GossipMessage::Chat { name, attachment, .. } = parsed { + assert_eq!(name, "Old"); + assert_eq!(attachment, None); + } else { + panic!("Expected GossipMessage::Chat"); + } + } + #[test] fn test_gossip_payload_chat_round_trip() { let secret = SecretKey::generate(); @@ -784,6 +843,7 @@ mod tests { name: "Bob".to_string(), text: "Hi there".to_string(), ts: 987654321, + attachment: None, }, ); @@ -791,7 +851,7 @@ mod tests { let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized.author, secret.public()); - if let GossipMessage::Chat { name, text, ts } = deserialized.msg { + if let GossipMessage::Chat { name, text, ts, .. } = deserialized.msg { assert_eq!(name, "Bob"); assert_eq!(text, "Hi there"); assert_eq!(ts, 987654321); @@ -806,10 +866,11 @@ mod tests { name: "🎙 User".to_string(), text: "héllo 🎙 世界".to_string(), ts: 1717171717, + attachment: None, }; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); - if let GossipMessage::Chat { name, text, ts } = deserialized { + if let GossipMessage::Chat { name, text, ts, .. } = deserialized { assert_eq!(name, "🎙 User"); assert_eq!(text, "héllo 🎙 世界"); assert_eq!(ts, 1717171717); @@ -849,7 +910,7 @@ mod tests { let secret = SecretKey::generate(); let topic = [4u8; 32]; let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); - p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000 }; + p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None }; assert_eq!( verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Err(GossipReject::BadSignature) @@ -922,8 +983,8 @@ mod tests { fn state_mutation_replay_gate_leaves_chat_ordering_untouched() { let author = fresh_id(); let mut seen = HashMap::new(); - let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 }; - let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 }; + let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None }; + let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None }; assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100)); diff --git a/src/network/mod.rs b/src/network/mod.rs index e57be30..942e5e6 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -57,7 +57,15 @@ pub enum RoomEvent { /// A peer sent a room text-chat message. Carries the sender's id, their /// display name (embedded so it shows even without a presence entry), the /// text, and a sender-stamped millisecond timestamp. - ChatMessage { from: EndpointId, name: String, text: String, ts: u64 }, + ChatMessage { + from: EndpointId, + name: String, + text: String, + ts: u64, + /// Optional file attachment descriptor; the bytes are fetched off-gossip + /// on the file plane. Already filename-sanitized + size-capped on ingest. + attachment: Option, + }, } /// Transport-level link state for a peer, surfaced so the UI can show when a @@ -206,8 +214,13 @@ pub trait RoomState: Send + Sync { fn mark_peer_disconnected(&self, peer_id: EndpointId); /// Broadcasts a room text-chat message authored by us (our display name is - /// taken from the current self-state). - async fn send_chat(&self, text: String) -> Result<(), NetError>; + /// taken from the current self-state), optionally carrying a file attachment + /// descriptor whose bytes are served separately on the file plane. + async fn send_chat( + &self, + text: String, + attachment: Option, + ) -> Result<(), NetError>; /// Leaves the room and announces departure. async fn leave(&self) -> Result<(), NetError>; diff --git a/src/protocol.rs b/src/protocol.rs index c229de1..9ab4334 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -22,16 +22,26 @@ 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`]. -pub const GOSSIP_PROTO: u32 = 1; +/// +/// 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. +pub const GOSSIP_PROTO: u32 = 2; +/// 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/`. pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1"; /// ALPN for the friends/presence plane: `peerspeak/friends/`. pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1"; +/// ALPN for the file-transfer plane: `peerspeak/files/`. 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`. 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-v1"; +pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2"; /// Version-namespace a room topic so peers on different gossip protocol versions /// derive **different subscription topics from the same ticket** and therefore @@ -62,6 +72,7 @@ mod tests { 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}")); }