Option2-무결성: assets.json SHA-256 무결성 계산 및 referencedAt 추적 TDD/구현

This commit is contained in:
2025-11-16 11:05:00 +09:00
parent 7bf16bfeee
commit 3708ca3521
3 changed files with 152 additions and 2 deletions
@@ -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-<base64(sha256(bytes))>', 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)
})
})
+96 -2
View File
@@ -12,12 +12,106 @@ export type AssetMetaItem = {
referencedAt: string[] 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 { export function buildAssetsMetadata(am: AssetManager): Uint8Array {
const list = am.list() const list = am.list()
const referenced = new Set(am.referencedList()) const referenced = new Set(am.referencedList())
const items: AssetMetaItem[] = list.map((ref) => { const items: AssetMetaItem[] = list.map((ref) => {
// Browser-safe synchronous integrity: base64 of bytes as placeholder // Compute real SHA-256(base64) integrity synchronously for browser/Node
const b64 = Buffer.from(ref.bytes).toString('base64') const b64 = sha256Base64(ref.bytes)
const group: 'images' | 'fonts' | 'other' = ref.mimeType.startsWith('image/') const group: 'images' | 'fonts' | 'other' = ref.mimeType.startsWith('image/')
? 'images' ? 'images'
: ref.mimeType.startsWith('font/') : ref.mimeType.startsWith('font/')
@@ -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:<name>', 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')
})
})