import { createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; import { access, readFile, stat } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; import type { WorksPublishData } from './works-publish-file'; import { GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID, CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, WORKS_DEPLOY_CHECK_FILE_NAME, WORKS_DEPLOY_CHECK_SCHEMA_VERSION, WORKS_DEPLOY_REPORT_FILE_NAME, type WorksCanvasEvidence, type WorksCanvasMeasurement, type WorksDeployCheckItem, type WorksDeployCheckReport, type WorksDeployCheckResult, type WorksDeployCheckExecution, type WorksDeployCheckStatus, } from '../../shared/works-square-deploy-check'; const AdmZip = createRequire(import.meta.url)('adm-zip') as typeof import('adm-zip'); const execFileAsync = promisify(execFile); const MAX_ZIP_BYTES = 50 * 1024 * 1024; const MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; const MAX_ZIP_ENTRIES = 2_000; const MAX_SCANNED_TEXT_BYTES = 32 * 1024 * 1024; const FORBIDDEN_RUNTIME_ACTIONS = /\b(?:npm\s+(?:install|ci|build|run\s+build)|pnpm\s+(?:install|i|build|run\s+build)|yarn\s+(?:install|build|run\s+build)|bun\s+(?:install|build|run\s+build)|pip\s+install|uv\s+sync|flutter\s+build)\b/i; const STORAGE_ACCESS = /\b(?:localStorage|sessionStorage|document\.cookie|indexedDB|navigator\.serviceWorker|serviceWorker\.register)\b/i; const PRIVATE_KEY_CONTENT = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i; const HIGH_CONFIDENCE_SECRET = /\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|sk-[A-Za-z0-9]{20,})\b/; const ENVIRONMENT_CAPABILITY_UNAVAILABLE = /(?:docker\s+(?:is\s+)?not\s+installed|docker\s+unavailable|no\s+(?:browser\s+automation|playwright|puppeteer)\s+available|(?:playwright|puppeteer|browser\s+automation)\s+unavailable)/i; const DYNAMIC_CHECK_IDS = new Set([ ...REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, ...CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, ]); const EXCLUDED_ENTRY = /^(?:\.git(?:\/|$)|node_modules(?:\/|$)|dist(?:\/|$)|build(?:\/|$)|\.next(?:\/|$)|\.nuxt(?:\/|$)|\.vite(?:\/|$)|\.dart_tool(?:\/|$)|\.pub-cache(?:\/|$)|android\/\.gradle(?:\/|$)|ios\/Pods(?:\/|$)|\.venv(?:\/|$)|venv(?:\/|$)|.*\/__pycache__(?:\/|$)|__pycache__(?:\/|$)|.*\.pyc$|.*\.DS_Store$)/i; const SENSITIVE_ENTRY = /(?:^|\/)(?:\.env(?!\.example$)[^/]*|id_rsa(?:\.[^/]*)?|id_ed25519(?:\.[^/]*)?|credentials?\.(?:json|ya?ml)|service[-_]?account\.(?:json|ya?ml)|.*\.(?:pem|key|p12|pfx|jks))$/i; const TEXT_ENTRY = /\.(?:c|cc|css|csv|html?|js|jsx|json|md|mjs|mts|py|scss|sh|sql|toml|ts|tsx|vue|yml|yaml|lock|conf|ini|txt|xml)$/i; type UnknownRecord = Record; type ArchiveEntry = ReturnType[number]; type ArchiveIndex = Map; export type WorksDeployCheckMode = 'local' | 'cloud'; function asRecord(value: unknown): UnknownRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value as UnknownRecord; } function readString(record: UnknownRecord, key: string): string { return typeof record[key] === 'string' ? record[key].trim() : ''; } function readRequiredString(record: UnknownRecord, key: string): string { const value = readString(record, key); if (!value) throw new Error(`Missing ${key}`); return value; } function isStatus(value: unknown): value is WorksDeployCheckStatus { return value === 'PASS' || value === 'SKIPPED' || value === 'BLOCKED'; } function normalizeCheckStatus(id: string, status: WorksDeployCheckStatus, detail: string): WorksDeployCheckStatus { if (status !== 'BLOCKED' || !DYNAMIC_CHECK_IDS.has(id)) return status; return ENVIRONMENT_CAPABILITY_UNAVAILABLE.test(detail) ? 'SKIPPED' : status; } function normalizeCanvasMeasurement(value: unknown): WorksCanvasMeasurement | null { const record = asRecord(value); const width = record?.width; const height = record?.height; if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) return null; if (typeof height !== 'number' || !Number.isFinite(height) || height <= 0) return null; return { width, height }; } function normalizeCanvasEvidence(value: unknown): WorksCanvasEvidence | null { const record = asRecord(value); const logicalResolution = normalizeCanvasMeasurement(record?.logical_resolution); const desktopViewport = normalizeCanvasMeasurement(record?.desktop_viewport); const mobileViewport = normalizeCanvasMeasurement(record?.mobile_viewport); const desktopCanvas = normalizeCanvasMeasurement(record?.desktop_canvas); const mobileCanvas = normalizeCanvasMeasurement(record?.mobile_canvas); const expectedMaxCanvas = asRecord(record?.expected_max_canvas); const expectedDesktop = normalizeCanvasMeasurement(expectedMaxCanvas?.desktop); const expectedMobile = normalizeCanvasMeasurement(expectedMaxCanvas?.mobile); if (!logicalResolution || !desktopViewport || !mobileViewport || !desktopCanvas || !mobileCanvas) return null; if (!expectedDesktop || !expectedMobile || typeof record?.resize_verified !== 'boolean') return null; return { logical_resolution: logicalResolution, desktop_viewport: desktopViewport, mobile_viewport: mobileViewport, desktop_canvas: desktopCanvas, mobile_canvas: mobileCanvas, expected_max_canvas: { desktop: expectedDesktop, mobile: expectedMobile, }, resize_verified: record.resize_verified, }; } function normalizeReport(value: unknown): WorksDeployCheckReport { const record = asRecord(value); if (!record) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} must contain a JSON object`); if (record.schema_version !== WORKS_DEPLOY_CHECK_SCHEMA_VERSION) { throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has unsupported schema_version`); } const status = record.status; if (!isStatus(status)) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid status`); const checkedAt = readRequiredString(record, 'checked_at'); const zipFilePath = readRequiredString(record, 'zip_file_path'); const zipSha256 = readRequiredString(record, 'zip_sha256').toLowerCase(); if (!/^[a-f0-9]{64}$/.test(zipSha256)) throw new Error('zip_sha256 must be a SHA-256 hex string'); const checksRecord = asRecord(record.checks); if (!checksRecord) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing checks`); const checks: Record = {}; for (const [id, rawCheck] of Object.entries(checksRecord)) { const check = asRecord(rawCheck); if (!check || !isStatus(check.status) || !readString(check, 'detail')) { throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid check: ${id}`); } const execution = check.execution; if (execution !== undefined && execution !== 'executed' && execution !== 'unavailable') { throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid execution for check: ${id}`); } const canvasEvidence = id === GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID && check.canvas_evidence !== undefined ? normalizeCanvasEvidence(check.canvas_evidence) : undefined; if (id === GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID && check.canvas_evidence !== undefined && !canvasEvidence) { throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid canvas_evidence`); } const detail = readString(check, 'detail'); checks[id] = { status: normalizeCheckStatus(id, check.status, detail), detail, ...(readString(check, 'command') ? { command: readString(check, 'command') } : {}), ...(readString(check, 'checked_at') ? { checked_at: readString(check, 'checked_at') } : {}), ...(execution ? { execution: execution as WorksDeployCheckExecution } : {}), ...(canvasEvidence ? { canvas_evidence: canvasEvidence } : {}), }; } for (const id of REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS) { if (!checks[id]) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing check: ${id}`); if (!checks[id]?.command) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing command for check: ${id}`); } for (const id of CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS) { if (checks[id] && !checks[id]?.command) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing command for check: ${id}`); } const normalizedStatus = Object.values(checks).some((check) => check.status === 'BLOCKED') ? 'BLOCKED' : Object.values(checks).some((check) => check.status === 'SKIPPED') ? 'SKIPPED' : status; return { schema_version: WORKS_DEPLOY_CHECK_SCHEMA_VERSION, status: normalizedStatus, checked_at: checkedAt, zip_file_path: zipFilePath, zip_sha256: zipSha256, checks, }; } function normalizeArchivePath(value: string): string | null { const replaced = value.replaceAll('\\', '/'); const withoutTrailingSlash = replaced.endsWith('/') ? replaced.slice(0, -1) : replaced; if (!withoutTrailingSlash || withoutTrailingSlash.startsWith('/') || /^[A-Za-z]:\//.test(withoutTrailingSlash)) return null; const parts = withoutTrailingSlash.split('/'); if (parts.some((part) => !part || part === '.' || part === '..' || part.includes('\0'))) return null; return parts.join('/'); } function isSymlink(entry: ArchiveEntry): boolean { const unixMode = (entry.attr >>> 16) & 0xffff; return (unixMode & 0xf000) === 0xa000; } function isTextEntry(name: string): boolean { return TEXT_ENTRY.test(name) || name.endsWith('Dockerfile') || name.endsWith('nginx.conf'); } function safePath(value: string): string | null { const normalized = value.replaceAll('\\', '/').trim(); if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return null; const parts = normalized.split('/'); if (parts.some((part) => !part || part === '..')) return null; return parts.filter((part) => part !== '.').join('/'); } function joinArchivePath(directory: string, file: string): string | null { const safeDirectory = safePath(directory) ?? ''; const safeFile = safePath(file); if (safeFile === null) return null; return [safeDirectory, safeFile].filter(Boolean).join('/'); } function getBuildSpec(service: UnknownRecord): { context: string; dockerfile: string } | null { const build = service.build; if (typeof build === 'string') return { context: build, dockerfile: 'Dockerfile' }; const buildRecord = asRecord(build); if (!buildRecord) return null; return { context: readString(buildRecord, 'context') || '.', dockerfile: readString(buildRecord, 'dockerfile') || 'Dockerfile', }; } function valuesAsList(value: unknown): unknown[] { return Array.isArray(value) ? value : value === undefined ? [] : [value]; } function stringifyCommand(value: unknown): string { if (typeof value === 'string') return value; if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').join(' '); return ''; } function isWindowsAbsolute(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value); } function resolveZipPath(projectPath: string, value: string): string { return path.isAbsolute(value) || isWindowsAbsolute(value) ? value : path.resolve(projectPath, value); } function samePath(left: string, right: string): boolean { return path.normalize(left).toLowerCase() === path.normalize(right).toLowerCase(); } function extractPort(value: unknown): { hostIp: string; hostPort: string; containerPort: string } | null { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const record = value as UnknownRecord; return { hostIp: readString(record, 'host_ip'), hostPort: record.published === undefined ? '' : String(record.published), containerPort: record.target === undefined ? '' : String(record.target), }; } if (typeof value !== 'string') return null; const parts = value.trim().split(':'); if (parts.length === 3) return { hostIp: parts[0] ?? '', hostPort: parts[1] ?? '', containerPort: parts[2] ?? '' }; if (parts.length === 2) return { hostIp: '', hostPort: parts[0] ?? '', containerPort: parts[1] ?? '' }; return { hostIp: '', hostPort: '', containerPort: parts[0] ?? '' }; } function volumeSource(value: unknown): { source: string; type: string } | null { if (typeof value === 'string') { const parts = value.split(':'); if (parts.length < 2) return { source: '', type: 'anonymous' }; return { source: parts.slice(0, -1).join(':'), type: 'string' }; } const record = asRecord(value); if (!record) return null; return { source: readString(record, 'source'), type: readString(record, 'type') || 'volume' }; } function hasSafeStorageAdapter(text: string): boolean { if (/safeStorage|safe-storage|safe_storage/i.test(text)) return true; return /try\s*\{[\s\S]{0,4000}(?:localStorage|sessionStorage|document\.cookie|indexedDB|serviceWorker)[\s\S]{0,4000}\}\s*catch\s*(?:\([^)]*\))?\s*\{/i.test(text); } function hasUnsafeComposeEnvironment(value: unknown): boolean { const text = JSON.stringify(value ?? ''); for (const match of text.matchAll(/\$\{([^}]+)\}/g)) { const expression = match[1]?.trim() ?? ''; const defaultMatch = expression.match(/^[A-Za-z_][A-Za-z0-9_]*:-(.+)$/); if (!defaultMatch || !defaultMatch[1]?.trim() || HIGH_CONFIDENCE_SECRET.test(defaultMatch[1])) return true; } return /\$(?!\{)[A-Za-z_][A-Za-z0-9_]*/.test(text); } function hasSafeEnvFile(value: unknown, index: ArchiveIndex): boolean { const files = valuesAsList(value); return files.length > 0 && files.every((item) => { if (typeof item !== 'string') return false; const safeName = safePath(item); return safeName !== null && Boolean(index.get(safeName)) && !SENSITIVE_ENTRY.test(safeName); }); } function sourceTextEntries(textEntries: Map): string[] { return [...textEntries.entries()] .filter(([name]) => !/\.(?:md|lock)$/i.test(name)) .map(([, text]) => text); } function isCanvasProject(publish: WorksPublishData, index: ArchiveIndex, textEntries: Map): boolean { if (/(?:game|canvas|游戏|画布)/i.test(publish.category)) return true; const names = [...index.keys()].filter((name) => /(?:game|canvas|phaser|webgl)[^/]*\.(?:css|html?|js|jsx|ts|tsx|vue)$/i.test(name)); const sourceText = sourceTextEntries(textEntries).join('\n'); return names.length > 0 || /): { pass: boolean; detail: string } { const sourceText = sourceTextEntries(textEntries).join('\n'); const hasRootSelector = /(?:^|[,{\s])(?:html|body|#(?:app|root|game-container))\b/i.test(sourceText); const hasFullWidth = /\bwidth\s*:\s*100%/i.test(sourceText); const hasFullHeight = /\bheight\s*:\s*100%/i.test(sourceText); const hasResponsiveScale = /Phaser\.Scale\.(?:FIT|RESIZE)|(?:mode|scaleMode)\s*[:=]\s*(?:Phaser\.Scale\.)?(?:FIT|RESIZE)|ResizeObserver|(?:addEventListener|on)\s*\(\s*['"]resize|(?:window\.)?resize\s*\(/i.test(sourceText); const hasFixedCanvas = /(?:canvas|#(?:game-container|app|root))[^{}]*\{[^}]*\b(?:width|height)\s*:\s*\d+(?:\.\d+)?px[^}]*\}/is.test(sourceText); const missing: string[] = []; if (!hasRootSelector || !hasFullWidth || !hasFullHeight) missing.push('根节点未声明 width/height: 100%'); if (!hasResponsiveScale) missing.push('未发现 FIT/RESIZE 或 resize/ResizeObserver 自适应逻辑'); if (hasFixedCanvas) missing.push('发现固定像素 canvas 或游戏容器尺寸'); return missing.length === 0 ? { pass: true, detail: '游戏/画布项目具备根节点全屏和响应式画布布局线索' } : { pass: false, detail: `游戏/画布项目响应式布局不完整:${missing.join('、')}` }; } function expectedCanvasSize(logical: WorksCanvasMeasurement, viewport: WorksCanvasMeasurement): WorksCanvasMeasurement { const scale = Math.min(viewport.width / logical.width, viewport.height / logical.height); return { width: logical.width * scale, height: logical.height * scale, }; } function measurementMatchesExpected(actual: WorksCanvasMeasurement, expected: WorksCanvasMeasurement): boolean { return actual.width >= expected.width * 0.95 && actual.height >= expected.height * 0.95 && actual.width <= expected.width * 1.05 && actual.height <= expected.height * 1.05; } function validateCanvasEvidence(evidence: WorksCanvasEvidence): string | null { if (!evidence.resize_verified) return '游戏/画布项目未提供 viewport/全屏变化后的重新布局证据'; const expectedDesktop = expectedCanvasSize(evidence.logical_resolution, evidence.desktop_viewport); const expectedMobile = expectedCanvasSize(evidence.logical_resolution, evidence.mobile_viewport); if (!measurementMatchesExpected(evidence.expected_max_canvas.desktop, expectedDesktop)) return '桌面视口的最大等比尺寸记录不一致'; if (!measurementMatchesExpected(evidence.expected_max_canvas.mobile, expectedMobile)) return '移动视口的最大等比尺寸记录不一致'; if (!measurementMatchesExpected(evidence.desktop_canvas, expectedDesktop)) return '桌面主画布比最大等比尺寸小超过 5% 或溢出'; if (!measurementMatchesExpected(evidence.mobile_canvas, expectedMobile)) return '移动主画布比最大等比尺寸小超过 5% 或溢出'; return null; } function checkCanvasReportEvidence(report: WorksDeployCheckReport, mode: WorksDeployCheckMode): WorksDeployCheckItem { const check = report.checks[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID]; if (!check) return { status: 'BLOCKED', detail: '游戏/画布项目缺少 game_canvas_smoke 检查' }; const unavailable = check.execution === 'unavailable' && (check.status === 'BLOCKED' || check.status === 'SKIPPED'); if (mode === 'cloud' && unavailable) { return { status: 'PASS', detail: '本机 game_canvas_smoke 环境不可用,交由 Works Square 云端构建/运行链路继续验证' }; } if (check.status === 'SKIPPED') return { status: 'SKIPPED', detail: check.detail }; if (check.status === 'BLOCKED') return { status: 'BLOCKED', detail: check.detail }; if (!check.command) return { status: 'BLOCKED', detail: 'game_canvas_smoke 缺少实际执行命令' }; if (!check.canvas_evidence) return { status: 'BLOCKED', detail: 'game_canvas_smoke 缺少桌面/移动视口和画布尺寸证据' }; const evidenceError = validateCanvasEvidence(check.canvas_evidence); return evidenceError ? { status: 'BLOCKED', detail: evidenceError } : { status: 'PASS', detail: 'game_canvas_smoke 包含桌面、移动、最大等比尺寸和 resize 复测证据' }; } function checkCanvasHumanReportEvidence( reportText: string, report: WorksDeployCheckReport, mode: WorksDeployCheckMode, ): WorksDeployCheckItem { const canvasCheck = report.checks[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID]; if (mode === 'cloud' && canvasCheck?.execution === 'unavailable' && (canvasCheck.status === 'BLOCKED' || canvasCheck.status === 'SKIPPED')) { return { status: 'PASS', detail: '本机画布报告字段因浏览器环境不可用暂缺,云端构建链路继续承担运行验收' }; } const requiredFields: Array<{ label: string; pattern: RegExp }> = [ { label: '逻辑分辨率', pattern: /逻辑分辨率|logical_resolution/i }, { label: '桌面视口', pattern: /桌面(?:视口|viewport)|desktop_viewport/i }, { label: '移动视口', pattern: /移动(?:视口|viewport)|mobile_viewport/i }, { label: '主画布实际尺寸', pattern: /(?:主画布|canvas).*(?:实际尺寸|actual|desktop_canvas|mobile_canvas)/is }, { label: '最大等比预期尺寸', pattern: /最大等比|expected_max_canvas/i }, { label: 'resize 复测', pattern: /resize|全屏.*(?:变化|复测)|重新布局/i }, ]; const missing = requiredFields.filter(({ pattern }) => !pattern.test(reportText)).map(({ label }) => label); return missing.length > 0 ? { status: 'BLOCKED', detail: `部署报告缺少游戏/画布字段:${missing.join('、')}` } : { status: 'PASS', detail: '部署报告包含游戏/画布逻辑分辨率、视口、画布尺寸、最大等比预期和 resize 证据' }; } async function readArchiveData(archivePath: string, entry: ArchiveEntry): Promise { const data = entry.getData(); if (data.byteLength > 0 || entry.header.size === 0) return data; try { const result = await execFileAsync('unzip', ['-p', archivePath, entry.entryName], { maxBuffer: MAX_SCANNED_TEXT_BYTES }); return Buffer.from(result.stdout); } catch (error) { throw new Error( `Unable to read archive entry ${entry.entryName}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } } async function readArchiveText(archivePath: string, entry: ArchiveEntry): Promise { return (await readArchiveData(archivePath, entry)).toString('utf8'); } function checkReportEvidence( projectPath: string, report: WorksDeployCheckReport, publish: WorksPublishData, zipSha256: string, mode: WorksDeployCheckMode, ): WorksDeployCheckItem[] { const checks: WorksDeployCheckItem[] = []; const add = (status: WorksDeployCheckStatus, detail: string) => checks.push({ status, detail }); const isEnvironmentUnavailable = (check: WorksDeployCheckItem): boolean => check.execution === 'unavailable' || (check.status === 'SKIPPED' && ENVIRONMENT_CAPABILITY_UNAVAILABLE.test(check.detail)); const isAllowedCloudUnavailable = (check: WorksDeployCheckItem): boolean => mode === 'cloud' && isEnvironmentUnavailable(check) && (check.status === 'BLOCKED' || check.status === 'SKIPPED'); const reportStatus = aggregateVerificationStatus([ { status: report.status, detail: '部署报告总状态' }, ...Object.values(report.checks), ]); const reportChecksPass = Object.values(report.checks).every((check) => check.status === 'PASS'); const reportChecksCloudReady = Object.values(report.checks).every((check) => check.status === 'PASS' || isAllowedCloudUnavailable(check)); const reportReady = mode === 'cloud' ? (report.status === 'PASS' && reportChecksPass || (report.status === 'BLOCKED' || report.status === 'SKIPPED') && reportChecksCloudReady) : reportStatus !== 'BLOCKED'; add(reportReady ? (mode === 'cloud' ? 'PASS' : reportStatus) : 'BLOCKED', reportReady ? (mode === 'cloud' && !reportChecksPass ? '本地动态 smoke 未执行但均明确标记 unavailable,ZIP 可交由 Works Square 云端构建' : reportStatus === 'SKIPPED' ? '部署报告包含因当前环境能力不足而未执行的检查' : '部署报告及其检查项标记为 PASS') : '部署报告或其中一项检查标记为 BLOCKED'); const reportZipPath = resolveZipPath(projectPath, report.zip_file_path); const publishZipPath = resolveZipPath(projectPath, publish.zip_file_path); add(samePath(reportZipPath, publishZipPath) ? 'PASS' : 'BLOCKED', samePath(reportZipPath, publishZipPath) ? '报告和 works-publish.json 指向同一个 zip' : '报告和 works-publish.json 指向不同 zip'); add(report.zip_sha256 === zipSha256 ? 'PASS' : 'BLOCKED', report.zip_sha256 === zipSha256 ? '报告绑定了当前 zip' : '报告中的 zip_sha256 与当前 zip 不一致'); for (const id of REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS) { const check = report.checks[id]; const status = check.status === 'PASS' || isAllowedCloudUnavailable(check) ? 'PASS' : check.status === 'SKIPPED' && mode !== 'cloud' ? 'SKIPPED' : 'BLOCKED'; add(status, check.detail); } return checks; } export async function readWorksDeployCheck( projectPath: string, publish: WorksPublishData | null, options: { mode?: WorksDeployCheckMode } = {}, ): Promise { const mode = options.mode ?? 'local'; const filePath = path.join(projectPath, WORKS_DEPLOY_CHECK_FILE_NAME); try { await access(filePath); } catch { return { status: 'missing', filePath }; } try { const report = normalizeReport(JSON.parse(await readFile(filePath, 'utf8')) as unknown); if (!publish) return { status: 'blocked', filePath, report, error: 'works-publish.json 缺失或无效' }; const verification = await verifyWorksDeployPackage(projectPath, publish, report, { mode }); return { status: verification.status === 'PASS' ? 'pass' : verification.status === 'SKIPPED' ? 'warning' : 'blocked', filePath, report, checks: report.checks, static_checks: Object.fromEntries(verification.checks.map((check, index) => [`static_${index + 1}`, check])), ...(verification.zipSha256 ? { zip_sha256: verification.zipSha256 } : {}), ...(verification.status === 'BLOCKED' ? { error: verification.error } : {}), }; } catch (error) { return { status: 'invalid', filePath, error: error instanceof Error ? error.message : String(error) }; } } type VerificationResult = { status: WorksDeployCheckStatus; checks: WorksDeployCheckItem[]; zipSha256?: string; error?: string; }; export async function verifyWorksDeployPackage( projectPath: string, publish: WorksPublishData, report: WorksDeployCheckReport, options: { mode?: WorksDeployCheckMode } = {}, ): Promise { const mode = options.mode ?? 'local'; const checks: WorksDeployCheckItem[] = []; const add = (status: WorksDeployCheckStatus, detail: string) => checks.push({ status, detail }); const zipPath = resolveZipPath(projectPath, publish.zip_file_path); let archiveStat; try { archiveStat = await stat(zipPath); if (!archiveStat.isFile()) throw new Error('zip_file_path must point to a file'); } catch (error) { add('BLOCKED', `找不到 zip 程序包:${error instanceof Error ? error.message : String(error)}`); return { status: 'BLOCKED', checks, error: '找不到 zip 程序包' }; } if (archiveStat.size > MAX_ZIP_BYTES) add('BLOCKED', `zip 大小超过 50 MB:${archiveStat.size} bytes`); else add('PASS', `zip 大小符合限制:${archiveStat.size} bytes`); let deploymentReportText = ''; try { deploymentReportText = await readFile(path.join(projectPath, WORKS_DEPLOY_REPORT_FILE_NAME), 'utf8'); add('PASS', '项目根目录包含部署报告'); } catch { add('BLOCKED', '项目根目录缺少部署报告'); } const zipBytes = await readFile(zipPath); const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); checks.push(...checkReportEvidence(projectPath, report, publish, zipSha256, mode)); let archive: AdmZip; try { archive = new AdmZip(zipPath); if (!archive.test()) throw new Error('zip integrity test failed'); } catch (error) { add('BLOCKED', `无法读取或解压 zip:${error instanceof Error ? error.message : String(error)}`); return finalizeVerification(checks, zipSha256); } const entries = archive.getEntries(); const index: ArchiveIndex = new Map(); let uncompressedBytes = 0; let invalidPath = false; let duplicatePath = false; let unsafeEntry = false; for (const entry of entries) { const normalized = normalizeArchivePath(entry.entryName); if (!normalized) { invalidPath = true; continue; } if (index.has(normalized)) duplicatePath = true; index.set(normalized, entry); uncompressedBytes += entry.header.size; if (isSymlink(entry)) unsafeEntry = true; if (EXCLUDED_ENTRY.test(normalized)) unsafeEntry = true; if (SENSITIVE_ENTRY.test(normalized)) unsafeEntry = true; } if (entries.length > MAX_ZIP_ENTRIES) add('BLOCKED', `zip 文件数量超过 2000:${entries.length}`); else add('PASS', `zip 文件数量符合限制:${entries.length}`); if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) add('BLOCKED', `zip 解压后超过 200 MB:${uncompressedBytes} bytes`); else add('PASS', `zip 解压后大小符合限制:${uncompressedBytes} bytes`); add(invalidPath ? 'BLOCKED' : 'PASS', invalidPath ? 'zip 包含绝对路径、空路径或路径穿越' : 'zip 路径安全'); add(duplicatePath ? 'BLOCKED' : 'PASS', duplicatePath ? 'zip 包含重复规范化路径' : 'zip 没有重复规范化路径'); add(unsafeEntry ? 'BLOCKED' : 'PASS', unsafeEntry ? 'zip 包含符号链接、缓存目录或敏感文件' : 'zip 没有符号链接、缓存目录或敏感文件'); const manifestEntry = index.get('niancode.yml'); const composeEntry = index.get('docker-compose.yml'); add(manifestEntry && !manifestEntry.isDirectory ? 'PASS' : 'BLOCKED', manifestEntry && !manifestEntry.isDirectory ? 'zip 根目录包含 niancode.yml' : 'zip 根目录缺少 niancode.yml'); add(composeEntry && !composeEntry.isDirectory ? 'PASS' : 'BLOCKED', composeEntry && !composeEntry.isDirectory ? 'zip 根目录包含 docker-compose.yml' : 'zip 根目录缺少 docker-compose.yml'); if (!manifestEntry || !composeEntry || manifestEntry.isDirectory || composeEntry.isDirectory) { return finalizeVerification(checks, zipSha256); } const manifestText = await readArchiveText(zipPath, manifestEntry); const composeText = await readArchiveText(zipPath, composeEntry); let parsedManifest: unknown; let parsedCompose: unknown; try { parsedManifest = parseYaml(manifestText); parsedCompose = parseYaml(composeText); } catch (error) { add('BLOCKED', `niancode.yml 或 docker-compose.yml 不是合法 YAML:${error instanceof Error ? error.message : String(error)}`); return finalizeVerification(checks, zipSha256); } const manifest = asRecord(parsedManifest); const compose = asRecord(parsedCompose); const manifestCompose = asRecord(manifest?.compose); const runtimeOk = manifest?.runtime === 'compose'; const fileOk = readString(manifestCompose ?? {}, 'file') === 'docker-compose.yml'; const publicService = readString(manifestCompose ?? {}, 'public_service'); const publicPort = manifestCompose?.public_port; add(runtimeOk && fileOk && publicPort === 8080 ? 'PASS' : 'BLOCKED', runtimeOk && fileOk && publicPort === 8080 ? 'niancode.yml 使用 runtime: compose、根目录 docker-compose.yml 和数字端口 8080' : 'niancode.yml 不符合 Works Square Compose manifest 要求'); const services = asRecord(compose?.services); const topLevelVolumes = asRecord(compose?.volumes); add(compose?.version === undefined ? 'PASS' : 'BLOCKED', compose?.version === undefined ? 'docker-compose.yml 没有顶层 version' : 'docker-compose.yml 禁止使用顶层 version'); add(services ? 'PASS' : 'BLOCKED', services ? 'Compose services 是对象' : 'Compose 缺少 services 对象'); if (!services) return finalizeVerification(checks, zipSha256); const publicConfig = asRecord(services[publicService]); add(publicConfig ? 'PASS' : 'BLOCKED', publicConfig ? `Compose 存在公开服务 ${publicService}` : `Compose 不存在公开服务 ${publicService}`); if (!publicConfig) return finalizeVerification(checks, zipSha256); add(getBuildSpec(publicConfig) ? 'PASS' : 'BLOCKED', getBuildSpec(publicConfig) ? '公开服务使用 build 构建自包含镜像' : '公开服务缺少 build,不能依赖运行时宿主机文件'); const buildContextPaths: Array<{ service: string; context: string; dockerfile: string }> = []; let hasUnsafeComposeConfig = false; for (const [serviceName, rawService] of Object.entries(services)) { const service = asRecord(rawService); if (!service) { hasUnsafeComposeConfig = true; continue; } if (service.privileged === true || service.network_mode === 'host' || service.pid === 'host' || service.ipc === 'host') hasUnsafeComposeConfig = true; if (service.env_file !== undefined && !hasSafeEnvFile(service.env_file, index)) hasUnsafeComposeConfig = true; if (service.ports !== undefined && !Array.isArray(service.ports)) hasUnsafeComposeConfig = true; if (service.volumes !== undefined && !Array.isArray(service.volumes)) hasUnsafeComposeConfig = true; if (service.expose !== undefined && !Array.isArray(service.expose)) hasUnsafeComposeConfig = true; if (hasUnsafeComposeEnvironment(service.environment)) hasUnsafeComposeConfig = true; const build = getBuildSpec(service); if (build) buildContextPaths.push({ service: serviceName, context: build.context, dockerfile: build.dockerfile }); if (FORBIDDEN_RUNTIME_ACTIONS.test(stringifyCommand(service.command)) || FORBIDDEN_RUNTIME_ACTIONS.test(stringifyCommand(service.entrypoint))) hasUnsafeComposeConfig = true; for (const volume of valuesAsList(service.volumes)) { const parsedVolume = volumeSource(volume); if (!parsedVolume || parsedVolume.type === 'bind' || !parsedVolume.source || parsedVolume.source.startsWith('.') || parsedVolume.source.startsWith('/') || parsedVolume.source.startsWith('~') || isWindowsAbsolute(parsedVolume.source) || parsedVolume.source.includes('..') || !topLevelVolumes?.[parsedVolume.source]) { hasUnsafeComposeConfig = true; } } if (serviceName === publicService) { const ports = valuesAsList(service.ports).map(extractPort); const publicPortOk = ports.length > 0 && ports.every((port) => port?.hostIp === '127.0.0.1' && port.hostPort === '' && port.containerPort === '8080'); add(publicPortOk ? 'PASS' : 'BLOCKED', publicPortOk ? '公开服务使用 127.0.0.1::8080 动态端口' : '公开服务没有使用 127.0.0.1::8080 动态端口'); } else if (service.ports !== undefined) { hasUnsafeComposeConfig = true; } } add(hasUnsafeComposeConfig ? 'BLOCKED' : 'PASS', hasUnsafeComposeConfig ? 'Compose 含有 bind mount、危险宿主配置、未声明环境变量或运行时构建命令' : 'Compose 没有 bind mount、危险宿主配置或运行时构建命令'); let scannedTextBytes = 0; let storageUnsafe = false; let secretUnsafe = false; const textEntries = new Map(); for (const [name, entry] of index) { if (entry.isDirectory || !isTextEntry(name)) continue; if (scannedTextBytes >= MAX_SCANNED_TEXT_BYTES) break; const data = await readArchiveData(zipPath, entry); scannedTextBytes += data.byteLength; const text = data.toString('utf8'); textEntries.set(name, text); if (PRIVATE_KEY_CONTENT.test(text) || HIGH_CONFIDENCE_SECRET.test(text)) secretUnsafe = true; if (STORAGE_ACCESS.test(text) && !hasSafeStorageAdapter(text)) storageUnsafe = true; } add(storageUnsafe ? 'BLOCKED' : 'PASS', storageUnsafe ? '浏览器持久化 API 使用没有可识别的异常降级保护' : 'Web Storage、Cookie、IndexedDB 和 Service Worker 使用具备降级保护或未使用'); add(secretUnsafe ? 'BLOCKED' : 'PASS', secretUnsafe ? '源码或配置包含私钥/高置信度凭据' : '没有发现私钥或高置信度凭据'); let contextUnsafe = false; for (const build of buildContextPaths) { const context = safePath(build.context); const dockerfile = joinArchivePath(build.context, build.dockerfile); if (context === null || dockerfile === null || !index.get(dockerfile) || index.get(dockerfile)?.isDirectory) { contextUnsafe = true; continue; } const prefix = context === '' ? '' : `${context}/`; const contextNames = [...index.keys()].filter((name) => name === context || name.startsWith(prefix)); const hasPackage = contextNames.includes(`${prefix}package.json`) || contextNames.includes('package.json') && context === ''; const hasNodeLock = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'].some((lock) => contextNames.includes(`${prefix}${lock}`) || context === '' && contextNames.includes(lock)); const hasPythonDeclaration = contextNames.some((name) => name === `${prefix}pyproject.toml` || name === `${prefix}requirements.txt` || context === '' && (name === 'pyproject.toml' || name === 'requirements.txt')); const hasPythonLock = contextNames.some((name) => /(?:^|\/)(?:uv\.lock|poetry\.lock|pdm\.lock|requirements\.lock)$/.test(name)); if (hasPackage && !hasNodeLock) contextUnsafe = true; if (hasPythonDeclaration && !hasPythonLock) contextUnsafe = true; const dockerfileText = index.get(dockerfile) ? await readArchiveText(zipPath, index.get(dockerfile) as ArchiveEntry) : ''; const dockerStartText = (dockerfileText.match(/^\s*(?:CMD|ENTRYPOINT).*$/gim) ?? []).join('\n').replace(/[,[\]"'()]/g, ' '); if (!/^\s*(?:CMD|ENTRYPOINT)\b/im.test(dockerfileText) || FORBIDDEN_RUNTIME_ACTIONS.test(dockerStartText)) contextUnsafe = true; if (hasPackage && !/\b(?:npm|pnpm|yarn)\s+(?:ci|install|i)\b/i.test(dockerfileText)) contextUnsafe = true; const packageEntryName = context === '' ? 'package.json' : `${prefix}package.json`; const packageText = hasPackage && index.get(packageEntryName) ? await readArchiveText(zipPath, index.get(packageEntryName) as ArchiveEntry) : ''; const viteConfigName = contextNames.find((name) => /(?:^|\/)vite\.config\.(?:js|ts|mjs|mts|cjs|cts)$/.test(name)); const isViteProject = Boolean(viteConfigName || /\b(?:vite|@vitejs\/plugin-[A-Za-z0-9_-]+)\b/i.test(packageText)); if (isViteProject) { const viteConfig = viteConfigName && index.get(viteConfigName) ? await readArchiveText(zipPath, index.get(viteConfigName) as ArchiveEntry) : ''; if (!hasPackage || !hasNodeLock || !viteConfigName || !/\bbase\s*:\s*["']\.\/["']/.test(viteConfig)) contextUnsafe = true; if (!/\b(?:npm|pnpm|yarn)\s+(?:run\s+build|build)\b/i.test(dockerfileText)) contextUnsafe = true; } if (hasPackage && /(?:^|\/)index\.html$/.test(contextNames.join('\n'))) { const htmlEntry = contextNames.find((name) => /(?:^|\/)index\.html$/.test(name)); const html = htmlEntry && index.get(htmlEntry) ? await readArchiveText(zipPath, index.get(htmlEntry) as ArchiveEntry) : ''; if (/(?:src|href)=["']\/assets\//i.test(html)) contextUnsafe = true; } let runtimeText = `${dockerfileText}\n${packageText}`; for (const name of contextNames) { const entry = index.get(name); if (!entry || !/(?:nginx\.conf|server\.(?:js|ts)|main\.(?:py|js|ts))$/.test(name)) continue; runtimeText += `\n${await readArchiveText(zipPath, entry)}`; } const mentions8080 = /8080/.test(runtimeText); const listensOnAllInterfaces = /0\.0\.0\.0|listen\s+8080\b|--host\s+0\.0\.0\.0/i.test(runtimeText); if (!mentions8080 || !listensOnAllInterfaces) contextUnsafe = true; } add(contextUnsafe ? 'BLOCKED' : 'PASS', contextUnsafe ? 'build context、Dockerfile、锁文件、Vite 相对资源、0.0.0.0:8080 监听或运行约束不完整' : 'build context、Dockerfile、锁文件、Vite 资源路径和 0.0.0.0:8080 运行约束通过'); const canvasProject = isCanvasProject(publish, index, textEntries); if (canvasProject) { const canvasLayout = inspectCanvasLayout(textEntries); add(canvasLayout.pass ? 'PASS' : 'BLOCKED', canvasLayout.detail); checks.push(checkCanvasReportEvidence(report, mode)); checks.push(checkCanvasHumanReportEvidence(deploymentReportText, report, mode)); } return finalizeVerification(checks, zipSha256); } function finalizeVerification(checks: WorksDeployCheckItem[], zipSha256?: string): VerificationResult { const failed = checks.filter((check) => check.status === 'BLOCKED'); return { status: aggregateVerificationStatus(checks), checks, ...(zipSha256 ? { zipSha256 } : {}), ...(failed.length > 0 ? { error: failed.map((check) => check.detail).join(';') } : {}), }; } function aggregateVerificationStatus(checks: WorksDeployCheckItem[]): WorksDeployCheckStatus { if (checks.some((check) => check.status === 'BLOCKED')) return 'BLOCKED'; if (checks.some((check) => check.status === 'SKIPPED')) return 'SKIPPED'; return 'PASS'; }