diff --git a/packages/fs/src/cas/bytes.ts b/packages/fs/src/cas/bytes.ts index 0de56c4..fb03241 100644 --- a/packages/fs/src/cas/bytes.ts +++ b/packages/fs/src/cas/bytes.ts @@ -62,10 +62,15 @@ export function equalBytes(left: Uint8Array, right: Uint8Array): boolean { return different === 0; } +const HEX_TABLE: readonly string[] = Array.from({ length: 256 }, (_, value) => + value.toString(16).padStart(2, "0"), +); + export function bytesToHex(bytes: Uint8Array): string { bytes = intrinsicByteRange(bytes); let result = ""; - for (const byte of bytes) result += byte.toString(16).padStart(2, "0"); + for (let index = 0; index < bytes.byteLength; index += 1) + result += HEX_TABLE[bytes[index]!]!; return result; } diff --git a/tests/algorithms/content.test.mjs b/tests/algorithms/content.test.mjs index 29be7bc..efa15c1 100644 --- a/tests/algorithms/content.test.mjs +++ b/tests/algorithms/content.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + bytesToHex, intrinsicByteLength as casIntrinsicByteLength, intrinsicByteRange as casIntrinsicByteRange, } from "../../packages/fs/dist/cas/bytes.js"; @@ -51,6 +52,53 @@ function fixture(length, seed = 0x12345678) { return bytes; } +test("bytesToHex is lowercase, zero-padded, and respects intrinsic byte ranges", () => { + const expectedHex = (bytes) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + + assert.equal(bytesToHex(new Uint8Array()), ""); + assert.equal( + bytesToHex(Uint8Array.of(0x00, 0x01, 0x0f, 0x10, 0xab, 0xff)), + "00010f10abff", + ); + + const allBytes = Uint8Array.from({ length: 256 }, (_, value) => value); + assert.equal(bytesToHex(allBytes), expectedHex(allBytes)); + assert.equal(bytesToHex(Buffer.from([0x00, 0x0f, 0x80, 0xff])), "000f80ff"); + + class AdversarialBytes extends Uint8Array { + get byteLength() { + return 1; + } + get byteOffset() { + return 0; + } + get buffer() { + return new ArrayBuffer(1); + } + subarray() { + return Uint8Array.of(0xff); + } + [Symbol.iterator]() { + return Uint8Array.of(0xee)[Symbol.iterator](); + } + } + const adversarial = new AdversarialBytes(4); + adversarial.set([0x00, 0x0f, 0x10, 0xff]); + assert.equal(bytesToHex(adversarial), "000f10ff"); + + let state = 0x9e3779b9; + for (let iteration = 0; iteration < 128; iteration += 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + const bytes = new Uint8Array(state % 1025); + for (let index = 0; index < bytes.byteLength; index += 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + bytes[index] = state >>> 24; + } + assert.equal(bytesToHex(bytes), expectedHex(bytes), `iteration ${iteration}`); + } +}); + test("CAS SHA-256 matches golden vectors and freezes inputs", () => { assert.equal( sha256Hex(new Uint8Array()),