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`);