Compare commits

...

5 Commits

Author SHA1 Message Date
jaybe ba2ba588f7 feat: 이미지 최적화 2·3차(imageAutoSizes/imageSizesPreset) 및 문서/플랜 반영 (TDD)
Auto PR / open-pr (push) Successful in 15s
Auto Label PR / add-automerge-label (pull_request) Successful in 4s
CI / test (pull_request) Successful in 1m8s
- ExportOptions.imageAutoSizes 추가 및 기본 sizes 자동 적용
- ExportOptions.imageSizesPreset 추가(mobile-first/desktop-fixed)
- 우선순위: imageSizes > preset > auto > none
- 테스트 케이스 확장(image.responsive.options.test.ts)
- README/MAIN_PLAN 업데이트
2025-11-16 20:03:33 +09:00
jaybe 396d0ac7a0 feat: 이미지 최적화 옵션 추가(fetchpriority/srcset/sizes) (TDD)
- ExportOptions에 이미지 옵션 추가
- hero 이미지에 조건부 속성 렌더링
- 테스트: image.responsive.options.test.ts
- 전체 테스트 그린 유지
2025-11-16 18:49:39 +09:00
jaybe 19a39230be feat: verifyIntegrity 리포트 확장(summary) 및 테스트 추가\n\n- VerifyResult에 summary(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가\n- verify.integrity.test.ts에 summary 단언 추가\n- 전체 테스트 그린 유지 2025-11-16 18:33:16 +09:00
jaybe c519055e90 feat: Sheets 템플릿 가이드 보강(TDD)
- Code.gs에 @tool/@version 헤더, 필드 매핑 TODO, try/catch 에러 처리 추가
- README.txt에 Web App 배포/필드 매핑 가이드 추가
- 테스트 추가: sheets.template.enhance.test.ts
- 전체 테스트 그린 유지
2025-11-16 18:29:54 +09:00
jaybe 7928f9b6c5 feat: ZIP 경로 가드 추가 및 무결성 size 검증 도입 (TDD)
- buildZip에 경로 하드닝 추가: ../, 절대경로(/), 역슬래시(\) 금지
- assets.integrity.index.json에 size(byte) 필드 추가
- verifyIntegrity에서 size 불일치 검증 추가
- 경로/사이즈 검증 테스트 추가: zip.pathGuard.test.ts
- 전체 테스트 그린 유지
2025-11-16 17:30:47 +09:00
12 changed files with 518 additions and 8 deletions
+17 -2
View File
@@ -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`에 옵션 추가 및 `<img>` 렌더 반영)
- 브랜치: `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 렌더 추가: `<section class="benefits" aria-labelledby>`, `<h2 id="benefits-heading">`, 리스트 렌더
- 테스트: sections.benefits.test.ts (h2/aria-labelledby/리스트 아이템 검증)
@@ -369,8 +377,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`
- 이미지 최적화 옵션(1차): `fetchpriority`/`srcset`/`sizes` 추가 및 테스트 그린
### 하드닝
- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함)
### TDD 계획 및 상태
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
@@ -384,9 +398,10 @@
### 다음 작업
1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity``main`
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사(요약 리포트 로깅)
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅)
4) README에 사용 가이드 보완(verifyIntegrity 활용 예시)
5) 이미지 최적화 2차: `sizes` 기본 정책 프리셋 및 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁)
### CI/CD 계획(초안)
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
+24 -1
View File
@@ -63,6 +63,13 @@ The Builder can export a production-ready ZIP that contains:
- Image Options
- Loading: `lazy` (default) or `eager` for `<img loading>`
- Decoding: `async` (default) or `sync` for `<img decoding>`
- Fetch Priority: optional `high|low` for `<img fetchpriority>`
- Srcset/Sizes: optional responsive sources via `<img srcset>` and `<img sizes>`
- Auto Sizes: when enabled and only `srcset` is provided, a sensible default is applied to `<img sizes>`: `(max-width: 640px) 100vw, 600px`
- Sizes Preset: choose a preset for `<img sizes>` 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
@@ -97,15 +104,31 @@ 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-<base64>` (real SHA-256 of the asset bytes)
- `size`: byte length of the asset
- bundleHash: `sha256-<base64>` 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.
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:
+2 -1
View File
@@ -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))
+15 -3
View File
@@ -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)
}
+17 -1
View File
@@ -9,6 +9,11 @@ export type ExportOptions = {
robotsMeta?: string
imageLoading?: 'lazy' | 'eager'
imageDecoding?: 'async' | 'sync'
imageFetchPriority?: 'high' | 'low'
imageSrcset?: string
imageSizes?: string
imageAutoSizes?: boolean
imageSizesPreset?: 'mobile-first' | 'desktop-fixed'
}
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
@@ -125,6 +130,17 @@ 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 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 `
<section class="hero" aria-labelledby="hero-heading">
<div class="container">
@@ -133,7 +149,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
</div>
<div class="media">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}" width="1200" height="675" />
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}"${fetchpriority}${srcset}${sizes} width="1200" height="675" />
</div>
</div>
</section>
@@ -0,0 +1,87 @@
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(/<img[^>]*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(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
expect(html).toMatch(/<img[^>]*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(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
expect(html).toMatch(/<img[^>]*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(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
expect(html).not.toMatch(/<img[^>]*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(/<img[^>]*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(/<img[^>]*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(/<img[^>]*sizes="\(max-width: 768px\) 100vw, 700px"/)
})
})
@@ -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)
})
})
+81
View File
@@ -0,0 +1,81 @@
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<n;i++) a[i]=i&0xff; return a }
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
function te(s: string){ return new TextEncoder().encode(s) }
describe('verifyIntegrity', () => {
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<string, Uint8Array> = { 'assets.integrity.index.json': idx }
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)
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)
expect(res.summary.integrityMismatches).toBeGreaterThanOrEqual(1)
})
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)
expect(res1.summary.bundleHashMismatch).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)
expect(res2.summary.missing).toBeGreaterThanOrEqual(1)
// keep lints happy
expect(removed).toBeTruthy()
})
})
+146
View File
@@ -0,0 +1,146 @@
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 {
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 verifyIntegrity(
extras: Record<string, Uint8Array>,
assets: Record<string, Uint8Array>
): 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'], 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'], 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)
}
// recompute bundle hash
const joined = new TextEncoder().encode(recomputedLines.join('\n'))
const bundle = `sha256-${sha256Base64(joined)}`
const bundleHashMismatch = bundle !== parsed.bundleHash
if (bundleHashMismatch) {
errors.push('BundleHash mismatch')
}
return {
ok: errors.length === 0,
errors,
summary: {
total: parsed.entries.length,
missing,
integrityMismatches,
sizeMismatches,
bundleHashMismatch,
},
}
}
+29
View File
@@ -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)
})
})
+12
View File
@@ -14,8 +14,19 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
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<Uint8Array> {
}
}
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)
}
+42
View File
@@ -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 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족
## 커밋/푸쉬 원칙
- 기능 단위 커밋은 한국어 메시지
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)