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
+74 -13
View File
@@ -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<crate::files::ChatAttachment>,
},
}
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<crate::files::ChatAttachment>,
) -> 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));