From 3708ca3521bfe0ff0bd9f3ce8e579fe13a0ef3bc Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 11:05:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Option2-=EB=AC=B4=EA=B2=B0=EC=84=B1:=20asse?= =?UTF-8?q?ts.json=20SHA-256=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=B0=8F=20referencedAt=20=EC=B6=94=EC=A0=81=20TDD?= =?UTF-8?q?/=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/assets.integrity.sha256.test.ts | 31 ++++++ lib/exporter/assets.meta.ts | 98 ++++++++++++++++++- .../assets.referenced.tracking.test.ts | 25 +++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 lib/exporter/assets.integrity.sha256.test.ts create mode 100644 lib/exporter/assets.referenced.tracking.test.ts diff --git a/lib/exporter/assets.integrity.sha256.test.ts b/lib/exporter/assets.integrity.sha256.test.ts new file mode 100644 index 0000000..176b28c --- /dev/null +++ b/lib/exporter/assets.integrity.sha256.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta' +import crypto from 'node:crypto' + +function u8pattern(n: number): Uint8Array { + const arr = new Uint8Array(n) + for (let i = 0; i < n; i++) arr[i] = (i * 131 + 7) & 0xff + return arr +} + +function sha256B64(bytes: Uint8Array): string { + const h = crypto.createHash('sha256') + h.update(Buffer.from(bytes)) + return h.digest('base64') +} + +describe('assets.json integrity = real SHA-256(base64)', () => { + it('integrity equals sha256-', async () => { + const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] }) + const bytes = u8pattern(64) + await am.add({ name: 'hero.png', type: 'image/png', bytes }) + const out = buildAssetsMetadata(am) + const json = new TextDecoder().decode(out) + const obj = JSON.parse(json) as { assets: AssetMetaItem[] } + expect(obj.assets.length).toBe(1) + const a = obj.assets[0] + const expected = 'sha256-' + sha256B64(bytes) + expect(a.integrity).toBe(expected) + }) +}) diff --git a/lib/exporter/assets.meta.ts b/lib/exporter/assets.meta.ts index eb36f7f..c85fafd 100644 --- a/lib/exporter/assets.meta.ts +++ b/lib/exporter/assets.meta.ts @@ -12,12 +12,106 @@ export type AssetMetaItem = { referencedAt: string[] } +// Minimal synchronous SHA-256 implementation returning base64 string. +// Derived from the SHA-256 specification. Suitable for small payloads (asset bytes). +function sha256Base64(bytes: Uint8Array): string { + const K = new Uint32Array([ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 + ]) + const H = new Uint32Array([ + 0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19 + ]) + const l = bytes.length + const bitLenHi = Math.floor((l >>> 29)) + const bitLenLo = (l << 3) >>> 0 + // Pre-processing (padding) + const withOne = l + 1 + // pad to congruent 56 mod 64 + const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64)) + const totalLen = withOne + padLen + 8 + const m = new Uint8Array(totalLen) + m.set(bytes) + m[l] = 0x80 + // append 64-bit big-endian length + m[totalLen - 8] = (bitLenHi >>> 24) & 0xff + m[totalLen - 7] = (bitLenHi >>> 16) & 0xff + m[totalLen - 6] = (bitLenHi >>> 8) & 0xff + m[totalLen - 5] = (bitLenHi >>> 0) & 0xff + m[totalLen - 4] = (bitLenLo >>> 24) & 0xff + m[totalLen - 3] = (bitLenLo >>> 16) & 0xff + m[totalLen - 2] = (bitLenLo >>> 8) & 0xff + m[totalLen - 1] = (bitLenLo >>> 0) & 0xff + const W = new Uint32Array(64) + for (let i = 0; i < totalLen; i += 64) { + for (let t = 0; t < 16; t++) { + const j = i + t * 4 + W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3]) + } + for (let t = 16; t < 64; t++) { + const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0 + const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0 + W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0 + } + let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7] + for (let t = 0; t < 64; t++) { + const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0 + const ch = ((e & f) ^ (~e & g)) >>> 0 + const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0 + const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0 + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0 + const temp2 = (S0 + maj) >>> 0 + h = g + g = f + f = e + e = (d + temp1) >>> 0 + d = c + c = b + b = a + a = (temp1 + temp2) >>> 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] + h) >>> 0 + } + const out = new Uint8Array(32) + for (let i = 0; i < 8; i++) { + out[i * 4 + 0] = (H[i] >>> 24) & 0xff + out[i * 4 + 1] = (H[i] >>> 16) & 0xff + out[i * 4 + 2] = (H[i] >>> 8) & 0xff + out[i * 4 + 3] = (H[i] >>> 0) & 0xff + } + // base64 encode + let b64 = '' + if (typeof Buffer !== 'undefined') { + b64 = Buffer.from(out).toString('base64') + } else { + let s = '' + for (let i = 0; i < out.length; i++) s += String.fromCharCode(out[i]) + // btoa is available in browsers + b64 = btoa(s) + } + return b64 + function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) } +} + export function buildAssetsMetadata(am: AssetManager): Uint8Array { const list = am.list() const referenced = new Set(am.referencedList()) const items: AssetMetaItem[] = list.map((ref) => { - // Browser-safe synchronous integrity: base64 of bytes as placeholder - const b64 = Buffer.from(ref.bytes).toString('base64') + // Compute real SHA-256(base64) integrity synchronously for browser/Node + const b64 = sha256Base64(ref.bytes) const group: 'images' | 'fonts' | 'other' = ref.mimeType.startsWith('image/') ? 'images' : ref.mimeType.startsWith('font/') diff --git a/lib/exporter/assets.referenced.tracking.test.ts b/lib/exporter/assets.referenced.tracking.test.ts new file mode 100644 index 0000000..6dce57b --- /dev/null +++ b/lib/exporter/assets.referenced.tracking.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta' + +function u8(n: number): Uint8Array { + const arr = new Uint8Array(n) + for (let i = 0; i < n; i++) arr[i] = (i * 17) & 0xff + return arr +} + +describe('referencedAt is populated when rewriteUrl is used', () => { + it('marks originalName as referenced via local:', async () => { + const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] }) + await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(16) }) + // use rewriteUrl to simulate page referencing the local asset + const rewritten = am.rewriteUrl('local:hero.png') + expect(rewritten).toMatch(/assets\//) + const metaBytes = buildAssetsMetadata(am) + const obj = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: AssetMetaItem[] } + expect(obj.assets.length).toBe(1) + const a = obj.assets[0] + expect(Array.isArray(a.referencedAt)).toBe(true) + expect(a.referencedAt).toContain('local:hero.png') + }) +}) From 92aa4f19c729747aa3747517cce7262e750f685f Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 12:30:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Option2=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20?= =?UTF-8?q?=EA=B0=95=ED=99=94:=20integrity=20index=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=A0=95=EB=A0=AC/=EA=B5=90?= =?UTF-8?q?=EC=B0=A8=20=EA=B2=80=EC=A6=9D,=20referencedAt=20=EB=A7=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=EC=8A=A4,=20README=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++ components/BuilderClientPage.tsx | 7 +- components/ExportOptions.ui.test.tsx | 2 + lib/exporter/assets.index.consistency.test.ts | 48 +++++++++ lib/exporter/assets.index.test.ts | 34 +++++++ lib/exporter/assets.index.ts | 5 + lib/exporter/assets.integrity.index.test.ts | 37 +++++++ lib/exporter/assets.integrity.index.ts | 98 +++++++++++++++++++ lib/exporter/assets.referenced.matrix.test.ts | 41 ++++++++ 9 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 lib/exporter/assets.index.consistency.test.ts create mode 100644 lib/exporter/assets.integrity.index.test.ts create mode 100644 lib/exporter/assets.integrity.index.ts create mode 100644 lib/exporter/assets.referenced.matrix.test.ts diff --git a/README.md b/README.md index cb3dd5b..9b178af 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ The Builder can export a production-ready ZIP that contains: - site.webmanifest, robots.txt - Optional: sheets/ (Google Apps Script template) - Metadata files: assets.json, assets.index.json + - Integrity: assets.integrity.index.json ### Export Options UI @@ -94,6 +95,17 @@ Simple index to quickly find assets by group: - fonts: string[] of zip paths - other: string[] of zip paths +### assets.integrity.index.json + +- entries: array of `{ path, integrity }` + - `path`: asset `zipPath` (e.g. `assets/abcd1234.png` or grouped `assets/images/...`) + - `integrity`: `sha256-` (real SHA-256 of the asset bytes) +- bundleHash: `sha256-` over a deterministic join of `path` and `integrity` for all entries + +Usage: +- Verify file-level integrity by recomputing SHA-256(base64) of each asset and comparing with `entries[].integrity`. +- Verify bundle integrity by recomputing `bundleHash` from sorted entries; useful for quick tamper detection. + ### Google Sheets template (optional) - When included, ZIP contains: diff --git a/components/BuilderClientPage.tsx b/components/BuilderClientPage.tsx index 263da36..4fc2705 100644 --- a/components/BuilderClientPage.tsx +++ b/components/BuilderClientPage.tsx @@ -9,6 +9,7 @@ import { buildZip } from '@/lib/exporter/zip' import { buildExtrasForPage } from '@/lib/exporter/extras' import { buildAssetsMetadata } from '@/lib/exporter/assets.meta' import { buildAssetsIndex } from '@/lib/exporter/assets.index' +import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index' import type { Section } from '@/lib/state/store' import { createFormStore, type FormState } from '@/lib/state/formStore' import FormBuilderPanel from '@/components/FormBuilderPanel' @@ -285,7 +286,11 @@ export default function BuilderClientPage() { const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride, sheetsMode }) const extras: Record = { ...baseExtras, - ...(am ? { 'assets.json': buildAssetsMetadata(am), 'assets.index.json': buildAssetsIndex(am) } : {}), + ...(am ? { + 'assets.json': buildAssetsMetadata(am), + 'assets.index.json': buildAssetsIndex(am), + 'assets.integrity.index.json': buildAssetsIntegrityIndex(am), + } : {}), } const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras }) download('landing.zip', zipBytes) diff --git a/components/ExportOptions.ui.test.tsx b/components/ExportOptions.ui.test.tsx index 4d12c8c..10e0c09 100644 --- a/components/ExportOptions.ui.test.tsx +++ b/components/ExportOptions.ui.test.tsx @@ -56,5 +56,7 @@ describe('Export Options UI → extras overrides', () => { expect(manifest.name).toBe(manifestName) expect(robotsRaw).toMatch(/Disallow: \/private/) expect(robotsRaw).toMatch(/Disallow: \/tmp/) + // integrity index should be included + expect(Object.prototype.hasOwnProperty.call(extras, 'assets.integrity.index.json')).toBe(true) }) }) diff --git a/lib/exporter/assets.index.consistency.test.ts b/lib/exporter/assets.index.consistency.test.ts new file mode 100644 index 0000000..4c5901c --- /dev/null +++ b/lib/exporter/assets.index.consistency.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsIndex } from '@/lib/exporter/assets.index' +import { buildAssetsMetadata } from '@/lib/exporter/assets.meta' + +function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i { + it.each([ + { mode: 'flat' as const }, + { mode: 'grouped' as const }, + ])('ensures index paths exist in assets.json zipPath with correct group ($mode)', async ({ mode }) => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt','jpg'], pathStrategy: mode }) + await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) }) + await am.add({ name: 'thumb.jpg', type: 'image/jpeg', bytes: u8(11) }) + await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) }) + await am.add({ name: 'readme.txt', type: 'text/plain', bytes: u8(1) }) + + const indexBytes = buildAssetsIndex(am) + const metaBytes = buildAssetsMetadata(am) + + const index = JSON.parse(new TextDecoder().decode(indexBytes)) as IndexShape + const meta = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: Array<{ zipPath: string; group: 'images'|'fonts'|'other' }> } + + const metaByPath = new Map(meta.assets.map(a => [a.zipPath, a.group])) + + for (const p of index.images) { + expect(metaByPath.has(p)).toBe(true) + expect(metaByPath.get(p)).toBe('images') + } + for (const p of index.fonts) { + expect(metaByPath.has(p)).toBe(true) + expect(metaByPath.get(p)).toBe('fonts') + } + for (const p of index.other) { + expect(metaByPath.has(p)).toBe(true) + expect(metaByPath.get(p)).toBe('other') + } + + // Optional: ensure no extra paths exist in meta that are missing in index groups + const indexSet = new Set([...index.images, ...index.fonts, ...index.other]) + for (const a of meta.assets) { + expect(indexSet.has(a.zipPath)).toBe(true) + } + }) +}) diff --git a/lib/exporter/assets.index.test.ts b/lib/exporter/assets.index.test.ts index fc278f9..f3f2da6 100644 --- a/lib/exporter/assets.index.test.ts +++ b/lib/exporter/assets.index.test.ts @@ -19,4 +19,38 @@ describe('Assets index builder', () => { expect(obj.fonts[0]).toMatch(/^assets\/[0-9a-f]{8}\.woff2$/) expect(obj.other[0]).toMatch(/^assets\/[0-9a-f]{8}\.txt$/) }) + + it('sorts each group array by path (ascending)', async () => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt','woff2'] }) + // Add in shuffled order so that sort is required + await am.add({ name: 'z.png', type: 'image/png', bytes: u8(3) }) + await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) }) + await am.add({ name: 'm.txt', type: 'text/plain', bytes: u8(2) }) + await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(4) }) + await am.add({ name: 'f.woff2', type: 'font/woff2', bytes: u8(6) }) + await am.add({ name: 'e.woff2', type: 'font/woff2', bytes: u8(7) }) + const bytes = buildAssetsIndex(am) + const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] } + const imgs = [...obj.images] + const fonts = [...obj.fonts] + const others = [...obj.other] + const sortedImgs = [...imgs].sort() + const sortedFonts = [...fonts].sort() + const sortedOthers = [...others].sort() + expect(imgs).toEqual(sortedImgs) + expect(fonts).toEqual(sortedFonts) + expect(others).toEqual(sortedOthers) + }) + + it('respects grouped pathStrategy and outputs grouped paths in index', async () => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: 'grouped' }) + await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) }) + await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) }) + await am.add({ name: 'readme.txt', type: 'text/plain', bytes: u8(1) }) + const bytes = buildAssetsIndex(am) + const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] } + expect(obj.images[0]).toMatch(/^assets\/images\/[0-9a-f]{8}\.png$/) + expect(obj.fonts[0]).toMatch(/^assets\/fonts\/[0-9a-f]{8}\.woff2$/) + expect(obj.other[0]).toMatch(/^assets\/other\/[0-9a-f]{8}\.txt$/) + }) }) diff --git a/lib/exporter/assets.index.ts b/lib/exporter/assets.index.ts index fb82246..c22bcf8 100644 --- a/lib/exporter/assets.index.ts +++ b/lib/exporter/assets.index.ts @@ -7,6 +7,7 @@ export type AssetsIndex = { } export function buildAssetsIndex(am: AssetManager): Uint8Array { + // 그룹별 경로 수집 const images: string[] = [] const fonts: string[] = [] const other: string[] = [] @@ -15,6 +16,10 @@ export function buildAssetsIndex(am: AssetManager): Uint8Array { else if (ref.mimeType.startsWith('font/')) fonts.push(ref.path) else other.push(ref.path) } + // 경로 사전순 정렬로 일정한 출력 보장 + images.sort() + fonts.sort() + other.sort() const obj: AssetsIndex = { images, fonts, other } const json = JSON.stringify(obj, null, 2) return new TextEncoder().encode(json) diff --git a/lib/exporter/assets.integrity.index.test.ts b/lib/exporter/assets.integrity.index.test.ts new file mode 100644 index 0000000..90f2a60 --- /dev/null +++ b/lib/exporter/assets.integrity.index.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index' +import { buildAssetsMetadata } from '@/lib/exporter/assets.meta' + +function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i { + it('emits path->integrity mapping consistent with assets.json and deterministically across modes and insertion order', async () => { + for (const mode of ['flat','grouped'] as const) { + // case1: insertion order A,B,C + const am1 = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: mode }) + await am1.add({ name: 'a.png', type: 'image/png', bytes: u8(3) }) + await am1.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(4) }) + await am1.add({ name: 'c.txt', type: 'text/plain', bytes: u8(5) }) + const idx1 = JSON.parse(new TextDecoder().decode(buildAssetsIntegrityIndex(am1))) as { entries: Array<{path:string; integrity:string}>; bundleHash: string } + const meta1 = JSON.parse(new TextDecoder().decode(buildAssetsMetadata(am1))) as { assets: Array<{ zipPath: string; integrity: string }> } + const metaMap1 = new Map(meta1.assets.map(a => [a.zipPath, a.integrity])) + const paths1 = idx1.entries.map(e => e.path) + const sorted1 = [...paths1].sort() + expect(paths1).toEqual(sorted1) + for (const e of idx1.entries) expect(metaMap1.get(e.path)).toBe(e.integrity) + + // case2: insertion order shuffled C,A,B -> determinism of output + const am2 = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: mode }) + await am2.add({ name: 'c.txt', type: 'text/plain', bytes: u8(5) }) + await am2.add({ name: 'a.png', type: 'image/png', bytes: u8(3) }) + await am2.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(4) }) + const idx2 = JSON.parse(new TextDecoder().decode(buildAssetsIntegrityIndex(am2))) as { entries: Array<{path:string; integrity:string}>; bundleHash: string } + // same entries set and order (sorted by path) -> equality + expect(idx2.entries.map(e => e.path)).toEqual(idx1.entries.map(e => e.path)) + expect(idx2.entries.map(e => e.integrity)).toEqual(idx1.entries.map(e => e.integrity)) + // bundle hash depends on entries -> equal as well + expect(idx2.bundleHash).toBe(idx1.bundleHash) + } + }) +}) diff --git a/lib/exporter/assets.integrity.index.ts b/lib/exporter/assets.integrity.index.ts new file mode 100644 index 0000000..aedd202 --- /dev/null +++ b/lib/exporter/assets.integrity.index.ts @@ -0,0 +1,98 @@ +import type { AssetManager } from '@/lib/assets/assetManager' + +export type AssetsIntegrityIndex = { + entries: Array<{ path: string; integrity: string }> + bundleHash: string +} + +// Minimal synchronous SHA-256 implementation returning base64 string. +function sha256Base64(bytes: Uint8Array): string { + const K = new Uint32Array([ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 + ]) + const H = new Uint32Array([ + 0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19 + ]) + const l = bytes.length + const bitLenHi = Math.floor((l >>> 29)) + const bitLenLo = (l << 3) >>> 0 + const withOne = l + 1 + const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64)) + const totalLen = withOne + padLen + 8 + const m = new Uint8Array(totalLen) + m.set(bytes) + m[l] = 0x80 + m[totalLen - 8] = (bitLenHi >>> 24) & 0xff + m[totalLen - 7] = (bitLenHi >>> 16) & 0xff + m[totalLen - 6] = (bitLenHi >>> 8) & 0xff + m[totalLen - 5] = (bitLenHi >>> 0) & 0xff + m[totalLen - 4] = (bitLenLo >>> 24) & 0xff + m[totalLen - 3] = (bitLenLo >>> 16) & 0xff + m[totalLen - 2] = (bitLenLo >>> 8) & 0xff + m[totalLen - 1] = (bitLenLo >>> 0) & 0xff + const W = new Uint32Array(64) + for (let i = 0; i < totalLen; i += 64) { + for (let t = 0; t < 16; t++) { + const j = i + t * 4 + W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3]) + } + for (let t = 16; t < 64; t++) { + const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0 + const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0 + W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0 + } + let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7] + for (let t = 0; t < 64; t++) { + const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0 + const ch = ((e & f) ^ (~e & g)) >>> 0 + const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0 + const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0 + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0 + const temp2 = (S0 + maj) >>> 0 + h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 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] + h) >>> 0 + } + const out = new Uint8Array(32) + for (let i = 0; i < 8; i++) { + out[i*4+0]=(H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=(H[i]>>>0)&0xff + } + if (typeof Buffer !== 'undefined') return Buffer.from(out).toString('base64') + let s = ''; for (let i=0;i string }).btoa + if (!btoaFn) throw new Error('btoa not available') + return btoaFn(s) + function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) } +} + +export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array { + // 1) Collect entries: path + sha256 integrity + const entries = am.list().map(ref => ({ + path: ref.path, + integrity: `sha256-${sha256Base64(ref.bytes)}`, + })) + // 2) Sort entries by path for deterministic output + entries.sort((a, b) => a.path.localeCompare(b.path)) + // 3) Compute bundleHash over sorted lines: path + integrity joined by \n + const lines: string[] = [] + for (const e of entries) { lines.push(e.path); lines.push(e.integrity) } + const joined = new TextEncoder().encode(lines.join('\n')) + const bundleHash = `sha256-${sha256Base64(joined)}` + const obj: AssetsIntegrityIndex = { entries, bundleHash } + const json = JSON.stringify(obj, null, 2) + return new TextEncoder().encode(json) +} diff --git a/lib/exporter/assets.referenced.matrix.test.ts b/lib/exporter/assets.referenced.matrix.test.ts new file mode 100644 index 0000000..02a6549 --- /dev/null +++ b/lib/exporter/assets.referenced.matrix.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta' + +function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i { + it.each([ + { mode: 'flat' as const }, + { mode: 'grouped' as const }, + ])('tracks only referenced originals once per asset ($mode)', async ({ mode }) => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2'], pathStrategy: mode }) + await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) }) + await am.add({ name: 'logo.png', type: 'image/png', bytes: u8(12) }) + await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(14) }) + + // reference some assets multiple times; leave logo.png unreferenced + am.rewriteUrl('local:hero.png') + am.rewriteUrl('local:hero.png') + am.rewriteUrl('local:main.woff2') + + const metaBytes = buildAssetsMetadata(am) + const obj = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: AssetMetaItem[] } + const byName = new Map(obj.assets.map(a => [a.originalName, a])) + + const hero = byName.get('hero.png')! + const logo = byName.get('logo.png')! + const font = byName.get('main.woff2')! + + expect(Array.isArray(hero.referencedAt)).toBe(true) + expect(hero.referencedAt).toContain('local:hero.png') + expect(hero.referencedAt.length).toBe(1) + + expect(Array.isArray(font.referencedAt)).toBe(true) + expect(font.referencedAt).toContain('local:main.woff2') + expect(font.referencedAt.length).toBe(1) + + expect(Array.isArray(logo.referencedAt)).toBe(true) + expect(logo.referencedAt.length).toBe(0) + }) +})