Files
peerspeak/src/network/mod.rs
T
molluskandClaude Opus 4.8 02cb46550e Chat file attachments, stage 1: protocol + data model + pure seams
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<ChatAttachment>. 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 <noreply@anthropic.com>
2026-06-20 23:00:51 -04:00

369 lines
16 KiB
Rust

use iroh::{EndpointId, EndpointAddr};
use bytes::Bytes;
use thiserror::Error;
use tokio::sync::mpsc::Receiver;
use async_trait::async_trait;
use serde::{Serialize, Deserialize};
use std::str::FromStr;
#[derive(Error, Debug)]
pub enum NetError {
#[error("Failed to initialize network: {0}")]
Init(String),
#[error("Failed to connect/dial peer: {0}")]
Connection(String),
#[error("Gossip swarm error: {0}")]
Gossip(String),
#[error("Serialization / Deserialization error: {0}")]
Serde(String),
#[error("Invalid ticket: {0}")]
InvalidTicket(String),
#[error("Other network error: {0}")]
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerState {
pub name: String,
pub is_muted: bool,
pub addr: iroh::EndpointAddr,
/// When this peer is sharing their screen, the pixelpass relay ticket a
/// viewer needs to watch it; `None` when not sharing. Riding presence means
/// the existing gossip re-announce delivers it to late joiners for free, and
/// a `PeerUpdated` fires automatically on share start/stop. Defaulted so
/// older configs / peers that predate the field still deserialize.
#[serde(default)]
pub sharing: Option<String>,
/// This peer's chosen avatar (W4). Rides presence so it reaches everyone
/// (incl. late joiners) with no server, like `sharing`. Defaulted so older
/// peers/configs that predate the field still deserialize (→ monogram).
#[serde(default)]
pub avatar: crate::avatar::Avatar,
}
#[derive(Debug, Clone)]
pub enum RoomEvent {
PeerJoined(EndpointId, PeerState),
/// A peer left *gracefully* (it broadcast a `Leave`). Evict it immediately.
PeerLeft(EndpointId),
PeerUpdated(EndpointId, PeerState),
/// A peer's gossip neighbour link dropped without a graceful `Leave` (network
/// blip, crash, walked out of range). This is transient: the audio supervisor
/// keeps redialing, so the core marks the peer "reconnecting" and only evicts
/// it if the link hasn't recovered within the reconnect grace window. Distinct
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
/// reconnect path the way it used to.
PeerConnectionLost(EndpointId),
/// 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,
/// Optional file attachment descriptor; the bytes are fetched off-gossip
/// on the file plane. Already filename-sanitized + size-capped on ingest.
attachment: Option<crate::files::ChatAttachment>,
},
}
/// Transport-level link state for a peer, surfaced so the UI can show when a
/// peer's audio connection is being (re)established versus actually carrying
/// audio. Distinct from `RoomEvent`: a peer can be present in the gossip room
/// while its audio link is momentarily down and reconnecting.
#[derive(Debug, Clone)]
pub enum ConnEvent {
/// No live audio link yet — initial connect or reconnecting after a drop.
Connecting(EndpointId),
/// A live audio link is established and carrying datagrams.
Connected(EndpointId),
/// The peer closed its link *gracefully* (an explicit QUIC application close,
/// which only happens on an intentional leave/quit — a network drop yields a
/// timeout, not this). Distinct from a transient drop so the core can evict
/// the peer immediately instead of waiting out the reconnect grace window.
/// This is the prompt, reliable leave signal; the gossip `Leave` is too slow.
Left(EndpointId),
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PeerSpeakTicket {
pub host_addr: iroh::EndpointAddr,
pub topic_id: [u8; 32],
/// Cosmetic room label (W7), chosen by the creator and carried in the ticket
/// so everyone who joins via it inherits the same label and reports "in
/// HangOut" in presence. Untrusted on receipt (it rides a peer-shared ticket)
/// — sanitize before display. `#[serde(default)]` keeps older, pre-label
/// tickets parseable (they decode to an empty label).
#[serde(default)]
pub name: String,
}
impl PeerSpeakTicket {
/// Member-issued ticket (W7 P3): re-stamp an existing ticket string with our
/// OWN address while keeping its room `topic_id` AND its cosmetic `name`, so
/// any member can hand out a working door that bootstraps off themselves — the
/// mechanism that lets a room outlive its creator. A no-op (returns the input
/// unchanged) if the string can't be parsed. Re-stamping with the same address
/// is idempotent.
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
match ticket_str.parse::<PeerSpeakTicket>() {
Ok(t) => {
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
.to_string()
}
Err(_) => ticket_str.to_string(),
}
}
/// The cosmetic room label embedded in a ticket string, or `""` if the ticket
/// can't be parsed or carries no label. Pure; used to label the gathering both
/// in the room UI and in the presence we report to friends.
pub fn label_of(ticket_str: &str) -> String {
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default()
}
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
/// can't be parsed. Pure; used as the stable room identity for de-duplicating
/// the recents list (the host address and label change between members/sessions,
/// but the topic uniquely identifies the gathering).
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
ticket_str.parse::<PeerSpeakTicket>().ok().map(|t| t.topic_id)
}
}
impl std::fmt::Display for PeerSpeakTicket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// serde_json on a struct of String/[u8;32] fields is infallible in practice,
// but Display can't surface an error, so fall back to an empty ticket body.
let serialized = serde_json::to_vec(self).unwrap_or_default();
let encoded = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&serialized,
);
f.write_str(&encoded)
}
}
impl FromStr for PeerSpeakTicket {
type Err = NetError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
Ok(ticket)
}
}
#[async_trait]
pub trait NetworkTransport: Send + Sync {
/// Establish (or ensure) a connection to a peer and set up its send path.
/// Idempotent; safe to call again for an already-connected peer — a repeat
/// call refreshes the retained dial address, so calling it again when a peer
/// re-announces (e.g. on a new address) keeps the dialer able to re-reach it.
/// The full `EndpointAddr` (relay + direct addrs) is retained so reconnects
/// dial it directly instead of depending on a lookup that a transient gossip
/// `Leave`/`NeighborDown` may have purged.
async fn connect_peer(&self, addr: iroh::EndpointAddr);
/// Tear down the connection and send path for a peer that has left.
async fn disconnect_peer(&self, peer_id: EndpointId);
/// Fan a single audio datagram out to every connected peer. Non-blocking:
/// per-peer queues drop the oldest-pending frame when full, so a slow link
/// can never stall the capture/encode thread. Callable from any thread.
fn broadcast(&self, data: Bytes);
/// Subscribes to incoming datagrams from any peer.
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError>;
/// Subscribes to per-peer link-state changes (connecting / connected).
async fn subscribe_conn_events(&self) -> Result<Receiver<ConnEvent>, NetError>;
}
#[async_trait]
pub trait RoomState: Send + Sync {
/// Joins a room using a gossip ticket string and announces our state.
///
/// `extra_bootstrap` are additional peer addresses to dial when entering the
/// gossip swarm, on top of the ticket's host. This is what lets the ROOM
/// CREATOR rejoin a room they left: their own ticket lists only themselves as
/// host, so without retained peers they'd have no dial target and never
/// re-enter the swarm (bug A8). Callers with no prior peers pass `vec![]`.
async fn join(
&self,
ticket: &str,
self_state: PeerState,
extra_bootstrap: Vec<EndpointAddr>,
) -> Result<(), NetError>;
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
/// Ask the active gossip topic to connect to retained peer addresses without
/// leaving or replacing the subscription. This is a recovery primitive only:
/// it does not add peers to the authenticated room roster. A peer becomes
/// active only after its normal signed `Announce` is received and verified.
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;
/// Remove a peer from the authenticated live roster before background
/// recovery. This only revokes membership; a fresh verified `Announce` is
/// required to add the peer again.
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), optionally carrying a file attachment
/// descriptor whose bytes are served separately on the file plane.
async fn send_chat(
&self,
text: String,
attachment: Option<crate::files::ChatAttachment>,
) -> Result<(), NetError>;
/// Leaves the room and announces departure.
async fn leave(&self) -> Result<(), NetError>;
/// Returns a list of currently active peers in the room.
fn active_peers(&self) -> Vec<(EndpointId, PeerState)>;
/// Subscribes to room events (peer joined, peer left, peer updated).
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
}
pub mod iroh_impl;
pub mod gossip;
#[cfg(test)]
mod tests {
use super::*;
use iroh::{SecretKey, EndpointAddr};
fn sample_peer_state() -> PeerState {
let secret = SecretKey::generate();
let public = secret.public();
let addr = EndpointAddr::from(public);
PeerState {
name: "TestPeer".to_string(),
is_muted: false,
addr,
sharing: None,
avatar: crate::avatar::Avatar::default(),
}
}
#[test]
fn test_ticket_round_trip() {
let state = sample_peer_state();
let topic_id = [7u8; 32];
let original_ticket = PeerSpeakTicket {
host_addr: state.addr.clone(),
topic_id,
name: "HangOut".to_string(),
};
let ticket_str = original_ticket.to_string();
let parsed_ticket = ticket_str.parse::<PeerSpeakTicket>().unwrap();
assert_eq!(parsed_ticket.host_addr.id, original_ticket.host_addr.id);
assert_eq!(parsed_ticket.topic_id, original_ticket.topic_id);
assert_eq!(parsed_ticket.name, "HangOut");
}
#[test]
fn test_ticket_label_helpers_and_backcompat() {
let host = SecretKey::generate().public();
let topic_id = [3u8; 32];
// A labelled ticket: restamp keeps the label, label_of reads it.
let labelled =
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
.to_string();
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
let member = SecretKey::generate().public();
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
assert_eq!(PeerSpeakTicket::label_of(&restamped), "HangOut");
// An unparseable ticket has no label rather than panicking.
assert_eq!(PeerSpeakTicket::label_of("not-a-ticket"), "");
// topic_of reads the room identity, and returns None for a bad ticket.
assert_eq!(PeerSpeakTicket::topic_of(&labelled), Some(topic_id));
assert_eq!(PeerSpeakTicket::topic_of(&restamped), Some(topic_id));
assert_eq!(PeerSpeakTicket::topic_of("not-a-ticket"), None);
// Backward-compat: a pre-label ticket JSON (no `name` key) still parses,
// defaulting the label to "".
let legacy_json = serde_json::json!({
"host_addr": serde_json::to_value(EndpointAddr::from(host)).unwrap(),
"topic_id": topic_id.to_vec(),
});
let legacy_str = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
serde_json::to_vec(&legacy_json).unwrap(),
);
let parsed = legacy_str.parse::<PeerSpeakTicket>().unwrap();
assert_eq!(parsed.name, "");
}
#[test]
fn test_malformed_rejection() {
// empty string
let res1 = "".parse::<PeerSpeakTicket>();
assert!(matches!(res1, Err(NetError::InvalidTicket(_))));
// non-base64 garbage
let res2 = "!!!not base64!!!".parse::<PeerSpeakTicket>();
assert!(matches!(res2, Err(NetError::InvalidTicket(_))));
// valid URL-safe-base64 that decodes to non-JSON bytes
let bad_json = b"hello world";
let encoded = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
bad_json,
);
let res3 = encoded.parse::<PeerSpeakTicket>();
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
}
#[test]
fn test_restamp_swaps_addr_keeps_topic() {
// A ticket "from" the host, then re-stamped by another member.
let host = SecretKey::generate().public();
let member = SecretKey::generate().public();
let topic_id = [42u8; 32];
let original =
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
.to_string();
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
// Same room (and label), but the door now points at the member, not the host.
assert_eq!(restamped.topic_id, topic_id);
assert_eq!(restamped.host_addr.id, member);
assert_ne!(restamped.host_addr.id, host);
assert_eq!(restamped.name, "HangOut");
}
#[test]
fn test_restamp_is_idempotent_for_same_addr() {
let me = SecretKey::generate().public();
let topic_id = [7u8; 32];
let mine =
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
.to_string();
// Re-stamping my own ticket with my own addr changes nothing.
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine);
}
#[test]
fn test_restamp_passes_through_unparseable() {
let me = SecretKey::generate().public();
// A malformed ticket is returned unchanged (the join will fail anyway).
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
}
#[test]
fn test_peer_state_serde_round_trip() {
let original = sample_peer_state();
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
}