diff --git a/headless/README.md b/headless/README.md index cb48b9f..cdfcaea 100644 --- a/headless/README.md +++ b/headless/README.md @@ -490,3 +490,13 @@ again after each real task yield. Byte/ordinal cursors, parser versions and sour fingerprints are unchanged. This is a soft budget: a bounded JSON decode is indivisible. Legacy and out-of-order document headers retain indexed preparation; canonical parser 5 headers allow progressive payload scanning. + + +The dedicated digest Worker uses native `crypto.subtle.digest('SHA-256', ...)` +for inputs up to 64 MiB. The browser pool admits two active Workers, each reading at most +64 MiB on this path. This bounds admitted input per Worker, not total browser +memory or native cleanup timing. Larger inputs or a missing native capability keep the 1 MiB +streaming SHA implementation. Main-thread fallback stays chunked/cooperative. +Native read/hash errors remain visible; they do not silently select another path. +Worker termination still owns cancellation. The full source check remains before +imported elements are published; no journal revisions or integrity gates are skipped. diff --git a/src/lib/canvas-runtime/digest-worker-source.ts b/src/lib/canvas-runtime/digest-worker-source.ts new file mode 100644 index 0000000..ed18a52 --- /dev/null +++ b/src/lib/canvas-runtime/digest-worker-source.ts @@ -0,0 +1,17 @@ +import {fault} from '../canvas-core/values.ts'; +import {importSourceDigest} from './import-integrity.ts'; + +// Each of the browser pool's two active Workers admits at most 64 MiB; larger inputs stream. +export const nativeDigestByteLimit=64*1024*1024; + +/** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread. + * The host still owns cancellation by terminating the request's Worker. */ +export async function digestWorkerSource(bytes:Blob,subtle:Pick|null|undefined=globalThis.crypto?.subtle):Promise{ + if(!(bytes instanceof Blob))throw fault('invalid_digest_input'); + if(subtle&&bytes.size<=nativeDigestByteLimit){ + const input=await bytes.arrayBuffer(),digest=await subtle.digest('SHA-256',input); + if(digest.byteLength!==32)throw fault('invalid_digest_response'); + return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join(''); + } + return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()}); +} diff --git a/src/lib/canvas-runtime/digest.worker.ts b/src/lib/canvas-runtime/digest.worker.ts index 016844b..1f9907f 100644 --- a/src/lib/canvas-runtime/digest.worker.ts +++ b/src/lib/canvas-runtime/digest.worker.ts @@ -1,8 +1,8 @@ -import {importSourceDigest} from './import-integrity.ts'; +import {digestWorkerSource} from './digest-worker-source.ts'; // Dedicated to one request. Host terminates on success/failure/abort; no storage, model or credentials here. const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; let accepted=false; scope.onmessage=event=>{ const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true; - void importSourceDigest(request.bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()}).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'})); + void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'})); }; diff --git a/tests/headless/digest-worker-source.test.mjs b/tests/headless/digest-worker-source.test.mjs new file mode 100644 index 0000000..247912c --- /dev/null +++ b/tests/headless/digest-worker-source.test.mjs @@ -0,0 +1,22 @@ +import {test} from 'node:test';import assert from 'node:assert/strict';import {createHash} from 'node:crypto'; +import {digestWorkerSource,nativeDigestByteLimit} from '../../src/lib/canvas-runtime/digest-worker-source.ts'; + +test('native Worker hashing and streaming fallback produce identical SHA256 for empty, UTF8 and binary sources',async()=>{ + for(const bytes of [new Blob(),new Blob(['δΈ­ζ–‡ πŸ˜€']),new Blob([new Uint8Array([0,255,128,10])])]){ + let calls=0;const native={digest(algorithm,input){calls++;assert.equal(algorithm,'SHA-256');assert.equal(input.byteLength,bytes.size);return crypto.subtle.digest(algorithm,input);}}; + assert.equal(await digestWorkerSource(bytes,native),await digestWorkerSource(bytes,null));assert.equal(calls,1); + } +}); + +test('native read or digest failures stay visible and never silently retry through the streaming fallback',async()=>{ + class BrokenRead extends Blob{arrayBuffer(){return Promise.reject(Error('read failed'));}slice(){throw Error('unexpected fallback');}} + await assert.rejects(digestWorkerSource(new BrokenRead(['input'])),/read failed/); + const bytes=new Blob(['input']);await assert.rejects(digestWorkerSource(bytes,{digest(){throw Error('native failed');}}),/native failed/);await assert.rejects(digestWorkerSource(bytes,{async digest(){return new ArrayBuffer(1);}}),/invalid_digest_response/); +}); + +test('inputs above the native allocation budget use bounded chunks and never request a whole buffer',async()=>{ + const size=nativeDigestByteLimit+1,hash=createHash('sha256');let largest=0; + class ChunkOnly extends Blob{get size(){return size;}arrayBuffer(){throw Error('unbounded read');}slice(start=0,end=size){const count=Math.max(0,Math.min(size,end)-start);largest=Math.max(largest,count);return new Blob([new Uint8Array(count)]);}} + for(let cursor=0;cursorvalue.toString(16).padStart(2,'0')).join('');assert(digest===expected,'compiled digest Worker SHA256'); assert(workerUrls.some(url=>url.endsWith('/image.worker.js'))&&workerUrls.some(url=>url.endsWith('/digest.worker.js')),'Worker URLs did not resolve emitted JS');assert(active===0,'compiled Workers leaked'); results.push('compiled image and digest module Workers load emitted JS, decode a real PNG, match native SHA256 and terminate'); + const largeBytes=new Blob([new Uint8Array(16*1024*1024).fill(97)]),largeExpected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await largeBytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),started=performance.now(),largeDigest=await digests.digest(largeBytes),digestMs=performance.now()-started;assert(largeDigest===largeExpected&&active===0,'large compiled digest returned wrong bytes or retained its Worker');results.push('16 MiB compiled digest Worker matched SHA256 in '+digestMs.toFixed(1)+' ms (observation, not an SLA)'); // Keep networking explicitly held: verify the actual browser durability adapter // independently of an invented backend protocol implementation. const persistence={...createFoundryPersistence({registry,rootId:scope.projectId,workspaceId:scope.workspaceId,transport:async()=>{throw Error('unexpected network');}}),supportsRequest:()=>false};