import { Writable } from 'stream'; import path from 'path'; import DockerController from './DockerController'; const HELPER_IMAGE = 'alpine:3.20'; const VOLUME_MOUNT = '/v'; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const EXEC_TIMEOUT_MS = 30_000; // Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU // coreutils alike. The user-supplied relative path arrives as $1; we cd into // it before iterating, so user input never lands as an argv element to a // command that might interpret it as a flag. const LIST_SCRIPT = `set -e cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; } for entry in * .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "") elif [ -d "$entry" ]; then t=d; link="" elif [ -f "$entry" ]; then t=f; link="" else t=o; link="" fi size=$(stat -c '%s' -- "$entry" 2>/dev/null || echo 0) mtime=$(stat -c '%Y' -- "$entry" 2>/dev/null || echo 0) printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link" done`; const STAT_SCRIPT = `set -e target="$1" [ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; } if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "") elif [ -d "$target" ]; then t=d; link="" elif [ -f "$target" ]; then t=f; link="" else t=o; link="" fi size=$(stat -c '%s' -- "$target" 2>/dev/null || echo 0) mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0) name=$(basename -- "$target") printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`; export interface VolumeEntry { name: string; type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtime: number; isProtected: boolean; symlinkTarget?: string; } export interface VolumeFileResult { content: string; encoding: 'utf-8' | 'base64'; binary: boolean; truncated: boolean; size: number; mime: string; } export type VolumeStat = VolumeEntry; export class PathTraversalError extends Error { status = 400; constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; } } export class VolumeNotFoundError extends Error { status = 404; constructor(name: string) { super(`Volume '${name}' not found`); this.name = 'VolumeNotFoundError'; } } export class HelperImageError extends Error { status = 503; constructor(reason: string) { super(`Volume browser helper unavailable: ${reason}`); this.name = 'HelperImageError'; } } export class ExecError extends Error { status: number; constructor(message: string, status = 500) { super(message); this.status = status; this.name = 'ExecError'; } } const helperImageReady = new Map(); export class VolumeBrowserService { private nodeId: number; private constructor(nodeId: number) { this.nodeId = nodeId; } static getInstance(nodeId?: number): VolumeBrowserService { return new VolumeBrowserService(nodeId ?? 1); } async listDir(volumeName: string, relPath: string): Promise { const safe = sanitizeRelPath(relPath); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); // Portable across BusyBox (Alpine) and GNU coreutils. Lists each // direct child with a tab-separated row: typesizemtime // namesymlinkTarget. We chdir to /v/ first so user input is // never an argv element passed to find/stat. const script = LIST_SCRIPT; const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ 'sh', '-c', script, 'sh', `./${safe || ''}`, ]); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/No such file or directory|cannot access|cd:.*?:/i.test(msg)) throw new ExecError('Path not found', 404); if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); throw new ExecError(`Listing failed: ${msg.substring(0, 200) || 'unknown error'}`); } const entries: VolumeEntry[] = []; const lines = stdout.toString('utf-8').split('\n').filter(Boolean); for (const line of lines) { const parts = line.split('\t'); if (parts.length < 4) continue; const [shType, sizeStr, mtimeStr, name, link = ''] = parts; if (!name) continue; const type: VolumeEntry['type'] = shType === 'd' ? 'directory' : shType === 'f' ? 'file' : shType === 'l' ? 'symlink' : 'other'; entries.push({ name, type, size: Number(sizeStr) || 0, mtime: Math.floor(Number(mtimeStr) || 0), isProtected: false, ...(type === 'symlink' && link ? { symlinkTarget: link } : {}), }); } entries.sort((a, b) => { if (a.type === 'directory' && b.type !== 'directory') return -1; if (a.type !== 'directory' && b.type === 'directory') return 1; return a.name.localeCompare(b.name); }); return entries; } async stat(volumeName: string, relPath: string): Promise { const safe = sanitizeRelPath(relPath); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ 'sh', '-c', STAT_SCRIPT, 'sh', `./${safe || ''}`, ]); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404); throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`); } const line = stdout.toString('utf-8').split('\n').filter(Boolean)[0] ?? ''; const parts = line.split('\t'); const [shType, sizeStr, mtimeStr, name, link = ''] = parts; const type: VolumeEntry['type'] = shType === 'd' ? 'directory' : shType === 'f' ? 'file' : shType === 'l' ? 'symlink' : 'other'; return { name: name || path.posix.basename(safe || '/'), type, size: Number(sizeStr) || 0, mtime: Math.floor(Number(mtimeStr) || 0), isProtected: false, ...(type === 'symlink' && link ? { symlinkTarget: link } : {}), }; } async readFile(volumeName: string, relPath: string, opts: { maxBytes?: number } = {}): Promise { const safe = sanitizeRelPath(relPath); if (!safe) throw new ExecError('Cannot read volume root', 400); const maxBytes = Math.max(1, Math.min(opts.maxBytes ?? DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES)); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const meta = await this.stat(volumeName, safe); if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400); if (meta.type !== 'file') throw new ExecError('Not a regular file', 400); // Read up to maxBytes+1 to detect truncation precisely. The path is // passed as $1 (an argv element, never concatenated) and read with // head -c -- "$1" so a leading-dash filename is never parsed as a flag. const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ 'sh', '-c', `head -c ${maxBytes + 1} -- "$1"`, 'sh', `./${safe}`, ]); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); throw new ExecError(`Read failed: ${msg.substring(0, 200) || 'unknown error'}`); } const truncated = stdout.length > maxBytes; const buf = truncated ? stdout.subarray(0, maxBytes) : stdout; const binary = isBinaryBuffer(buf); const mime = binary ? 'application/octet-stream' : 'text/plain'; return { content: binary ? buf.toString('base64') : buf.toString('utf-8'), encoding: binary ? 'base64' : 'utf-8', binary, truncated, size: meta.size, mime, }; } // --- internals ----------------------------------------------------------- private async assertVolumeExists(volumeName: string): Promise { if (!isValidVolumeName(volumeName)) throw new ExecError('Invalid volume name', 400); const docker = DockerController.getInstance(this.nodeId).getDocker(); try { await docker.getVolume(volumeName).inspect(); } catch (err: unknown) { const e = err as { statusCode?: number; message?: string }; if (e.statusCode === 404 || /no such volume/i.test(e.message ?? '')) { throw new VolumeNotFoundError(volumeName); } throw new ExecError(`Volume inspect failed: ${e.message ?? 'unknown'}`); } } private async ensureHelperImage(): Promise { if (helperImageReady.get(this.nodeId)) return; const docker = DockerController.getInstance(this.nodeId).getDocker(); try { await docker.getImage(HELPER_IMAGE).inspect(); helperImageReady.set(this.nodeId, true); return; } catch { /* not present, pull it */ } try { await new Promise((resolve, reject) => { docker.pull(HELPER_IMAGE, (err: Error | null, stream: NodeJS.ReadableStream | null) => { if (err || !stream) { reject(err ?? new Error('pull stream missing')); return; } docker.modem.followProgress(stream, (finishErr: Error | null) => { if (finishErr) reject(finishErr); else resolve(); }); }); }); helperImageReady.set(this.nodeId, true); } catch (err) { throw new HelperImageError((err as Error).message ?? 'pull failed'); } } private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> { const docker = DockerController.getInstance(this.nodeId).getDocker(); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } }); const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } }); // Manual lifecycle (create -> attach -> start -> wait -> remove). Using // dockerode's docker.run() with AutoRemove races: Docker can delete the // container before run()'s internal wait() callback fires, surfacing as // a 404 "no such container" from docker-modem. const container = await docker.createContainer({ Image: HELPER_IMAGE, Cmd: cmd, Tty: false, User: '65534:65534', WorkingDir: VOLUME_MOUNT, AttachStdout: true, AttachStderr: true, HostConfig: { ReadonlyRootfs: true, NetworkMode: 'none', CapDrop: ['ALL'], Privileged: false, SecurityOpt: ['no-new-privileges:true'], PidsLimit: 64, Memory: 128 * 1024 * 1024, Mounts: [{ Type: 'volume', Source: volumeName, Target: VOLUME_MOUNT, ReadOnly: true, }], }, }); let timer: NodeJS.Timeout | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new ExecError('Helper exec timed out', 504)), EXEC_TIMEOUT_MS); }); const runPromise = (async () => { const stream = await container.attach({ stream: true, stdout: true, stderr: true }); const streamEnded = new Promise((resolve) => { stream.once('end', () => resolve()); stream.once('close', () => resolve()); }); docker.modem.demuxStream(stream, stdoutStream, stderrStream); await container.start(); const exitInfo = await container.wait(); // Wait for the attach stream to finish flushing demuxed output. await streamEnded; return { stdout: Buffer.concat(stdoutChunks), stderr: Buffer.concat(stderrChunks), exitCode: typeof exitInfo?.StatusCode === 'number' ? exitInfo.StatusCode : 0, }; })(); try { return await Promise.race([runPromise, timeout]); } finally { if (timer) clearTimeout(timer); // Always tear down the helper, regardless of how runPromise resolved. try { await container.remove({ force: true }); } catch { /* container may have been killed by timeout already */ } } } } // --- helpers ----------------------------------------------------------------- const VOLUME_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$/; export function isValidVolumeName(name: string): boolean { return typeof name === 'string' && VOLUME_NAME_RE.test(name); } export function sanitizeRelPath(relPath: string): string { if (typeof relPath !== 'string') throw new PathTraversalError(); if (relPath.length > 1024) throw new PathTraversalError(); if (relPath.includes('\0')) throw new PathTraversalError(); // Normalize away leading slashes; reject absolute and parent-escape. const p = relPath.replace(/^\/+/, ''); if (p === '' || p === '.') return ''; if (p.split('/').some((seg) => seg === '..')) throw new PathTraversalError(); // posix-resolve and re-check that it stays under the mount root. const resolved = path.posix.resolve(VOLUME_MOUNT, p); if (resolved !== VOLUME_MOUNT && !resolved.startsWith(`${VOLUME_MOUNT}/`)) { throw new PathTraversalError(); } // Return the trailing portion (without the /v prefix) so callers can re-join. return resolved === VOLUME_MOUNT ? '' : resolved.slice(VOLUME_MOUNT.length + 1); } export function isBinaryBuffer(buf: Buffer): boolean { const limit = Math.min(buf.length, 8192); for (let i = 0; i < limit; i++) { if (buf[i] === 0) return true; } return false; }