Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dce80e56d | |||
| e32d59ca44 | |||
| 86155c9fd3 | |||
| ba2ba588f7 | |||
| 396d0ac7a0 | |||
| 19a39230be | |||
| c519055e90 | |||
| 7928f9b6c5 |
@@ -43,6 +43,33 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Ensure artifacts export folder
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ./artifacts/export
|
||||
|
||||
- name: Verify bundle integrity (optional)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f ./artifacts/export/assets.integrity.index.json ]; then
|
||||
echo "Found integrity index; generating verify inputs"
|
||||
npm run verify:make-json
|
||||
echo "Running verify CLI"
|
||||
npm run verify:run | tee /tmp/verify_output.json
|
||||
echo "--- verify summary (for quick view) ---"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
try {
|
||||
const txt = fs.readFileSync("/tmp/verify_output.json", "utf-8");
|
||||
const obj = JSON.parse(txt);
|
||||
const s = obj.summary || {};
|
||||
console.log(`ok=${obj.ok} total=${s.total||0} missing=${s.missing||0} integMismatch=${s.integrityMismatches||0} sizeMismatch=${s.sizeMismatches||0} bundleHashMismatch=${s.bundleHashMismatch||false}`);
|
||||
} catch (e) { console.log("(verify summary parse skipped)"); }
|
||||
'
|
||||
else
|
||||
echo "No integrity index at ./artifacts/export/assets.integrity.index.json; skipping integrity verification"
|
||||
fi
|
||||
|
||||
- name: Auto-merge PR on green
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
|
||||
+34
-6
@@ -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/리스트 아이템 검증)
|
||||
@@ -353,7 +361,7 @@
|
||||
- [ci] auto pipeline 최종 재검증 최종 ping (2025-11-14 22:26:59)
|
||||
|
||||
|
||||
## Export ZIP 무결성 강화(Option2) 진행 현황
|
||||
### Export ZIP 무결성 강화(Option2) 진행 현황
|
||||
|
||||
### 목표
|
||||
- Export ZIP 무결성 강화: 자산 무결성 지표와 번들 검증 체계 구축
|
||||
@@ -369,8 +377,22 @@
|
||||
- `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` 추가 및 테스트 그린
|
||||
- 이미지 최적화 옵션(2차): `imageAutoSizes`(srcset만 있을 때 기본 sizes 자동 적용)
|
||||
- 이미지 최적화 옵션(3차): `imageSizesPreset`(mobile-first/desktop-fixed) + 우선순위(imageSizes > preset > auto > none)
|
||||
- 브랜치/PR: `feat/image-optimization` 푸쉬 완료
|
||||
- verifyIntegrity CLI 래퍼(TDD) 추가: `lib/exporter/verify.cli.ts` + 테스트
|
||||
- 폴더 기반 직렬화 유틸(TDD) 추가: `lib/exporter/verify.serialize.ts` + 테스트
|
||||
- CI 통합(권장 플로우): `.gitea/workflows/ci.yml`에 `./artifacts/export` 존재 시 검증 스텝 자동 실행
|
||||
- 스크립트: `scripts/make-verify-json.js`, `scripts/verify.js` + `npm run verify:make-json`/`verify:run`
|
||||
- 브랜치/PR: `feat/verify-integrity-cli` 푸쉬 완료
|
||||
|
||||
### 하드닝
|
||||
- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함)
|
||||
|
||||
### TDD 계획 및 상태
|
||||
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
|
||||
@@ -383,10 +405,16 @@
|
||||
- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용
|
||||
|
||||
### 다음 작업
|
||||
1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main`
|
||||
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅)
|
||||
4) README에 사용 가이드 보완(verifyIntegrity 활용 예시)
|
||||
1) PR 생성/머지 순서 관리: `feat/image-optimization`, `feat/verify-integrity-cli` → main
|
||||
2) CI 검증 리포트 형식 보강: 요약 필드(total/missing/...)를 워크플로 로그에 하이라이트
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 유지
|
||||
4) 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁)
|
||||
5) README/개발자 가이드에 `./artifacts/export` 폴더 정책 명시(로컬/CI 공통)
|
||||
|
||||
### 산출물 경로 정책(CI)
|
||||
- 기본 폴더: `./artifacts/export/` (ZIP 해제 또는 직접 생성)
|
||||
- 검증 입력: `npm run verify:make-json` → `./artifacts/extras.json`/`./artifacts/assets.json`
|
||||
- 검증 실행: `npm run verify:run` (종료코드 0/1/2)
|
||||
|
||||
### CI/CD 계획(초안)
|
||||
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +80,26 @@ The Builder can export a production-ready ZIP that contains:
|
||||
### Asset paths and grouping
|
||||
|
||||
- AssetManager writes files to `assets/` with stable hash-based filenames.
|
||||
|
||||
### verifyIntegrity CLI
|
||||
|
||||
- Purpose: Validate exported bundle assets against `assets.integrity.index.json`.
|
||||
- Input format: two JSON files mapping file path → base64 bytes
|
||||
- Extras JSON: must contain `"assets.integrity.index.json"` key with base64 of the index file
|
||||
- Assets JSON: keys are asset paths, values are base64 of file bytes
|
||||
- Usage:
|
||||
|
||||
```bash
|
||||
node scripts/verify.js --extras extras.json --assets assets.json
|
||||
```
|
||||
|
||||
- Output: prints a JSON with fields of `VerifyResult` (`ok`, `errors`, `summary`)
|
||||
- Exit codes:
|
||||
- 0: ok
|
||||
- 1: verification failed or invalid inputs
|
||||
- 2: usage error (missing args)
|
||||
|
||||
- CI: add a step to run the CLI after build/export; fail the job if exit code != 0.
|
||||
- Path strategy can be configured:
|
||||
- `flat` (default): `assets/<hash>.<ext>`
|
||||
- `grouped`: `assets/images/...`, `assets/fonts/...`, `assets/other/...`
|
||||
@@ -97,15 +124,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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { verifyCli } from '@/lib/exporter/verify.cli'
|
||||
|
||||
// helpers to build base64 from string
|
||||
function b64(s: string) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'utf-8').toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
describe('verify.cli', () => {
|
||||
const EMPTY_SHA256_B64 = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
||||
|
||||
it('returns 0 and logs summary when ok', async () => {
|
||||
// empty entries integrity index; bundleHash = sha256("") in base64
|
||||
const extras = {
|
||||
'assets.integrity.index.json': b64(JSON.stringify({ entries: [], bundleHash: 'sha256-' + EMPTY_SHA256_B64 }))
|
||||
}
|
||||
const assets = {}
|
||||
const extrasJson = JSON.stringify(extras)
|
||||
const assetsJson = JSON.stringify(assets)
|
||||
|
||||
const paths: Record<string, string> = {
|
||||
'/tmp/extras.ok.json': extrasJson,
|
||||
'/tmp/assets.ok.json': assetsJson,
|
||||
}
|
||||
const logs: string[] = []
|
||||
const readText = async (p: string) => {
|
||||
const v = paths[p]
|
||||
if (v == null) throw new Error('no such file: ' + p)
|
||||
return v
|
||||
}
|
||||
const code = await verifyCli(['--extras', '/tmp/extras.ok.json', '--assets', '/tmp/assets.ok.json'], readText, (s: string) => logs.push(s))
|
||||
expect(code).toBe(0)
|
||||
expect(logs.join('\n')).toMatch(/"ok": true/)
|
||||
expect(logs.join('\n')).toMatch(/"total": 0/)
|
||||
})
|
||||
|
||||
it('returns 1 and logs errors when invalid JSON', async () => {
|
||||
const paths: Record<string, string> = {
|
||||
'/tmp/extras.bad.json': JSON.stringify({ 'assets.integrity.index.json': 'not-base64' }),
|
||||
'/tmp/assets.empty.json': JSON.stringify({}),
|
||||
}
|
||||
const logs: string[] = []
|
||||
const readText = async (p: string) => {
|
||||
const v = paths[p]
|
||||
if (v == null) throw new Error('no such file: ' + p)
|
||||
return v
|
||||
}
|
||||
const code = await verifyCli(['--extras', '/tmp/extras.bad.json', '--assets', '/tmp/assets.empty.json'], readText, (s: string) => logs.push(s))
|
||||
expect(code).toBe(1)
|
||||
expect(logs.join('\n')).toMatch(/"ok": false/)
|
||||
expect(logs.join('\n')).toMatch(/Invalid assets\.integrity\.index\.json JSON|Missing assets\.integrity\.index\.json/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
export type ReadText = (path: string) => Promise<string>
|
||||
export type Logger = (line: string) => void
|
||||
|
||||
// base64 -> Uint8Array
|
||||
function fromBase64(b64: string): Uint8Array {
|
||||
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'))
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
// parse args like: [--extras, path, --assets, path]
|
||||
function parseArgs(argv: string[]): { extras?: string; assets?: string } {
|
||||
const out: { extras?: string; assets?: string } = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--extras') out.extras = argv[++i]
|
||||
else if (a === '--assets') out.assets = argv[++i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function verifyCli(argv: string[], readText: ReadText, log: Logger): Promise<number> {
|
||||
try {
|
||||
const { extras: extrasPath, assets: assetsPath } = parseArgs(argv)
|
||||
if (!extrasPath || !assetsPath) {
|
||||
log('Usage: verify --extras <extras.json> --assets <assets.json>')
|
||||
return 2
|
||||
}
|
||||
const extrasJson = await readText(extrasPath)
|
||||
const assetsJson = await readText(assetsPath)
|
||||
let extrasMap: Record<string, string>
|
||||
let assetsMap: Record<string, string>
|
||||
try {
|
||||
extrasMap = JSON.parse(extrasJson)
|
||||
assetsMap = JSON.parse(assetsJson)
|
||||
} catch (e) {
|
||||
log('Invalid JSON input files')
|
||||
return 1
|
||||
}
|
||||
// decode base64 into Uint8Array maps
|
||||
const extrasBytes: Record<string, Uint8Array> = {}
|
||||
for (const [k, v] of Object.entries(extrasMap)) {
|
||||
try {
|
||||
extrasBytes[k] = fromBase64(v)
|
||||
} catch {
|
||||
// mark invalid; let verifyIntegrity handle missing/invalid as possible
|
||||
log(`Failed to decode base64 for extras entry: ${k}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const assetsBytes: Record<string, Uint8Array> = {}
|
||||
for (const [k, v] of Object.entries(assetsMap)) {
|
||||
try {
|
||||
assetsBytes[k] = fromBase64(v)
|
||||
} catch {
|
||||
log(`Failed to decode base64 for asset entry: ${k}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const result = verifyIntegrity(extrasBytes, assetsBytes)
|
||||
log(JSON.stringify(result, null, 2))
|
||||
return result.ok ? 0 : 1
|
||||
} catch (e) {
|
||||
log(String(e instanceof Error ? e.message : e))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateVerifyInputs } from '@/lib/exporter/verify.serialize'
|
||||
|
||||
// helper to b64
|
||||
function toB64(u8: Uint8Array) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
describe('verify.serialize (from folder maps)', () => {
|
||||
it('collects assets.integrity.index.json into extras and assets/* into assets map', async () => {
|
||||
// virtual FS
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'/artifacts/export/assets.integrity.index.json': new TextEncoder().encode(JSON.stringify({ entries: [], bundleHash: 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' })),
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/artifacts/export/assets/img/a.png': new Uint8Array([1,2,3]),
|
||||
'/artifacts/export/assets/fonts/f.woff2': new Uint8Array([4,5,6,7]),
|
||||
}
|
||||
const listDir = async (dir: string): Promise<string[]> => {
|
||||
// return child paths (recursive listing simulated by filtering)
|
||||
return Object.keys(files).filter((p) => p.startsWith(dir)).map((p) => p)
|
||||
}
|
||||
const readFile = async (p: string) => {
|
||||
const v = files[p]
|
||||
if (!v) throw new Error('not found: ' + p)
|
||||
return v
|
||||
}
|
||||
const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile)
|
||||
// extras must have only the integrity index
|
||||
expect(Object.keys(extras)).toEqual(['assets.integrity.index.json'])
|
||||
expect(extras['assets.integrity.index.json']).toBe(toB64(files['/artifacts/export/assets.integrity.index.json']))
|
||||
// assets must include assets/* files only
|
||||
expect(Object.keys(assets).sort()).toEqual(['assets/fonts/f.woff2','assets/img/a.png'])
|
||||
expect(assets['assets/img/a.png']).toBe(toB64(files['/artifacts/export/assets/img/a.png']))
|
||||
})
|
||||
|
||||
it('ignores non-asset files and missing integrity index', async () => {
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/artifacts/export/assets/img/a.png': new Uint8Array([9,9]),
|
||||
}
|
||||
const listDir = async (dir: string) => Object.keys(files).filter((p) => p.startsWith(dir))
|
||||
const readFile = async (p: string) => files[p]!
|
||||
const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile)
|
||||
expect(Object.keys(extras)).toEqual([])
|
||||
expect(Object.keys(assets)).toEqual(['assets/img/a.png'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export type ListDir = (dir: string) => Promise<string[]>
|
||||
export type ReadFile = (path: string) => Promise<Uint8Array>
|
||||
|
||||
function toBase64(u8: Uint8Array): string {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
// Generate inputs for verify CLI from a folder tree.
|
||||
// - root: export folder (contains assets.integrity.index.json and assets/*)
|
||||
// - listDir: returns list of file paths under root (can be recursive listing)
|
||||
// - readFile: reads a file into bytes
|
||||
export async function generateVerifyInputs(
|
||||
root: string,
|
||||
listDir: ListDir,
|
||||
readFile: ReadFile
|
||||
): Promise<{ extras: Record<string, string>; assets: Record<string, string> }> {
|
||||
const all = await listDir(root)
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
const rootNorm = norm(root).replace(/\/$/, '')
|
||||
const rel = (p: string) => {
|
||||
const pn = norm(p)
|
||||
return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn
|
||||
}
|
||||
|
||||
const extras: Record<string, string> = {}
|
||||
const assets: Record<string, string> = {}
|
||||
|
||||
// assets.integrity.index.json at root
|
||||
const integrityPath = all.find((p) => rel(p) === 'assets.integrity.index.json')
|
||||
if (integrityPath) {
|
||||
const bytes = await readFile(integrityPath)
|
||||
extras['assets.integrity.index.json'] = toBase64(bytes)
|
||||
}
|
||||
|
||||
// collect assets/**/*
|
||||
for (const abs of all) {
|
||||
const r = rel(abs)
|
||||
if (!r.startsWith('assets/')) continue
|
||||
// skip directories: best-effort by checking trailing slash
|
||||
if (r.endsWith('/')) continue
|
||||
const bytes = await readFile(abs)
|
||||
assets[r] = toBase64(bytes)
|
||||
}
|
||||
|
||||
return { extras, assets }
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest -w",
|
||||
"coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"verify:make-json": "node scripts/make-verify-json.js --root ./artifacts/export --extras ./artifacts/extras.json --assets ./artifacts/assets.json",
|
||||
"verify:run": "node scripts/verify.js --extras ./artifacts/extras.json --assets ./artifacts/assets.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Generate verify inputs from an export folder
|
||||
Usage: node scripts/make-verify-json.js --root <export_folder> --extras <out_extras.json> --assets <out_assets.json>
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { root: '', extras: '', assets: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--root') out.root = argv[++i];
|
||||
else if (a === '--extras') out.extras = argv[++i];
|
||||
else if (a === '--assets') out.assets = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toB64(buf) { return Buffer.from(buf).toString('base64'); }
|
||||
|
||||
async function readFileB64(p) { const b = await fsp.readFile(p); return toB64(b); }
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
const abs = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
const sub = await walk(abs);
|
||||
out.push(...sub);
|
||||
} else if (ent.isFile()) {
|
||||
out.push(abs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { root, extras, assets } = parseArgs(process.argv);
|
||||
if (!root || !extras || !assets) {
|
||||
console.log('Usage: node scripts/make-verify-json.js --root <export_folder> --extras <out_extras.json> --assets <out_assets.json>');
|
||||
process.exit(2);
|
||||
}
|
||||
const rootAbs = path.resolve(process.cwd(), root);
|
||||
const files = await walk(rootAbs);
|
||||
const norm = (p) => p.split(path.sep).join('/');
|
||||
const rootNorm = norm(rootAbs).replace(/\/$/, '');
|
||||
const rel = (p) => {
|
||||
const pn = norm(p);
|
||||
return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn;
|
||||
};
|
||||
|
||||
const extrasMap = {};
|
||||
const assetsMap = {};
|
||||
|
||||
// integrity index
|
||||
const idx = files.find((p) => rel(p) === 'assets.integrity.index.json');
|
||||
if (idx) {
|
||||
extrasMap['assets.integrity.index.json'] = await readFileB64(idx);
|
||||
}
|
||||
|
||||
// assets/**/*
|
||||
for (const abs of files) {
|
||||
const r = rel(abs);
|
||||
if (!r.startsWith('assets/')) continue;
|
||||
const b64 = await readFileB64(abs);
|
||||
assetsMap[r] = b64;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(path.resolve(process.cwd(), extras)), { recursive: true });
|
||||
await fsp.mkdir(path.dirname(path.resolve(process.cwd(), assets)), { recursive: true });
|
||||
await fsp.writeFile(extras, JSON.stringify(extrasMap));
|
||||
await fsp.writeFile(assets, JSON.stringify(assetsMap));
|
||||
console.log(`Wrote ${extras} and ${assets}`);
|
||||
} catch (e) {
|
||||
console.error(e && e.stack || String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Verify bundle integrity using extras/assets JSON maps.
|
||||
Usage: node scripts/verify.js --extras <extras.json> --assets <assets.json>
|
||||
*/
|
||||
const fs = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { extras: '', assets: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--extras') out.extras = argv[++i];
|
||||
else if (a === '--assets') out.assets = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromB64(b64) { return Buffer.from(b64, 'base64'); }
|
||||
function sha256b64(buf) { return crypto.createHash('sha256').update(buf).digest('base64'); }
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { extras, assets } = parseArgs(process.argv);
|
||||
if (!extras || !assets) {
|
||||
console.log('Usage: node scripts/verify.js --extras <extras.json> --assets <assets.json>');
|
||||
process.exit(2);
|
||||
}
|
||||
const extrasJson = await fs.readFile(extras, 'utf-8');
|
||||
const assetsJson = await fs.readFile(assets, 'utf-8');
|
||||
const extrasMap = JSON.parse(extrasJson);
|
||||
const assetsMap = JSON.parse(assetsJson);
|
||||
|
||||
const result = { ok: true, errors: [], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } };
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(extrasMap, 'assets.integrity.index.json')) {
|
||||
result.ok = false; result.errors.push('Missing assets.integrity.index.json in extras');
|
||||
console.log(JSON.stringify(result, null, 2)); process.exit(1);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fromB64(extrasMap['assets.integrity.index.json']).toString('utf-8'));
|
||||
} catch {
|
||||
result.ok = false; result.errors.push('Invalid assets.integrity.index.json JSON');
|
||||
console.log(JSON.stringify(result, null, 2)); process.exit(1);
|
||||
}
|
||||
|
||||
const recomputedLines = [];
|
||||
let missing = 0, integrityMismatches = 0, sizeMismatches = 0;
|
||||
for (const e of parsed.entries) {
|
||||
const b64 = assetsMap[e.path];
|
||||
if (!b64) { result.errors.push(`Missing asset for path in integrity index: ${e.path}`); missing++; continue; }
|
||||
const buf = fromB64(b64);
|
||||
if (typeof e.size === 'number' && e.size !== buf.length) {
|
||||
result.errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${buf.length}`); sizeMismatches++; }
|
||||
const integ = 'sha256-' + sha256b64(buf);
|
||||
if (integ !== e.integrity) { result.errors.push(`Integrity mismatch for ${e.path}`); integrityMismatches++; }
|
||||
recomputedLines.push(e.path); recomputedLines.push(integ);
|
||||
}
|
||||
const bundle = 'sha256-' + sha256b64(Buffer.from(recomputedLines.join('\n'), 'utf-8'));
|
||||
const bundleHashMismatch = bundle !== parsed.bundleHash;
|
||||
if (bundleHashMismatch) result.errors.push('BundleHash mismatch');
|
||||
|
||||
result.summary.total = parsed.entries.length;
|
||||
result.summary.missing = missing;
|
||||
result.summary.integrityMismatches = integrityMismatches;
|
||||
result.summary.sizeMismatches = sizeMismatches;
|
||||
result.summary.bundleHashMismatch = bundleHashMismatch;
|
||||
result.ok = result.errors.length === 0;
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
} catch (e) {
|
||||
console.error(e && e.stack || String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -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 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족
|
||||
|
||||
## 커밋/푸쉬 원칙
|
||||
- 기능 단위 커밋은 한국어 메시지
|
||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||
Reference in New Issue
Block a user