chat: attachment cache, download, and transfer hardening (Phase 3)
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:
2026-07-18 02:09:30 -04:00
co-authored by Claude Fable 5
parent 8898652349
commit 554b613466
9 changed files with 1305 additions and 147 deletions
+91
View File
@@ -570,3 +570,94 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
"audio should resume after reconnecting via the retained address; got {received} frames"
);
}
/// A node that also serves the file plane (`FILES_ALPN`), mirroring how core
/// registers the `FileRouter` for a session.
async fn spawn_file_server() -> Node {
let lookup = MemoryLookup::new();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(iroh::SecretKey::generate())
.relay_mode(RelayMode::Disabled)
.address_lookup(lookup.clone())
.bind()
.await
.expect("bind endpoint");
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
let audio_router = AudioRouter::new();
audio_router.bind(&transport);
let file_router = peerspeak::network::iroh_impl::FileRouter::new();
file_router.bind(&transport);
let router = Router::builder(endpoint.clone())
.accept(AUDIO_ALPN, audio_router)
.accept(peerspeak::protocol::FILES_ALPN, file_router)
.spawn();
Node {
endpoint,
transport,
_router: router,
lookup,
}
}
/// Phase 3C: a file fetch must deliver EXACTLY the declared size — short,
/// overlong, and unknown-id transfers are all rejected with local errors, and
/// an exact transfer round-trips byte-identically.
#[tokio::test]
async fn file_plane_requires_exact_declared_size() {
let fetcher = spawn_node().await;
let server = spawn_file_server().await;
fetcher.lookup.add_endpoint_info(server.endpoint.addr());
server.lookup.add_endpoint_info(fetcher.endpoint.addr());
let server_id = server.endpoint.id();
// Member gating: the server only serves current room members.
server.transport.admit_audio_sender(fetcher.endpoint.id());
let blob = vec![42u8; 1000];
let id = [7u8; 32];
server
.transport
.serve_attachment(id, Arc::new(blob.clone()));
// Exact declared size: byte-identical round trip.
let got = fetcher
.transport
.fetch_blob(server_id, id, 1000)
.await
.expect("exact-size fetch succeeds");
assert_eq!(got, blob);
// Declared larger than served (short transfer): rejected, not cached as-is.
let err = fetcher
.transport
.fetch_blob(server_id, id, 2000)
.await
.expect_err("short transfer must fail");
assert!(
err.to_string().contains("incomplete transfer"),
"unexpected error: {err}"
);
// Declared smaller than served (overlong transfer): the bounded read
// rejects the stream rather than truncating it into a "valid" result.
let err = fetcher
.transport
.fetch_blob(server_id, id, 500)
.await
.expect_err("overlong transfer must fail");
assert!(
err.to_string().contains("read failed"),
"unexpected error: {err}"
);
// Unknown id: the empty body reads as the sender no longer having it.
let err = fetcher
.transport
.fetch_blob(server_id, [9u8; 32], 1000)
.await
.expect_err("unknown id must fail");
assert!(
err.to_string().contains("no longer has the file"),
"unexpected error: {err}"
);
}