Use bounded native SHA256 inside digest Workers

This commit is contained in:
2026-11-09 20:16:39 +08:00
parent fea813ac4f
commit 70bc134436
5 changed files with 52 additions and 2 deletions
+10
View File
@@ -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.
@@ -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<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{
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()});
}
+2 -2
View File
@@ -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'}));
};
@@ -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;cursor<size;cursor+=1024*1024)hash.update(new Uint8Array(Math.min(1024*1024,size-cursor)));
const digest=await digestWorkerSource(new ChunkOnly(),{digest(){throw Error('native budget exceeded');}});assert.equal(digest,hash.digest('hex'));assert.ok(largest<=1024*1024);
});
+1
View File
@@ -16,6 +16,7 @@ export async function run(){
const digest=await digests.digest(bytes),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.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};