diff --git a/src/__tests__/utils/sha256.test.ts b/src/__tests__/utils/sha256.test.ts new file mode 100644 index 000000000..64767e3d2 --- /dev/null +++ b/src/__tests__/utils/sha256.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test' +import { sha256Hex } from '@core/utils/sha256' + +// Reference digest via the platform's native WebCrypto (always available in +// Bun / the test runner), so the pure-JS implementation is checked against the +// real algorithm rather than a hand-copied constant. +async function nativeSha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)) + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') +} + +describe('sha256Hex', () => { + it('matches the published empty-string and "abc" vectors', () => { + expect(sha256Hex('')).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ) + expect(sha256Hex('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ) + }) + + it('always returns 64 lowercase hex chars', () => { + for (const input of ['', 'a', 'hello world', 'x'.repeat(1000)]) { + expect(sha256Hex(input)).toMatch(/^[0-9a-f]{64}$/) + } + }) + + it('agrees with native WebCrypto across sizes and block boundaries', async () => { + const inputs = [ + '', + 'a', + 'The quick brown fox jumps over the lazy dog', + // Multi-byte UTF-8 (emoji + accents) — exercises the byte encoding. + 'héllo — 世界 🚀', + // Exactly 55, 56, 63, 64, 65 bytes: the padding edge cases. + 'x'.repeat(55), + 'x'.repeat(56), + 'x'.repeat(63), + 'x'.repeat(64), + 'x'.repeat(65), + // A realistic code-asset payload spanning several blocks. + `body { color: var(--text); }\n`.repeat(400), + ] + for (const input of inputs) { + expect(sha256Hex(input)).toBe(await nativeSha256Hex(input)) + } + }) + + it('is deterministic and sensitive to single-character changes', () => { + expect(sha256Hex('const x = 1')).toBe(sha256Hex('const x = 1')) + expect(sha256Hex('const x = 1')).not.toBe(sha256Hex('const x = 2')) + }) +}) diff --git a/src/admin/pages/site/agent/codeAssetTools.ts b/src/admin/pages/site/agent/codeAssetTools.ts index 90106ad27..5e2809fae 100644 --- a/src/admin/pages/site/agent/codeAssetTools.ts +++ b/src/admin/pages/site/agent/codeAssetTools.ts @@ -20,6 +20,7 @@ import { normalizeStyleRuntimeConfig, } from '@core/site-runtime' import { isSafePackageName } from '@core/site-dependencies/packageNames' +import { sha256Hex } from '@core/utils/sha256' import type { EditorStore } from '@site/store/types' import { activeRenderPage } from './documentTools' import { getAgentStoreApi } from './storeRef' @@ -46,11 +47,13 @@ function contentForCodeAsset(file: CodeAssetFile): string { return file.content ?? '' } -async function hashCodeAssetContent(content: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(content)) - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join('') +// SHA-256 content fingerprint used for read→patch optimistic concurrency. The +// agent's tools run in the browser, and the CMS is often reached over Tailscale +// on plain http:// (a non-secure context where `crypto.subtle` is undefined), +// so this must NOT depend on Web Crypto. `sha256Hex` produces the identical +// 64-char digest in any context. +function hashCodeAssetContent(content: string): string { + return sha256Hex(content) } function normalizeCodeAssetPath(path: string): string | null { @@ -65,7 +68,7 @@ function codeAssetRuntime(store: EditorStore, file: CodeAssetFile) { : (runtime.styles[file.id] ?? { ...DEFAULT_STYLE_RUNTIME_CONFIG }) } -async function describeCodeAsset(store: EditorStore, file: CodeAssetFile) { +function describeCodeAsset(store: EditorStore, file: CodeAssetFile) { const content = contentForCodeAsset(file) return { fileId: file.id, @@ -73,7 +76,7 @@ async function describeCodeAsset(store: EditorStore, file: CodeAssetFile) { type: file.type, contentChars: content.length, bytes: textEncoder.encode(content).byteLength, - hash: await hashCodeAssetContent(content), + hash: hashCodeAssetContent(content), createdAt: file.createdAt, updatedAt: file.updatedAt, generated: file.generated === true, @@ -209,7 +212,7 @@ export async function runListCodeAssets(input: ListCodeAssetsInput): Promise>> bits) | (value << (32 - bits)) +} + +export function sha256Hex(input: string): string { + const bytes = new TextEncoder().encode(input) + const bitLength = bytes.length * 8 + + // Pad: append 0x80, then zeros, so the total length (incl. the trailing + // 64-bit big-endian bit count) is a multiple of 64 bytes. + const withMarker = bytes.length + 1 + const totalLength = withMarker + ((56 - (withMarker % 64) + 64) % 64) + 8 + const padded = new Uint8Array(totalLength) + padded.set(bytes) + padded[bytes.length] = 0x80 + const view = new DataView(padded.buffer) + view.setUint32(totalLength - 8, Math.floor(bitLength / 0x100000000), false) + view.setUint32(totalLength - 4, bitLength >>> 0, false) + + // Initial hash values (fractional parts of the square roots of the first 8 + // primes). + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]) + const w = new Uint32Array(64) + + for (let offset = 0; offset < totalLength; offset += 64) { + for (let i = 0; i < 16; i++) { + w[i] = view.getUint32(offset + i * 4, false) + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15]!, 7) ^ rotr(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3) + const s1 = rotr(w[i - 2]!, 17) ^ rotr(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10) + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0 + } + + let a = h[0]! + let b = h[1]! + let c = h[2]! + let d = h[3]! + let e = h[4]! + let f = h[5]! + let g = h[6]! + let hh = h[7]! + + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) + const ch = (e & f) ^ (~e & g) + const t1 = (hh + S1 + ch + K[i]! + w[i]!) >>> 0 + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) + const maj = (a & b) ^ (a & c) ^ (b & c) + const t2 = (S0 + maj) >>> 0 + hh = 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]! + hh) >>> 0 + } + + let hex = '' + for (let i = 0; i < 8; i++) { + hex += h[i]!.toString(16).padStart(8, '0') + } + return hex +}