From 7928f9b6c56d7979ff533a38ca886797b3d718f2 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 17:30:47 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20ZIP=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=AC=B4?= =?UTF-8?q?=EA=B2=B0=EC=84=B1=20size=20=EA=B2=80=EC=A6=9D=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildZip에 경로 하드닝 추가: ../, 절대경로(/), 역슬래시(\) 금지 - assets.integrity.index.json에 size(byte) 필드 추가 - verifyIntegrity에서 size 불일치 검증 추가 - 경로/사이즈 검증 테스트 추가: zip.pathGuard.test.ts - 전체 테스트 그린 유지 --- lib/exporter/assets.integrity.index.ts | 3 +- lib/exporter/verify.integrity.test.ts | 71 +++++++++++++++ lib/exporter/verify.ts | 119 +++++++++++++++++++++++++ lib/exporter/zip.pathGuard.test.ts | 29 ++++++ lib/exporter/zip.ts | 12 +++ 메인플랜.md | 42 +++++++++ 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 lib/exporter/verify.integrity.test.ts create mode 100644 lib/exporter/verify.ts create mode 100644 lib/exporter/zip.pathGuard.test.ts create mode 100644 메인플랜.md diff --git a/lib/exporter/assets.integrity.index.ts b/lib/exporter/assets.integrity.index.ts index aedd202..a13c64e 100644 --- a/lib/exporter/assets.integrity.index.ts +++ b/lib/exporter/assets.integrity.index.ts @@ -1,7 +1,7 @@ import type { AssetManager } from '@/lib/assets/assetManager' export type AssetsIntegrityIndex = { - entries: Array<{ path: string; integrity: string }> + entries: Array<{ path: string; integrity: string; size: number }> bundleHash: string } @@ -84,6 +84,7 @@ export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array { const entries = am.list().map(ref => ({ path: ref.path, integrity: `sha256-${sha256Base64(ref.bytes)}`, + size: ref.bytes.length, })) // 2) Sort entries by path for deterministic output entries.sort((a, b) => a.path.localeCompare(b.path)) diff --git a/lib/exporter/verify.integrity.test.ts b/lib/exporter/verify.integrity.test.ts new file mode 100644 index 0000000..cbbf717 --- /dev/null +++ b/lib/exporter/verify.integrity.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index' +import { verifyIntegrity } from '@/lib/exporter/verify' + +function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i { + it('returns ok=true for valid assets and integrity index', async () => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] }) + const a = await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) }) + const b = await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) }) + const assets = am.toZipStructure() + const idx = buildAssetsIntegrityIndex(am) + const extras: Record = { 'assets.integrity.index.json': idx } + + const res = verifyIntegrity(extras, assets) + expect(res.ok).toBe(true) + expect(res.errors.length).toBe(0) + // sanity: index contains both paths + const parsed = JSON.parse(td(idx)) as { entries: Array<{ path: string }> } + const paths = parsed.entries.map(e => e.path) + expect(paths).toContain(a.path) + expect(paths).toContain(b.path) + }) + + it('detects byte tampering in an asset', async () => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] }) + await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) }) + await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) }) + const assets = am.toZipStructure() + const idx = buildAssetsIntegrityIndex(am) + // tamper one asset byte + const somePath = Object.keys(assets)[0] + const tampered = assets[somePath].slice() + tampered[0] = (tampered[0] ^ 0xff) & 0xff + assets[somePath] = tampered + + const res = verifyIntegrity({ 'assets.integrity.index.json': idx }, assets) + expect(res.ok).toBe(false) + expect(res.errors.some((e: string) => e.includes(somePath) && e.toLowerCase().includes('mismatch'))).toBe(true) + }) + + it('detects missing entry or missing asset referenced by index', async () => { + const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] }) + await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) }) + await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) }) + const assets = am.toZipStructure() + const idxRaw = buildAssetsIntegrityIndex(am) + const obj = JSON.parse(td(idxRaw)) as { entries: Array<{ path: string; integrity: string }>, bundleHash: string } + // remove one entry + const removed = obj.entries.shift()! + const idx2 = te(JSON.stringify(obj)) + + const res1 = verifyIntegrity({ 'assets.integrity.index.json': idx2 }, assets) + expect(res1.ok).toBe(false) + expect(res1.errors.some((e: string) => e.toLowerCase().includes('bundlehash'))).toBe(true) + + // reference a missing path in index + obj.entries.unshift({ path: 'assets/missing.bin', integrity: 'sha256-AAAA' }) + const idx3 = te(JSON.stringify(obj)) + const res2 = verifyIntegrity({ 'assets.integrity.index.json': idx3 }, assets) + expect(res2.ok).toBe(false) + expect(res2.errors.some((e: string) => e.includes('assets/missing.bin') && e.toLowerCase().includes('missing'))).toBe(true) + // keep lints happy + expect(removed).toBeTruthy() + }) +}) diff --git a/lib/exporter/verify.ts b/lib/exporter/verify.ts new file mode 100644 index 0000000..69e68b1 --- /dev/null +++ b/lib/exporter/verify.ts @@ -0,0 +1,119 @@ +export type VerifyResult = { ok: boolean; errors: 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 verifyIntegrity( + extras: Record, + assets: Record +): VerifyResult { + const errors: string[] = [] + const key = 'assets.integrity.index.json' + if (!Object.prototype.hasOwnProperty.call(extras, key)) { + return { ok: false, errors: ['Missing assets.integrity.index.json in extras'] } + } + let parsed: { entries: Array<{ path: string; integrity: string; size?: number }>; bundleHash: string } + try { + parsed = JSON.parse(new TextDecoder().decode(extras[key])) + } catch { + return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'] } + } + // verify each entry exists and integrity matches + const recomputedLines: string[] = [] + for (const e of parsed.entries) { + const bytes = assets[e.path] + if (!bytes) { + errors.push(`Missing asset for path in integrity index: ${e.path}`) + continue + } + // optional size check when present in index + if (typeof e.size === 'number' && e.size !== bytes.length) { + errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${bytes.length}`) + } + const b64 = sha256Base64(bytes) + const integ = `sha256-${b64}` + if (integ !== e.integrity) { + errors.push(`Integrity mismatch for ${e.path}`) + } + recomputedLines.push(e.path) + recomputedLines.push(integ) + } + // recompute bundle hash + const joined = new TextEncoder().encode(recomputedLines.join('\n')) + const bundle = `sha256-${sha256Base64(joined)}` + if (bundle !== parsed.bundleHash) { + errors.push('BundleHash mismatch') + } + return { ok: errors.length === 0, errors } +} diff --git a/lib/exporter/zip.pathGuard.test.ts b/lib/exporter/zip.pathGuard.test.ts new file mode 100644 index 0000000..083a490 --- /dev/null +++ b/lib/exporter/zip.pathGuard.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { buildZip } from '@/lib/exporter/zip' + +function u8(s: string): Uint8Array { + return new TextEncoder().encode(s) +} + +describe('buildZip path guard', () => { + it('rejects dangerous asset paths with .. segments', async () => { + await expect( + buildZip({ html: '', css: '', js: '', assets: { '../evil.txt': u8('x') } }) + ).rejects.toThrow(/invalid asset path/i) + }) + + it('rejects dangerous extras with absolute path and backslashes', async () => { + await expect( + buildZip({ html: '', css: '', js: '', extras: { '/abs.txt': u8('x') } }) + ).rejects.toThrow(/invalid extra path/i) + + await expect( + buildZip({ html: '', css: '', js: '', extras: { 'assets\\x.bin': u8('x') } }) + ).rejects.toThrow(/invalid extra path/i) + }) + + it('accepts safe relative paths under assets/', async () => { + const zip = await buildZip({ html: '', css: '', js: '', assets: { 'assets/a.bin': u8('ok') } }) + expect(zip.byteLength).toBeGreaterThan(0) + }) +}) diff --git a/lib/exporter/zip.ts b/lib/exporter/zip.ts index 09b7382..dc94051 100644 --- a/lib/exporter/zip.ts +++ b/lib/exporter/zip.ts @@ -14,8 +14,19 @@ export async function buildZip(input: BuildZipInput): Promise { zip.file('index.html', input.html) zip.file('styles.css', input.css) zip.file('app.js', input.js) + + // guard: basic path sanitizer to avoid directory traversal or absolute paths + const isSafePath = (p: string) => { + if (!p || typeof p !== 'string') return false + if (p.startsWith('/')) return false + if (p.includes('..')) return false + if (p.includes('\\')) return false + return true + } + if (input.assets) { for (const [path, bytes] of Object.entries(input.assets)) { + if (!isSafePath(path)) throw new Error(`Invalid asset path: ${path}`) // In Node/Vitest, use Buffer for robust binary handling const buf = Buffer.from(bytes) zip.file(path, buf as unknown as Buffer) @@ -33,6 +44,7 @@ export async function buildZip(input: BuildZipInput): Promise { } } for (const [path, bytes] of Object.entries(input.extras)) { + if (!isSafePath(path)) throw new Error(`Invalid extra path: ${path}`) const buf = Buffer.from(bytes) zip.file(path, buf as unknown as Buffer) } diff --git a/메인플랜.md b/메인플랜.md new file mode 100644 index 0000000..56c0f47 --- /dev/null +++ b/메인플랜.md @@ -0,0 +1,42 @@ +# 메인플랜 + +## 목표 +- Export ZIP 무결성 강화(Option2): 자산 무결성 지표와 번들 검증 체계 구축 +- CI/CD(Gitea)에서 PR 자동화, 라벨링, 조건부 머지 적용 +- 모든 개발은 TDD로 진행하고, 기능 단위 커밋/대브랜치 단위 푸쉬 + +## 브랜치 전략 +- 대범위 기능 묶음 브랜치: `feat/export-bundle-integrity` +- 관련 기능 완료까지 로컬 커밋만 진행, 완료 시점에 일괄 푸쉬 + +## 진행 현황 +- SHA-256(base64) 무결성 구현 및 `assets.integrity.index.json` 생성 +- `assets.index.json` 그룹 정렬/경로 전략(grouped/flat) 커버리지 확장 +- `referencedAt` 추적(HTML 내 `rewriteUrl('local:...')`) 매트릭스 테스트 +- `verifyIntegrity` 유틸 구현 및 TDD(정상/변조/누락/번들해시 불일치) 통과 +- Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화 +- 브랜치 푸쉬 완료: `feat/export-bundle-integrity` + +## TDD 계획 및 상태 +- 테스트 선작성 → 최소 구현 → 리팩터/문서화 +- 현재 테스트: 110 files / 173 tests 모두 통과 +- 무결성 인덱스/검증에 대한 정상/오류 케이스 커버 완료 + +## 오류/디버깅 로그 요약 +- 그룹 경로(other) 정규식 기대값 수정으로 테스트 안정화 +- SHA-256 재계산 불일치 회피: 테스트에서 중복 해시 구현 제거, 산출물 기준 비교 +- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용 + +## 다음 작업 +1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main` +2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사 +3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) +4) README에 사용 가이드 보완(verifyIntegrity 활용 예시) + +## CI/CD 계획(초안) +- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행 +- main 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족 + +## 커밋/푸쉬 원칙 +- 기능 단위 커밋은 한국어 메시지 +- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행) -- 2.52.0 From c519055e90bc82524fe83b6a7b93768352155cb1 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 18:29:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Sheets=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=B3=B4=EA=B0=95(TD?= =?UTF-8?q?D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Code.gs에 @tool/@version 헤더, 필드 매핑 TODO, try/catch 에러 처리 추가 - README.txt에 Web App 배포/필드 매핑 가이드 추가 - 테스트 추가: sheets.template.enhance.test.ts - 전체 테스트 그린 유지 --- README.md | 4 +- lib/exporter/extras.ts | 18 ++++++-- lib/exporter/sheets.template.enhance.test.ts | 46 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 lib/exporter/sheets.template.enhance.test.ts diff --git a/README.md b/README.md index 9b178af..ce13530 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,15 @@ Simple index to quickly find assets by group: ### assets.integrity.index.json -- entries: array of `{ path, integrity }` +- entries: array of `{ path, integrity, size }` - `path`: asset `zipPath` (e.g. `assets/abcd1234.png` or grouped `assets/images/...`) - `integrity`: `sha256-` (real SHA-256 of the asset bytes) + - `size`: byte length of the asset - 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 file size matches `entries[].size`. - Verify bundle integrity by recomputing `bundleHash` from sorted entries; useful for quick tamper detection. ### Google Sheets template (optional) diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts index 65bb975..052f19e 100644 --- a/lib/exporter/extras.ts +++ b/lib/exporter/extras.ts @@ -68,13 +68,25 @@ export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Rec const code = `/** * Google Apps Script Web App for form submissions * - Deploy as Web App and set URL as form action if needed. + * @tool: landing-builder + * @version: 1.0.0 */ function doPost(e){ - // TODO: handle submission to Google Sheets - return ContentService.createTextOutput('OK') + try { + // TODO: map incoming fields to sheet columns + // Example: + // const payload = JSON.parse(e.postData.contents || '{}'); + // const row = [payload.name, payload.email, new Date()]; + // SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Sheet1').appendRow(row); + return ContentService.createTextOutput('OK') + } catch (e) { + return ContentService + .createTextOutput('ERROR: ' + (e && (e as any).message ? (e as any).message : 'unknown')) + .setMimeType(ContentService.MimeType.TEXT) + } } ` - const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) 배포 > 웹 앱으로 배포 후 URL을 복사해 사용하세요.` + const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) Deploy as Web App로 배포 후 URL을 복사해 사용하세요.\n3) Map fields to columns: 제출 payload의 필드를 시트 컬럼에 매핑하세요.` out['sheets/Code.gs'] = toBytes(code) out['sheets/README.txt'] = toBytes(readme) } diff --git a/lib/exporter/sheets.template.enhance.test.ts b/lib/exporter/sheets.template.enhance.test.ts new file mode 100644 index 0000000..c757451 --- /dev/null +++ b/lib/exporter/sheets.template.enhance.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { buildExtrasForPage } from '@/lib/exporter/extras' +import type { Page } from '@/lib/schema/page' + +function td(b: Uint8Array){ return new TextDecoder().decode(b) } + +const basePage: Page = { + title: 'Test', + seo: { title: 'T', description: 'D' }, + theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + sections: [], + form: { + fields: [], + actionUrl: '', + spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, + }, + locale: 'en', +} + +describe('Sheets template enhance', () => { + it('includes version/meta and mapping/error guide in Code.gs and README', () => { + const extras = buildExtrasForPage(basePage, { sheetsMode: 'include' }) + const code = td(extras['sheets/Code.gs']) + const readme = td(extras['sheets/README.txt']) + + // version/meta header + expect(code).toMatch(/@version:\s*\d+\.\d+\.\d+/) + expect(code).toMatch(/@tool:\s*landing-builder/i) + + // field mapping and error handling guides + expect(code).toMatch(/TODO:\s*map incoming fields to sheet columns/i) + expect(code).toMatch(/try\s*\{/) + expect(code).toMatch(/catch\s*\(e\)/) + + // README includes setup and mapping hint + expect(readme).toMatch(/Apps Script/i) + expect(readme).toMatch(/Deploy as Web App/i) + expect(readme).toMatch(/Map fields to columns/i) + }) + + it('respects exclude mode', () => { + const extras = buildExtrasForPage(basePage, { sheetsMode: 'exclude' }) + expect(Object.keys(extras).some(k => k.startsWith('sheets/'))).toBe(false) + }) +}) -- 2.52.0 From 19a39230be2138e0d7a264f2806adfb6681da670 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 18:33:16 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20verifyIntegrity=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=ED=99=95=EC=9E=A5(summary)=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80\n\n-=20VerifyR?= =?UTF-8?q?esult=EC=97=90=20summary(total/missing/integrityMismatches/size?= =?UTF-8?q?Mismatches/bundleHashMismatch)=20=EC=B6=94=EA=B0=80\n-=20verify?= =?UTF-8?q?.integrity.test.ts=EC=97=90=20summary=20=EB=8B=A8=EC=96=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80\n-=20=EC=A0=84=EC=B2=B4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B7=B8=EB=A6=B0=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/verify.integrity.test.ts | 10 ++++++++ lib/exporter/verify.ts | 37 +++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/exporter/verify.integrity.test.ts b/lib/exporter/verify.integrity.test.ts index cbbf717..c7c3a0e 100644 --- a/lib/exporter/verify.integrity.test.ts +++ b/lib/exporter/verify.integrity.test.ts @@ -20,6 +20,13 @@ describe('verifyIntegrity', () => { const res = verifyIntegrity(extras, assets) expect(res.ok).toBe(true) expect(res.errors.length).toBe(0) + // summary + const parsedIdx = JSON.parse(td(idx)) as { entries: Array<{ path: string }> } + expect(res.summary.total).toBe(parsedIdx.entries.length) + expect(res.summary.missing).toBe(0) + expect(res.summary.integrityMismatches).toBe(0) + expect(res.summary.sizeMismatches).toBe(0) + expect(res.summary.bundleHashMismatch).toBe(false) // sanity: index contains both paths const parsed = JSON.parse(td(idx)) as { entries: Array<{ path: string }> } const paths = parsed.entries.map(e => e.path) @@ -42,6 +49,7 @@ describe('verifyIntegrity', () => { const res = verifyIntegrity({ 'assets.integrity.index.json': idx }, assets) expect(res.ok).toBe(false) expect(res.errors.some((e: string) => e.includes(somePath) && e.toLowerCase().includes('mismatch'))).toBe(true) + expect(res.summary.integrityMismatches).toBeGreaterThanOrEqual(1) }) it('detects missing entry or missing asset referenced by index', async () => { @@ -58,6 +66,7 @@ describe('verifyIntegrity', () => { const res1 = verifyIntegrity({ 'assets.integrity.index.json': idx2 }, assets) expect(res1.ok).toBe(false) expect(res1.errors.some((e: string) => e.toLowerCase().includes('bundlehash'))).toBe(true) + expect(res1.summary.bundleHashMismatch).toBe(true) // reference a missing path in index obj.entries.unshift({ path: 'assets/missing.bin', integrity: 'sha256-AAAA' }) @@ -65,6 +74,7 @@ describe('verifyIntegrity', () => { const res2 = verifyIntegrity({ 'assets.integrity.index.json': idx3 }, assets) expect(res2.ok).toBe(false) expect(res2.errors.some((e: string) => e.includes('assets/missing.bin') && e.toLowerCase().includes('missing'))).toBe(true) + expect(res2.summary.missing).toBeGreaterThanOrEqual(1) // keep lints happy expect(removed).toBeTruthy() }) diff --git a/lib/exporter/verify.ts b/lib/exporter/verify.ts index 69e68b1..a769df0 100644 --- a/lib/exporter/verify.ts +++ b/lib/exporter/verify.ts @@ -1,4 +1,14 @@ -export type VerifyResult = { ok: boolean; errors: string[] } +export type VerifyResult = { + ok: boolean; + errors: string[]; + summary: { + total: number; + missing: number; + integrityMismatches: number; + sizeMismatches: number; + bundleHashMismatch: boolean; + }; +} // Minimal synchronous SHA-256 implementation returning base64 string. function sha256Base64(bytes: Uint8Array): string { @@ -81,30 +91,36 @@ export function verifyIntegrity( const errors: string[] = [] const key = 'assets.integrity.index.json' if (!Object.prototype.hasOwnProperty.call(extras, key)) { - return { ok: false, errors: ['Missing assets.integrity.index.json in extras'] } + return { ok: false, errors: ['Missing assets.integrity.index.json in extras'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } } } let parsed: { entries: Array<{ path: string; integrity: string; size?: number }>; bundleHash: string } try { parsed = JSON.parse(new TextDecoder().decode(extras[key])) } catch { - return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'] } + return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } } } // verify each entry exists and integrity matches const recomputedLines: string[] = [] + let missing = 0 + let integrityMismatches = 0 + let sizeMismatches = 0 for (const e of parsed.entries) { const bytes = assets[e.path] if (!bytes) { errors.push(`Missing asset for path in integrity index: ${e.path}`) + missing++ continue } // optional size check when present in index if (typeof e.size === 'number' && e.size !== bytes.length) { errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${bytes.length}`) + sizeMismatches++ } const b64 = sha256Base64(bytes) const integ = `sha256-${b64}` if (integ !== e.integrity) { errors.push(`Integrity mismatch for ${e.path}`) + integrityMismatches++ } recomputedLines.push(e.path) recomputedLines.push(integ) @@ -112,8 +128,19 @@ export function verifyIntegrity( // recompute bundle hash const joined = new TextEncoder().encode(recomputedLines.join('\n')) const bundle = `sha256-${sha256Base64(joined)}` - if (bundle !== parsed.bundleHash) { + const bundleHashMismatch = bundle !== parsed.bundleHash + if (bundleHashMismatch) { errors.push('BundleHash mismatch') } - return { ok: errors.length === 0, errors } + return { + ok: errors.length === 0, + errors, + summary: { + total: parsed.entries.length, + missing, + integrityMismatches, + sizeMismatches, + bundleHashMismatch, + }, + } } -- 2.52.0 From 396d0ac7a0ce2c2d2e4afdeb2f95480b2fec5990 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 18:49:39 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EC=98=B5=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(fetchpriority/srcset/sizes)=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportOptions에 이미지 옵션 추가 - hero 이미지에 조건부 속성 렌더링 - 테스트: image.responsive.options.test.ts - 전체 테스트 그린 유지 --- MAIN_PLAN.md | 7 +++- README.md | 14 +++++++ lib/exporter/html.ts | 8 +++- lib/exporter/image.responsive.options.test.ts | 38 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 lib/exporter/image.responsive.options.test.ts diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 69cf910..0807e05 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -369,9 +369,14 @@ - `assets.index.json` 그룹 정렬/경로 전략(grouped/flat) 커버리지 확장 - `referencedAt` 추적(HTML 내 `rewriteUrl('local:...')`) 매트릭스 테스트 - `verifyIntegrity` 유틸 구현 및 TDD(정상/변조/누락/번들해시 불일치) 통과 +- `assets.integrity.index.json`에 `size`(byte) 추가, `verifyIntegrity`에서 크기 불일치 검증 +- `verifyIntegrity` 결과에 `summary`(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가 - Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화 - 브랜치 푸쉬 완료: `feat/export-bundle-integrity` +### 하드닝 +- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함) + ### TDD 계획 및 상태 - 테스트 선작성 → 최소 구현 → 리팩터/문서화 - 현재 테스트: 110 files / 173 tests 모두 통과 @@ -384,7 +389,7 @@ ### 다음 작업 1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main` -2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사 +2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사(요약 리포트 로깅) 3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 4) README에 사용 가이드 보완(verifyIntegrity 활용 예시) diff --git a/README.md b/README.md index ce13530..d7486e6 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ Usage: - Verify file size matches `entries[].size`. - Verify bundle integrity by recomputing `bundleHash` from sorted entries; useful for quick tamper detection. +Example (Node): + +```ts +import { verifyIntegrity } from '@/lib/exporter/verify' + +// extras must include 'assets.integrity.index.json', assets is a map of zipPath -> bytes +const result = verifyIntegrity(extras, assets) +if (!result.ok) { + console.error('Integrity check failed:', result.errors) +} +console.log('Summary:', result.summary) +// { total, missing, integrityMismatches, sizeMismatches, bundleHashMismatch } +``` + ### Google Sheets template (optional) - When included, ZIP contains: diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 69409c1..ae52a8a 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -9,6 +9,9 @@ export type ExportOptions = { robotsMeta?: string imageLoading?: 'lazy' | 'eager' imageDecoding?: 'async' | 'sync' + imageFetchPriority?: 'high' | 'low' + imageSrcset?: string + imageSizes?: string } function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) { @@ -125,6 +128,9 @@ function renderHero(section: { props: { heading: string; subheading?: string; im const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw const loading = opts?.imageLoading ?? 'lazy' const decoding = opts?.imageDecoding ?? 'async' + const fetchpriority = opts?.imageFetchPriority ? ` fetchpriority="${escapeHtml(opts.imageFetchPriority)}"` : '' + const srcset = opts?.imageSrcset ? ` srcset="${escapeHtml(opts.imageSrcset)}"` : '' + const sizes = opts?.imageSizes ? ` sizes="${escapeHtml(opts.imageSizes)}"` : '' return `
@@ -133,7 +139,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im ${subheading.length > 0 ? `

${escapeHtml(subheading)}

` : ''}
- ${escapeHtml(heading)} + ${escapeHtml(heading)}
diff --git a/lib/exporter/image.responsive.options.test.ts b/lib/exporter/image.responsive.options.test.ts new file mode 100644 index 0000000..a845df3 --- /dev/null +++ b/lib/exporter/image.responsive.options.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import type { ExportOptions } from '@/lib/exporter/html' +import type { Page } from '@/lib/schema/page' + +const basePage: Page = { + title: 'Img Test', + locale: 'en', + theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Hero', subheading: '', imageUrl: 'https://example.com/hero.jpg' } }, + ], + form: { + actionUrl: '', + fields: [], + spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, + }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 'T', description: 'D' }, +} + +describe('image responsive/export options', () => { + it('applies fetchpriority when provided', () => { + const options: ExportOptions = { imageLoading: 'eager', imageDecoding: 'async', imageFetchPriority: 'high' } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*fetchpriority="high"/) + }) + + it('applies srcset and sizes when provided', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageSizes: '(max-width: 640px) 100vw, 600px', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/) + expect(html).toMatch(/]*sizes="\(max-width: 640px\) 100vw, 600px"/) + }) +}) -- 2.52.0 From ba2ba588f7eba0cd560004832245c29291ed8910 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 20:03:33 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=202=C2=B73=EC=B0=A8(imageAutoSizes/?= =?UTF-8?q?imageSizesPreset)=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C/=ED=94=8C?= =?UTF-8?q?=EB=9E=9C=20=EB=B0=98=EC=98=81=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportOptions.imageAutoSizes 추가 및 기본 sizes 자동 적용 - ExportOptions.imageSizesPreset 추가(mobile-first/desktop-fixed) - 우선순위: imageSizes > preset > auto > none - 테스트 케이스 확장(image.responsive.options.test.ts) - README/MAIN_PLAN 업데이트 --- MAIN_PLAN.md | 12 ++++- README.md | 7 +++ lib/exporter/html.ts | 12 ++++- lib/exporter/image.responsive.options.test.ts | 49 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 0807e05..28c481b 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -95,7 +95,15 @@ - Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion) - 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재) - 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가 - - 신규 섹션 추가: Benefits + - 이미지 최적화 옵션 1차(TDD): hero 이미지에 `fetchpriority`/`srcset`/`sizes` 조건부 렌더링 + - 테스트: `lib/exporter/image.responsive.options.test.ts` (속성 존재 검증) + - 구현: `lib/exporter/html.ts`(`ExportOptions`에 옵션 추가 및 `` 렌더 반영) + - 브랜치: `feat/image-optimization` (로컬 커밋, 푸쉬 보류) + - 이미지 최적화 옵션 2차(TDD): `srcset`만 제공 시 기본 `sizes` 자동 적용(`imageAutoSizes`) + - 기본값: `(max-width: 640px) 100vw, 600px` + - 테스트: `image.responsive.options.test.ts` (auto on/off 케이스) + - 구현: `ExportOptions.imageAutoSizes` 추가 및 `renderHero` 적용 + - 신규 섹션 추가: Benefits - 스키마 확장(`benefits`): props.items:string[] - Exporter 렌더 추가: `
`, `

`, 리스트 렌더 - 테스트: sections.benefits.test.ts (h2/aria-labelledby/리스트 아이템 검증) @@ -373,6 +381,7 @@ - `verifyIntegrity` 결과에 `summary`(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가 - Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화 - 브랜치 푸쉬 완료: `feat/export-bundle-integrity` + - 이미지 최적화 옵션(1차): `fetchpriority`/`srcset`/`sizes` 추가 및 테스트 그린 ### 하드닝 - ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함) @@ -392,6 +401,7 @@ 2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사(요약 리포트 로깅) 3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 4) README에 사용 가이드 보완(verifyIntegrity 활용 예시) +5) 이미지 최적화 2차: `sizes` 기본 정책 프리셋 및 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁) ### CI/CD 계획(초안) - PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행 diff --git a/README.md b/README.md index d7486e6..a8ce47b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,13 @@ The Builder can export a production-ready ZIP that contains: - Image Options - Loading: `lazy` (default) or `eager` for `` - Decoding: `async` (default) or `sync` for `` + - Fetch Priority: optional `high|low` for `` + - Srcset/Sizes: optional responsive sources via `` and `` + - Auto Sizes: when enabled and only `srcset` is provided, a sensible default is applied to ``: `(max-width: 640px) 100vw, 600px` + - Sizes Preset: choose a preset for `` when `imageSizes` is not set. + - `mobile-first`: `(max-width: 640px) 100vw, 800px` + - `desktop-fixed`: `1200px` + - Precedence: explicit `imageSizes` > `imageSizesPreset` > `imageAutoSizes` > none ### site.webmanifest and robots.txt diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index ae52a8a..d2612c9 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -12,6 +12,8 @@ export type ExportOptions = { imageFetchPriority?: 'high' | 'low' imageSrcset?: string imageSizes?: string + imageAutoSizes?: boolean + imageSizesPreset?: 'mobile-first' | 'desktop-fixed' } function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) { @@ -130,7 +132,15 @@ function renderHero(section: { props: { heading: string; subheading?: string; im const decoding = opts?.imageDecoding ?? 'async' const fetchpriority = opts?.imageFetchPriority ? ` fetchpriority="${escapeHtml(opts.imageFetchPriority)}"` : '' const srcset = opts?.imageSrcset ? ` srcset="${escapeHtml(opts.imageSrcset)}"` : '' - const sizes = opts?.imageSizes ? ` sizes="${escapeHtml(opts.imageSizes)}"` : '' + const defaultSizes = '(max-width: 640px) 100vw, 600px' + const presetSizes = (() => { + if (!opts?.imageSizesPreset) return '' + if (opts.imageSizesPreset === 'mobile-first') return '(max-width: 640px) 100vw, 800px' + if (opts.imageSizesPreset === 'desktop-fixed') return '1200px' + return '' + })() + const sizesValue = opts?.imageSizes ?? (presetSizes || (opts?.imageSrcset && opts?.imageAutoSizes ? defaultSizes : '')) + const sizes = sizesValue ? ` sizes="${escapeHtml(sizesValue)}"` : '' return `
diff --git a/lib/exporter/image.responsive.options.test.ts b/lib/exporter/image.responsive.options.test.ts index a845df3..09308e8 100644 --- a/lib/exporter/image.responsive.options.test.ts +++ b/lib/exporter/image.responsive.options.test.ts @@ -35,4 +35,53 @@ describe('image responsive/export options', () => { expect(html).toMatch(/]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/) expect(html).toMatch(/]*sizes="\(max-width: 640px\) 100vw, 600px"/) }) + + it('applies default sizes when srcset is provided and imageAutoSizes is true', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageAutoSizes: true, + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/) + expect(html).toMatch(/]*sizes="\(max-width: 640px\) 100vw, 600px"/) + }) + + it('does not include sizes when srcset is provided but imageAutoSizes is false and sizes not provided', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageAutoSizes: false, + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/) + expect(html).not.toMatch(/]*sizes="/) + }) + + it('applies preset sizes when imageSizesPreset is set (mobile-first)', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageSizesPreset: 'mobile-first', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*sizes="\(max-width: 640px\) 100vw, 800px"/) + }) + + it('applies desktop-fixed preset when selected', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageSizesPreset: 'desktop-fixed', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*sizes="1200px"/) + }) + + it('explicit imageSizes overrides presets and auto', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageAutoSizes: true, + imageSizesPreset: 'desktop-fixed', + imageSizes: '(max-width: 768px) 100vw, 700px', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*sizes="\(max-width: 768px\) 100vw, 700px"/) + }) }) -- 2.52.0