cursedbelt-core 2.13.3 → 2.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/upload-precheck/contract.d.ts +71 -0
- package/dist/core/upload-precheck/contract.js +76 -0
- package/dist/core/upload-precheck/hash.d.ts +26 -0
- package/dist/core/upload-precheck/hash.js +46 -0
- package/dist/core/upload-precheck/index.d.ts +21 -0
- package/dist/core/upload-precheck/index.js +21 -0
- package/dist/core/upload-precheck/memo.d.ts +52 -0
- package/dist/core/upload-precheck/memo.js +160 -0
- package/dist/core/upload-precheck/plan.d.ts +116 -0
- package/dist/core/upload-precheck/plan.js +274 -0
- package/dist/core/upload-precheck/sha256.d.ts +27 -0
- package/dist/core/upload-precheck/sha256.js +124 -0
- package/package.json +7 -1
- package/src/core/upload-precheck/contract.spec.ts +67 -0
- package/src/core/upload-precheck/contract.ts +107 -0
- package/src/core/upload-precheck/hash.ts +57 -0
- package/src/core/upload-precheck/index.ts +46 -0
- package/src/core/upload-precheck/memo.ts +170 -0
- package/src/core/upload-precheck/plan.spec.ts +335 -0
- package/src/core/upload-precheck/plan.ts +345 -0
- package/src/core/upload-precheck/sha256.spec.ts +47 -0
- package/src/core/upload-precheck/sha256.ts +127 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide, for a drop of files, which ones to SEND — before sending any (forge task 3579).
|
|
3
|
+
*
|
|
4
|
+
* The order of the work is the whole design, cheapest question first:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Sizes, one request.** A file whose byte size the library holds nothing of — and that no
|
|
7
|
+
* other file in this drop shares — cannot be a copy of anything. It is uploaded at once, never
|
|
8
|
+
* hashed here (binary-server hashes what it receives anyway). A folder of new videos is decided
|
|
9
|
+
* almost entirely by this step.
|
|
10
|
+
* 2. **The memo.** A file this browser (or script) hashed before, at the same path with the same
|
|
11
|
+
* size and date, takes its remembered hash — a second import of the same huge folder reads none
|
|
12
|
+
* of its bytes.
|
|
13
|
+
* 3. **Hash the rest**, a few at a time and within a memory budget, and ask the library about the
|
|
14
|
+
* hashes AS they finish: one request in flight, each carrying every hash completed since the
|
|
15
|
+
* last. Uploads of the first new files begin while later ones are still being hashed.
|
|
16
|
+
* 4. **A repeat inside the drop** (the same bytes under two names) is caught by its hash without
|
|
17
|
+
* asking anybody, and only the first IN THE DROP is sent — never whichever hash happened to
|
|
18
|
+
* finish first. Hashes finish out of order (a busy disk, `crypto.subtle` on its own threads), so
|
|
19
|
+
* a file's hash is held until every earlier file of its SIZE has one: only those can be its
|
|
20
|
+
* repeat, and they started hashing before it did. music's gate went red on exactly that race
|
|
21
|
+
* (forge task 4407): "song again.wav" sent and "song.wav" called the copy.
|
|
22
|
+
*
|
|
23
|
+
* A pre-check that FAILS never blocks an upload: the sizes question failing sends everything as
|
|
24
|
+
* it would have before the pre-check existed (each app's own after-the-fact check still stands),
|
|
25
|
+
* and is reported as `degraded`. It costs bandwidth, never a file.
|
|
26
|
+
*/
|
|
27
|
+
import { PRECHECK_MAX_HASHES, PRECHECK_MAX_SIZES, type PrecheckHit, type PrecheckRequest, type PrecheckResponse } from "./contract.js";
|
|
28
|
+
import { type HashOptions, NATIVE_HASH_MAX_BYTES, STREAM_SLICE_BYTES, sha256OfBlob } from "./hash.js";
|
|
29
|
+
import { type HashMemo, memoKey } from "./memo.js";
|
|
30
|
+
|
|
31
|
+
/** Asks the app's pre-check route. Each app supplies its own fetch. */
|
|
32
|
+
export type PrecheckAsk = (request: PrecheckRequest) => Promise<PrecheckResponse>;
|
|
33
|
+
|
|
34
|
+
export type Verdict<T> =
|
|
35
|
+
/** Send it. `sha256` is set when it was hashed on the way (an app may pass it on). */
|
|
36
|
+
| { kind: "upload"; item: T; sha256?: string }
|
|
37
|
+
/** The library already answers for these bytes. Do not send. */
|
|
38
|
+
| { kind: "duplicate"; item: T; sha256: string; hit: PrecheckHit }
|
|
39
|
+
/** Another file in this same drop has these bytes; only that one is sent. */
|
|
40
|
+
| { kind: "repeat"; item: T; sha256: string; of: T };
|
|
41
|
+
|
|
42
|
+
export interface PlanStats {
|
|
43
|
+
files: number;
|
|
44
|
+
bytes: number;
|
|
45
|
+
/** Files sent without being hashed, because their size matched nothing. */
|
|
46
|
+
sizeOnly: number;
|
|
47
|
+
/** Files whose hash came from the memo — no bytes read. */
|
|
48
|
+
memoHits: number;
|
|
49
|
+
/** Files hashed here, and how many bytes that read. */
|
|
50
|
+
hashedFiles: number;
|
|
51
|
+
hashedBytes: number;
|
|
52
|
+
/** Requests made to the pre-check route. */
|
|
53
|
+
asks: number;
|
|
54
|
+
/** Bytes the plan kept from being sent. */
|
|
55
|
+
savedBytes: number;
|
|
56
|
+
/** Files whose hash failed (an unreadable file) — sent unhashed rather than ending the plan. */
|
|
57
|
+
hashErrors: number;
|
|
58
|
+
/** Set when the pre-check could not be asked; the plan then sent what it could not rule out. */
|
|
59
|
+
degraded?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PlanResult<T> {
|
|
63
|
+
upload: Extract<Verdict<T>, { kind: "upload" }>[];
|
|
64
|
+
duplicates: Extract<Verdict<T>, { kind: "duplicate" }>[];
|
|
65
|
+
repeats: Extract<Verdict<T>, { kind: "repeat" }>[];
|
|
66
|
+
stats: PlanStats;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PlanOptions<T> {
|
|
70
|
+
items: readonly T[];
|
|
71
|
+
/** The bytes of an item. Its `size` is the size asked about. */
|
|
72
|
+
blobOf: (item: T) => Blob;
|
|
73
|
+
/** The item's path inside the drop (or its name) — the memo's key. Without it there is no memo. */
|
|
74
|
+
pathOf?: (item: T) => string;
|
|
75
|
+
/** Modification time in ms (a `File`'s `lastModified`). Part of the memo's key. */
|
|
76
|
+
lastModifiedOf?: (item: T) => number | undefined;
|
|
77
|
+
/** A script's {@link statIdentity} of the item — part of the memo's key where it is known. */
|
|
78
|
+
identityOf?: (item: T) => string | undefined;
|
|
79
|
+
ask: PrecheckAsk;
|
|
80
|
+
memo?: HashMemo;
|
|
81
|
+
/** Defaults to {@link sha256OfBlob}. The specs count calls through it. */
|
|
82
|
+
hash?: (blob: Blob, options: HashOptions) => Promise<string>;
|
|
83
|
+
/** Files hashed at once. Default 3 — a disk is the bottleneck past that. */
|
|
84
|
+
concurrency?: number;
|
|
85
|
+
/** Bytes held in memory by hashes in flight. Default 320 MiB; one file is always allowed. */
|
|
86
|
+
maxInFlightBytes?: number;
|
|
87
|
+
/**
|
|
88
|
+
* How long a finished hash waits for company before it is asked about (default 250 ms), unless
|
|
89
|
+
* {@link batchSize} are already waiting or the hashing is over. Bounds a drop of 10,000 small
|
|
90
|
+
* files to about one request per batch rather than one per file, at a quarter-second's delay.
|
|
91
|
+
*/
|
|
92
|
+
lingerMs?: number;
|
|
93
|
+
/** Hashes that are asked about at once without waiting out the linger. Default 200. */
|
|
94
|
+
batchSize?: number;
|
|
95
|
+
signal?: AbortSignal;
|
|
96
|
+
/** Each verdict as it is reached — start uploading on the first `upload`, not at the end. */
|
|
97
|
+
onVerdict?: (verdict: Verdict<T>) => void;
|
|
98
|
+
/** Hashing progress of one item, 0..1. */
|
|
99
|
+
onHashProgress?: (item: T, fraction: number) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function chunks<V>(values: readonly V[], size: number): V[][] {
|
|
103
|
+
const out: V[][] = [];
|
|
104
|
+
for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size));
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
109
|
+
|
|
110
|
+
export async function planUploads<T>(options: PlanOptions<T>): Promise<PlanResult<T>> {
|
|
111
|
+
const {
|
|
112
|
+
items,
|
|
113
|
+
blobOf,
|
|
114
|
+
pathOf,
|
|
115
|
+
lastModifiedOf,
|
|
116
|
+
identityOf,
|
|
117
|
+
ask,
|
|
118
|
+
memo,
|
|
119
|
+
hash = sha256OfBlob,
|
|
120
|
+
concurrency = 3,
|
|
121
|
+
maxInFlightBytes = 320 * 1024 * 1024,
|
|
122
|
+
lingerMs = 250,
|
|
123
|
+
batchSize = 200,
|
|
124
|
+
signal,
|
|
125
|
+
onVerdict,
|
|
126
|
+
onHashProgress,
|
|
127
|
+
} = options;
|
|
128
|
+
|
|
129
|
+
const result: PlanResult<T> = {
|
|
130
|
+
upload: [],
|
|
131
|
+
duplicates: [],
|
|
132
|
+
repeats: [],
|
|
133
|
+
stats: {
|
|
134
|
+
files: items.length,
|
|
135
|
+
bytes: 0,
|
|
136
|
+
sizeOnly: 0,
|
|
137
|
+
memoHits: 0,
|
|
138
|
+
hashedFiles: 0,
|
|
139
|
+
hashedBytes: 0,
|
|
140
|
+
asks: 0,
|
|
141
|
+
savedBytes: 0,
|
|
142
|
+
hashErrors: 0,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const decide = (verdict: Verdict<T>): void => {
|
|
146
|
+
if (verdict.kind === "upload") result.upload.push(verdict);
|
|
147
|
+
else {
|
|
148
|
+
result.stats.savedBytes += blobOf(verdict.item).size;
|
|
149
|
+
if (verdict.kind === "duplicate") result.duplicates.push(verdict);
|
|
150
|
+
else result.repeats.push(verdict);
|
|
151
|
+
}
|
|
152
|
+
onVerdict?.(verdict);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const sizeOf = (item: T): number => blobOf(item).size;
|
|
156
|
+
const inDrop = new Map<number, number>();
|
|
157
|
+
for (const item of items) {
|
|
158
|
+
const size = sizeOf(item);
|
|
159
|
+
result.stats.bytes += size;
|
|
160
|
+
inDrop.set(size, (inDrop.get(size) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
if (items.length === 0) return result;
|
|
163
|
+
|
|
164
|
+
// 1 — sizes
|
|
165
|
+
const held = new Set<number>();
|
|
166
|
+
try {
|
|
167
|
+
for (const batch of chunks([...inDrop.keys()], PRECHECK_MAX_SIZES)) {
|
|
168
|
+
result.stats.asks++;
|
|
169
|
+
for (const size of (await ask({ sizes: batch })).sizes) held.add(size);
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (signal?.aborted) throw error;
|
|
173
|
+
result.stats.degraded = `pre-check unavailable (${message(error)}) — sending without it`;
|
|
174
|
+
for (const item of items) decide({ kind: "upload", item });
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const candidates: T[] = [];
|
|
179
|
+
for (const item of items) {
|
|
180
|
+
const size = sizeOf(item);
|
|
181
|
+
if (held.has(size) || (inDrop.get(size) ?? 0) > 1) candidates.push(item);
|
|
182
|
+
else {
|
|
183
|
+
result.stats.sizeOnly++;
|
|
184
|
+
decide({ kind: "upload", item });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (candidates.length === 0) return result;
|
|
188
|
+
|
|
189
|
+
// 2 — the memo
|
|
190
|
+
const keyOf = pathOf
|
|
191
|
+
? (item: T) => memoKey(pathOf(item), sizeOf(item), lastModifiedOf?.(item), identityOf?.(item))
|
|
192
|
+
: undefined;
|
|
193
|
+
const remembered = memo && keyOf ? await memo.get(candidates.map(keyOf)) : new Map<string, string>();
|
|
194
|
+
|
|
195
|
+
// 3 + 4 — hash, and ask as hashes finish
|
|
196
|
+
const firstWith = new Map<string, T>();
|
|
197
|
+
let pending: { item: T; sha256: string }[] = [];
|
|
198
|
+
let toRemember: [string, string][] = [];
|
|
199
|
+
let asking: Promise<void> | undefined;
|
|
200
|
+
let askFailed: string | undefined;
|
|
201
|
+
let hashingDone = false;
|
|
202
|
+
let linger: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
|
|
204
|
+
const flush = async (): Promise<void> => {
|
|
205
|
+
while (pending.length > 0) {
|
|
206
|
+
const batch = pending.splice(0, PRECHECK_MAX_HASHES);
|
|
207
|
+
const remember = toRemember;
|
|
208
|
+
toRemember = [];
|
|
209
|
+
if (askFailed) {
|
|
210
|
+
for (const { item, sha256 } of batch) decide({ kind: "upload", item, sha256 });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
let have: Record<string, PrecheckHit> = {};
|
|
214
|
+
try {
|
|
215
|
+
result.stats.asks++;
|
|
216
|
+
have = (await ask({ sha256: [...new Set(batch.map((b) => b.sha256))] })).have;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (signal?.aborted) throw error;
|
|
219
|
+
askFailed = message(error);
|
|
220
|
+
result.stats.degraded = `pre-check failed mid-drop (${askFailed}) — sent the rest without it`;
|
|
221
|
+
}
|
|
222
|
+
for (const { item, sha256 } of batch) {
|
|
223
|
+
const hit = have[sha256];
|
|
224
|
+
decide(hit ? { kind: "duplicate", item, sha256, hit } : { kind: "upload", item, sha256 });
|
|
225
|
+
}
|
|
226
|
+
if (memo && remember.length > 0) await memo.set(remember);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const kick = (): void => {
|
|
230
|
+
if (linger !== undefined) {
|
|
231
|
+
clearTimeout(linger);
|
|
232
|
+
linger = undefined;
|
|
233
|
+
}
|
|
234
|
+
if (asking) return;
|
|
235
|
+
asking = flush().finally(() => {
|
|
236
|
+
asking = undefined;
|
|
237
|
+
if (pending.length > 0) kick();
|
|
238
|
+
});
|
|
239
|
+
// Awaited at the end; this keeps an abort mid-hashing from surfacing as unhandled first.
|
|
240
|
+
asking.catch(() => {});
|
|
241
|
+
};
|
|
242
|
+
const settle = (item: T, sha256: string): void => {
|
|
243
|
+
const first = firstWith.get(sha256);
|
|
244
|
+
if (first !== undefined) {
|
|
245
|
+
decide({ kind: "repeat", item, sha256, of: first });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
firstWith.set(sha256, item);
|
|
249
|
+
pending.push({ item, sha256 });
|
|
250
|
+
if (hashingDone || pending.length >= batchSize) kick();
|
|
251
|
+
else linger ??= setTimeout(kick, lingerMs);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Step 4's drop order: each size shared inside the drop is a line, in drop order, and a hash
|
|
255
|
+
// (or a failure to read, `null`) is settled only once everything ahead of it in its line has one.
|
|
256
|
+
const lines = new Map<number, T[]>();
|
|
257
|
+
for (const item of candidates) {
|
|
258
|
+
const size = sizeOf(item);
|
|
259
|
+
if ((inDrop.get(size) ?? 0) > 1) lines.set(size, [...(lines.get(size) ?? []), item]);
|
|
260
|
+
}
|
|
261
|
+
const arrived = new Map<T, string | null>();
|
|
262
|
+
const apply = (item: T, sha256: string | null): void => {
|
|
263
|
+
if (sha256 !== null) settle(item, sha256);
|
|
264
|
+
else decide({ kind: "upload", item });
|
|
265
|
+
};
|
|
266
|
+
const arrive = (item: T, sha256: string | null): void => {
|
|
267
|
+
const line = lines.get(sizeOf(item));
|
|
268
|
+
if (!line) {
|
|
269
|
+
apply(item, sha256);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
arrived.set(item, sha256);
|
|
273
|
+
while (line.length > 0 && arrived.has(line[0] as T)) {
|
|
274
|
+
const head = line.shift() as T;
|
|
275
|
+
apply(head, arrived.get(head) ?? null);
|
|
276
|
+
arrived.delete(head);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const toHash: T[] = [];
|
|
281
|
+
for (const item of candidates) {
|
|
282
|
+
const known = keyOf ? remembered.get(keyOf(item)) : undefined;
|
|
283
|
+
if (known) {
|
|
284
|
+
result.stats.memoHits++;
|
|
285
|
+
arrive(item, known);
|
|
286
|
+
} else toHash.push(item);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let next = 0;
|
|
290
|
+
let inFlightBytes = 0;
|
|
291
|
+
let running = 0;
|
|
292
|
+
const footprint = (size: number): number =>
|
|
293
|
+
size <= NATIVE_HASH_MAX_BYTES ? size : STREAM_SLICE_BYTES;
|
|
294
|
+
await new Promise<void>((resolve, reject) => {
|
|
295
|
+
let failed = false;
|
|
296
|
+
const pump = (): void => {
|
|
297
|
+
if (failed) return;
|
|
298
|
+
if (next >= toHash.length && running === 0) {
|
|
299
|
+
resolve();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
while (next < toHash.length && running < concurrency) {
|
|
303
|
+
const item = toHash[next];
|
|
304
|
+
const blob = blobOf(item);
|
|
305
|
+
const cost = footprint(blob.size);
|
|
306
|
+
if (running > 0 && inFlightBytes + cost > maxInFlightBytes) break;
|
|
307
|
+
next++;
|
|
308
|
+
running++;
|
|
309
|
+
inFlightBytes += cost;
|
|
310
|
+
hash(blob, { signal, onProgress: onHashProgress ? (f) => onHashProgress(item, f) : undefined })
|
|
311
|
+
.then((sha256) => {
|
|
312
|
+
result.stats.hashedFiles++;
|
|
313
|
+
result.stats.hashedBytes += blob.size;
|
|
314
|
+
if (keyOf) toRemember.push([keyOf(item), sha256]);
|
|
315
|
+
arrive(item, sha256);
|
|
316
|
+
})
|
|
317
|
+
.catch((error) => {
|
|
318
|
+
// An abort ends the plan. One file that cannot be READ does not: it is sent
|
|
319
|
+
// unhashed like any file the pre-check cannot rule out, and its own upload is
|
|
320
|
+
// where the person learns it is unreadable (roms, forge task 3579).
|
|
321
|
+
if (signal?.aborted) {
|
|
322
|
+
failed = true;
|
|
323
|
+
reject(error);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
result.stats.hashErrors++;
|
|
327
|
+
arrive(item, null);
|
|
328
|
+
})
|
|
329
|
+
.finally(() => {
|
|
330
|
+
running--;
|
|
331
|
+
inFlightBytes -= cost;
|
|
332
|
+
pump();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
pump();
|
|
337
|
+
}).catch((error) => {
|
|
338
|
+
if (linger !== undefined) clearTimeout(linger);
|
|
339
|
+
throw error;
|
|
340
|
+
});
|
|
341
|
+
hashingDone = true;
|
|
342
|
+
if (pending.length > 0) kick();
|
|
343
|
+
while (asking) await asking;
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Sha256 } from "./sha256.js";
|
|
3
|
+
|
|
4
|
+
const subtle = async (bytes: Uint8Array<ArrayBuffer>): Promise<string> =>
|
|
5
|
+
[...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
|
|
7
|
+
const bytesOf = (n: number): Uint8Array<ArrayBuffer> => {
|
|
8
|
+
const out = new Uint8Array(n);
|
|
9
|
+
let x = 2463534242;
|
|
10
|
+
for (let i = 0; i < n; i++) {
|
|
11
|
+
x ^= x << 13;
|
|
12
|
+
x ^= x >>> 17;
|
|
13
|
+
x ^= x << 5;
|
|
14
|
+
out[i] = x & 0xff;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("Sha256", () => {
|
|
20
|
+
test("the empty message and 'abc' — the FIPS vectors", () => {
|
|
21
|
+
expect(new Sha256().hex()).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
|
22
|
+
expect(new Sha256().update(new TextEncoder().encode("abc")).hex()).toBe(
|
|
23
|
+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// 55/56/64 are the padding boundaries: one length byte fits, does not fit, a whole block.
|
|
28
|
+
for (const n of [1, 55, 56, 63, 64, 65, 127, 128, 1000]) {
|
|
29
|
+
test(`agrees with WebCrypto on ${n} bytes, fed whole and byte by byte`, async () => {
|
|
30
|
+
const data = bytesOf(n);
|
|
31
|
+
const whole = new Sha256().update(data).hex();
|
|
32
|
+
const drip = new Sha256();
|
|
33
|
+
for (let i = 0; i < n; i++) drip.update(data.subarray(i, i + 1));
|
|
34
|
+
expect(whole).toBe(await subtle(data));
|
|
35
|
+
expect(drip.hex()).toBe(whole);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("a megabyte split at an odd offset — the shape a resumed download has", async () => {
|
|
40
|
+
const data = bytesOf(1024 * 1024 + 7);
|
|
41
|
+
const hasher = new Sha256();
|
|
42
|
+
hasher.update(data.subarray(0, 333_333));
|
|
43
|
+
hasher.update(data.subarray(333_333, 700_001));
|
|
44
|
+
hasher.update(data.subarray(700_001));
|
|
45
|
+
expect(hasher.hex()).toBe(await subtle(data));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An INCREMENTAL SHA-256 — fed chunk by chunk, for a Blob too big to hold in one buffer.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 Why not `crypto.subtle.digest`: it takes the whole message in one buffer, and a PS2 disc is
|
|
5
|
+
* up to 8.5 GB, a camera video several. Born in roms (`src/ps2/sha256.ts`, the disc download's
|
|
6
|
+
* verifier) and moved here so every app's upload pre-check hashes with the same code
|
|
7
|
+
* (forge task 3579). `sha256.spec.ts` pins it against WebCrypto across every chunk boundary that
|
|
8
|
+
* matters (55/56/64 bytes, and a 1 MB split at an odd offset).
|
|
9
|
+
*
|
|
10
|
+
* Measured on this Mac under Bun, 256 MiB in 8 MiB slices: 184 MiB/s here, 195 MiB/s for
|
|
11
|
+
* `@noble/hashes`, 1,960 MiB/s for `crypto.subtle` — which is why {@link sha256OfBlob} only falls
|
|
12
|
+
* back to this past the size it is willing to read into memory at once.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const K = new Uint32Array([
|
|
16
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
|
|
17
|
+
0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
|
18
|
+
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
|
|
19
|
+
0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
20
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
|
|
21
|
+
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
|
22
|
+
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
|
|
23
|
+
0xc67178f2,
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const INITIAL = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
|
27
|
+
|
|
28
|
+
export class Sha256 {
|
|
29
|
+
private readonly h = new Uint32Array(INITIAL);
|
|
30
|
+
|
|
31
|
+
/** Back to the empty message — a download that has to start over. */
|
|
32
|
+
reset(): this {
|
|
33
|
+
this.h.set(INITIAL);
|
|
34
|
+
this.tailLength = 0;
|
|
35
|
+
this.length = 0;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
private readonly w = new Uint32Array(64);
|
|
39
|
+
private readonly tail = new Uint8Array(64);
|
|
40
|
+
private tailLength = 0;
|
|
41
|
+
/** Total bytes fed, as a double — exact to 2^53, far past any disc. */
|
|
42
|
+
private length = 0;
|
|
43
|
+
|
|
44
|
+
update(data: Uint8Array): this {
|
|
45
|
+
this.length += data.length;
|
|
46
|
+
let offset = 0;
|
|
47
|
+
if (this.tailLength > 0) {
|
|
48
|
+
const take = Math.min(64 - this.tailLength, data.length);
|
|
49
|
+
this.tail.set(data.subarray(0, take), this.tailLength);
|
|
50
|
+
this.tailLength += take;
|
|
51
|
+
offset = take;
|
|
52
|
+
if (this.tailLength < 64) return this;
|
|
53
|
+
this.block(this.tail, 0);
|
|
54
|
+
this.tailLength = 0;
|
|
55
|
+
}
|
|
56
|
+
const whole = data.length - ((data.length - offset) % 64);
|
|
57
|
+
for (; offset < whole; offset += 64) this.block(data, offset);
|
|
58
|
+
if (offset < data.length) {
|
|
59
|
+
this.tail.set(data.subarray(offset), 0);
|
|
60
|
+
this.tailLength = data.length - offset;
|
|
61
|
+
}
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The digest as lowercase hex. The hasher is spent afterwards. */
|
|
66
|
+
hex(): string {
|
|
67
|
+
const bits = this.length * 8;
|
|
68
|
+
const pad = new Uint8Array(this.tailLength < 56 ? 64 : 128);
|
|
69
|
+
pad.set(this.tail.subarray(0, this.tailLength));
|
|
70
|
+
pad[this.tailLength] = 0x80;
|
|
71
|
+
const view = new DataView(pad.buffer);
|
|
72
|
+
view.setUint32(pad.length - 8, Math.floor(bits / 2 ** 32));
|
|
73
|
+
view.setUint32(pad.length - 4, bits >>> 0);
|
|
74
|
+
for (let offset = 0; offset < pad.length; offset += 64) this.block(pad, offset);
|
|
75
|
+
let out = "";
|
|
76
|
+
for (const word of this.h) out += word.toString(16).padStart(8, "0");
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private block(data: Uint8Array, offset: number): void {
|
|
81
|
+
const w = this.w;
|
|
82
|
+
for (let i = 0; i < 16; i++) {
|
|
83
|
+
const j = offset + i * 4;
|
|
84
|
+
w[i] = (data[j] << 24) | (data[j + 1] << 16) | (data[j + 2] << 8) | data[j + 3];
|
|
85
|
+
}
|
|
86
|
+
for (let i = 16; i < 64; i++) {
|
|
87
|
+
const a = w[i - 15];
|
|
88
|
+
const b = w[i - 2];
|
|
89
|
+
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
|
|
90
|
+
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
|
|
91
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
|
92
|
+
}
|
|
93
|
+
const h = this.h;
|
|
94
|
+
let a = h[0];
|
|
95
|
+
let b = h[1];
|
|
96
|
+
let c = h[2];
|
|
97
|
+
let d = h[3];
|
|
98
|
+
let e = h[4];
|
|
99
|
+
let f = h[5];
|
|
100
|
+
let g = h[6];
|
|
101
|
+
let hh = h[7];
|
|
102
|
+
for (let i = 0; i < 64; i++) {
|
|
103
|
+
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
|
|
104
|
+
const ch = (e & f) ^ (~e & g);
|
|
105
|
+
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
|
106
|
+
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
|
|
107
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
108
|
+
const t2 = (S0 + maj) | 0;
|
|
109
|
+
hh = g;
|
|
110
|
+
g = f;
|
|
111
|
+
f = e;
|
|
112
|
+
e = (d + t1) | 0;
|
|
113
|
+
d = c;
|
|
114
|
+
c = b;
|
|
115
|
+
b = a;
|
|
116
|
+
a = (t1 + t2) | 0;
|
|
117
|
+
}
|
|
118
|
+
h[0] = (h[0] + a) | 0;
|
|
119
|
+
h[1] = (h[1] + b) | 0;
|
|
120
|
+
h[2] = (h[2] + c) | 0;
|
|
121
|
+
h[3] = (h[3] + d) | 0;
|
|
122
|
+
h[4] = (h[4] + e) | 0;
|
|
123
|
+
h[5] = (h[5] + f) | 0;
|
|
124
|
+
h[6] = (h[6] + g) | 0;
|
|
125
|
+
h[7] = (h[7] + hh) | 0;
|
|
126
|
+
}
|
|
127
|
+
}
|