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
+13 -2
View File
@@ -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/<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-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}"));
}