Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/services/p533/dataLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,32 @@ 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);
if (!res.ok) {
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) {
Expand Down Expand Up @@ -254,4 +272,9 @@ export const __internal = {
padMonth,
gunzip,
sha256Hex,
canVerifyIntegrity,
fetchAndDecompress,
resetIntegrityWarning: () => {
warnedNoSubtle = false;
},
};
29 changes: 29 additions & 0 deletions src/services/p533/dataLoader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down