66 lines
3.3 KiB
TypeScript
66 lines
3.3 KiB
TypeScript
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<string> {
|
|
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
|
|
}
|
|
}
|