203 lines
9.4 KiB
TypeScript
203 lines
9.4 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { createRequire } from 'node:module';
|
|
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { dirname, join, relative, sep } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager';
|
|
import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime';
|
|
import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
|
const FIXED_ZIP_TIME = new Date(1980, 0, 1);
|
|
const FILE_MODE = 0o100644;
|
|
const MAX_OUTPUT_FILES = 2_000;
|
|
const MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
|
|
|
|
export type ArtifactContract = {
|
|
schema_version: 1;
|
|
entry_path: 'index.html';
|
|
source_digest: string;
|
|
built_archive_digest: string;
|
|
artifact_digest: string;
|
|
file_count: number;
|
|
total_bytes: number;
|
|
files: Array<{ path: string; size: number; sha256: string }>;
|
|
security_profile: 'works-square-static-sandbox-v1';
|
|
toolchain: { client: 'makelore'; client_version: string; node: string; npm: string; vite: string };
|
|
};
|
|
|
|
export type PreparedRelease = {
|
|
sourceArchive: { path: string; name: 'project.zip'; bytes: Buffer; summary: StaticProjectPackageSummary };
|
|
builtArchive: { path: string; name: 'built-project.zip'; bytes: Buffer };
|
|
contract: ArtifactContract;
|
|
distRoot: string;
|
|
staticArtifact: StaticArtifactSnapshot;
|
|
dispose(): Promise<void>;
|
|
};
|
|
|
|
export class ProjectReleaseBuildError extends Error {
|
|
constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_OUTPUT_MISSING') {
|
|
super(code);
|
|
this.name = 'ProjectReleaseBuildError';
|
|
}
|
|
}
|
|
|
|
const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
|
|
const archivePath = (value: string) => sep === '/' ? value : value.split(sep).join('/');
|
|
const comparePaths = (left: string, right: string) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
|
|
|
function isSafeArchiveFile(entry: import('adm-zip').IZipEntry): boolean {
|
|
const unixMode = (entry.attr >>> 16) & 0xffff;
|
|
const unixType = unixMode & 0xf000;
|
|
return unixType === 0 || unixType === 0x8000;
|
|
}
|
|
|
|
function safeArchivePath(name: string): string[] | null {
|
|
if (!name || name.includes('\\') || name.startsWith('/') || name.includes('\0')) return null;
|
|
const parts = name.split('/');
|
|
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return null;
|
|
if (parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) return null;
|
|
return parts;
|
|
}
|
|
|
|
async function extractSnapshot(archive: string, target: string): Promise<void> {
|
|
const zip = new AdmZip(archive);
|
|
const destinations = new Set<string>();
|
|
for (const entry of zip.getEntries()) {
|
|
const name = entry.entryName;
|
|
const parts = safeArchivePath(name);
|
|
if (entry.header.encripted || !parts || (!entry.isDirectory && !isSafeArchiveFile(entry))) {
|
|
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
|
}
|
|
if (entry.isDirectory) {
|
|
const unixType = ((entry.attr >>> 16) & 0xffff) & 0xf000;
|
|
if (unixType !== 0 && unixType !== 0x4000) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
|
continue;
|
|
}
|
|
const destinationKey = name.toLowerCase();
|
|
if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
|
destinations.add(destinationKey);
|
|
if (entry.header.size > MAX_OUTPUT_BYTES) {
|
|
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
|
}
|
|
const destination = join(target, ...parts);
|
|
await mkdir(dirname(destination), { recursive: true });
|
|
try {
|
|
const data = entry.getData();
|
|
await writeFile(destination, data, { flag: 'wx', mode: 0o600 });
|
|
} catch {
|
|
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function collectOutput(root: string): Promise<Array<{ path: string; bytes: Buffer }>> {
|
|
const files: Array<{ path: string; bytes: Buffer }> = [];
|
|
let total = 0;
|
|
async function walk(directory: string): Promise<void> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
entries.sort((a, b) => comparePaths(a.name, b.name));
|
|
for (const entry of entries) {
|
|
const absolute = join(directory, entry.name);
|
|
const stats = await lstat(absolute);
|
|
if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
|
if (stats.isDirectory()) await walk(absolute);
|
|
else {
|
|
total += stats.size;
|
|
if (files.length + 1 > MAX_OUTPUT_FILES || total > MAX_OUTPUT_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
|
files.push({ path: archivePath(relative(root, absolute)), bytes: await readFile(absolute) });
|
|
}
|
|
}
|
|
}
|
|
try { await walk(root); } catch (error) {
|
|
if (error instanceof ProjectReleaseBuildError) throw error;
|
|
throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
|
}
|
|
files.sort((a, b) => comparePaths(a.path, b.path));
|
|
if (files.some((file) => file.path.toLowerCase() === 'release.json')) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
|
const index = files.find((file) => file.path === 'index.html');
|
|
if (!index) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
|
try {
|
|
if (!new TextDecoder('utf-8', { fatal: true }).decode(index.bytes).trim()) throw new Error('empty');
|
|
} catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); }
|
|
return files;
|
|
}
|
|
|
|
export async function prepareProjectRelease(input: { projectPath: string; clientVersion: string }): Promise<PreparedRelease> {
|
|
const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-'));
|
|
try {
|
|
const sourcePath = join(taskRoot, 'project.zip');
|
|
const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath });
|
|
const sourceBytes = await readFile(sourcePath);
|
|
const snapshot = join(taskRoot, 'snapshot');
|
|
const distRoot = join(taskRoot, 'owned-dist');
|
|
const npmUserConfig = join(taskRoot, 'empty-npmrc');
|
|
const npmCache = join(taskRoot, 'npm-cache');
|
|
await mkdir(snapshot, { recursive: true });
|
|
await writeFile(npmUserConfig, '', 'utf8');
|
|
await extractSnapshot(sourcePath, snapshot);
|
|
const runtime = await resolvePublishRuntime();
|
|
await runElectronNode({
|
|
args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache],
|
|
cwd: snapshot,
|
|
deadlineMs: 5 * 60_000,
|
|
});
|
|
let viteVersion = '';
|
|
try {
|
|
const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown };
|
|
if (typeof vitePackage.version !== 'string') throw new Error('missing vite version');
|
|
viteVersion = vitePackage.version;
|
|
} catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); }
|
|
// Vite config and plugins execute with desktop-user authority. This build is not a sandbox or trust proof.
|
|
await runElectronNode({
|
|
args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'],
|
|
cwd: snapshot,
|
|
deadlineMs: 3 * 60_000,
|
|
});
|
|
const output = await collectOutput(distRoot);
|
|
const zip = new AdmZip();
|
|
const files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) }));
|
|
for (const file of output) {
|
|
zip.addFile(file.path, file.bytes, '', FILE_MODE);
|
|
const entry = zip.getEntry(file.path);
|
|
if (entry) entry.header.time = FIXED_ZIP_TIME;
|
|
}
|
|
const builtBytes = zip.toBuffer();
|
|
const builtPath = join(taskRoot, 'built-project.zip');
|
|
await writeFile(builtPath, builtBytes);
|
|
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
// Matches the server's canonical JSON: sorted files, sorted object keys, no whitespace.
|
|
const artifactDigest = sha256(JSON.stringify(files.map((file) => ({
|
|
path: file.path,
|
|
sha256: file.sha256,
|
|
size: file.size,
|
|
}))));
|
|
const staticArtifact = createStaticArtifactSnapshot(output);
|
|
return {
|
|
sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes, summary },
|
|
builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes },
|
|
distRoot,
|
|
staticArtifact,
|
|
contract: {
|
|
schema_version: 1,
|
|
entry_path: 'index.html',
|
|
source_digest: sha256(sourceBytes),
|
|
built_archive_digest: sha256(builtBytes),
|
|
artifact_digest: artifactDigest,
|
|
file_count: files.length,
|
|
total_bytes: totalBytes,
|
|
files,
|
|
security_profile: 'works-square-static-sandbox-v1',
|
|
toolchain: { client: 'makelore', client_version: input.clientVersion, node: runtime.nodeVersion, npm: runtime.npmVersion, vite: viteVersion },
|
|
},
|
|
dispose: async () => { await rm(taskRoot, { recursive: true, force: true }); },
|
|
};
|
|
} catch (error) {
|
|
await rm(taskRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
if (error instanceof ProjectReleaseBuildError) throw error;
|
|
if (error instanceof PublishRuntimeError) throw new ProjectReleaseBuildError(error.code);
|
|
throw error;
|
|
}
|
|
}
|