166 lines
5.0 KiB
JavaScript
166 lines
5.0 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
|
'server.mjs',
|
|
'memind-canary-proxy.mjs',
|
|
'deepseek-no-think-proxy.mjs',
|
|
'wechat-mp.bundle.mjs',
|
|
'mindspace-sandbox-mcp.mjs',
|
|
'tkmind-search-mcp.mjs',
|
|
'tkmind-excel-mcp.mjs',
|
|
'mindspace-public-links.mjs',
|
|
'dist',
|
|
'package.json',
|
|
'scripts/run-memind-portal-prod.sh',
|
|
'scripts/run-memind-portal-candidate.sh',
|
|
'scripts/run-memind-canary-proxy-prod.sh',
|
|
'scripts/run-deepseek-compat-proxy-candidate.sh',
|
|
'scripts/goosed-canary.compose.yml',
|
|
'scripts/check-mindspace-public-links.mjs',
|
|
'scripts/load-env.mjs',
|
|
'scripts/wechat-mp-menu.mjs',
|
|
'scripts/memind-portal-tunnel.sh',
|
|
]);
|
|
|
|
export const FORBIDDEN_PORTAL_RUNTIME_PATHS = Object.freeze([
|
|
'.env',
|
|
'.git',
|
|
'data',
|
|
'dist/dev',
|
|
'dist/hello-john.html',
|
|
'users',
|
|
'logs',
|
|
'MindSpace',
|
|
'.tailscale',
|
|
'public/plaza-covers',
|
|
]);
|
|
|
|
export async function removeForbiddenPortalRuntimePaths(artifactPath) {
|
|
const root = path.resolve(artifactPath);
|
|
const segments = root.split(path.sep);
|
|
if (path.basename(root) !== 'portal' || !segments.includes('.runtime')) {
|
|
throw new Error('Refusing to sanitize a path outside an explicit .runtime/portal directory');
|
|
}
|
|
for (const relative of FORBIDDEN_PORTAL_RUNTIME_PATHS) {
|
|
await fsp.rm(path.join(root, relative), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export function assertPortalRuntimePath(artifactPath, {
|
|
repoRoot,
|
|
} = {}) {
|
|
const expected = path.resolve(repoRoot, '.runtime', 'portal');
|
|
const actual = path.resolve(artifactPath);
|
|
if (actual !== expected) {
|
|
throw new Error(`Portal release gate artifact must be ${expected}`);
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
async function sha256File(filePath) {
|
|
const hash = crypto.createHash('sha256');
|
|
await new Promise((resolve, reject) => {
|
|
const stream = fs.createReadStream(filePath);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('error', reject);
|
|
stream.on('end', resolve);
|
|
});
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
async function walk(root, current = root) {
|
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
const result = [];
|
|
for (const entry of entries) {
|
|
const absolute = path.join(current, entry.name);
|
|
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
const stat = await fsp.lstat(absolute);
|
|
if (stat.isDirectory()) {
|
|
result.push({ type: 'dir', path: relative, mode: stat.mode & 0o777 });
|
|
result.push(...await walk(root, absolute));
|
|
} else if (stat.isFile()) {
|
|
result.push({
|
|
type: 'file',
|
|
path: relative,
|
|
mode: stat.mode & 0o777,
|
|
size: stat.size,
|
|
sha256: await sha256File(absolute),
|
|
});
|
|
} else if (stat.isSymbolicLink()) {
|
|
const target = await fsp.readlink(absolute);
|
|
const resolved = path.resolve(path.dirname(absolute), target);
|
|
const relativeResolved = path.relative(root, resolved);
|
|
if (path.isAbsolute(target) || relativeResolved.startsWith(`..${path.sep}`) || relativeResolved === '..') {
|
|
throw new Error(`Artifact symlink escapes root: ${relative}`);
|
|
}
|
|
result.push({ type: 'symlink', path: relative, target });
|
|
} else {
|
|
throw new Error(`Unsupported artifact entry: ${relative}`);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function hashArtifact(artifactPath) {
|
|
const absolute = path.resolve(artifactPath);
|
|
const stat = await fsp.stat(absolute);
|
|
if (stat.isFile()) {
|
|
return {
|
|
sha256: await sha256File(absolute),
|
|
kind: 'file',
|
|
files: 1,
|
|
bytes: stat.size,
|
|
};
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
throw new Error(`Artifact must be a file or directory: ${absolute}`);
|
|
}
|
|
const entries = await walk(absolute);
|
|
const hash = crypto.createHash('sha256');
|
|
for (const entry of entries) {
|
|
hash.update(JSON.stringify(entry));
|
|
hash.update('\n');
|
|
}
|
|
return {
|
|
sha256: hash.digest('hex'),
|
|
kind: 'directory-tree',
|
|
files: entries.filter((entry) => entry.type === 'file').length,
|
|
bytes: entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0),
|
|
entries,
|
|
};
|
|
}
|
|
|
|
export async function inspectPortalRuntime(artifactPath) {
|
|
const root = path.resolve(artifactPath);
|
|
const stat = await fsp.stat(root);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error('Portal runtime inspection requires a directory artifact');
|
|
}
|
|
const missing = [];
|
|
for (const relative of REQUIRED_PORTAL_RUNTIME_PATHS) {
|
|
try {
|
|
await fsp.access(path.join(root, relative));
|
|
} catch {
|
|
missing.push(relative);
|
|
}
|
|
}
|
|
const forbidden = [];
|
|
for (const relative of FORBIDDEN_PORTAL_RUNTIME_PATHS) {
|
|
try {
|
|
await fsp.access(path.join(root, relative));
|
|
forbidden.push(relative);
|
|
} catch {
|
|
// Expected: runtime artifacts must not contain persisted production state.
|
|
}
|
|
}
|
|
return {
|
|
missing,
|
|
forbidden,
|
|
passed: missing.length === 0 && forbidden.length === 0,
|
|
};
|
|
}
|