diff --git a/package-lock.json b/package-lock.json index f608a54..fa3f700 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4e04cbc..edd5e24 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/shims/crypto/create-hash.js b/src/shims/crypto/create-hash.js index 9d73c44..f070a0b 100644 --- a/src/shims/crypto/create-hash.js +++ b/src/shims/crypto/create-hash.js @@ -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"); -} diff --git a/src/shims/crypto/create-hash.test.js b/src/shims/crypto/create-hash.test.js new file mode 100644 index 0000000..2faf417 --- /dev/null +++ b/src/shims/crypto/create-hash.test.js @@ -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); + }); +}); diff --git a/src/shims/fs/content-cache.test.js b/src/shims/fs/content-cache.test.js index 396c344..004bd17 100644 --- a/src/shims/fs/content-cache.test.js +++ b/src/shims/fs/content-cache.test.js @@ -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");