update createHash implementation and add tests

This commit is contained in:
Nystik
2026-05-16 15:07:26 +02:00
parent c225f73859
commit c1a169a3ed
5 changed files with 167 additions and 52 deletions

14
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@noble/hashes": "^2.2.0",
"esbuild": "^0.20.0",
"esbuild-svelte": "^0.9.4",
"lucide-svelte": "^0.577.0",
@@ -537,6 +538,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",

View File

@@ -20,6 +20,7 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@noble/hashes": "^2.2.0",
"esbuild": "^0.20.0",
"esbuild-svelte": "^0.9.4",
"lucide-svelte": "^0.577.0",

View File

@@ -1,74 +1,88 @@
export function createHash(algorithm) {
const alg = algorithm.toUpperCase().replace("-", "");
import { sha256, sha512 } from "@noble/hashes/sha2.js";
import { sha1, md5 } from "@noble/hashes/legacy.js";
const subtleAlg =
alg === "SHA256"
? "SHA-256"
: alg === "SHA1"
? "SHA-1"
: alg === "SHA512"
? "SHA-512"
: alg;
const HASHERS = {
SHA1: sha1,
SHA256: sha256,
SHA512: sha512,
MD5: md5,
};
const SUBTLE_ALG = {
SHA1: "SHA-1",
SHA256: "SHA-256",
SHA512: "SHA-512",
};
function normalizeAlgorithm(algorithm) {
return algorithm.toUpperCase().replace(/-/g, "");
}
function encode(bytes, encoding) {
if (!encoding) {
return bytes;
}
if (encoding === "hex") {
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
if (encoding === "base64") {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
throw new Error(`Unsupported digest encoding: ${encoding}`);
}
export function createHash(algorithm) {
const alg = normalizeAlgorithm(algorithm);
const hasher = HASHERS[alg];
if (!hasher) {
throw new Error(`Unsupported hash algorithm: ${algorithm}`);
}
let inputData = new Uint8Array(0);
return {
update(data) {
if (typeof data === "string") {
data = new TextEncoder().encode(data);
}
const merged = new Uint8Array(inputData.length + data.length);
const bytes =
typeof data === "string" ? new TextEncoder().encode(data) : data;
const merged = new Uint8Array(inputData.length + bytes.length);
merged.set(inputData);
merged.set(data, inputData.length);
merged.set(bytes, inputData.length);
inputData = merged;
return this;
},
digest(encoding) {
console.warn("[shim:crypto] createHash.digest - using placeholder");
const hash = simpleHash(inputData);
if (encoding === "hex") {
return hash;
}
if (encoding === "base64") {
return btoa(hash);
}
return hash;
return encode(hasher(inputData), encoding);
},
async digestAsync(encoding) {
const hashBuffer = await crypto.subtle.digest(subtleAlg, inputData);
const hashArray = new Uint8Array(hashBuffer);
const subtleAlg = SUBTLE_ALG[alg];
if (encoding === "hex") {
return Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (!subtleAlg) {
// SubtleCrypto doesn't cover MD5; fall back to the sync hasher.
return encode(hasher(inputData), encoding);
}
if (encoding === "base64") {
return btoa(String.fromCharCode(...hashArray));
}
return hashArray;
const buf = await crypto.subtle.digest(subtleAlg, inputData);
return encode(new Uint8Array(buf), encoding);
},
};
}
function simpleHash(data) {
let hash = 0;
for (let i = 0; i < data.length; i++) {
hash = ((hash << 5) - hash + data[i]) | 0;
}
return Math.abs(hash).toString(16).padStart(8, "0");
}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { createHash } from "./create-hash.js";
// "abc" / empty SHA digests: NIST FIPS 180-4 worked examples (SHA_All.pdf).
// MD5: RFC 1321 §A.5 test suite.
const VECTORS = {
SHA1: {
empty: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
abc: "a9993e364706816aba3e25717850c26c9cd0d89d",
},
SHA256: {
empty: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
abc: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
},
SHA512: {
empty:
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
abc: "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
},
MD5: {
empty: "d41d8cd98f00b204e9800998ecf8427e",
abc: "900150983cd24fb0d6963f7d28e17f72",
},
};
describe("createHash", () => {
for (const [alg, vec] of Object.entries(VECTORS)) {
it(`${alg} digests "abc" correctly (hex)`, () => {
expect(createHash(alg).update("abc").digest("hex")).toBe(vec.abc);
});
}
it("handles empty input (no update calls)", () => {
expect(createHash("sha256").digest("hex")).toBe(VECTORS.SHA256.empty);
});
it("normalizes algorithm names (sha-256 -> SHA256)", () => {
expect(createHash("sha-256").update("abc").digest("hex")).toBe(
VECTORS.SHA256.abc,
);
});
it("digest() with no encoding returns raw bytes", () => {
const result = createHash("sha256").update("abc").digest();
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(32);
});
it("digest('base64') returns the base64 of the raw bytes", () => {
const result = createHash("sha256").update("abc").digest("base64");
expect(result).toBe("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=");
});
it("supports multiple update() calls", () => {
const result = createHash("sha256")
.update("a")
.update("b")
.update("c")
.digest("hex");
expect(result).toBe(VECTORS.SHA256.abc);
});
it("throws on unsupported algorithm", () => {
expect(() => createHash("whirlpool")).toThrow(/Unsupported hash algorithm/);
});
it("throws on unsupported encoding", () => {
expect(() => createHash("sha256").update("abc").digest("utf8")).toThrow(
/Unsupported digest encoding/,
);
});
});
describe("digestAsync", () => {
it("SHA-256 async matches the sync result", async () => {
const h = createHash("sha256");
h.update("abc");
expect(await h.digestAsync("hex")).toBe(VECTORS.SHA256.abc);
});
it("MD5 async falls back to the sync hasher (SubtleCrypto doesn't support it)", async () => {
const h = createHash("md5");
h.update("abc");
expect(await h.digestAsync("hex")).toBe(VECTORS.MD5.abc);
});
});

View File

@@ -17,7 +17,7 @@ describe("ContentCache size accounting", () => {
expect(cache.currentBytes).toBe(0);
});
it("replacing an entry reflects the new size, not old + new", () => {
it("replacing an entry reflects the new size", () => {
const cache = new ContentCache(1024);
cache.set("a.md", "short");
cache.set("a.md", "a much longer string");