chat: attachment cache, download, and transfer hardening (Phase 3)
CI / check (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn into unbounded memory, bandwidth, decoder, or task pressure (S15 closed; S14's filename half closed). Cache and image cost (3A): AttachmentCache now carries encoded- and decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with per-entry weights, replacement accounting, and oldest-first eviction; an individually over-budget fetch services any pending Save/Play from the bytes in hand and is exposed as Evicted instead of retained. validate_image_bytes prechecks header dimensions (per-side AND a new 14 MP total-pixel limit) before any decode; the renderer only ever receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts against the decoded budget — originals stay encoded-only for Save. sanitize_filename strips the bidi/zero-width spoofing set (RTL-override extension spoof). Download policy and state (3B): images auto-fetch only when roster- authored AND declared ≤4 MiB, gated by a new deterministic AutoFetchBudget (per-author and session request+byte token buckets, check-then-take, bounded author map) alongside the existing dedup and four-permit bound. Attachment state is now explicit — absence/Loading/ Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so skipped or evicted images render a "Load image" button instead of an indefinite "loading…", and repeated clicks can never spawn duplicate fetch tasks. Exact transfers and serve store (3C): fetch_blob requires the received length to equal the declared size (short = local error, overlong = bounded-read reject, empty keeps meaning "sender no longer has it"); the file picker's unbounded read is replaced by a metadata-prechecked cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command queue, and serve store; served_files is a count- and byte-budgeted FIFO ServeStore (16 entries / 128 MiB). 37 new tests (568 lib total) including a real two-endpoint loopback exercising exact/short/overlong/unknown-id transfers. Plan checkboxes ticked and constant deviations decision-logged. Tests-green-only: the plan's two-machine field-test section remains open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,9 @@ struct Shared {
|
||||
/// the random attachment id. Populated when we send a chat file; read by the
|
||||
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
|
||||
/// blob is already byte-capped at send time.
|
||||
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
|
||||
/// Blobs we serve to room members, bounded by count and byte budgets
|
||||
/// (Phase 3C) — an evicted id reads as "sender no longer has the file".
|
||||
served_files: StdMutex<crate::files::ServeStore>,
|
||||
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||
/// Best-effort link-state notifications for the UI (connecting / connected).
|
||||
conn_events_tx: mpsc::Sender<ConnEvent>,
|
||||
@@ -518,7 +520,7 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
|
||||
let Some(id) = crate::files::parse_request(&req) else {
|
||||
return Ok(());
|
||||
};
|
||||
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
|
||||
let blob = shared.served_files.lock().unwrap().get(&id);
|
||||
if let Some(blob) = blob {
|
||||
let _ = send.write_all(&blob).await;
|
||||
}
|
||||
@@ -563,7 +565,7 @@ impl IrohTransport {
|
||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||
live_conns: StdMutex::new(HashMap::new()),
|
||||
admitted_audio: StdMutex::new(HashSet::new()),
|
||||
served_files: StdMutex::new(HashMap::new()),
|
||||
served_files: StdMutex::new(crate::files::ServeStore::default()),
|
||||
incoming_tx,
|
||||
conn_events_tx,
|
||||
});
|
||||
@@ -634,7 +636,9 @@ impl IrohTransport {
|
||||
/// session (served by the [`FileRouter`] handler). Called by core when we
|
||||
/// send a chat file. The blob is cleared on leave.
|
||||
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
|
||||
self.shared.served_files.lock().unwrap().insert(id, bytes);
|
||||
if !self.shared.served_files.lock().unwrap().insert(id, bytes) {
|
||||
crate::log_msg("Transport: refused to serve an over-budget blob");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
|
||||
@@ -676,6 +680,8 @@ impl IrohTransport {
|
||||
send.finish()
|
||||
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
||||
|
||||
// `read_to_end(size)` errors if the stream exceeds `size`, rejecting an
|
||||
// overlong transfer; the exact-length check below rejects a short one.
|
||||
let read = recv.read_to_end(size as usize);
|
||||
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
||||
.await
|
||||
@@ -686,6 +692,14 @@ impl IrohTransport {
|
||||
"file fetch: sender no longer has the file".to_string(),
|
||||
));
|
||||
}
|
||||
// Exact transfer required (Phase 3C): a truncated body must not be
|
||||
// cached/saved/decoded as if it were the declared attachment.
|
||||
if bytes.len() as u64 != size {
|
||||
return Err(NetError::Other(format!(
|
||||
"file fetch: incomplete transfer ({} of {size} bytes)",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user