Makelore 2.0 initial clean snapshot
This commit is contained in:
732
electron/opencode/works-square-deploy-check.ts
Normal file
732
electron/opencode/works-square-deploy-check.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
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 未执行但均明确标记 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<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';
|
||||
}
|
||||
Reference in New Issue
Block a user