import { promises as fs } from 'node:fs' import { createHash, randomUUID } from 'node:crypto' import { dirname, join, relative, resolve, isAbsolute } from 'node:path' import type { SandboxDependencyIdentity, SandboxDependencyKind, SandboxToolchainIdentity } from '@shared/sandbox' import { sandboxConfiguration } from './configuration' import { assertActive, protectedPath, resolveSandboxPath, within } from './policy' import { effectiveSandboxPolicy, sandboxPathDenied } from './policy-store' import { sandboxToolchainIdentity } from './toolchains' interface DependencyFile { path: string; bytes: number; sha256: string } interface DependencyManifest { version: 1; workspaceRoot: string; identity: SandboxDependencyIdentity; files: DependencyFile[]; bytes: number; excluded: number } const hash = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex') const rootKey = (root: string): string => hash(process.platform === 'win32' ? root.toLowerCase() : root) const limits = { files: 30_000, bytes: 512 * 1024 * 1024, fileBytes: 32 * 1024 * 1024, manifestBytes: 8 * 1024 * 1024 } const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ function kindValue(kind: string): SandboxDependencyKind { if (kind !== 'node' && kind !== 'python') throw new Error('依赖缓存只支持 node/python') return kind } function validPath(path: string): boolean { return !!path && !isAbsolute(path) && !path.split(/[\\/]/).some(part => !part || part === '.' || part === '..') && !protectedPath(path) } async function regular(path: string, maximum = limits.fileBytes): Promise { const stat = await fs.lstat(path) if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error('依赖文件是链接、特殊文件或超过上限') const handle = await fs.open(path, 'r') try { const opened = await handle.stat() if (opened.ino !== stat.ino || opened.dev !== stat.dev || opened.nlink !== 1 || opened.size !== stat.size) throw new Error('依赖文件读取前发生替换') const bytes = await handle.readFile(), after = await handle.stat() if (bytes.length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error('依赖文件读取期间发生变化') return bytes } finally { await handle.close() } } async function directory(path: string): Promise { const stat = await fs.lstat(path) if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('依赖缓存目录不能是链接') } export async function dependencyLockHash(workspace: string, kind: SandboxDependencyKind): Promise { kindValue(kind) const names = kind === 'node' ? ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'] : ['pyproject.toml', 'requirements.txt', 'requirements.lock', 'poetry.lock', 'uv.lock'] const values = [] for (const name of names) { const path = await resolveSandboxPath(workspace, name) const exists = await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error }) values.push([name, exists ? hash(await regular(path, 8 * 1024 * 1024)) : null]) } return hash(JSON.stringify(values)) } function cacheRoot(workspace: string): string { return join(sandboxConfiguration().stateRoot, 'dependencies', rootKey(workspace)) } /** Only explicit workspace-local dependency roots are imported; no arbitrary model-supplied path is opened. */ export async function importSandboxDependencies(workspaceInput: string, kindInput: string): Promise<{ identity: SandboxDependencyIdentity; files: number; bytes: number; excluded: number }> { const kind = kindValue(kindInput), workspace = await fs.realpath(workspaceInput) const policy = await effectiveSandboxPolicy(workspace) const source = await resolveSandboxPath(workspace, kind === 'node' ? 'node_modules' : '.venv/Lib/site-packages') if (sandboxPathDenied(policy, source)) throw new Error('沙盒策略禁止导入该依赖路径') if (within(source, resolve(sandboxConfiguration().stateRoot))) throw new Error('依赖源目录不能包含沙盒状态目录') await directory(source) const runtime = await sandboxToolchainIdentity(kind) if (!runtime) throw new Error('请先注册对应运行时再导入依赖') const id = randomUUID(), parent = cacheRoot(workspace), object = join(parent, 'objects', id), target = join(object, 'files') await fs.mkdir(target, { recursive: true, mode: 0o700 }) for (const path of [join(sandboxConfiguration().stateRoot, 'dependencies'), parent, join(parent, 'objects'), object, target]) await directory(path) const identity: SandboxDependencyIdentity = { kind, id, lockHash: await dependencyLockHash(workspace, kind), manifestSha256: '', runtimeId: runtime.id, runtimeManifestSha256: runtime.manifestSha256 } const manifest: DependencyManifest = { version: 1, workspaceRoot: workspace, identity, files: [], bytes: 0, excluded: 0 } async function walk(path: string, depth = 0): Promise { if (depth > 64) throw new Error('依赖目录层级过深') await directory(path) for await (const entry of await fs.opendir(path)) { if (entry.name === '__pycache__') { manifest.excluded++; continue } const absolute = join(path, entry.name), file = relative(source, absolute).replaceAll('\\', '/') if (protectedPath(file)) { manifest.excluded++; continue } if (sandboxPathDenied(policy, absolute)) throw new Error('依赖文件被工作区策略禁止') await resolveSandboxPath(workspace, absolute) if (entry.isSymbolicLink()) throw new Error('依赖缓存不接受链接;请先生成实际文件布局') if (entry.isDirectory()) await walk(absolute, depth + 1) else { if (!validPath(file) || manifest.files.length >= limits.files) throw new Error('依赖路径或总项数无效') const bytes = await regular(absolute) manifest.bytes += bytes.length if (manifest.bytes > limits.bytes) throw new Error('依赖超过 512 MiB 缓存上限') await fs.mkdir(dirname(join(target, file)), { recursive: true }) await fs.writeFile(join(target, file), bytes, { flag: 'wx', mode: 0o600 }) manifest.files.push({ path: file, bytes: bytes.length, sha256: hash(bytes) }) } } } await walk(source) if (await dependencyLockHash(workspace, kind) !== identity.lockHash) throw new Error('依赖导入期间项目锁文件变化') const bytes = Buffer.from(JSON.stringify(manifest) + '\n') if (bytes.length > limits.manifestBytes) throw new Error('依赖清单超过上限') await fs.writeFile(join(object, 'manifest.json'), bytes, { flag: 'wx', mode: 0o600 }) const active: SandboxDependencyIdentity = { ...identity, manifestSha256: hash(bytes) }, temporary = join(parent, `${kind}-${id}.tmp`) try { await fs.writeFile(temporary, JSON.stringify(active) + '\n', { flag: 'wx', mode: 0o600 }) await fs.rename(temporary, join(parent, `${kind}.json`)) } finally { await fs.unlink(temporary).catch(() => undefined) } return { identity: active, files: manifest.files.length, bytes: manifest.bytes, excluded: manifest.excluded } } export async function stageSandboxDependencies(workspace: string, stage: string, requested: string[], runtimes: SandboxToolchainIdentity[], signal?: AbortSignal): Promise { if (!Array.isArray(requested) || requested.length > 2 || new Set(requested).size !== requested.length) throw new Error('依赖缓存选择无效') const identities: SandboxDependencyIdentity[] = [] const policy = await effectiveSandboxPolicy(workspace) for (const name of requested) { assertActive(signal) const kind = kindValue(name), parent = cacheRoot(workspace) await directory(join(sandboxConfiguration().stateRoot, 'dependencies')); await directory(parent) const active = JSON.parse((await regular(join(parent, `${kind}.json`), 4096)).toString('utf8')) as SandboxDependencyIdentity if (active.kind !== kind || !uuid.test(active.id) || !/^[a-f0-9]{64}$/.test(active.manifestSha256) || !/^[a-f0-9]{64}$/.test(active.lockHash)) throw new Error('依赖缓存身份无效') const runtime = runtimes.find(item => item.kind === kind) if (!runtime || runtime.id !== active.runtimeId || runtime.manifestSha256 !== active.runtimeManifestSha256) throw new Error('依赖缓存与所选运行时身份不匹配,请选择运行时或重新导入依赖') if (await dependencyLockHash(join(stage, 'workspace'), kind) !== active.lockHash) throw new Error('项目配置或锁文件已变化,请重新导入依赖缓存') const object = join(parent, 'objects', active.id), source = join(object, 'files') await directory(join(parent, 'objects')); await directory(object); await directory(source) const bytes = await regular(join(object, 'manifest.json'), limits.manifestBytes) if (hash(bytes) !== active.manifestSha256) throw new Error('依赖清单摘要不匹配') const manifest = JSON.parse(bytes.toString('utf8')) as DependencyManifest if (manifest.version !== 1 || rootKey(manifest.workspaceRoot) !== rootKey(workspace) || manifest.identity.id !== active.id || manifest.identity.kind !== kind || manifest.identity.lockHash !== active.lockHash || manifest.identity.runtimeId !== active.runtimeId || manifest.identity.runtimeManifestSha256 !== active.runtimeManifestSha256 || !Array.isArray(manifest.files) || manifest.files.length > limits.files || !Number.isSafeInteger(manifest.bytes) || manifest.bytes > limits.bytes) throw new Error('依赖清单归属或容量无效') const target = kind === 'node' ? join(stage, 'workspace/node_modules') : join(stage, 'home/python-site') await fs.mkdir(target, { recursive: true }) let total = 0; const seen = new Set(), directories = new Set([source]) for (const file of manifest.files) { assertActive(signal) if (!validPath(file.path) || seen.has(file.path.toLowerCase()) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > limits.fileBytes || !/^[a-f0-9]{64}$/.test(file.sha256)) throw new Error('依赖清单条目无效') if (sandboxPathDenied(policy, join(workspace, kind === 'node' ? 'node_modules' : '.venv/Lib/site-packages', file.path))) throw new Error('依赖文件被当前工作区策略禁止') seen.add(file.path.toLowerCase()) const path = resolve(source, file.path) if (!within(source, path)) throw new Error('依赖文件越界') let ancestor = dirname(path) while (!directories.has(ancestor)) { if (!within(source, ancestor)) throw new Error('依赖父目录越界') await directory(ancestor); directories.add(ancestor); ancestor = dirname(ancestor) } const data = await regular(path) if (data.length !== file.bytes || hash(data) !== file.sha256) throw new Error('依赖文件摘要不匹配') total += data.length if (total > limits.bytes) throw new Error('依赖总容量超过上限') await fs.mkdir(dirname(join(target, file.path)), { recursive: true }) await fs.writeFile(join(target, file.path), data, { flag: 'wx', mode: 0o600 }) } if (total !== manifest.bytes) throw new Error('依赖清单总容量不匹配') identities.push(active) } return identities }