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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user