import { Writable, Readable } from 'stream'; import { pipeline } from 'stream/promises'; import path from 'path'; import { createHash } from 'crypto'; import DockerController from './DockerController'; const HELPER_IMAGE = 'alpine:3.20'; const VOLUME_MOUNT = '/v'; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // Named-volume downloads are bounded (not chunk-streamed): the helper output is // buffered up to this size and a larger file is rejected rather than silently // truncated. The bound matches the upload limit so the in-memory footprint is no // worse than the existing multipart upload path. export const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024; const EXEC_TIMEOUT_MS = 30_000; // Containment guard run inside the helper after every `cd`. Even though // sanitizeRelPath strips `..`, a symlinked directory component could redirect // `cd` outside the mounted volume; `pwd -P` resolves symlinks so we can assert // the working directory is still under the volume root before touching anything. const ROOT_GUARD = `case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "path escapes volume root" >&2; exit 7 ;; esac`; // 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" || exit 1 ${ROOT_GUARD} lim="$2" n=0 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" n=$((n+1)) if [ -n "$lim" ] && [ "$n" -ge "$lim" ]; then break; fi done`; // Contain the parent before statting the leaf: cd into the leaf's directory and // assert (via pwd -P) it is still inside the volume, so a symlinked path // component cannot make stat report on a file outside the mount. const STAT_SCRIPT = `set -e p="$1" d=$(dirname -- "$p"); b=$(basename -- "$p") cd -- "$d" || exit 1 ${ROOT_GUARD} target="$b" [ -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"`; // --- mutation + probe scripts (all contain the parent, then act on the leaf) --- // $1 = relative path. New file → owned by the helper user (65534); existing file // → in-place truncate (`>` keeps the inode, so owner/mode are preserved). A // symlink leaf is refused so a write never follows a link out of the volume. const WRITE_SCRIPT = `set -e p="$1" d=$(dirname -- "$p"); f=$(basename -- "$p") cd -- "$d" || exit 1 ${ROOT_GUARD} [ -L "$f" ] && { echo "refusing to write through a symlink" >&2; exit 8; } [ -d "$f" ] && { echo "target is a directory" >&2; exit 9; } cat > "$f"`; // $1 = relative path of the new directory; its parent must already exist. const MKDIR_SCRIPT = `set -e p="$1" d=$(dirname -- "$p"); f=$(basename -- "$p") cd -- "$d" || exit 1 ${ROOT_GUARD} mkdir -- "$f"`; // $1 = relative path, $2 = "1" for recursive directory delete. Removing a symlink // leaf is allowed (rm unlinks the link itself, never its target). const DELETE_SCRIPT = `set -e p="$1"; recursive="$2" d=$(dirname -- "$p"); f=$(basename -- "$p") cd -- "$d" || exit 1 ${ROOT_GUARD} [ -e "$f" ] || [ -L "$f" ] || { echo "no such path" >&2; exit 1; } if [ -d "$f" ] && [ ! -L "$f" ]; then if [ "$recursive" = "1" ]; then rm -rf -- "$f"; else rmdir -- "$f" 2>/dev/null || { echo "Directory is not empty" >&2; exit 10; }; fi else rm -f -- "$f" fi`; // $1 = from, $2 = to. Both parents are contained; the destination must not exist. const RENAME_SCRIPT = `set -e from="$1"; to="$2" fd=$(dirname -- "$from"); td=$(dirname -- "$to") ( cd -- "$fd" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "source escapes volume root" >&2; exit 7; } ( cd -- "$td" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "destination escapes volume root" >&2; exit 7; } { [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; } mv -- "$from" "$to"`; // $1 = from, $2 = to. Both parents are contained and the destination must not // exist. cp -RP recurses without dereferencing symlinks (copying links as links, // matching the fs backend) and does NOT preserve ownership, since the helper runs // unprivileged and cannot chown; new entries are owned by the helper user, like // the helper write. A directory may not be copied into itself or a descendant // (cp would otherwise recurse into the copy it is creating). const COPY_SCRIPT = `set -e from="$1"; to="$2" fd=$(dirname -- "$from"); td=$(dirname -- "$to") sp=$( cd -- "$fd" 2>/dev/null && pwd -P ) || { echo "source escapes volume root" >&2; exit 7; } case "$sp" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "source escapes volume root" >&2; exit 7 ;; esac dp=$( cd -- "$td" 2>/dev/null && pwd -P ) || { echo "destination escapes volume root" >&2; exit 7; } case "$dp" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "destination escapes volume root" >&2; exit 7 ;; esac { [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; } src="$sp/$(basename -- "$from")" if [ -d "$src" ] && [ ! -L "$src" ]; then case "$dp/" in "$src/"*) echo "cannot copy a folder into itself" >&2; exit 12 ;; esac fi cp -RP -- "$from" "$to"`; // $1 = relative path. Prints directory|file|none. Used for upload-overwrite checks. const PATHKIND_SCRIPT = `set -e p="$1" d=$(dirname -- "$p"); f=$(basename -- "$p") cd -- "$d" || exit 2 ${ROOT_GUARD} if [ -d "$f" ] && [ ! -L "$f" ]; then echo directory elif [ -e "$f" ] || [ -L "$f" ]; then echo file else echo none fi`; 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; /** mtime in milliseconds (seconds resolution from the helper `stat`, ×1000). */ mtimeMs: number; } /** Raw bytes of a volume file for download, bounded to DOWNLOAD_MAX_BYTES. */ export interface VolumeDownload { buffer: Buffer; size: number; filename: string; } export type VolumeStat = VolumeEntry; /** * Opaque optimistic-concurrency token for an editable volume file. Seconds- * resolution mtime alone collides for two edits within the same second, so the * size and a content hash are folded in to keep the guarantee close to the * millisecond-mtime fs path. */ export function makeHelperVersion(mtimeSeconds: number, size: number, content: Buffer): string { const hash = createHash('sha256').update(content).digest('hex').slice(0, 16); return `"v1:${mtimeSeconds}-${size}-${hash}"`; } 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, limit?: number): 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. When a limit is given the // script breaks after limit+1 rows, so a huge directory is never fully // buffered; the caller detects truncation from the overflow row. const limArg = limit !== undefined ? String(limit + 1) : ''; const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ 'sh', '-c', LIST_SCRIPT, 'sh', `./${safe || ''}`, limArg, ]); 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 (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); 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 parent is // contained (cd + pwd -P) and the leaf symlink refused so the read can never // follow a link out of the volume. const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ 'sh', '-c', `p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${maxBytes + 1} -- "$b"`, '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, mtimeMs: meta.mtime * 1000, }; } /** * Read a file's full bytes for download, bounded to DOWNLOAD_MAX_BYTES so a * large file is rejected (413) rather than silently truncated. The parent is * contained and the leaf symlink refused, like readFile. */ async downloadFile(volumeName: string, relPath: string): Promise { const safe = sanitizeRelPath(relPath); if (!safe) throw new ExecError('Cannot download volume root', 400); 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); if (meta.size > DOWNLOAD_MAX_BYTES) { throw new ExecError('File is too large to download from this volume', 413); } const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ 'sh', '-c', `p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${DOWNLOAD_MAX_BYTES + 1} -- "$b"`, '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(`Download failed: ${msg.substring(0, 200) || 'unknown error'}`); } if (stdout.length > DOWNLOAD_MAX_BYTES) { throw new ExecError('File is too large to download from this volume', 413); } return { buffer: stdout, size: stdout.length, filename: path.posix.basename(safe) }; } /** Probe whether a path is a directory, file, or absent (upload-overwrite check). */ async pathKind(volumeName: string, relPath: string): Promise<'file' | 'directory' | null> { const safe = sanitizeRelPath(relPath); if (!safe) return 'directory'; await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stdout, stderr, exitCode } = await this.runHelper(volumeName, ['sh', '-c', PATHKIND_SCRIPT, 'sh', `./${safe}`]); if (exitCode !== 0) { // A permission failure on the parent must not be reported as "absent" // (which would let an exclusive create proceed); surface it as 403. A // genuinely missing parent (ENOENT) means nothing exists at this path. if (/Permission denied/i.test(stderr.toString('utf-8'))) throw new ExecError('Permission denied', 403); return null; } const kind = stdout.toString('utf-8').trim(); if (kind === 'directory') return 'directory'; if (kind === 'file') return 'file'; return null; } /** * Write `content` to a volume file. New files are created owned by the helper * user (65534); existing files are truncated in place so their owner/mode are * preserved. The write is intentionally NON-ATOMIC (a helper death mid-write * can leave a truncated file) because the helper cannot chown a renamed temp * back to the original owner; ownership preservation is the better default for * editing a service's config. Returns the new mtime/size for the version token. */ async writeFile(volumeName: string, relPath: string, content: Buffer): Promise<{ mtimeMs: number; size: number }> { const safe = sanitizeRelPath(relPath); if (!safe) throw new ExecError('Cannot write the volume root', 400); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stderr, exitCode } = await this.runHelper( volumeName, ['sh', '-c', WRITE_SCRIPT, 'sh', `./${safe}`], { writable: true, stdin: content }, ); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg) || exitCode === 8) throw new ExecError('Permission denied', 403); if (exitCode === 9) throw new ExecError('Target is a directory', 400); throw new ExecError(`Write failed: ${msg.substring(0, 200) || 'unknown error'}`); } const meta = await this.stat(volumeName, safe); return { mtimeMs: meta.mtime * 1000, size: meta.size }; } /** * Like writeFile but streams the content from a Readable (an upload spooled to * a temp file) straight into the helper's stdin, so a large upload is never * held in memory. Non-atomic in-place write (`cat > file`), matching writeFile * (see writeFile for the ownership-preservation rationale); the caller owns the * source stream's underlying temp file. `expectedSize` is the source byte count: * `cat` cannot report a short write, so the post-write size is checked against * it to catch a truncated transfer that exited cleanly. */ async writeFileStream(volumeName: string, relPath: string, source: Readable, expectedSize: number): Promise<{ mtimeMs: number; size: number }> { const safe = sanitizeRelPath(relPath); if (!safe) throw new ExecError('Cannot write the volume root', 400); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stderr, exitCode } = await this.runHelper( volumeName, ['sh', '-c', WRITE_SCRIPT, 'sh', `./${safe}`], { writable: true, stdin: source }, ); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg) || exitCode === 8) throw new ExecError('Permission denied', 403); if (exitCode === 9) throw new ExecError('Target is a directory', 400); throw new ExecError(`Write failed: ${msg.substring(0, 200) || 'unknown error'}`); } const meta = await this.stat(volumeName, safe); if (meta.size !== expectedSize) { throw new ExecError('Upload did not fully write to the volume', 500); } return { mtimeMs: meta.mtime * 1000, size: meta.size }; } /** Create a single directory; its parent must already exist. */ async mkdir(volumeName: string, relPath: string): Promise { const safe = sanitizeRelPath(relPath); if (!safe) throw new ExecError('Invalid directory path', 400); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stderr, exitCode } = await this.runHelper( volumeName, ['sh', '-c', MKDIR_SCRIPT, 'sh', `./${safe}`], { writable: true }, ); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); if (/File exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409); throw new ExecError(`Create folder failed: ${msg.substring(0, 200) || 'unknown error'}`); } } /** Delete a file or directory. Removing a symlink unlinks the link, not its target. */ async deletePath(volumeName: string, relPath: string, recursive: boolean): Promise { const safe = sanitizeRelPath(relPath); if (!safe) throw new ExecError('Cannot delete the volume root', 400); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stderr, exitCode } = await this.runHelper( volumeName, ['sh', '-c', DELETE_SCRIPT, 'sh', `./${safe}`, recursive ? '1' : '0'], { writable: true }, ); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); if (exitCode === 10 || /not empty/i.test(msg)) throw new ExecError('Directory is not empty', 409); throw new ExecError(`Delete failed: ${msg.substring(0, 200) || 'unknown error'}`); } } /** Rename/move a file or directory within the same volume. */ async rename(volumeName: string, fromRel: string, toRel: string): Promise { const from = sanitizeRelPath(fromRel); const to = sanitizeRelPath(toRel); if (!from || !to) throw new ExecError('Invalid rename path', 400); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stderr, exitCode } = await this.runHelper( volumeName, ['sh', '-c', RENAME_SCRIPT, 'sh', `./${from}`, `./${to}`], { writable: true }, ); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); if (exitCode === 11 || /exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409); throw new ExecError(`Rename failed: ${msg.substring(0, 200) || 'unknown error'}`); } } /** Copy a file or directory within the same volume (symlink-as-link, no chown). */ async copy(volumeName: string, fromRel: string, toRel: string): Promise { const from = sanitizeRelPath(fromRel); const to = sanitizeRelPath(toRel); if (!from || !to) throw new ExecError('Invalid copy path', 400); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); const { stderr, exitCode } = await this.runHelper( volumeName, ['sh', '-c', COPY_SCRIPT, 'sh', `./${from}`, `./${to}`], { writable: true }, ); if (exitCode !== 0) { const msg = stderr.toString('utf-8').trim(); if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403); if (exitCode === 11 || /exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409); if (exitCode === 12) throw new ExecError('Cannot copy a folder into itself', 400); throw new ExecError(`Copy failed: ${msg.substring(0, 200) || 'unknown error'}`); } } // --- 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[], opts: { writable?: boolean; stdin?: Buffer | Readable } = {}, ): 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(); } }); const wantStdin = opts.stdin !== undefined; // 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. Only the target volume mount // becomes writable for mutations; every other hardening flag is unchanged. const container = await docker.createContainer({ Image: HELPER_IMAGE, Cmd: cmd, Tty: false, User: '65534:65534', WorkingDir: VOLUME_MOUNT, AttachStdout: true, AttachStderr: true, AttachStdin: wantStdin, OpenStdin: wantStdin, StdinOnce: wantStdin, 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: !opts.writable, }], }, }); let timer: NodeJS.Timeout | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new ExecError('Helper exec timed out', 504)), EXEC_TIMEOUT_MS); }); let stdinError: unknown = null; const runPromise = (async () => { const stream = await container.attach({ stream: true, stdin: wantStdin, stdout: true, stderr: true, hijack: wantStdin }); const streamEnded = new Promise((resolve) => { stream.once('end', () => resolve()); stream.once('close', () => resolve()); }); docker.modem.demuxStream(stream, stdoutStream, stderrStream); await container.start(); if (wantStdin && opts.stdin) { // Feed the file content to the container's stdin, then close it so the // helper's `cat > file` sees EOF and exits. if (Buffer.isBuffer(opts.stdin)) { stream.write(opts.stdin); stream.end(); } else { // A Readable (an upload spooled to a temp file) is piped so a large // upload is never held in memory. pipeline tears the stdin side down // (destroys it) on a source error rather than ending it cleanly, so // cat sees an abnormal close instead of a normal EOF and does not // commit a truncated file as a success. Remember the error so the run // is reported as failed after the container exits. try { await pipeline(opts.stdin, stream); } catch (err) { stdinError = err; } } } const exitInfo = await container.wait(); // Wait for the attach stream to finish flushing demuxed output. await streamEnded; const exitCode = typeof exitInfo?.StatusCode === 'number' ? exitInfo.StatusCode : 0; // A nonzero helper exit is the authoritative failure: its exit code carries // the intended 4xx mapping (target-is-a-directory, permission denied, etc.), // which a stdin EPIPE from the helper closing stdin early would otherwise // mask as a generic 500. Only surface the stream error when the helper // itself exited cleanly (or its status was unreadable). if (stdinError) { if (exitCode === 0) { throw new ExecError('Upload stream failed before the volume write completed', 500); } // The nonzero exit's mapping is the user-facing error, but the stream // failure is the root cause; log it so an intermittent upload failure is // debuggable rather than being silently attributed to the exit code. console.error('[VolumeBrowser] helper stdin stream error masked by nonzero exit', exitCode, stdinError); } return { stdout: Buffer.concat(stdoutChunks), stderr: Buffer.concat(stderrChunks), exitCode, }; })(); 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; }