Files
ytplayer/plans/queue/011-browser-sha256-e1793d.md

243 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
id: 011-browser-sha256-e1793d
title: Add an incremental SHA-256 library for the browser and node tests
created: 2026-09-29
depends_on: []
est_files: 5
---
# 011 — Incremental SHA-256 for the browser
## Objective
Devices must compute a file's content id (SHA-256) while bytes stream past —
during a download, a peer transfer, or a chunked OPFS read — without holding the
file in memory. WebCrypto's `digest()` needs the whole input at once, so add a
small pure-JS incremental hasher, usable from the page, from Web Workers
(`importScripts('/sha256.js')`) and from node tests. Pre-tested: all vectors and
every length 0–200 match `node:crypto`; random chunking of a 3 MiB buffer matches;
~116 MB/s in Node.
## Context the executor must NOT rediscover
- Module style to copy: `frontend/stats-core.js` — an IIFE `(function (root) { … })(typeof globalThis !== 'undefined' ? globalThis : this);`
that sets `module.exports` under node and `root.StatsCore` in the browser. Tests are
CommonJS `node:test` (`frontend/stats-core.test.js`).
- `frontend/index.html:562-569` loads scripts in this order:
```html
<script src="fingerprint.js"></script>
<script src="opfs.js"></script>
<script src="video-edit.js"></script>
<script src="lyrics-core.js"></script>
<script src="stats-core.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="app.js"></script>
```
- `frontend/sw.js` `SHELL` array (~line 55) must list every shell file.
## Steps
1. Create `frontend/sha256.js` — copy VERBATIM from Appendix A.
2. Create `frontend/sha256.test.js` — copy VERBATIM from Appendix B.
3. `frontend/index.html` — add ` <script src="sha256.js"></script>` directly after the
`stats-core.js` script line.
4. `frontend/sw.js` `SHELL` — add `'/sha256.js',` directly after `'/stats-core.js',`.
5. `CLAUDE.md` "Testing" section — in the list `(sw, sw-update, async-guard, video-edit, lyrics-core, stats-core)`
add `, sha256`.
## Out of scope / do NOT touch
- No callers yet (plans 012/013/017 use it). Do not touch `app.js`.
## Verification
```bash
cd /home/user/ytplayer && node --test frontend/*.test.js 2>&1 | grep -E "^# (pass|fail)"
node -e "const S=require('./frontend/sha256');console.log(S.hex(new TextEncoder().encode('abc')))"
grep -c "sha256.js" frontend/index.html frontend/sw.js
```
Expected: `# fail 0`; `ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad`; each grep `1`.
## Report format (executor: follow exactly)
Output ONLY the following, no other prose:
1. `git diff` (unified) of all changes.
2. Raw output of the Verification commands.
3. `Findings:` — max 10 lines.
Do not commit. Do not push. Do not touch files outside the Steps.
---
## Appendix A — frontend/sha256.js
```js
/* ============================================================================
* sha256.js — incremental SHA-256 (pure JS; window.Sha256 / worker / node)
*
* WebCrypto's digest() needs the whole input at once, which would pull a
* multi-hundred-MB video into memory. This hasher takes chunks as they stream
* past (downloads, peer transfers, OPFS reads) and keeps ~100 bytes of state.
*
* const h = Sha256.create(); h.update(u8); …; const hex = h.hex();
* Sha256.hex(u8) // one-shot convenience
* ========================================================================== */
(function (root) {
'use strict';
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function create() {
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const W = new Uint32Array(64);
const block = new Uint8Array(64);
let blockLen = 0;
let total = 0; // bytes hashed so far (safe to 2^53)
let done = false;
function compress(buf, off) {
for (let i = 0; i < 16; i++) {
const j = off + i * 4;
W[i] = (buf[j] << 24) | (buf[j + 1] << 16) | (buf[j + 2] << 8) | buf[j + 3];
}
for (let i = 16; i < 64; i++) {
const w15 = W[i - 15], w2 = W[i - 2];
const s0 = ((w15 >>> 7) | (w15 << 25)) ^ ((w15 >>> 18) | (w15 << 14)) ^ (w15 >>> 3);
const s1 = ((w2 >>> 17) | (w2 << 15)) ^ ((w2 >>> 19) | (w2 << 13)) ^ (w2 >>> 10);
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0;
}
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
for (let i = 0; i < 64; i++) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K[i] + W[i]) | 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
h = g; g = f; f = e; e = (d + t1) | 0;
d = c; c = b; b = a; a = (t1 + t2) | 0;
}
H[0] = (H[0] + a) | 0; H[1] = (H[1] + b) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
}
function update(data) {
if (done) throw new Error('sha256: update() after digest');
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data);
let i = 0;
total += u8.length;
if (blockLen) {
const take = Math.min(64 - blockLen, u8.length);
block.set(u8.subarray(0, take), blockLen);
blockLen += take;
i = take;
if (blockLen === 64) { compress(block, 0); blockLen = 0; }
}
for (; i + 64 <= u8.length; i += 64) compress(u8, i);
if (i < u8.length) { block.set(u8.subarray(i), 0); blockLen = u8.length - i; }
return api;
}
function digest() {
if (!done) {
done = true;
const bits = total * 8;
block[blockLen++] = 0x80;
if (blockLen > 56) { block.fill(0, blockLen); compress(block, 0); blockLen = 0; }
block.fill(0, blockLen, 56);
const hi = Math.floor(bits / 0x100000000), lo = bits >>> 0;
block[56] = hi >>> 24; block[57] = hi >>> 16; block[58] = hi >>> 8; block[59] = hi;
block[60] = lo >>> 24; block[61] = lo >>> 16; block[62] = lo >>> 8; block[63] = lo;
compress(block, 0);
}
const out = new Uint8Array(32);
for (let i = 0; i < 8; i++) {
out[i * 4] = H[i] >>> 24; out[i * 4 + 1] = H[i] >>> 16; out[i * 4 + 2] = H[i] >>> 8; out[i * 4 + 3] = H[i];
}
return out;
}
function hex() {
let s = '';
for (const b of digest()) s += (b < 16 ? '0' : '') + b.toString(16);
return s;
}
const api = { update, digest, hex, get bytes() { return total; } };
return api;
}
const Sha256 = {
create,
hex: (data) => create().update(data).hex(),
isHex: (s) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s),
};
if (typeof module !== 'undefined' && module.exports) module.exports = Sha256;
else root.Sha256 = Sha256;
})(typeof globalThis !== 'undefined' ? globalThis : this);
```
## Appendix B — frontend/sha256.test.js
```js
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const crypto = require('node:crypto');
const Sha256 = require('./sha256');
const ref = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
test('known vectors', () => {
assert.strictEqual(Sha256.hex(new Uint8Array(0)), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
assert.strictEqual(Sha256.hex(new TextEncoder().encode('abc')), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
assert.strictEqual(
Sha256.hex(new TextEncoder().encode('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')),
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1');
});
test('every length around block boundaries matches node:crypto', () => {
for (let n = 0; n <= 200; n++) {
const buf = crypto.randomBytes(n);
assert.strictEqual(Sha256.hex(new Uint8Array(buf)), ref(buf), 'length ' + n);
}
});
test('random chunk boundaries give the same digest as one shot', () => {
const buf = crypto.randomBytes(3 * 1024 * 1024 + 17);
for (let round = 0; round < 5; round++) {
const h = Sha256.create();
let pos = 0;
while (pos < buf.length) {
const n = Math.min(buf.length - pos, 1 + Math.floor(Math.random() * 200000));
h.update(new Uint8Array(buf.buffer, buf.byteOffset + pos, n));
pos += n;
}
assert.strictEqual(h.hex(), ref(buf));
assert.strictEqual(h.bytes, buf.length);
}
});
test('update after digest throws; isHex', () => {
const h = Sha256.create();
h.hex();
assert.throws(() => h.update(new Uint8Array(1)));
assert.ok(Sha256.isHex(ref(Buffer.from('x'))));
assert.ok(!Sha256.isHex('ABC'));
});
```