Use bounded native SHA256 inside digest Workers
This commit is contained in:
@@ -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()});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user