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>
369 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|