From d8367cab8037f4b45977675680061261ba44f61e Mon Sep 17 00:00:00 2001 From: Willem Horsten Date: Thu, 27 Aug 2026 15:56:12 +0200 Subject: [PATCH 1/4] test(adapter): add response limit regression (#394) Reproduces the 16 MiB adapter ceiling despite the core's 256 MiB response default across the shared adapter conformance path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/iroh-http-adapter/src/lib.rs | 41 +++++++++++++++++++ packages/iroh-http-node/test/adapter.test.mjs | 4 +- tests/suites/adapter-validation.mjs | 36 +++++++++------- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/crates/iroh-http-adapter/src/lib.rs b/crates/iroh-http-adapter/src/lib.rs index 6eb5b329..0b7ff555 100644 --- a/crates/iroh-http-adapter/src/lib.rs +++ b/crates/iroh-http-adapter/src/lib.rs @@ -991,6 +991,47 @@ mod tests { )); } + #[test] + fn coerce_fetch_options_accepts_response_limits_through_core_default() { + const CORE_DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 256 * 1024 * 1024; + + for value in [ + MAX_BODY_BYTES, + 64 * 1024 * 1024, + CORE_DEFAULT_MAX_RESPONSE_BODY_BYTES, + ] { + let options = coerce_fetch_options(RawFetchOptions { + node_id: "aaaa".to_string(), + url: "httpi://peer/".to_string(), + method: "GET".to_string(), + direct_addrs: None, + headers: vec![], + timeout_ms: None, + max_response_body_bytes: Some(value as f64), + }) + .expect("response limit through the core default should be accepted"); + + assert_eq!(options.max_response_body_bytes, Some(value)); + } + + let too_large = coerce_fetch_options(RawFetchOptions { + node_id: "aaaa".to_string(), + url: "httpi://peer/".to_string(), + method: "GET".to_string(), + direct_addrs: None, + headers: vec![], + timeout_ms: None, + max_response_body_bytes: Some((CORE_DEFAULT_MAX_RESPONSE_BODY_BYTES + 1) as f64), + }); + assert!(matches!( + too_large, + Err(AdapterInputError::InvalidArgument { + field: "maxResponseBodyBytes", + .. + }) + )); + } + #[test] fn coerce_endpoint_options_validates_and_coerces() { let ok = coerce_endpoint_options(RawEndpointOptions { diff --git a/packages/iroh-http-node/test/adapter.test.mjs b/packages/iroh-http-node/test/adapter.test.mjs index ac1ebc85..422e5a7e 100644 --- a/packages/iroh-http-node/test/adapter.test.mjs +++ b/packages/iroh-http-node/test/adapter.test.mjs @@ -85,7 +85,7 @@ test("fetch numeric validation rejects invalid values instead of defaulting", as const { id } = await node.addr(); const url = `httpi://${id}/validation`; const maxTimeoutMs = 300_000; - const maxBodyBytes = 16 * 1024 * 1024; + const maxResponseBodyBytes = 256 * 1024 * 1024; try { for ( @@ -110,7 +110,7 @@ test("fetch numeric validation rejects invalid values instead of defaulting", as Number.POSITIVE_INFINITY, -1, 1.5, - maxBodyBytes + 1, + maxResponseBodyBytes + 1, ] ) { await assert.rejects( diff --git a/tests/suites/adapter-validation.mjs b/tests/suites/adapter-validation.mjs index c9a58c7f..be0e9567 100644 --- a/tests/suites/adapter-validation.mjs +++ b/tests/suites/adapter-validation.mjs @@ -6,7 +6,7 @@ */ const MAX_TIMEOUT_MS = 300_000; -const MAX_BODY_BYTES = 16 * 1024 * 1024; +const MAX_RESPONSE_BODY_BYTES = 256 * 1024 * 1024; export function adapterValidationTests({ createNode, @@ -63,7 +63,7 @@ export function adapterValidationTests({ Number.POSITIVE_INFINITY, -1, 1.5, - MAX_BODY_BYTES + 1, + MAX_RESPONSE_BODY_BYTES + 1, ]; for (const value of timeoutValues) { @@ -169,19 +169,27 @@ export function adapterValidationTests({ }); }); - const res = await node.fetch(`httpi://${id}/validation`, { - headers: [["x-conformance", "ok"]], - requestTimeout: 30_000, - maxResponseBodyBytes: 1024, - }); + for ( + const maxResponseBodyBytes of [ + 16 * 1024 * 1024, + 64 * 1024 * 1024, + MAX_RESPONSE_BODY_BYTES, + ] + ) { + const res = await node.fetch(`httpi://${id}/validation`, { + headers: [["x-conformance", "ok"]], + requestTimeout: 30_000, + maxResponseBodyBytes, + }); - assertEqual(res.status, 200, "valid request should succeed"); - assertEqual(await res.text(), "ok", "valid header should pass through"); - assertEqual( - res.headers.get("x-conformance-response"), - "ok", - "valid response header should pass through", - ); + assertEqual(res.status, 200, "valid request should succeed"); + assertEqual(await res.text(), "ok", "valid header should pass through"); + assertEqual( + res.headers.get("x-conformance-response"), + "ok", + "valid response header should pass through", + ); + } } finally { await node.close(); if (handle) await handle.finished.catch(() => {}); From 8991fdd38e4f0685e2475b7624118d79f8bdfcdb Mon Sep 17 00:00:00 2001 From: Willem Horsten Date: Thu, 27 Aug 2026 16:14:35 +0200 Subject: [PATCH 2/4] fix(adapter): align response body limit with core (#394) Source adapter body bounds from the core defaults so explicit response limits up to 256 MiB are accepted while request limits remain capped at 16 MiB. Preserve structured terminal stream errors instead of retrying hard failures as transient empty reads. Closes #394 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/iroh-http-adapter/src/lib.rs | 29 ++++++++++++++---- crates/iroh-http-core/src/http/server/mod.rs | 7 +++-- .../iroh-http-core/src/http/server/options.rs | 4 +-- crates/iroh-http-core/src/lib.rs | 5 +++- docs/specification.md | 9 ++++++ docs/troubleshooting.md | 2 +- packages/iroh-http-node/lib.ts | 10 ++++++- packages/iroh-http-shared/src/IrohAdapter.ts | 7 +++-- packages/iroh-http-shared/src/streams.ts | 16 ++++++---- packages/iroh-http-tauri/guest-js/index.ts | 17 ++++++++--- tests/suites/adapter-validation.mjs | 30 ++++++++++++++++++- 11 files changed, 110 insertions(+), 26 deletions(-) diff --git a/crates/iroh-http-adapter/src/lib.rs b/crates/iroh-http-adapter/src/lib.rs index 0b7ff555..6893ab2f 100644 --- a/crates/iroh-http-adapter/src/lib.rs +++ b/crates/iroh-http-adapter/src/lib.rs @@ -10,6 +10,7 @@ use iroh_http_core::{ respond, CoreError, ErrorCode, HandleStore, RequestPayload, ResponseHeadEntry, + DEFAULT_MAX_REQUEST_BODY_BYTES, DEFAULT_MAX_RESPONSE_BODY_BYTES, }; /// Maximum number of header rows accepted at an adapter boundary. @@ -20,8 +21,10 @@ pub const MAX_HEADER_NAME_LEN: usize = 256; pub const MAX_HEADER_VALUE_LEN: usize = 8_192; /// Maximum adapter-level timeout in milliseconds. pub const MAX_TIMEOUT_MS: u64 = 300_000; -/// Maximum adapter-level body cap in bytes. -pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; +/// Maximum adapter-level request body cap in bytes. +pub const MAX_BODY_BYTES: usize = DEFAULT_MAX_REQUEST_BODY_BYTES; +/// Maximum adapter-level response body cap in bytes. +pub const MAX_RESPONSE_BODY_BYTES: usize = DEFAULT_MAX_RESPONSE_BODY_BYTES; /// Maximum total simultaneous connections a served endpoint will accept. pub const MAX_TOTAL_CONNECTIONS: usize = 100_000; /// Maximum header block size in bytes accepted for a served endpoint. @@ -418,7 +421,7 @@ pub fn coerce_fetch_options(raw: RawFetchOptions) -> Result`. @@ -25,12 +26,17 @@ export function makeReadable( ): ReadableStream { return new ReadableStream({ async pull(controller) { - const chunk = await adapter.nextChunk(handle); - if (chunk === null) { - controller.close(); + try { + const chunk = await adapter.nextChunk(handle); + if (chunk === null) { + controller.close(); + onClose?.(); + } else { + controller.enqueue(chunk); + } + } catch (error) { onClose?.(); - } else { - controller.enqueue(chunk); + throw classifyError(error); } }, cancel() { diff --git a/packages/iroh-http-tauri/guest-js/index.ts b/packages/iroh-http-tauri/guest-js/index.ts index 68955d04..cc983308 100644 --- a/packages/iroh-http-tauri/guest-js/index.ts +++ b/packages/iroh-http-tauri/guest-js/index.ts @@ -7,6 +7,7 @@ import { installForegroundHealthCheck } from "./lifecycle.js"; import { bigintToSafeNumber, classifyBindError, + classifyError, encodeBase64, IrohNode, type IrohNodeWithSecret, @@ -93,15 +94,23 @@ class TauriAdapter extends IrohAdapter { const v = new Uint8Array(buf); return v.length > 0 && v[0] !== 0 ? v.subarray(1) : null; }, - // Channel empty or lock contended — fall back to async. - () => - invoke(`${PLUGIN}|next_chunk`, { + (error) => { + const classified = classifyError(error); + if ( + classified.code !== "INTERNAL" || + !classified.message.startsWith("try_next_chunk:") + ) { + throw classified; + } + // Channel empty or lock contended — fall back to async. + return invoke(`${PLUGIN}|next_chunk`, { endpointHandle: this.#epHandle, handle: safeHandle, }).then((buf) => { const v = new Uint8Array(buf); return v.length > 0 && v[0] !== 0 ? v.subarray(1) : null; - }), + }); + }, ); } diff --git a/tests/suites/adapter-validation.mjs b/tests/suites/adapter-validation.mjs index be0e9567..8cb3afab 100644 --- a/tests/suites/adapter-validation.mjs +++ b/tests/suites/adapter-validation.mjs @@ -63,7 +63,6 @@ export function adapterValidationTests({ Number.POSITIVE_INFINITY, -1, 1.5, - MAX_RESPONSE_BODY_BYTES + 1, ]; for (const value of timeoutValues) { @@ -76,11 +75,40 @@ export function adapterValidationTests({ await node.fetch(url, { maxResponseBodyBytes: value }); }, `maxResponseBodyBytes=${String(value)}`); } + + const overCapError = await assertThrows(async () => { + await node.fetch(url, { + maxResponseBodyBytes: MAX_RESPONSE_BODY_BYTES + 1, + }); + }, "maxResponseBodyBytes over cap"); + assertEqual(overCapError.code, "INVALID_ARGUMENT"); + assertEqual(overCapError.name, "TypeError"); } finally { await node.close(); } }); + test("adapter validation enforces the configured response body limit", async () => { + const node = await createNode({ disableNetworking: true }); + let handle; + try { + const { id } = await node.addr(); + handle = node.serve(() => new Response(new Uint8Array(1025))); + + const error = await assertThrows(async () => { + const res = await node.fetch(`httpi://${id}/response-limit`, { + maxResponseBodyBytes: 1024, + }); + await res.arrayBuffer(); + }, "response body above configured cap"); + + assertEqual(error.code, "BODY_TOO_LARGE"); + } finally { + await node.close(); + if (handle) await handle.finished.catch(() => {}); + } + }); + test("adapter validation rejects invalid fetch inputs", async () => { const node = await createNode({ disableNetworking: true }); try { From 41e8968fb60a5cd5e4de8cb9a9b5c11f20957f37 Mon Sep 17 00:00:00 2001 From: Willem Horsten Date: Thu, 27 Aug 2026 22:36:37 +0200 Subject: [PATCH 3/4] build(deps): refresh Rust security fixes Update h2 to the release fixing RUSTSEC-2026-0258 and replace the newly yanked chacha20 0.10.1 release so the dependency policy gate can complete. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 8 ++++---- packages/iroh-http-tauri/Cargo.lock | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41c6dbd4..8bbba0b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,9 +316,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1229,9 +1229,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", diff --git a/packages/iroh-http-tauri/Cargo.lock b/packages/iroh-http-tauri/Cargo.lock index 16fb5575..5881e183 100644 --- a/packages/iroh-http-tauri/Cargo.lock +++ b/packages/iroh-http-tauri/Cargo.lock @@ -490,9 +490,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1857,9 +1857,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", From 8d830922738e80f05d533af6e6628762cc762905 Mon Sep 17 00:00:00 2001 From: Willem Horsten Date: Thu, 27 Aug 2026 23:50:00 +0200 Subject: [PATCH 4/4] build(deps): refresh npm security fixes Refresh compatible transitive dependencies for newly published js-yaml, nanoid, postcss, and undici advisories so the npm audit gate passes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e952fc0..dd3507e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1353,9 +1353,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -1507,7 +1507,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1569,7 +1571,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1587,7 +1591,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1804,7 +1808,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": {