runlocally

runlocally engineering notes

File Hash

How File Hash is built

By Geppetto · · Open File Hash →

File Hash computes a file’s SHA-256, SHA-1 and MD5 digest in the browser and can check it against a pasted expected value. This post is about the two different hashing paths that sit under those three digests — one native, one third-party — why they have different memory behavior on the same file, and how the pasted-hash comparison decides which algorithm it’s looking at.

Tech used

SHA-256 and SHA-1 via WebCrypto’s SubtleCrypto.digest()

SHA-256 and SHA-1 are computed with crypto.subtle.digest(), the browser’s built-in Web Crypto API — no third-party library. Decode Certificate already uses the same crypto.subtle.digest() call for SHA-1/SHA-256 certificate fingerprints, but there the input is a certificate’s raw DER bytes; here the input is an arbitrary file’s full contents, and both digests are produced from it at once.

computeShaDigests() in src/utils/hashEngine.ts reads the file once with file.arrayBuffer(), then calls crypto.subtle.digest('SHA-256', buffer) and crypto.subtle.digest('SHA-1', buffer) together via Promise.all, both against that same ArrayBuffer. That reuse is deliberate and stated directly in the code: digest() reads its input without consuming or transferring it, so one buffer read safely serves both algorithms instead of reading the file twice. Each call resolves to a raw digest ArrayBuffer, which a small bufferToHex() helper turns into the lowercase hex string the UI displays — SubtleCrypto returns bytes, not hex, so that encoding step is the tool’s own code: it walks a Uint8Array view of the buffer and appends byte.toString(16).padStart(2, '0') per byte.

The caveat, also called out directly in the source: SubtleCrypto has no incremental or streaming digest API, so computeShaDigests() is not a true streaming implementation — the whole file has to be materialized in memory via arrayBuffer() before either digest call can start. Rather than trying to work around that, the tool surfaces it: isLargeFile() checks the file’s size against LARGE_FILE_THRESHOLD_BYTES (200MB), and files over that threshold get a non-blocking UI warning that the whole file is being read into memory for this step, instead of the tool hanging or failing silently on a large input with no explanation.

Streaming MD5 with spark-md5’s incremental API and file.slice()

MD5 isn’t in SubtleCrypto at all — it was dropped from the Web Crypto spec — so it’s computed with spark-md5 instead, the tool’s only third-party runtime dependency (^3.0.2 in package.json). Unlike the SHA path, this one is genuinely incremental: SparkMD5.ArrayBuffer exposes an append() method that feeds it one chunk of bytes at a time and an end() method that finalizes the digest once every chunk has been appended.

computeMd5Chunked() drives that API with a loop over file.slice(offset, end).arrayBuffer(): File.slice() returns a Blob covering just that byte range without copying the rest of the file, and awaiting its arrayBuffer() reads only that slice. The loop advances offset by a fixed MD5_CHUNK_SIZE (2MB, i.e. 2 * 1024 * 1024 bytes) until it reaches file.size, calling spark.append(chunk) each time and reporting offset / file.size through an optional onProgress callback the UI uses to drive its progress bar. Because only one chunk is ever held in memory at a time, this is the one hashing path in the tool whose peak memory doesn’t scale with file size — the opposite of the SHA path’s single full-file read. A unit test pins this down directly: hashing the same file with a 64-byte chunk size and a 4096-byte chunk size produces an identical MD5, confirming the chunk size is purely a memory/performance knob with no effect on the result. A zero-byte file is handled by falling straight through the loop (offset < file.size is false immediately) to spark.end(), which still returns the well-defined MD5 of empty input — verified in the test suite against the known value d41d8cd98f00b204e9800998ecf8427e.

No Web Worker — a measured call, not a default

Neither hashing path runs in a Web Worker, which Hex Viewer also does without one, though for a different reason (there, the per-row formatting cost is bounded by a render window, not file size). File Hash’s reasoning is specific to this code and stated in hashEngine.ts: SubtleCrypto.digest() already resolves off the observable main JS thread in real browsers, since JS execution isn’t blocked while the native crypto library runs. For the MD5 loop, a Node benchmark of the same chunked append() logic — used as a proxy for desktop V8 performance — showed the longest synchronous per-chunk call taking roughly 5-25ms even for gigabyte-scale input, with a genuine task-queue yield (the await on each chunk’s arrayBuffer() read, not just a microtask) between every chunk. That’s called out as staying well under the ~50ms jank budget, so a worker/postMessage protocol was judged to add complexity without a measurable responsiveness gain here — a conclusion the code contrasts explicitly with heic-to-jpg’s WASM decode, which is long-running synchronous work and does need a worker.

Auto-detecting the algorithm from a pasted hash’s length

Pasting an expected hash into the compare field doesn’t require picking an algorithm first — detectExpectedAlgorithm() infers it purely from the trimmed string’s length after checking it’s all hex characters (/^[0-9a-f]+$/i): 32 characters is read as MD5, 64 as SHA-256. Anything else — the wrong length, or any non-hex character — returns null, and the UI shows a neutral “unrecognized” state rather than a false mismatch.

One detail worth being explicit about: even though the tool computes and displays SHA-1 alongside the other two, detectExpectedAlgorithm() does not recognize a 40-character SHA-1 hex string at all — it’s covered by a test asserting the SHA-1-length case returns null — so pasting a SHA-1 value into the compare field always falls into the neutral “unrecognized” state rather than being matched. The comparison itself, in compareHash(), lowercases both the computed digest and the trimmed pasted value before comparing them, so a hash pasted in uppercase (as some OS-native tools print them) still matches.

Implementation & operational notes

SHA and MD5 run concurrently, not in sequence. FileHashTool.tsx kicks off computeShaDigests(file) and computeMd5Chunked(file, onProgress) together inside one Promise.all, so the full-buffer SHA read and the chunked MD5 loop are both in flight on the same File object at once rather than one waiting for the other to finish.

The progress bar only reflects the MD5 loop. computeShaDigests() has no progress callback — arrayBuffer() plus digest() gives no intermediate signal — so the single progress bar shown while status === 'hashing' is driven entirely by computeMd5Chunked()’s onProgress ratio, even though it’s timing the SHA-256/SHA-1 work too.

A request-id guard prevents a stale result from landing. handleFile() increments a requestIdRef counter on every new file and checks it against the current value before committing progress updates or the final digest state, so if a user picks a second file while the first is still hashing, the first request’s in-flight .then() callbacks become no-ops instead of overwriting the newer file’s result.

File validation is minimal by design. There’s no extension or MIME allow-list — any file type can be hashed — so validateFile() in fileValidation.ts only guards against a degenerate File-like value whose size isn’t a finite, non-negative number, a case real picker/drop paths don’t produce but that fails loudly with a stable error code instead of hanging in the hashing step if it somehow occurred.

The large-file warning is informational, not a limit. Crossing the 200MB threshold doesn’t block hashing or downgrade behavior; it just tells the user the SHA-256/SHA-1 step is about to read the whole file into memory, since that’s the one path in the tool that can’t avoid it.

Try it / source