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>
This commit is contained in:
2026-06-20 23:00:51 -04:00
co-authored by Claude Opus 4.8
parent cbba4b644e
commit 02cb46550e
10 changed files with 424 additions and 22 deletions
+16 -3
View File
@@ -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<crate::files::ChatAttachment>,
},
}
/// 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<crate::files::ChatAttachment>,
) -> Result<(), NetError>;
/// Leaves the room and announces departure.
async fn leave(&self) -> Result<(), NetError>;