Option2 무결성 강화: integrity index 추가, 인덱스 정렬/교차 검증, referencedAt 매트릭스, README 문서화 (TDD)
This commit is contained in:
@@ -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<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
type IndexShape = { images: string[]; fonts: string[]; other: string[] }
|
||||
|
||||
describe('assets.index.json ↔ assets.json consistency', () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
describe('assets.integrity.index.json', () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<out.length;i++) s += String.fromCharCode(out[i])
|
||||
const btoaFn = (globalThis as unknown as { btoa?: (s: string) => 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)
|
||||
}
|
||||
@@ -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<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
describe('referencedAt matrix across modes and multiple assets', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user