diff --git a/backend/src/__tests__/volume-browser-service.test.ts b/backend/src/__tests__/volume-browser-service.test.ts new file mode 100644 index 00000000..0c90ea55 --- /dev/null +++ b/backend/src/__tests__/volume-browser-service.test.ts @@ -0,0 +1,99 @@ +/** + * Coverage for VolumeBrowserService pure helpers: path traversal sanitization, + * volume-name validation, and binary detection. The Docker-facing exec path + * is exercised in manual E2E only — mocking dockerode.run reliably is not + * worth the brittleness for this PR. + */ +import { describe, it, expect } from 'vitest'; +import { + sanitizeRelPath, + isValidVolumeName, + isBinaryBuffer, + PathTraversalError, +} from '../services/VolumeBrowserService'; + +describe('sanitizeRelPath', () => { + it('returns empty string for the volume root', () => { + expect(sanitizeRelPath('')).toBe(''); + expect(sanitizeRelPath('.')).toBe(''); + expect(sanitizeRelPath('/')).toBe(''); + }); + + it('strips leading slashes', () => { + expect(sanitizeRelPath('/etc/foo')).toBe('etc/foo'); + expect(sanitizeRelPath('//etc/foo')).toBe('etc/foo'); + }); + + it('preserves nested relative paths', () => { + expect(sanitizeRelPath('a/b/c.txt')).toBe('a/b/c.txt'); + }); + + it('rejects parent-escape segments', () => { + expect(() => sanitizeRelPath('../etc/passwd')).toThrow(PathTraversalError); + expect(() => sanitizeRelPath('foo/../../etc/passwd')).toThrow(PathTraversalError); + expect(() => sanitizeRelPath('..')).toThrow(PathTraversalError); + }); + + it('rejects null bytes', () => { + expect(() => sanitizeRelPath('foo\0.txt')).toThrow(PathTraversalError); + }); + + it('rejects oversize paths', () => { + const huge = 'a/'.repeat(700); + expect(() => sanitizeRelPath(huge)).toThrow(PathTraversalError); + }); + + it('rejects non-string input', () => { + expect(() => sanitizeRelPath(undefined as unknown as string)).toThrow(PathTraversalError); + expect(() => sanitizeRelPath(null as unknown as string)).toThrow(PathTraversalError); + }); +}); + +describe('isValidVolumeName', () => { + it('accepts typical Docker volume names', () => { + expect(isValidVolumeName('my-stack_data')).toBe(true); + expect(isValidVolumeName('volume.with.dots')).toBe(true); + expect(isValidVolumeName('a')).toBe(true); + expect(isValidVolumeName('Vol_123')).toBe(true); + }); + + it('rejects names with disallowed characters', () => { + expect(isValidVolumeName('vol/with/slashes')).toBe(false); + expect(isValidVolumeName('vol with space')).toBe(false); + expect(isValidVolumeName('vol;rm -rf')).toBe(false); + expect(isValidVolumeName('')).toBe(false); + }); + + it('rejects names that start with non-alphanumeric', () => { + expect(isValidVolumeName('-leading-dash')).toBe(false); + expect(isValidVolumeName('.leading-dot')).toBe(false); + }); + + it('rejects oversize names', () => { + expect(isValidVolumeName('a'.repeat(256))).toBe(false); + }); +}); + +describe('isBinaryBuffer', () => { + it('flags buffers containing null bytes as binary', () => { + expect(isBinaryBuffer(Buffer.from('hello\0world'))).toBe(true); + expect(isBinaryBuffer(Buffer.from([0xff, 0x00, 0x42]))).toBe(true); + }); + + it('treats plain UTF-8 text as non-binary', () => { + expect(isBinaryBuffer(Buffer.from('hello world\nlet me see\n'))).toBe(false); + expect(isBinaryBuffer(Buffer.from('héllo wörld'))).toBe(false); + }); + + it('only inspects the first 8 KB', () => { + // Pure text in the first 8KB; null byte after. + const head = Buffer.alloc(8192, 0x41); + const tail = Buffer.from([0]); + const buf = Buffer.concat([head, tail]); + expect(isBinaryBuffer(buf)).toBe(false); + }); + + it('handles an empty buffer', () => { + expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 7a8224f8..98d89b4c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -39,6 +39,7 @@ import { consoleRouter } from './routes/console'; import { ssoConfigRouter } from './routes/ssoConfig'; import { registriesRouter } from './routes/registries'; import { systemMaintenanceRouter } from './routes/systemMaintenance'; +import { volumesRouter } from './routes/volumes'; import { templatesRouter } from './routes/templates'; import { securityRouter } from './routes/security'; import { dashboardRouter } from './routes/dashboard'; @@ -118,6 +119,7 @@ app.use('/api/system', consoleRouter); app.use('/api/sso/config', ssoConfigRouter); app.use('/api/registries', registriesRouter); app.use('/api/system', systemMaintenanceRouter); +app.use('/api/volumes', volumesRouter); app.use('/api/templates', templatesRouter); app.use('/api/security', securityRouter); app.use('/api/containers', containersRouter); diff --git a/backend/src/routes/volumes.ts b/backend/src/routes/volumes.ts new file mode 100644 index 00000000..91000188 --- /dev/null +++ b/backend/src/routes/volumes.ts @@ -0,0 +1,78 @@ +import { Router, type Request, type Response } from 'express'; +import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotFoundError, HelperImageError, ExecError } from '../services/VolumeBrowserService'; +import { DatabaseService } from '../services/DatabaseService'; +import { requireAdmin } from '../middleware/tierGates'; +import { sanitizeForLog } from '../utils/safeLog'; + +export const volumesRouter = Router(); + +function readPathParam(req: Request): string { + const raw = req.query.path; + if (typeof raw !== 'string') return ''; + return raw; +} + +function mapServiceError(error: unknown, res: Response, fallback: string): Response { + if (error instanceof PathTraversalError) return res.status(400).json({ error: error.message }); + if (error instanceof VolumeNotFoundError) return res.status(404).json({ error: error.message }); + if (error instanceof HelperImageError) return res.status(503).json({ error: error.message }); + if (error instanceof ExecError) return res.status(error.status).json({ error: error.message }); + console.error(`[Volumes] ${fallback}:`, error); + return res.status(500).json({ error: fallback }); +} + +volumesRouter.get('/:name/list', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const name = req.params.name as string; + if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); + const path = readPathParam(req); + const entries = await VolumeBrowserService.getInstance(req.nodeId).listDir(name, path); + res.json(entries); + } catch (error: unknown) { + mapServiceError(error, res, 'Failed to list volume directory'); + } +}); + +volumesRouter.get('/:name/stat', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const name = req.params.name as string; + if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); + const path = readPathParam(req); + const meta = await VolumeBrowserService.getInstance(req.nodeId).stat(name, path); + res.json(meta); + } catch (error: unknown) { + mapServiceError(error, res, 'Failed to stat volume path'); + } +}); + +volumesRouter.get('/:name/read', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + const name = req.params.name as string; + const requestPath = readPathParam(req); + let outcome: 'success' | 'error' = 'error'; + try { + if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' }); + const result = await VolumeBrowserService.getInstance(req.nodeId).readFile(name, requestPath); + outcome = 'success'; + res.json(result); + } catch (error: unknown) { + mapServiceError(error, res, 'Failed to read volume file'); + } finally { + try { + DatabaseService.getInstance().insertAuditLog({ + timestamp: Date.now(), + username: req.user?.username ?? 'unknown', + method: 'GET', + path: req.path, + status_code: res.statusCode, + node_id: req.nodeId ?? null, + ip_address: req.ip ?? 'unknown', + summary: `${outcome === 'success' ? 'Read' : 'Failed read of'} volume file: ${sanitizeForLog(name)}:${sanitizeForLog(requestPath || '/')}`, + }); + } catch (auditErr) { + console.error('[Volumes] Audit log insert failed:', auditErr); + } + } +}); diff --git a/backend/src/services/VolumeBrowserService.ts b/backend/src/services/VolumeBrowserService.ts new file mode 100644 index 00000000..177a8e12 --- /dev/null +++ b/backend/src/services/VolumeBrowserService.ts @@ -0,0 +1,352 @@ +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; +} diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index f900e43e..9abb2821 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -79,6 +79,20 @@ When any volumes are present, a two-card landing strip above the table highlight Deleting a volume is permanent. Any data stored in it will be lost. Always back up important volume data before pruning. +#### Browse volume contents + +Click the folder icon on any volume row (admin only) to open a read-only browser for that volume. The panel shows a directory tree on the left and a file viewer on the right. + +- **Read only.** No edits, no writes, no deletions. The browser launches a short-lived helper container with the volume mounted read-only. +- **Size cap.** Files are read up to 5 MB. Larger files show a truncation banner. +- **Binary files** are displayed as a hex view. Plain text is shown directly. +- **Symlinks** are listed but never followed. Reading a symlink path returns an error. +- **Audit log.** Every file read is recorded in the audit log, including failed reads, with the volume and path. Use this for compliance and incident review. + + + Volumes commonly contain database files, password hashes, certificates, and other secrets. Treat the volume browser as a high-power admin tool. + + ### Networks Lists all Docker networks. Columns: ID, name, driver, scope (`local`, `global`, `swarm`), and managed status. diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 2ab1c96e..74e8be40 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -18,7 +18,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; -import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History } from 'lucide-react'; +import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History, FolderOpen } from 'lucide-react'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; @@ -38,6 +38,7 @@ import { lazy, Suspense } from 'react'; import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; +import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { TabLanding, type TabLandingEntry } from './resources/TabLanding'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); @@ -394,6 +395,7 @@ export default function ResourcesView() { const [inspectNetwork, setInspectNetwork] = useState(null); const [inspectLoadingId, setInspectLoadingId] = useState(null); const [inspectImageId, setInspectImageId] = useState(null); + const [browseVolume, setBrowseVolume] = useState(null); // Unmanaged container state const [selectedOrphans, setSelectedOrphans] = useState([]); @@ -961,9 +963,23 @@ export default function ResourcesView() { {vol.Mountpoint} - {isAdmin && } +
+ {isAdmin && ( + + )} + {isAdmin && } +
))} @@ -1348,6 +1364,10 @@ export default function ResourcesView() { {/* Image Details Sheet */} setInspectImageId(null)} /> + {/* Volume Browser Sheet */} + setBrowseVolume(null)} /> + + {/* Network Inspect Sheet */} !open && setInspectNetwork(null)}> diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx index 9f4505c2..8635ce33 100644 --- a/frontend/src/components/files/FileTree.tsx +++ b/frontend/src/components/files/FileTree.tsx @@ -3,12 +3,14 @@ import type { ReactNode } from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from '@/components/ui/toast-store'; -import { listStackDirectory } from '@/lib/stackFilesApi'; import type { FileEntry } from '@/lib/stackFilesApi'; import { FileTreeNode } from './FileTreeNode'; interface FileTreeProps { - stackName: string; + /** Loads directory contents at `relPath` (use '' for the tree root). */ + loadDir: (relPath: string) => Promise; + /** Stable identity for the source. Changing it remounts the tree. */ + sourceKey: string; refreshKey?: number; selectedPath: string; onSelectFile: (relPath: string, entry: FileEntry) => void; @@ -21,7 +23,8 @@ const ENV_NAMES = new Set(['.env']); const MAX_ENTRIES = 500; export function FileTree({ - stackName, + loadDir, + sourceKey, refreshKey, selectedPath, onSelectFile, @@ -34,13 +37,18 @@ export function FileTree({ const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [dirContents, setDirContents] = useState>(new Map()); const [loadingDirs, setLoadingDirs] = useState>(new Set()); - const stackNameRef = useRef(stackName); + const sourceKeyRef = useRef(sourceKey); + const loadDirRef = useRef(loadDir); + + // Always keep the latest loader in a ref so callers can pass a fresh + // function each render without re-triggering the root fetch effect below. + loadDirRef.current = loadDir; useEffect(() => { - stackNameRef.current = stackName; + sourceKeyRef.current = sourceKey; let cancelled = false; - listStackDirectory(stackName, '') + loadDirRef.current('') .then((entries) => { if (!cancelled) { setRootEntries(entries); @@ -59,7 +67,11 @@ export function FileTree({ return () => { cancelled = true; }; - }, [stackName, refreshKey]); + // loadDir is intentionally read through loadDirRef to avoid refetch on + // identity-only changes from the parent (StackFileExplorer rebuilds the + // arrow on every render). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourceKey, refreshKey]); function handleDirClick(dirRelPath: string) { if (expandedDirs.has(dirRelPath)) { @@ -78,10 +90,10 @@ export function FileTree({ setLoadingDirs((prev) => new Set(prev).add(dirRelPath)); - const capturedStackName = stackName; - listStackDirectory(capturedStackName, dirRelPath) + const capturedSourceKey = sourceKey; + loadDirRef.current(dirRelPath) .then((entries) => { - if (stackNameRef.current !== capturedStackName) return; + if (sourceKeyRef.current !== capturedSourceKey) return; setDirContents((prev) => { const next = new Map(prev); next.set(dirRelPath, entries); diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx index d8b52a4e..8c587cdf 100644 --- a/frontend/src/components/files/StackFileExplorer.tsx +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -3,7 +3,7 @@ import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui/toast-store'; import { useLicense } from '@/context/LicenseContext'; -import { downloadStackFile } from '@/lib/stackFilesApi'; +import { downloadStackFile, listStackDirectory } from '@/lib/stackFilesApi'; import { FileTree } from './FileTree'; import { FileViewer } from './FileViewer'; import { FileUploadDropzone } from './FileUploadDropzone'; @@ -110,7 +110,8 @@ export function StackFileExplorer({
listStackDirectory(stackName, p)} refreshKey={refreshKey} selectedPath={selectedPath ?? ''} onSelectFile={handleSelectFile} diff --git a/frontend/src/components/files/__tests__/FileTree.test.tsx b/frontend/src/components/files/__tests__/FileTree.test.tsx index a14444b3..99049dd4 100644 --- a/frontend/src/components/files/__tests__/FileTree.test.tsx +++ b/frontend/src/components/files/__tests__/FileTree.test.tsx @@ -10,10 +10,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { FileEntry } from '@/lib/stackFilesApi'; -vi.mock('@/lib/stackFilesApi', () => ({ - listStackDirectory: vi.fn(), -})); - vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), @@ -34,11 +30,8 @@ vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () =>
, })); -import { listStackDirectory } from '@/lib/stackFilesApi'; import { FileTree } from '../FileTree'; -const mockListDir = listStackDirectory as unknown as ReturnType; - function makeFile(name: string): FileEntry { return { name, type: 'file', size: 100, mtime: 0, isProtected: false }; } @@ -54,59 +47,65 @@ function fakeOk(entries: FileEntry[]): Promise { return Promise.resolve(entries); } -const defaultProps = { - stackName: 'my-stack', - selectedPath: '', - onSelectFile: vi.fn(), -}; +let onSelectFile: ReturnType & ((relPath: string, entry: FileEntry) => void); +let mockLoadDir: ReturnType & ((relPath: string) => Promise); + +function defaultProps() { + return { + sourceKey: 'my-stack', + loadDir: mockLoadDir, + selectedPath: '', + onSelectFile, + }; +} beforeEach(() => { - mockListDir.mockReset(); - defaultProps.onSelectFile = vi.fn(); + onSelectFile = vi.fn() as typeof onSelectFile; + mockLoadDir = vi.fn() as typeof mockLoadDir; }); afterEach(() => vi.clearAllMocks()); describe('FileTree', () => { it('fetches root entries on mount and renders them', async () => { - mockListDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); - render(); + render(); - await waitFor(() => expect(mockListDir).toHaveBeenCalledWith('my-stack', '')); + await waitFor(() => expect(mockLoadDir).toHaveBeenCalledWith('')); expect(await screen.findByText('src')).toBeInTheDocument(); expect(screen.getByText('README.md')).toBeInTheDocument(); }); it('fetches subdirectory on first expand and shows children', async () => { - mockListDir + mockLoadDir .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); const user = userEvent.setup(); - render(); + render(); await screen.findByText('src'); // One call so far: root fetch. - expect(mockListDir).toHaveBeenCalledTimes(1); + expect(mockLoadDir).toHaveBeenCalledTimes(1); await user.click(screen.getByText('src')); - await waitFor(() => expect(mockListDir).toHaveBeenCalledTimes(2)); - expect(mockListDir).toHaveBeenNthCalledWith(2, 'my-stack', 'src'); + await waitFor(() => expect(mockLoadDir).toHaveBeenCalledTimes(2)); + expect(mockLoadDir).toHaveBeenNthCalledWith(2, 'src'); expect(await screen.findByText('index.ts')).toBeInTheDocument(); expect(screen.getByText('app.ts')).toBeInTheDocument(); }); it('collapses on second click (no additional fetch)', async () => { - mockListDir + mockLoadDir .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); const user = userEvent.setup(); - render(); + render(); await screen.findByText('src'); @@ -114,23 +113,23 @@ describe('FileTree', () => { await user.click(screen.getByText('src')); await screen.findByText('index.ts'); - const callsAfterExpand = mockListDir.mock.calls.length; + const callsAfterExpand = mockLoadDir.mock.calls.length; // Collapse. await user.click(screen.getByText('src')); await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument()); // No extra fetch should have happened. - expect(mockListDir).toHaveBeenCalledTimes(callsAfterExpand); + expect(mockLoadDir).toHaveBeenCalledTimes(callsAfterExpand); }); it('re-expands from cache on third click (no second fetch for that dir)', async () => { - mockListDir + mockLoadDir .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); const user = userEvent.setup(); - render(); + render(); await screen.findByText('src'); @@ -142,28 +141,28 @@ describe('FileTree', () => { await user.click(screen.getByText('src')); await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument()); - const callsAfterCollapse = mockListDir.mock.calls.length; + const callsAfterCollapse = mockLoadDir.mock.calls.length; // Third click: re-expand from cache. await user.click(screen.getByText('src')); await screen.findByText('index.ts'); // Fetch count must not have increased. - expect(mockListDir).toHaveBeenCalledTimes(callsAfterCollapse); + expect(mockLoadDir).toHaveBeenCalledTimes(callsAfterCollapse); }); it('shows error message when root fetch fails', async () => { - mockListDir.mockRejectedValue(new Error('Network error')); + mockLoadDir.mockRejectedValue(new Error('Network error')); - render(); + render(); expect(await screen.findByText('Network error')).toBeInTheDocument(); }); it('shows empty state when root returns no entries', async () => { - mockListDir.mockReturnValue(fakeOk([])); + mockLoadDir.mockReturnValue(fakeOk([])); - render(); + render(); expect(await screen.findByText(/empty folder/i)).toBeInTheDocument(); }); diff --git a/frontend/src/components/resources/VolumeBrowserSheet.tsx b/frontend/src/components/resources/VolumeBrowserSheet.tsx new file mode 100644 index 00000000..f600367d --- /dev/null +++ b/frontend/src/components/resources/VolumeBrowserSheet.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { HardDrive, RefreshCw } from 'lucide-react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast-store'; +import { FileTree } from '@/components/files/FileTree'; +import type { FileEntry } from '@/lib/stackFilesApi'; +import { listVolumeDirectory, readVolumeFile } from '@/lib/volumeApi'; +import type { VolumeFileResult } from '@/lib/volumeApi'; +import { formatBytes } from '@/lib/utils'; + +interface VolumeBrowserSheetProps { + volumeName: string | null; + onClose: () => void; +} + +export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetProps) { + const [refreshKey, setRefreshKey] = useState(0); + const [selectedPath, setSelectedPath] = useState(''); + const [fileLoading, setFileLoading] = useState(false); + const [fileResult, setFileResult] = useState(null); + // Generation counter so a slow read for a stale (volume, path) selection + // cannot stomp the visible result after the user has moved on. + const readGenerationRef = useRef(0); + + useEffect(() => { + // Cancel any in-flight read when the volume context changes. + readGenerationRef.current += 1; + }, [volumeName]); + + const handleSelectFile = useCallback(async (relPath: string) => { + if (!volumeName) return; + const generation = ++readGenerationRef.current; + const targetVolume = volumeName; + setSelectedPath(relPath); + setFileLoading(true); + setFileResult(null); + try { + const result = await readVolumeFile(targetVolume, relPath); + if (readGenerationRef.current !== generation) return; + setFileResult(result); + } catch (err: unknown) { + if (readGenerationRef.current !== generation) return; + const msg = err instanceof Error ? err.message : 'Failed to read file.'; + toast.error(msg); + } finally { + if (readGenerationRef.current === generation) setFileLoading(false); + } + }, [volumeName]); + + const handleClose = (open: boolean) => { + if (!open) { + setSelectedPath(''); + setFileResult(null); + setFileLoading(false); + onClose(); + } + }; + + const loadDir = useCallback( + (relPath: string) => { + if (!volumeName) return Promise.resolve([]); + return listVolumeDirectory(volumeName, relPath); + }, + [volumeName] + ); + + return ( + + + + + + {volumeName} + + + + + {volumeName && ( +
+
+ +
+ +
+ {!selectedPath && ( +
+ Select a file to preview. +
+ )} + {selectedPath && fileLoading && ( +
+ + + + +
+ )} + {selectedPath && !fileLoading && fileResult && ( + + )} +
+
+ )} + +

+ File reads are recorded in the audit log. +

+
+
+ ); +} + +function FileResultPanel({ path, result }: { path: string; result: VolumeFileResult }) { + const decoded = result.binary ? base64ToHex(result.content) : result.content; + return ( +
+
+ {path} + + {formatBytes(result.size)}{result.binary ? ' · binary' : ''}{result.truncated ? ' · truncated' : ''} + +
+ {result.truncated && ( +
+ Showing first {formatBytes(5 * 1024 * 1024)}. Larger files cannot be downloaded from this view. +
+ )} +
+        {decoded}
+      
+
+ ); +} + +function base64ToHex(b64: string): string { + try { + const binary = atob(b64); + const out: string[] = []; + for (let i = 0; i < binary.length; i += 16) { + const offset = i.toString(16).padStart(8, '0'); + const chunk = binary.slice(i, i + 16); + const hex = Array.from(chunk).map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' '); + const ascii = Array.from(chunk).map((c) => { + const code = c.charCodeAt(0); + return code >= 32 && code <= 126 ? c : '.'; + }).join(''); + out.push(`${offset} ${hex.padEnd(48, ' ')} ${ascii}`); + } + return out.join('\n'); + } catch { + return '(unable to decode binary content)'; + } +} diff --git a/frontend/src/lib/volumeApi.ts b/frontend/src/lib/volumeApi.ts new file mode 100644 index 00000000..01720224 --- /dev/null +++ b/frontend/src/lib/volumeApi.ts @@ -0,0 +1,47 @@ +import { apiFetch } from './api'; +import { parseApiError } from './stackFilesApi'; +import type { FileEntry } from './stackFilesApi'; + +export interface VolumeFileResult { + content: string; + encoding: 'utf-8' | 'base64'; + binary: boolean; + truncated: boolean; + size: number; + mime: string; +} + +export interface VolumeStat extends FileEntry { + symlinkTarget?: string; +} + +function volumeUrl(volumeName: string, suffix: string): string { + return `/volumes/${encodeURIComponent(volumeName)}${suffix}`; +} + +export async function listVolumeDirectory( + volumeName: string, + relPath: string +): Promise { + const res = await apiFetch(volumeUrl(volumeName, `/list?path=${encodeURIComponent(relPath)}`)); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function readVolumeFile( + volumeName: string, + relPath: string +): Promise { + const res = await apiFetch(volumeUrl(volumeName, `/read?path=${encodeURIComponent(relPath)}`)); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function statVolumePath( + volumeName: string, + relPath: string +): Promise { + const res = await apiFetch(volumeUrl(volumeName, `/stat?path=${encodeURIComponent(relPath)}`)); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +}