/** * StackFileRootsService discovery: bind classification (relative + absolute), * file-bind and inaccessible degradation, the dangerous-mount blocklist, the * managed-area overlap guard (stack-dir fold + sibling/ancestor suppression), * named-volume Docker-name resolution and unresolvable degradation, mixed * read-only aggregation, render-failure keeping only the stack-source root, and * the no-stale-allowlist guarantee. Real temp directories back the bind probe; * ComposeService / DockerController / FileSystemService are stubbed. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { StackFileRootsService, isDangerousHostPath, STACK_SOURCE_ROOT_ID, } from '../services/StackFileRootsService'; import { ComposeService } from '../services/ComposeService'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; // Real symlink creation needs admin or Developer Mode on Windows, so the // real-filesystem dangling-symlink case runs on Linux/macOS (CI) and skips on the // Windows dev box, mirroring filesystem-symlink-escape.test.ts. const isWindows = process.platform === 'win32'; const STACK = 'app'; let baseDir: string; let stackDir: string; // The real OS temp, captured before beforeEach redirects os.tmpdir() for the // service. Test scratch (baseDir, legitimate external binds) lives here and stays // browsable, while the service's view of the OS temp root is pointed at a separate // dir so the managed-temp containment can be exercised without flagging the // test's own scratch. const REAL_TMP = os.tmpdir(); let managedTmpDir: string; // Env keys the managed-temp tests mutate. Snapshotted and restored around every // case so an inherited value (CI or a developer shell) is never clobbered. const MANAGED_ENV_KEYS = ['TMPDIR', 'TEMP', 'TMP', 'SENCHO_UPLOAD_DIR', 'TRIVY_BIN', 'TRIVY_CACHE_DIR'] as const; let savedEnv: Partial>; interface RawMount { type: 'bind' | 'volume' | 'tmpfs'; source?: string; target: string; read_only?: boolean } function renderModel(servicesVolumes: Record, volumes: Record = {}): string { const services: Record = {}; for (const [svc, vols] of Object.entries(servicesVolumes)) services[svc] = { volumes: vols }; return JSON.stringify({ services, volumes }); } /** Stub the three singletons the service depends on. */ function stub(opts: { rendered: string | null; volumeInspect?: (name: string) => Promise }): void { vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ renderConfig: vi.fn().mockResolvedValue({ rendered: opts.rendered, stderr: '', timedOut: false }), } as unknown as ReturnType); vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ getBaseDir: () => baseDir, } as unknown as ReturnType); vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getDocker: () => ({ getVolume: (name: string) => ({ inspect: () => (opts.volumeInspect ?? (async () => ({ Name: name })))(name), }), }), } as unknown as ReturnType); } beforeEach(async () => { baseDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-base-'))); stackDir = path.join(baseDir, STACK); await fs.mkdir(stackDir, { recursive: true }); // Redirect os.tmpdir() (the service's "OS temp root") to a dedicated dir, kept // separate from REAL_TMP where the test scratch lives, so a bind to the OS temp // root can be asserted non-browsable while ordinary REAL_TMP binds stay browsable. managedTmpDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-managed-tmp-'))); savedEnv = {}; for (const key of MANAGED_ENV_KEYS) savedEnv[key] = process.env[key]; process.env.TMPDIR = managedTmpDir; process.env.TEMP = managedTmpDir; process.env.TMP = managedTmpDir; }); afterEach(async () => { vi.restoreAllMocks(); for (const key of MANAGED_ENV_KEYS) { if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; } await fs.rm(baseDir, { recursive: true, force: true }).catch(() => {}); await fs.rm(managedTmpDir, { recursive: true, force: true }).catch(() => {}); // The service cache is module-level; clear it between cases. StackFileRootsService.invalidate(1, STACK); }); describe('isDangerousHostPath', () => { it('flags the root and protected system directories and their descendants', () => { for (const p of [ '/', '/etc', '/etc/nginx', '/proc', '/sys/x', '/dev/sda', '/var/run', '/var/run/docker.sock', '/run/x', // System locations holding the executables/libraries Sencho's runtime // depends on: a bind here could overwrite a binary a deploy later runs. '/usr', '/usr/local/bin', '/usr/local/bin/node', '/usr/bin', '/usr/lib', '/bin', '/bin/sh', '/sbin', '/lib', '/lib64', '/boot', '/root', '/root/.ssh', ]) { expect(isDangerousHostPath(p)).toBe(true); } }); it('allows ordinary host paths, including ones whose name only prefixes a protected root', () => { for (const p of ['/home/user/config', '/srv/app/data', '/opt/app', '/mnt/data', 'C:\\data', '/etcetera', '/usrdata', '/libreoffice', '/booted']) { expect(isDangerousHostPath(p)).toBe(false); } }); }); describe('StackFileRootsService.listRoots', () => { it('always includes a writable stack-source root', async () => { stub({ rendered: renderModel({}) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const src = roots.find((r) => r.id === STACK_SOURCE_ROOT_ID); expect(src).toBeDefined(); expect(src?.browsable && src?.writable && src?.backend === 'fs').toBe(true); }); it('classifies a relative bind to a directory as a browsable, writable root', async () => { await fs.mkdir(path.join(stackDir, 'config')); stub({ rendered: renderModel({ web: [{ type: 'bind', source: './config', target: '/config', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.browsable).toBe(true); expect(bind?.writable).toBe(true); expect(bind?.hostPathOrName).toBe(await fs.realpath(path.join(stackDir, 'config'))); expect(bind?.mounts[0]).toMatchObject({ service: 'web', containerPath: '/config', readOnly: false }); }); it('marks a bind to a single file as non-browsable', async () => { const file = path.join(stackDir, 'single.conf'); await fs.writeFile(file, 'x'); stub({ rendered: renderModel({ web: [{ type: 'bind', source: file, target: '/c', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.browsable).toBe(false); expect(bind?.warning).toBeTruthy(); }); it('marks an inaccessible absolute bind as non-browsable with a warning', async () => { const missing = path.join(baseDir, 'does-not-exist-xyz'); stub({ rendered: renderModel({ web: [{ type: 'bind', source: missing, target: '/c' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.accessible).toBe(false); expect(bind?.browsable).toBe(false); }); it('classifies a reachable absolute bind outside the compose base as browsable and writable', async () => { // A config directory mounted into both the app and the Sencho container can // legitimately live outside the compose base. When Sencho can stat it, the // root must be fully browsable/editable, not silently dropped as unreachable. const outside = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-ext-'))); try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: outside, target: '/config', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.accessible).toBe(true); expect(bind?.browsable).toBe(true); expect(bind?.writable).toBe(true); expect(bind?.managedSourceOverlap).toBe(false); expect(bind?.hostPathOrName).toBe(outside); } finally { await fs.rm(outside, { recursive: true, force: true }).catch(() => {}); } }); it('marks an unreachable absolute bind outside the compose base as non-browsable', async () => { const outsideMissing = path.join(os.tmpdir(), 'sfr-ext-absent-xyz-12345'); stub({ rendered: renderModel({ web: [{ type: 'bind', source: outsideMissing, target: '/config' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.accessible).toBe(false); expect(bind?.browsable).toBe(false); expect(bind?.warning).toBeTruthy(); }); it('blocks a dangerous host bind (/etc) and never exposes it as browsable', async () => { stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/etc', target: '/host-etc' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.dangerous).toBe(true); expect(bind?.browsable).toBe(false); }); it('blocks a bind into a system binary directory (/usr/local/bin) so Sencho binaries cannot be overwritten', async () => { // A stack author with stack:edit could otherwise declare /usr/local/bin as a // bind source, overwrite node/docker/the entrypoint, and have a later deploy // execute it. The declared source is dangerous regardless of how realpath // rewrites it (covered by the dangerousSource term), so this holds on any host. stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/usr/local/bin', target: '/host-bin', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.dangerous).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); expect(bind?.chmodable).toBe(false); }); it('blocks a dangerous declared source even when realpath rewrites it to a benign canonical', async () => { // Guards the dangerousSource term: realpath can rewrite a dangerous POSIX // source to a benign-looking canonical (a non-existent POSIX path resolves // drive-prefixed on a non-Linux host), so isDangerousHostPath(canonical) // alone would miss it. The classification must also read the literal source. vi.spyOn(fs, 'realpath').mockResolvedValue('/srv/benign-canonical' as never); vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => true } as never); stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/etc', target: '/host-etc' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.dangerous).toBe(true); expect(bind?.browsable).toBe(false); }); it('folds a bind equal to the stack dir into stack-source (no second editable root)', async () => { stub({ rendered: renderModel({ web: [{ type: 'bind', source: stackDir, target: '/app' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); expect(roots.some((r) => r.kind === 'bind')).toBe(false); expect(roots.filter((r) => r.id === STACK_SOURCE_ROOT_ID)).toHaveLength(1); }); it('suppresses a bind that points into a sibling stack as a managed-area overlap', async () => { const sibling = path.join(baseDir, 'other'); await fs.mkdir(sibling); stub({ rendered: renderModel({ web: [{ type: 'bind', source: sibling, target: '/x' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); }); it('suppresses a bind that is an ancestor of the compose base dir', async () => { stub({ rendered: renderModel({ web: [{ type: 'bind', source: baseDir, target: '/x' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); }); it("suppresses a bind that overlaps Sencho's own application directory", async () => { // process.cwd() is Sencho's install root in the container (/app, holding // dist/, public/, node_modules). A non-admin must not be able to declare a // bind into it and reach Sencho's program files via the file explorer. const appDir = await fs.realpath(await fs.mkdtemp(path.join(process.cwd(), 'sfr-app-'))); try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: appDir, target: '/config', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); } finally { await fs.rm(appDir, { recursive: true, force: true }).catch(() => {}); } }); it("suppresses a bind to the OS temp root, where Sencho writes transient registry credentials", async () => { // ComposeService and TrivyService write a docker config.json (resolved // registry auth) under os.tmpdir(); a bind that exposes that dir would let // the file explorer read those secrets. (os.tmpdir() is managedTmpDir here.) stub({ rendered: renderModel({ web: [{ type: 'bind', source: managedTmpDir, target: '/host-tmp', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); expect(bind?.chmodable).toBe(false); }); it('suppresses a bind into a subdirectory of the OS temp root (the per-scan credential dir)', async () => { const sub = path.join(managedTmpDir, 'sencho-trivy-xyz'); await fs.mkdir(sub); stub({ rendered: renderModel({ web: [{ type: 'bind', source: sub, target: '/c' }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); }); it('suppresses a bind to a relocated upload spool (SENCHO_UPLOAD_DIR outside the OS temp root)', async () => { const uploadDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-upload-'))); process.env.SENCHO_UPLOAD_DIR = uploadDir; try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: uploadDir, target: '/u', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); } finally { // env is restored by afterEach; only the scratch dir needs removing here. await fs.rm(uploadDir, { recursive: true, force: true }).catch(() => {}); } }); it('suppresses a bind that exposes a relocated Trivy binary (TRIVY_BIN), so it cannot be overwritten then run', async () => { const binDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-trivybin-'))); process.env.TRIVY_BIN = path.join(binDir, 'trivy'); try { // A bind to the directory that holds the configured Trivy binary is an // ancestor of that binary, so it must be suppressed. stub({ rendered: renderModel({ web: [{ type: 'bind', source: binDir, target: '/opt/trivy', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.writable).toBe(false); } finally { await fs.rm(binDir, { recursive: true, force: true }).catch(() => {}); } }); it('suppresses a bind that is an ancestor of a relocated Trivy cache (TRIVY_CACHE_DIR)', async () => { // The cache lives in a subdirectory; binding its parent is the reverse // overlap direction (the managed dir is within the bind), which must also // be caught so the cache cannot be reached through the parent bind. const parent = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-cacheparent-'))); process.env.TRIVY_CACHE_DIR = path.join(parent, 'trivy-cache'); try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: parent, target: '/cache', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); } finally { await fs.rm(parent, { recursive: true, force: true }).catch(() => {}); } }); it('suppresses a bind to the real target of a symlinked managed Trivy binary (canonical managed root)', async () => { // TRIVY_BIN (and the other managed paths) may itself be a symlink, e.g. // /opt/trivy -> /srv/tool-target/trivy. Bind sources are canonicalized before // the overlap check, so the managed paths must be canonicalized too. Without // it, a bind to the symlink target's real directory does not overlap the // configured symlink path, and a stack editor could overwrite the real binary // a later scan executes. realpath is the only canonicalization seam, so stub // it to resolve the configured symlink to the real binary path inside the // bound directory; identity for every other path leaves the real // baseDir/app/tmp comparisons intact. const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-symtarget-'))); const realBinary = path.join(targetDir, 'trivy'); const symlinkBin = path.join(REAL_TMP, 'sfr-symlinked-trivy-bin'); process.env.TRIVY_BIN = symlinkBin; vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => path.resolve(p) === path.resolve(symlinkBin) ? realBinary : p) as typeof fs.realpath); try { // Bind the real target directory, NOT the symlink's directory: only the // canonicalized managed root catches this overlap. stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, target: '/opt/trivy', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); } finally { await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {}); } }); it('suppresses a bind to the target parent of a dangling symlinked managed Trivy binary (missing leaf)', async () => { // The exploitable edge: TRIVY_BIN=/opt/trivy -> /srv/tool/trivy where /srv/tool // exists but the leaf /srv/tool/trivy does not exist yet. fs.realpath on the // dangling symlink throws ENOENT, so canonicalization must follow the link // chain manually and preserve the resolved target parent plus the missing leaf. // Otherwise a bind to /srv/tool is allowed, the stack editor creates trivy // there, and a later scan executes the attacker-supplied binary. const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-dangling-target-'))); const danglingLeaf = path.join(targetDir, 'trivy'); // intentionally never created const symlinkBin = path.join(REAL_TMP, 'sfr-dangling-trivy-bin'); // dangling symlink -> danglingLeaf process.env.TRIVY_BIN = symlinkBin; const enoent = (): never => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }; const isAt = (p: string, q: string): boolean => path.resolve(p) === path.resolve(q); // Simulate the OS view of a dangling symlink: realpath of the link (and of its // missing target) is ENOENT, lstat reports the link as a symlink, readlink // yields the absolute target. Every existing path resolves to itself. vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => (isAt(p, symlinkBin) || isAt(p, danglingLeaf)) ? enoent() : p) as typeof fs.realpath); vi.spyOn(fs, 'lstat').mockImplementation((async (p: string) => (isAt(p, symlinkBin) ? { isSymbolicLink: () => true } : enoent())) as unknown as typeof fs.lstat); vi.spyOn(fs, 'readlink').mockImplementation((async (p: string) => (isAt(p, symlinkBin) ? danglingLeaf : enoent())) as typeof fs.readlink); try { // Bind the existing target directory, whose missing leaf the symlink points at. stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, target: '/opt/trivy', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); } finally { await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {}); } }); it('follows a relative dangling managed symlink target when canonicalizing (relative readlink)', async () => { // Guards the relative-target branch of realpathAllowingMissing: a managed // symlink whose readlink returns a relative path must be resolved against the // link's own directory, not the cwd. Package managers commonly create such links. const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-reltarget-'))); const danglingLeaf = path.join(targetDir, 'trivy'); // never created const linkDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-rellink-'))); const symlinkBin = path.join(linkDir, 'trivy'); const relativeLink = path.relative(linkDir, danglingLeaf); // resolves to danglingLeaf against linkDir process.env.TRIVY_BIN = symlinkBin; const enoent = (): never => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }; const isAt = (p: string, q: string): boolean => path.resolve(p) === path.resolve(q); vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => (isAt(p, symlinkBin) || isAt(p, danglingLeaf)) ? enoent() : p) as typeof fs.realpath); vi.spyOn(fs, 'lstat').mockImplementation((async (p: string) => (isAt(p, symlinkBin) ? { isSymbolicLink: () => true } : enoent())) as unknown as typeof fs.lstat); vi.spyOn(fs, 'readlink').mockImplementation((async (p: string) => (isAt(p, symlinkBin) ? relativeLink : enoent())) as typeof fs.readlink); try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, target: '/opt/trivy', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); } finally { await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {}); await fs.rm(linkDir, { recursive: true, force: true }).catch(() => {}); } }); it.skipIf(isWindows)('suppresses a bind to the real target parent of a dangling managed symlink (real fs)', async () => { // Authoritative real-symlink form of the dangling-leaf case: create an actual // dangling symlink whose leaf is never created, and confirm a bind to the // existing target directory is suppressed. This exercises real fs.realpath / // lstat / readlink rather than a mock, so it catches any divergence between // the canonicalization helper and real OS symlink semantics. const targetDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-rdangling-target-'))); const danglingLeaf = path.join(targetDir, 'trivy'); // never created const linkDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-rdangling-link-'))); const symlinkBin = path.join(linkDir, 'trivy'); await fs.symlink(danglingLeaf, symlinkBin); // dangling: the target leaf is absent process.env.TRIVY_BIN = symlinkBin; try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: targetDir, target: '/opt/trivy', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(true); expect(bind?.browsable).toBe(false); expect(bind?.writable).toBe(false); } finally { await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {}); await fs.rm(linkDir, { recursive: true, force: true }).catch(() => {}); } }); it('keeps a legitimate external bind browsable when a configured managed path is absent (ENOENT realpath tolerated)', async () => { // A relocated TRIVY_BIN (or upload spool) routinely points at a path that does // not exist yet on a fresh install, so realpath on a configured managed path // throws ENOENT every discovery. That must be tolerated: the configured path // still anchors containment and an unrelated external bind stays browsable, // rather than the realpath failure collapsing discovery to stack-source only. process.env.TRIVY_BIN = path.join(REAL_TMP, 'sfr-absent-trivy-bin-xyz-12345'); const outside = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-legit-'))); try { stub({ rendered: renderModel({ web: [{ type: 'bind', source: outside, target: '/config', read_only: false }] }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const bind = roots.find((r) => r.kind === 'bind'); expect(bind?.managedSourceOverlap).toBe(false); expect(bind?.browsable).toBe(true); expect(bind?.writable).toBe(true); } finally { await fs.rm(outside, { recursive: true, force: true }).catch(() => {}); } }); it('degrades safely when a managed path cannot be canonicalized (non-ENOENT error keeps containment)', async () => { // A non-ENOENT realpath failure on one managed path (e.g. EACCES on an // intermediate dir) must not collapse discovery or silently drop containment: // the loop continues for other paths, the configured path stays anchored, and // the failure is logged. const binDir = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-eacces-bin-'))); const trivyBin = path.join(binDir, 'trivy'); process.env.TRIVY_BIN = trivyBin; const outside = await fs.realpath(await fs.mkdtemp(path.join(REAL_TMP, 'sfr-eacces-legit-'))); const isAt = (p: string, q: string): boolean => path.resolve(p) === path.resolve(q); vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => { if (isAt(p, trivyBin)) throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); return p; }) as typeof fs.realpath); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { stub({ rendered: renderModel({ legit: [{ type: 'bind', source: outside, target: '/config', read_only: false }], tool: [{ type: 'bind', source: binDir, target: '/opt/trivy', read_only: false }], }) }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const legit = roots.find((r) => r.hostPathOrName === outside); const tool = roots.find((r) => r.hostPathOrName === binDir); // Discovery did not collapse: the unrelated bind is still browsable. expect(legit?.browsable).toBe(true); // The configured TRIVY_BIN literal still anchors containment for its own dir. expect(tool?.managedSourceOverlap).toBe(true); expect(tool?.browsable).toBe(false); expect(warnSpy).toHaveBeenCalled(); } finally { await fs.rm(binDir, { recursive: true, force: true }).catch(() => {}); await fs.rm(outside, { recursive: true, force: true }).catch(() => {}); } }); it('resolves a named volume by its Docker name (not the compose key) and inspects that name', async () => { const inspected: string[] = []; stub({ rendered: renderModel({ db: [{ type: 'volume', source: 'cache', target: '/c' }] }, { cache: { name: 'app_cache' } }), volumeInspect: async (name) => { inspected.push(name); return { Name: name }; }, }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const vol = roots.find((r) => r.kind === 'volume'); expect(vol?.hostPathOrName).toBe('app_cache'); expect(vol?.backend).toBe('helper'); expect(vol?.browsable).toBe(true); expect(inspected).toContain('app_cache'); }); it('degrades a named volume that cannot be inspected to non-browsable', async () => { stub({ rendered: renderModel({ db: [{ type: 'volume', source: 'gone', target: '/c' }] }), volumeInspect: async () => { throw new Error('no such volume'); }, }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const vol = roots.find((r) => r.kind === 'volume'); expect(vol?.accessible).toBe(false); expect(vol?.browsable).toBe(false); expect(vol?.warning).toBeTruthy(); }); it('aggregates a source mounted :ro and :rw across services to a single writable root', async () => { await fs.mkdir(path.join(stackDir, 'shared')); const src = path.join(stackDir, 'shared'); stub({ rendered: renderModel({ a: [{ type: 'bind', source: src, target: '/s', read_only: true }], b: [{ type: 'bind', source: src, target: '/s', read_only: false }], }), }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); const binds = roots.filter((r) => r.kind === 'bind'); expect(binds).toHaveLength(1); expect(binds[0].readonly).toBe(false); expect(binds[0].writable).toBe(true); expect(binds[0].mounts).toHaveLength(2); }); it('returns only the stack-source root when the model render fails', async () => { stub({ rendered: null }); const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); expect(roots).toHaveLength(1); expect(roots[0].id).toBe(STACK_SOURCE_ROOT_ID); }); it('never serves a stale allowlist: a later render failure drops previously-discovered roots', async () => { await fs.mkdir(path.join(stackDir, 'config')); const ok = renderModel({ web: [{ type: 'bind', source: './config', target: '/config' }] }); stub({ rendered: ok }); const svc = StackFileRootsService.getInstance(1); expect((await svc.listRoots(STACK)).some((r) => r.kind === 'bind')).toBe(true); // Mounts change such that the model no longer renders; after invalidation the // recompute must not fall back to the prior good allowlist. vi.restoreAllMocks(); stub({ rendered: null }); StackFileRootsService.invalidate(1, STACK); const after = await StackFileRootsService.getInstance(1).listRoots(STACK); expect(after).toHaveLength(1); expect(after[0].id).toBe(STACK_SOURCE_ROOT_ID); }); it('resolveRoot rejects an unknown rootId and resolves stack-source without a render', async () => { stub({ rendered: renderModel({}) }); const svc = StackFileRootsService.getInstance(1); await expect(svc.resolveRoot(STACK, 'bind:deadbeef', { fresh: true })).rejects.toMatchObject({ code: 'INVALID_ROOT' }); const src = await svc.resolveRoot(STACK, STACK_SOURCE_ROOT_ID); expect(src.kind).toBe('stack-source'); }); it('invalidateNode clears the cached allowlist so a recreated stack cannot serve old roots', async () => { await fs.mkdir(path.join(stackDir, 'config')); stub({ rendered: renderModel({ web: [{ type: 'bind', source: './config', target: '/config' }] }) }); expect((await StackFileRootsService.getInstance(1).listRoots(STACK)).some((r) => r.kind === 'bind')).toBe(true); // Stack deleted + recreated under the same name with no declared volume; a // node-level invalidation (as the lifecycle routes trigger) must drop the // cached bind root rather than serve it from the TTL cache. vi.restoreAllMocks(); stub({ rendered: renderModel({}) }); StackFileRootsService.invalidateNode(1); const after = await StackFileRootsService.getInstance(1).listRoots(STACK); expect(after.some((r) => r.kind === 'bind')).toBe(false); expect(after).toHaveLength(1); }); });