import { promises as fs } from 'node:fs' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' export class SandboxPolicyError extends Error {} export function assertActive(signal?: AbortSignal): void { if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' }) } export function within(root: string, target: string): boolean { const value = relative(root, target) return value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value) } const protectedNames = new Set(['.git', '.ssh', '.aws', '.azure', '.kube', '.gnupg', '.ycode', '.npmrc', '.pypirc', '.netrc', 'credentials', 'credentials.json', 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519']) export function protectedPath(value: string): boolean { return value.split(/[\\/]/).some(part => { const name = part.toLowerCase() return protectedNames.has(name) || name.startsWith('.ycode-sandbox-') || name === '.env' || name.startsWith('.env.') || /\.(?:pem|pfx|p12|key)$/i.test(name) || /[:\x00-\x1f]/.test(part) || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part) }) } /** Inspect each existing component. Node realpath alone does not identify hard links. */ export async function resolveSandboxPath(rootInput: string, input: string, fullAccess = false): Promise { if (typeof input !== 'string' || input.includes('\0')) throw new SandboxPolicyError('文件路径无效') const root = await fs.realpath(rootInput) const target = resolve(root, input.trim() || '.') if (!fullAccess && (!within(root, target) || protectedPath(relative(root, target)))) throw new SandboxPolicyError('默认权限不允许越界或访问受保护文件') if (fullAccess) { let existing = target const suffix: string[] = [] for (;;) { try { return resolve(await fs.realpath(existing), ...suffix) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(existing) if (parent === existing) throw error suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1))) existing = parent } } } let existing = target const suffix: string[] = [] for (;;) { const stat = await fs.lstat(existing).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null throw error }) if (stat) { if (stat.isSymbolicLink() || stat.isFile() && stat.nlink > 1) throw new SandboxPolicyError('默认权限不允许符号链接、junction 或多重硬链接') const real = await fs.realpath(existing) if (!within(root, real)) throw new SandboxPolicyError('文件真实路径不在工作区内') const result = resolve(real, ...suffix) let ancestor = dirname(existing) while (within(root, ancestor)) { if ((await fs.lstat(ancestor)).isSymbolicLink()) throw new SandboxPolicyError('文件父目录包含链接') if (ancestor === root) break ancestor = dirname(ancestor) } return result } const parent = dirname(existing) if (parent === existing || !within(root, parent)) throw new SandboxPolicyError('无法解析工作区路径') suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1))) existing = parent } }