Bound browser upload identity memory

This commit is contained in:
ms2k 2026-07-20 04:16:13 +09:00
commit 4d31c441d0
3 changed files with 544 additions and 5 deletions

View file

@ -24,6 +24,15 @@ test('Svelte components own neither HTTP nor browser persistence policy', async
]);
});
test('upload identity never buffers or digests a complete File', async () => {
const sources = await sourceFiles('src');
for (const file of sources) {
const source = await readFile(file, 'utf8');
assert.doesNotMatch(source, /\bfile\.arrayBuffer\s*\(/i, file);
assert.doesNotMatch(source, /\bcrypto\.subtle\.digest\s*\(/i, file);
}
});
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
return (

View file

@ -0,0 +1,31 @@
[
{
"name": "empty",
"source": { "kind": "hex", "value": "" },
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"name": "unicode",
"source": { "kind": "utf8", "value": "문서 α — upload 🐦" },
"sha256": "4f208359621d2df948b5590f2ca1dce5d9aa0a0cdfea99e2c1f14cd68ad6673d"
},
{
"name": "binary",
"source": { "kind": "hex", "value": "0001027f80feff00a55a" },
"sha256": "3503f46719b47bbc0705dfe864c012d47dd98f043e2e31e88c81ae8c02450378"
},
{
"name": "exact-chunk",
"source": { "kind": "repeat-hex", "pattern": "a5", "bytes": 1048576 },
"sha256": "16c7f1d8a38b4b84560e558ab03b13c82e2ff374d87eaacb4df22f03604e7a4f"
},
{
"name": "multi-chunk",
"source": {
"kind": "repeat-hex",
"pattern": "0123456789abcdef",
"bytes": 2097289
},
"sha256": "f5c3a40a9469303cefc467312c16b5728e7b242d60c7e2791e651ea219d4a2c0"
}
]

View file

@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { createIncrementalSha256 } from '../src/workflows/upload-hash-core.ts';
import {
UPLOAD_HASH_CHUNK_BYTES,
uploadContentSha256
} from '../src/workflows/upload-identity.ts';
import {
captureUpload,
executeUpload,
initialUploadState
} from '../src/workflows/upload.ts';
const fixtures = JSON.parse(
await readFile(
new URL('./fixtures/upload-content-sha256.json', import.meta.url),
'utf8'
)
);
test('incremental browser SHA-256 matches shared fixtures', () => {
for (const fixture of fixtures) {
const hash = createIncrementalSha256();
const bytes = fixtureBytes(fixture.source);
for (let offset = 0; offset < bytes.length; offset += 7919) {
hash.update(bytes.subarray(offset, offset + 7919));
}
assert.equal(hash.digest(), fixture.sha256, fixture.name);
hash.destroy();
}
});
test('upload hashing retains at most one bounded chunk', async () => {
const size = UPLOAD_HASH_CHUNK_BYTES * 5 + 137;
const progress = [];
const worker = new FakeHashWorker();
const digest = await uploadContentSha256(new Blob([new Uint8Array(size)]), {
workerFactory: () => worker,
onProgress: (sample) => progress.push(sample)
});
assert.equal(
digest,
'ab58d3a8f7489bd83685df43c86a2b69b083dc86542f69603f57f7ae304939c2'
);
assert.equal(Math.max(...progress.map(({ liveChunks }) => liveChunks)), 1);
assert.equal(Math.max(...progress.map(({ peakLiveChunks }) => peakLiveChunks)), 1);
assert.ok(
progress.every(({ chunkBytes }) => chunkBytes <= UPLOAD_HASH_CHUNK_BYTES)
);
assert.equal(worker.terminateCount, 1);
});
test('cancellation terminates an in-flight worker without uploading', async () => {
let chunkPosted;
const posted = new Promise((resolve) => {
chunkPosted = resolve;
});
const worker = new FakeHashWorker({
holdChunks: true,
onChunk: chunkPosted
});
const controller = new AbortController();
const state = initialUploadState();
captureUpload(
state,
'Personal',
'/payload.bin',
new File([new Uint8Array(32)], 'payload.bin'),
'operation-1'
);
let uploads = 0;
const client = {
upload: async () => {
uploads += 1;
throw new Error('unexpected upload');
}
};
const hashing = executeUpload(client, { csrf: 'csrf' }, state, (file) =>
uploadContentSha256(file, {
signal: controller.signal,
workerFactory: () => worker
})
);
await posted;
controller.abort();
await assert.rejects(hashing, { name: 'AbortError' });
assert.equal(uploads, 0);
assert.equal(worker.terminateCount, 1);
});
test('cancellation interrupts stalled progress instrumentation', async () => {
let progressStarted;
const started = new Promise((resolve) => {
progressStarted = resolve;
});
const worker = new FakeHashWorker();
const controller = new AbortController();
const hashing = uploadContentSha256(new Blob([new Uint8Array(32)]), {
signal: controller.signal,
workerFactory: () => worker,
onProgress: () => {
progressStarted();
return new Promise(() => {});
}
});
await started;
controller.abort();
await assert.rejects(hashing, { name: 'AbortError' });
assert.equal(worker.terminateCount, 1);
});
test('worker failure prevents the upload request', async () => {
const file = new File([new Uint8Array(32)], 'payload.bin');
const state = initialUploadState();
captureUpload(state, 'Personal', '/payload.bin', file, 'operation-1');
let uploads = 0;
const client = {
upload: async () => {
uploads += 1;
throw new Error('unexpected upload');
}
};
const worker = new FakeHashWorker({ failure: 'worker unavailable' });
await assert.rejects(
executeUpload(client, { csrf: 'csrf' }, state, (pending) =>
uploadContentSha256(pending, { workerFactory: () => worker })
),
/worker unavailable/
);
assert.equal(uploads, 0);
assert.equal(worker.terminateCount, 1);
});
class FakeHashWorker {
onmessage = null;
onerror = null;
terminateCount = 0;
#failure;
#hash = createIncrementalSha256();
#holdChunks;
#onChunk;
constructor({ failure, holdChunks = false, onChunk = () => {} } = {}) {
this.#failure = failure;
this.#holdChunks = holdChunks;
this.#onChunk = onChunk;
}
postMessage(message, transfer) {
const cloned = structuredClone(message, { transfer });
queueMicrotask(() => {
if (this.#failure !== undefined) {
this.onmessage?.({
data: { kind: 'error', message: this.#failure }
});
return;
}
if (cloned.kind === 'chunk') {
this.#onChunk();
if (this.#holdChunks) return;
this.#hash.update(new Uint8Array(cloned.bytes));
this.onmessage?.({ data: { kind: 'ready' } });
return;
}
const digest = this.#hash.digest();
this.onmessage?.({ data: { kind: 'digest', digest } });
});
}
terminate() {
this.terminateCount += 1;
this.#hash.destroy();
}
}
function fixtureBytes(source) {
if (source.kind === 'utf8') return new TextEncoder().encode(source.value);
if (source.kind === 'hex') return hexBytes(source.value);
const pattern = hexBytes(source.pattern);
return Uint8Array.from(
{ length: source.bytes },
(_, index) => pattern[index % pattern.length]
);
}
function hexBytes(value) {
return Uint8Array.from(
value.match(/.{2}/g) ?? [],
(byte) => Number.parseInt(byte, 16)
);
}