From 581c6ddef8e95c750fbcd69e4c1a590e8d7ab166 Mon Sep 17 00:00:00 2001 From: osobh Date: Sun, 27 Sep 2026 07:51:30 -0500 Subject: [PATCH] wasm-viewer tests: the cross-origin path without exposed headers serve.py always exposed Content-Range and ETag, and Node has no CORS, so openUrl's documented cross-origin path (length from a HEAD request, answers checked by body length alone, no validator) was never run. - serve.py /noexpose/ serves ranges without Content-Range, ETag, Last-Modified or Accept-Ranges (what a page sees of a server that does not expose them); /unexposed/ sends them but exposes none, for a real browser. HEAD requests are counted (0 bytes). - test.mjs: every fixture check through /noexpose/ at 1 MiB and 512-byte blocks (one HEAD each, requests and bytes as the server counted them), concurrent reads with cacheSize 0, a short answer still caught, and a server without a HEAD length a clear error. - browser.sh: the page on 127.0.0.1 opens the file from localhost, once with Content-Range exposed and once through /unexposed/, where the server's log must show the HEAD. Checked by breaking the HEAD length in remote.js: the new checks fail. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/wasm-viewer/test/browser.sh | 14 ++++++++ examples/wasm-viewer/test/serve.py | 50 ++++++++++++++++++++-------- examples/wasm-viewer/test/test.mjs | 46 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/examples/wasm-viewer/test/browser.sh b/examples/wasm-viewer/test/browser.sh index 7689ff1..df31f7f 100644 --- a/examples/wasm-viewer/test/browser.sh +++ b/examples/wasm-viewer/test/browser.sh @@ -95,6 +95,20 @@ expect $H5 "/pairs" '[2, 3]' '
array[2]<i32>
' expect $H5 "/cube" '
(2, 5, 6)
' '29' 'dim 0' # Unsupported type: an error, not values. expect $H5 "/table" 'class="error"' 'reading compound{x: f64, n: i32} datasets is not supported' +# Cross-origin: the page is on 127.0.0.1, the file on localhost. CORS that +# exposes Content-Range, and CORS that exposes nothing (/unexposed/: the +# browser hides Content-Range and ETag, so the page learns the length with +# a HEAD request, which the server's log must show). +expect "http://localhost:$port/$H5" "/grid" '14.75' 'request' +get() { "$PY" -c 'import sys, urllib.request; print(urllib.request.urlopen(sys.argv[1]).read().decode())' "$1"; } +get "http://127.0.0.1:$port/__reset" >/dev/null +expect "http://localhost:$port/unexposed/$H5" "/grid" '0.25' '14.75' 'request' +heads="$(get "http://127.0.0.1:$port/__stats" | "$PY" -c \ + 'import json, sys; print(sum(1 for l in json.load(sys.stdin)["log"] if l[4] == "HEAD" and l[0].endswith("fixture.h5")))')" +if [ "$heads" -lt 1 ]; then + echo "FAIL: no HEAD request for the length with unexposed headers" >&2 + fails=$((fails + 1)) +fi # A server without range support: the file is downloaded whole, and says so. expect norange/$H5 "/grid" '14.75' 'downloaded whole' # A large file: a small dataset, and a window of the large one, fetch a few diff --git a/examples/wasm-viewer/test/serve.py b/examples/wasm-viewer/test/serve.py index f4a6e09..d478a14 100644 --- a/examples/wasm-viewer/test/serve.py +++ b/examples/wasm-viewer/test/serve.py @@ -12,6 +12,15 @@ killed. No symlinks or copies are made: files are read where they are. headers exposing Content-Range, so a page on another origin can use it. - Under `/norange/...` the same files are served but Range is ignored (200 with the whole file), as by a server without range support. +- Under `/noexpose/...` ranges are served without Content-Range, ETag, + Last-Modified or Accept-Ranges, and CORS exposes none of them: what a + page sees of a cross-origin server that does not list them in + Access-Control-Expose-Headers. The length comes from Content-Length of + a HEAD request (always readable). +- Under `/unexposed/...` ranges are served with all those headers, but + CORS exposes none of them: a browser page on another origin cannot read + them (test/browser.sh loads the page from 127.0.0.1 and the file from + localhost), so it has to take the same path. - `GET /__stats` returns `{"requests": n, "bytes": n, "log": [...]}` for file requests since the last `GET /__reset`, which zeroes them. """ @@ -80,10 +89,11 @@ def make_handler(roots): def log_message(self, *args): pass - def cors(self): + def cors(self, expose=True): self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Expose-Headers", - "Content-Range, Content-Length, ETag, Accept-Ranges") + if expose: + self.send_header("Access-Control-Expose-Headers", + "Content-Range, Content-Length, ETag, Accept-Ranges") def do_OPTIONS(self): self.send_response(204) @@ -117,10 +127,18 @@ def make_handler(roots): with lock: stats.update(requests=0, bytes=0, log=[]) return self.json({}) - ranges = True + # ranges: honour Range; send_all: send Content-Range, ETag and + # Accept-Ranges; expose: list them for CORS. + ranges = send_all = expose = True if path.startswith("/norange/"): ranges = False path = path[len("/norange"):] + elif path.startswith("/noexpose/"): + expose = send_all = False + path = path[len("/noexpose"):] + elif path.startswith("/unexposed/"): + expose = False + path = path[len("/unexposed"):] f = resolve(roots, path) if f is None: self.send_response(404) @@ -142,22 +160,26 @@ def make_handler(roots): return start, end = r if r else (0, size) self.send_response(206 if r else 200) - self.cors() + self.cors(expose) ext = os.path.splitext(f)[1] self.send_header("Content-Type", TYPES.get(ext, "application/octet-stream")) self.send_header("Content-Length", str(end - start)) - self.send_header("ETag", etag) self.send_header("Cache-Control", "no-store") - if ranges: - self.send_header("Accept-Ranges", "bytes") - if r: - self.send_header("Content-Range", f"bytes {start}-{end - 1}/{size}") + if send_all: + self.send_header("ETag", etag) + if ranges: + self.send_header("Accept-Ranges", "bytes") + if r: + self.send_header("Content-Range", f"bytes {start}-{end - 1}/{size}") self.end_headers() + with lock: + # A HEAD is a request too (openUrl makes one when it cannot + # see Content-Range); it sends no bytes. + stats["requests"] += 1 + sent = 0 if head else end - start + stats["bytes"] += sent + stats["log"].append([path, start, end, 206 if r else 200, "HEAD" if head else "GET"]) if not head: - with lock: - stats["requests"] += 1 - stats["bytes"] += end - start - stats["log"].append([path, start, end, 206 if r else 200]) with open(f, "rb") as fh: fh.seek(start) left = end - start diff --git a/examples/wasm-viewer/test/test.mjs b/examples/wasm-viewer/test/test.mjs index 5669199..d53b23e 100644 --- a/examples/wasm-viewer/test/test.mjs +++ b/examples/wasm-viewer/test/test.mjs @@ -254,6 +254,52 @@ async function remoteTests() { checks += 2; } + // A cross-origin server that does not expose Content-Range, ETag or + // Last-Modified (serve.py's /noexpose/): the length comes from a HEAD + // request and answers are checked by their length alone. Every fixture + // check, calls in flight at once, and what the page counts. + for (const opts of [undefined, { blockSize: 512, cacheSize: 1024 }]) { + for (const [name, exp] of Object.entries(expected)) { + await fetch(`${base}/__reset`); + const f = await pkg.openUrl(`${base}/noexpose/fix/${name}`, opts); + await checkFile(`${name} (no exposed headers, ${JSON.stringify(opts ?? {})})`, exp, f); + const st = f.stats(); + eq(st.lazy, true, "no exposed headers: read by ranges"); + eq(st.size, statSync(join(fixDir, name)).size, "no exposed headers: size from HEAD"); + const server = await serverStats(); + eq(server.log.filter((l) => l[4] === "HEAD").length, 1, "no exposed headers: one HEAD"); + eq(st.requests, server.requests, "no exposed headers: requests counted"); + eq(st.bytesFetched, server.bytes, "no exposed headers: bytes counted"); + f.free(); + } + } + { + const f = await pkg.openUrl(`${base}/noexpose/fix/fixture.h5`, { blockSize: 512, cacheSize: 0, parallel: 3 }); + const exp = expected["fixture.h5"]; + const paths = ["/grid", "/sensors/temp", "/vlen_str", "/cube"]; + const got = await Promise.all([...paths, ...paths].map((p) => f.read(p))); + got.forEach((r, i) => eq(values(exp.datasets[paths[i % 4]].kind, r.data), exp.datasets[paths[i % 4]].values, + `no exposed headers: concurrent ${paths[i % 4]}`)); + await checkErrors(f); + } + // Without a validator a changed file cannot be told apart; a short + // answer still can. + await fails(async () => { + const f = await pkg.openUrl(`${base}/noexpose/fix/fixture.h5`, { + blockSize: 512, + fetch: async (url, init) => { + const r = await fetch(url, init); + if (init.method === "HEAD" || init.headers.Range === "bytes=0-511") return r; + return new Response((await r.arrayBuffer()).slice(1), { status: 206 }); + }, + }); + await f.read("/grid"); + }, /got \d+/, "no exposed headers: short answer"); + // No HEAD length either: a clear error. + await fails(() => pkg.openUrl(`${base}/noexpose/fix/fixture.h5`, { + fetch: async (url, init) => (init.method === "HEAD" ? new Response(null, { status: 405 }) : fetch(url, init)), + }), /cannot learn the file's size/, "no exposed headers, no HEAD"); + // A server without range support: downloaded whole (the default), or // refused. const whole = await pkg.openUrl(`${base}/norange/fix/fixture.h5`);