diff --git a/src/services/p533/dataLoader.js b/src/services/p533/dataLoader.js index 943926ac..e0c66bfa 100644 --- a/src/services/p533/dataLoader.js +++ b/src/services/p533/dataLoader.js @@ -160,6 +160,24 @@ async function gunzip(bytes) { return new Uint8Array(ab); } +// crypto.subtle only exists in secure contexts (HTTPS or localhost). A +// self-hosted instance reached over plain HTTP (http://192.168.x.x:3001) +// doesn't have it — skip the integrity check there instead of killing the +// whole engine (issue #1129: the failure surfaced as "WASM engine +// unavailable" even though the wasm bundle was fine). gzip's own CRC still +// catches corruption, and the bytes come from our own same-origin proxy. +let warnedNoSubtle = false; +function canVerifyIntegrity() { + if (globalThis.crypto?.subtle) return true; + if (!warnedNoSubtle) { + warnedNoSubtle = true; + console.warn( + '[p533] crypto.subtle unavailable (page not served over HTTPS/localhost) — skipping data integrity checks', + ); + } + return false; +} + async function fetchAndDecompress(asset) { const url = urlFor(asset); const res = await fetch(url); @@ -167,7 +185,7 @@ async function fetchAndDecompress(asset) { throw new Error(`p533 fetch ${asset} failed: ${res.status} ${res.statusText}`); } const gz = new Uint8Array(await res.arrayBuffer()); - const expected = await expectedSha256For(asset); + const expected = canVerifyIntegrity() ? await expectedSha256For(asset) : null; if (expected) { const actual = await sha256Hex(gz); if (actual !== expected) { @@ -254,4 +272,9 @@ export const __internal = { padMonth, gunzip, sha256Hex, + canVerifyIntegrity, + fetchAndDecompress, + resetIntegrityWarning: () => { + warnedNoSubtle = false; + }, }; diff --git a/src/services/p533/dataLoader.test.js b/src/services/p533/dataLoader.test.js index 2f5a5829..80908f2c 100644 --- a/src/services/p533/dataLoader.test.js +++ b/src/services/p533/dataLoader.test.js @@ -88,6 +88,35 @@ describe('sha256Hex', () => { }); }); +describe('insecure context (no crypto.subtle)', () => { + // Plain-HTTP self-hosted instances have no crypto.subtle (issue #1129) — + // the integrity check must step aside instead of killing the engine. + it('skips the integrity check and still delivers data', async () => { + __internal.resetIntegrityWarning(); + vi.stubGlobal('crypto', {}); // secure-context API absent + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(__internal.canVerifyIntegrity()).toBe(false); + + const payload = new TextEncoder().encode('fake-decile-payload'); + stubFetch(async (url) => { + if (url.includes('P1239-3-Decile-Factors.txt.gz')) return new Response(gz(payload)); + // Manifest with a WRONG hash — it must not even be consulted + if (url.includes('manifest.json')) + return new Response(makeManifest([{ name: 'P1239-3-Decile-Factors.txt.gz', sha256: 'f'.repeat(64) }])); + return null; + }); + + const out = await __internal.fetchAndDecompress('P1239-3-Decile-Factors.txt.gz'); + expect(Array.from(out)).toEqual(Array.from(payload)); + expect(warn).toHaveBeenCalledTimes(1); // warned once, not per asset + + await __internal.fetchAndDecompress('P1239-3-Decile-Factors.txt.gz'); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); + describe('getMonthFiles', () => { it('fetches both ionos + COEFF for the requested month', async () => { const ionos = new TextEncoder().encode('fake-ionos-payload');