Files
sencho/backend/src/__tests__/stack-file-roots-service.test.ts
T
Anso bee0dfd094 fix: contain file-explorer binds that reach dangling symlinked managed paths (#1490)
A previous fix canonicalized each Sencho-managed path (compose base, data dir,
application root, OS temp root, upload spool, Trivy binary, Trivy cache) with
fs.realpath so a managed path that is itself a symlink could not be reached through
a bind to its real target. fs.realpath returns ENOENT for a dangling symlink whose
final leaf does not exist yet, so that case fell through to the configured path only
and the implied target was lost. With TRIVY_BIN pointing at a symlink to a not yet
created binary, a bind to the symlink target's existing parent directory was still
classified as browsable and writable. A stack editor could create the leaf there
and have a later pre or post deploy scan execute the attacker supplied binary, which
the default image runs as root. The same gap applied to dangling symlinked upload
spool, Trivy cache, and OS temp roots.

Resolve managed paths with a helper that follows the symlink chain even when the
final leaf is absent: it follows a dangling link through readlink (resolving a
relative target against the link's own directory) and, for a missing leaf, resolves
the longest existing ancestor and re-appends the absent suffix, so the real target
location is always represented in the overlap set. A bounded hop count guards
against a symlink cycle that never surfaces as ELOOP, falling back to the lexical
path with a log. An unexpected resolution failure (EACCES, ELOOP) keeps the
configured path as the containment anchor, continues discovery for the other
managed paths, and is logged.

Tests: a mock-based regression test that fails before the fix, a real-filesystem
dangling-symlink test on Linux and macOS CI, a relative-symlink-target case, and a
non-ENOENT failure case asserting discovery degrades safely.
2026-06-27 14:32:50 -04:00

602 lines
33 KiB
TypeScript

/**
* 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<Record<(typeof MANAGED_ENV_KEYS)[number], string | undefined>>;
interface RawMount { type: 'bind' | 'volume' | 'tmpfs'; source?: string; target: string; read_only?: boolean }
function renderModel(servicesVolumes: Record<string, RawMount[]>, volumes: Record<string, { name: string }> = {}): string {
const services: Record<string, unknown> = {};
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<unknown> }): void {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered: opts.rendered, stderr: '', timedOut: false }),
} as unknown as ReturnType<typeof ComposeService.getInstance>);
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getBaseDir: () => baseDir,
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDocker: () => ({
getVolume: (name: string) => ({
inspect: () => (opts.volumeInspect ?? (async () => ({ Name: name })))(name),
}),
}),
} as unknown as ReturnType<typeof DockerController.getInstance>);
}
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);
});
});