import { promises as fs } from 'node:fs' import { createHash, randomUUID } from 'node:crypto' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import type { SandboxToolchainIdentity, SandboxToolchainKind, SandboxToolchainView } from '@shared/sandbox' import { sandboxConfiguration } from './configuration' import { assertActive, protectedPath, within } from './policy' import { gitRuntimeExecutable, gitRuntimeImports, gitSystemDll } from './git-runtime' interface ToolchainFile { path: string; bytes: number; sha256: string } interface ToolchainManifest { version: 1; kind: SandboxToolchainKind; id: string; createdAt: number; files: ToolchainFile[]; bytes: number; features: string[]; distribution?: { version: string; archiveSha256: string; releaseUrl: string } } interface LocatedToolchain { manifest: ToolchainManifest; directory: string; identity: SandboxToolchainIdentity; source: 'bundled' | 'imported' } const kinds: SandboxToolchainKind[] = ['node', 'python', 'git'] const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ const hashPattern = /^[a-f0-9]{64}$/ const ceilings = { files: 12_000, bytes: 512 * 1024 * 1024, fileBytes: 128 * 1024 * 1024, manifestBytes: 4 * 1024 * 1024 } const hash = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex') const root = (): string => join(sandboxConfiguration().stateRoot, 'toolchains') function kindValue(value: string): SandboxToolchainKind { if (!kinds.includes(value as SandboxToolchainKind)) throw new Error('当前受控运行时只支持 node/python/git') return value as SandboxToolchainKind } function filePath(value: unknown): string { if (typeof value !== 'string' || !value || value.length > 2048 || isAbsolute(value) || /[:\x00-\x1f]/.test(value)) throw new Error('运行时文件路径无效') const path = value.replaceAll('\\', '/') const publicCertificate = path === 'Lib/site-packages/pip/_vendor/certifi/cacert.pem' if (path.split('/').some(part => !part || part === '.' || part === '..') || protectedPath(path) && !publicCertificate) throw new Error(`运行时文件包含受保护路径或链接目标:${path}`) return path } async function regularBytes(path: string, ceiling: number, allowSourceHardlink = false): Promise { const stat = await fs.lstat(path) if (!stat.isFile() || stat.isSymbolicLink() || !allowSourceHardlink && stat.nlink !== 1 || stat.size > ceiling) throw new Error('运行时文件类型或大小无效') const handle = await fs.open(path, 'r') try { const before = await handle.stat() if (before.ino !== stat.ino || before.dev !== stat.dev || !before.isFile() || before.size > ceiling) throw new Error('运行时文件读取前发生替换') const bytes = await handle.readFile(), after = await handle.stat() if (bytes.length !== before.size || bytes.length > ceiling || after.mtimeMs !== before.mtimeMs || after.size !== before.size) throw new Error('运行时文件读取期间发生变化') return bytes } finally { await handle.close() } } async function nonLinkDirectory(path: string): Promise { const stat = await fs.lstat(path) if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('运行时目录不能是链接') } async function manifestFor(kind: SandboxToolchainKind): Promise { const alias = join(root(), `${kind}.json`) const stat = await fs.lstat(alias).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error }) if (!stat) return kind === 'git' && sandboxConfiguration().useBundledGit !== false ? bundledGit() : null await nonLinkDirectory(root()) const active = JSON.parse((await regularBytes(alias, 4096)).toString('utf8')) as SandboxToolchainIdentity if (active.kind !== kind || !uuid.test(active.id) || !hashPattern.test(active.manifestSha256)) throw new Error('运行时引用无效') const directory = join(root(), 'objects', active.id) await nonLinkDirectory(join(root(), 'objects')); await nonLinkDirectory(directory); await nonLinkDirectory(join(directory, 'files')) const bytes = await regularBytes(join(directory, 'manifest.json'), ceilings.manifestBytes) if (hash(bytes) !== active.manifestSha256) throw new Error('运行时清单摘要不匹配') const manifest = validateManifest(bytes, active) return { manifest, directory, identity: active, source: 'imported' } } function validateManifest(bytes: Buffer, active: SandboxToolchainIdentity): ToolchainManifest { const kind = active.kind, manifest = JSON.parse(bytes.toString('utf8')) as ToolchainManifest if (manifest.version !== 1 || manifest.id !== active.id || manifest.kind !== kind || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > ceilings.files || !Array.isArray(manifest.features) || manifest.features.some(feature => typeof feature !== 'string' || feature.length > 64)) throw new Error('运行时清单格式无效') const seen = new Set(); let total = 0 for (const file of manifest.files) { const path = filePath(file.path), key = path.toLowerCase() if (seen.has(key) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > ceilings.fileBytes || !hashPattern.test(file.sha256)) throw new Error('运行时清单文件身份无效') seen.add(key); total += file.bytes } if (manifest.bytes !== total || total > ceilings.bytes || !seen.has(`${kind}.exe`)) throw new Error('运行时文件清单或容量无效') return manifest } async function bundledGit(): Promise { const resources = sandboxConfiguration().resourceDirectory const componentPath = join(resources, 'manifest.json') if (!await fs.lstat(componentPath).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null await nonLinkDirectory(resources) const component = JSON.parse((await regularBytes(componentPath, ceilings.manifestBytes)).toString('utf8')) as { bundledGit?: { manifestSha256?: string; version?: string } } if (!component.bundledGit) return null if (!hashPattern.test(component.bundledGit.manifestSha256 ?? '') || typeof component.bundledGit.version !== 'string') throw new Error('内置 Git 引用无效;请重新构建或安装应用') const directory = join(resources, 'git') await nonLinkDirectory(directory); await nonLinkDirectory(join(directory, 'files')) const bytes = await regularBytes(join(directory, 'manifest.json'), ceilings.manifestBytes) if (hash(bytes) !== component.bundledGit.manifestSha256) throw new Error('内置 Git 清单摘要不匹配;请重新构建或安装应用') const raw = JSON.parse(bytes.toString('utf8')) as ToolchainManifest if (!uuid.test(raw.id)) throw new Error('内置 Git 身份无效') const identity: SandboxToolchainIdentity = { kind: 'git', id: raw.id, manifestSha256: hash(bytes) } const manifest = validateManifest(bytes, identity) if (manifest.distribution?.version !== component.bundledGit.version || !hashPattern.test(manifest.distribution.archiveSha256)) throw new Error('内置 Git 发行信息无效') return { manifest, directory, identity, source: 'bundled' } } export async function listSandboxToolchains(): Promise { return Promise.all(kinds.map(async kind => { try { const value = await manifestFor(kind) return value ? { kind, state: 'registered' as const, id: value.manifest.id, files: value.manifest.files.length, bytes: value.manifest.bytes, features: value.manifest.features, source: value.source, ...(value.manifest.distribution ? { version: value.manifest.distribution.version } : {}) } : { kind, state: 'missing' as const, reason: kind === 'git' ? '内置 Git 不可用;请修复应用资源,或由维护者导入替代版本' : '尚未注册受控运行时;不会继承宿主 PATH' } } catch (error) { return { kind, state: 'invalid' as const, reason: error instanceof Error ? error.message : String(error) } } })) } export async function sandboxToolchainIdentity(kind: SandboxToolchainKind): Promise { return (await manifestFor(kind))?.identity ?? null } /** Import explicit local runtime bytes. The source interpreter is never executed on the host. */ export async function importSandboxToolchain(kindInput: string, executableInput: string): Promise { const kind = kindValue(kindInput) if (!isAbsolute(executableInput)) throw new Error('运行时源程序必须是显式绝对路径') const selected = await fs.realpath(executableInput) if (basename(selected).toLowerCase() !== `${kind}.exe`) throw new Error('运行时源程序名称不匹配') const executable = kind === 'git' ? await gitRuntimeExecutable(selected) : selected if (basename(executable).toLowerCase() !== `${kind}.exe`) throw new Error('运行时源程序名称不匹配') const sourceRoot = dirname(executable), id = randomUUID(), directory = join(root(), 'objects', id), filesRoot = join(directory, 'files') await fs.mkdir(filesRoot, { recursive: true, mode: 0o700 }) await nonLinkDirectory(root()); await nonLinkDirectory(join(root(), 'objects')); await nonLinkDirectory(directory); await nonLinkDirectory(filesRoot) const manifest: ToolchainManifest = { version: 1, kind, id, createdAt: Date.now(), files: [], bytes: 0, features: [kind] } async function put(destination: string, bytes: Buffer): Promise { const path = filePath(destination) if (manifest.files.length >= ceilings.files || bytes.length > ceilings.fileBytes || manifest.bytes + bytes.length > ceilings.bytes) throw new Error('运行时超过受控导入容量上限') const target = join(filesRoot, path) await fs.mkdir(dirname(target), { recursive: true }) await fs.writeFile(target, bytes, { flag: 'wx', mode: 0o600 }) manifest.files.push({ path, bytes: bytes.length, sha256: hash(bytes) }); manifest.bytes += bytes.length } async function copyFile(source: string, destination: string): Promise { if (!within(sourceRoot, source)) throw new Error('运行时导入源越界') const bytes = await regularBytes(source, ceilings.fileBytes, true) await put(destination, bytes) return bytes } async function copyTree(source: string, destination: string, depth = 0): Promise { if (depth > 64) throw new Error('运行时目录层级过深') await nonLinkDirectory(source) for await (const entry of await fs.opendir(source)) { if (entry.name === '__pycache__' || entry.name === '.git' || entry.name === 'site-packages' || kind === 'python' && ['test', 'tests', 'idlelib'].includes(entry.name)) continue const path = join(source, entry.name), target = `${destination}/${entry.name}` if (protectedPath(target) && target !== 'Lib/site-packages/pip/_vendor/certifi/cacert.pem') continue if (entry.isSymbolicLink()) throw new Error('运行时源包含符号链接') if (entry.isDirectory()) await copyTree(path, target, depth + 1) else await copyFile(path, target) } } const executableBytes = await copyFile(executable, `${kind}.exe`) if (kind === 'node') { const npm = join(sourceRoot, 'node_modules/npm') if (await fs.stat(npm).catch(() => null)) { const pkg = JSON.parse((await regularBytes(join(npm, 'package.json'), 1024 * 1024)).toString('utf8')) if (pkg.name !== 'npm') throw new Error('npm 源目录身份无效') await copyTree(npm, 'node_modules/npm') await put('npm.cmd', Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\npm-cli.js" %*\r\n')) await put('npx.cmd', Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\npx-cli.js" %*\r\n')) manifest.features.push('npm', 'npx') } } else if (kind === 'python') { for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) { if (entry.isFile() && /^(?:python3\d*|vcruntime\d+(?:_\d+)?)\.dll$/i.test(entry.name)) await copyFile(join(sourceRoot, entry.name), entry.name) } if (!manifest.files.some(file => /^python3\d+\.dll$/i.test(file.path))) throw new Error('Python 源目录缺少版本 DLL') await copyTree(join(sourceRoot, 'Lib'), 'Lib') if (await fs.stat(join(sourceRoot, 'DLLs')).catch(() => null)) await copyTree(join(sourceRoot, 'DLLs'), 'DLLs') const pip = join(sourceRoot, 'Lib/site-packages/pip') if (await fs.stat(pip).catch(() => null)) { await copyTree(pip, 'Lib/site-packages/pip'); manifest.features.push('pip') } } else { await nonLinkDirectory(sourceRoot) const seen = new Set(['git.exe']), pending = [{ bytes: executableBytes, directory: sourceRoot }] const addExecutable = async (path: string, name: string): Promise => { if (!await fs.lstat(path).catch(() => null)) return false const bytes = await regularBytes(path, ceilings.fileBytes, true) gitRuntimeImports(bytes) await put(name, bytes); seen.add(name.toLowerCase()); pending.push({ bytes, directory: dirname(path) }) return true } const http = await addExecutable(join(sourceRoot, 'git-remote-http.exe'), 'git-remote-http.exe') const https = await addExecutable(join(sourceRoot, 'git-remote-https.exe'), 'git-remote-https.exe') for (const name of ['git-upload-pack.exe', 'git-receive-pack.exe']) await addExecutable(join(sourceRoot, name), name) const usr = join(dirname(dirname(sourceRoot)), 'usr/bin') const shell = await addExecutable(join(usr, 'sh.exe'), 'sh.exe') const ssh = await addExecutable(join(usr, 'ssh.exe'), 'ssh.exe') if (http && https) { const curl = await regularBytes(join(sourceRoot, 'libcurl-4.dll'), ceilings.fileBytes, true) await put('libcurl-4.dll', curl); seen.add('libcurl-4.dll'); pending.push({ bytes: curl, directory: sourceRoot }) } while (pending.length) { const image = pending.shift()! for (const dll of gitRuntimeImports(image.bytes)) { if (gitSystemDll(dll) || seen.has(dll)) continue seen.add(dll) if (seen.size > 128) throw new Error('Git DLL dependency closure exceeds the import limit') await nonLinkDirectory(image.directory) const dependency = await regularBytes(join(image.directory, dll), ceilings.fileBytes, true) await put(dll, dependency) pending.push({ bytes: dependency, directory: image.directory }) } } await put('empty-hooks/.keep', Buffer.alloc(0)) await put('empty-template/.keep', Buffer.alloc(0)) manifest.features.push('git-local', 'private-snapshot-repository') if (http && https) manifest.features.push('git-http') if (shell && ssh) manifest.features.push('git-ssh') } const bytes = Buffer.from(JSON.stringify(manifest) + '\n') if (bytes.length > ceilings.manifestBytes) throw new Error('运行时清单过大') await fs.writeFile(join(directory, 'manifest.json'), bytes, { flag: 'wx', mode: 0o600 }) const identity: SandboxToolchainIdentity = { kind, id, manifestSha256: hash(bytes) }, temporary = join(root(), `${kind}-${id}.tmp`) try { await fs.writeFile(temporary, JSON.stringify(identity) + '\n', { flag: 'wx', mode: 0o600 }) await fs.rename(temporary, join(root(), `${kind}.json`)) } finally { await fs.unlink(temporary).catch(() => undefined) } return { kind, state: 'registered', id, files: manifest.files.length, bytes: manifest.bytes, features: manifest.features, source: 'imported' } } export async function stageSandboxToolchains(stage: string, requested: string[], signal?: AbortSignal): Promise { if (!Array.isArray(requested) || requested.length > kinds.length || new Set(requested).size !== requested.length) throw new Error('沙盒运行时选择无效') const identities: SandboxToolchainIdentity[] = [] for (const input of requested) { assertActive(signal) const kind = kindValue(input), value = await manifestFor(kind) if (!value) throw new Error(`受控运行时 ${kind} 尚未注册;请用 sandbox:maintain toolchain-add 显式导入`) const source = join(value.directory, 'files'), target = join(stage, 'tools', kind) const checkedDirectories = new Set([source]) for (const file of value.manifest.files) { assertActive(signal) const path = join(source, file.path) let parent = dirname(path) while (!checkedDirectories.has(parent)) { if (!within(source, parent)) throw new Error('运行时目录越界') await nonLinkDirectory(parent); checkedDirectories.add(parent); parent = dirname(parent) } const bytes = await regularBytes(path, ceilings.fileBytes) if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error(`受控运行时文件摘要不匹配:${kind}/${file.path}`) const destination = resolve(target, file.path) if (!within(target, destination)) throw new Error('运行时目标越界') await fs.mkdir(dirname(destination), { recursive: true }) await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 }) } identities.push(value.identity) } return identities }