Files
makelore/electron/opencode/works-square-deploy-check.ts
2026-07-29 17:22:35 +08:00

733 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>([
...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<string, unknown>;
type ArchiveEntry = ReturnType<AdmZip['getEntries']>[number];
type ArchiveIndex = Map<string, ArchiveEntry>;
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<string, WorksDeployCheckItem> = {};
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, string>): string[] {
return [...textEntries.entries()]
.filter(([name]) => !/\.(?:md|lock)$/i.test(name))
.map(([, text]) => text);
}
function isCanvasProject(publish: WorksPublishData, index: ArchiveIndex, textEntries: Map<string, string>): 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 || /<canvas\b|canvas\.getContext|HTMLCanvasElement|Phaser(?:\.|\s)|WebGLRenderingContext|game-container/i.test(sourceText);
}
function inspectCanvasLayout(textEntries: Map<string, string>): { 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<Buffer> {
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<string> {
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 未执行但均明确标记 unavailableZIP 可交由 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<WorksDeployCheckResult> {
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<VerificationResult> {
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<string, string>();
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';
}