feat(stacks): browse and edit mounted volume files in the explorer (#1403)

* feat(stacks): browse and edit mounted volume files in the explorer

Reposition the stack file explorer around runtime configuration access:
discover a stack's declared mounts and expose each as a safe, stack-scoped
file root. The explorer opens on a Volumes group (bind mounts and named
Docker volumes) by default, with the stack source directory as a secondary
group, on a "Files & Volumes" tab.

- Discover roots from the rendered effective compose model; resolve named
  volumes to their Docker name and browse/edit them through the hardened
  helper container, with bind mounts handled directly when reachable.
- Re-derive the allowed roots server-side on every file operation and match
  the client root id against them, so a request can never address a path the
  stack did not declare. Block dangerous host mounts and binds that overlap
  Sencho's managed directories; reject writes to read-only mounts.
- Thread an optional root id through the existing file endpoints and an
  opaque, parseable optimistic-concurrency token through read, conflict,
  and write, for both filesystem and helper backends.
- Keep compose and env file protection on the stack source root only.

* fix(stacks): theme the Files & Volumes root switcher

Replace the raw native select in the file-root switcher with the design
system Select component. The native control did not honour the dark theme,
so the panel rendered white with unreadable text. The themed Select gives a
dark popover with grouped Volumes / Stack source labels and disabled items.

* fix(stacks): contain the bind-root probe and de-taint the file-op error log

Gate the volume-root bind probe's realpath/stat behind a compose-base
containment check (mirroring the storage host-path probe) so they never run
on an unvalidated host path; a source outside the compose dir is unreachable
in the containerized deployment anyway and is reported non-accessible without
touching the filesystem. Log the helper-backed file-op failure through a
constant format string with sanitized arguments instead of an interpolated
template literal.

* fix(stacks): inline the bind-probe containment guard at the fs sinks

The wrapped containment predicate was not recognized as a path barrier, so
the bind probe's realpath/stat still flagged as uncontrolled-data-in-path.
Inline the path.resolve + startsWith check directly at each filesystem sink
(and re-check the resolved canonical before stat, so a within-base symlink
that resolves outside the compose dir is also rejected).

* fix(stacks): harden file-root lifecycle, upload race, and helper errors

Address review findings on the Files & Volumes feature:

- Invalidate the file-root allowlist on stack create/delete/import/from-git
  (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a
  stack deleted and recreated under the same name cannot serve the old stack's
  roots from the 15s cache.
- Use the atomic exclusive write for a non-overwrite upload so a file created
  by another writer after the existence check is not silently clobbered.
- Let the helper's real cd errno through and map permission failures to 403
  consistently across list/stat/read/write/mkdir/delete/pathKind, instead of
  reporting EACCES as 404/500; pathKind no longer reports a permission-denied
  parent as absent.
- Document the realpath-then-open TOCTOU as a known, pre-existing limitation of
  every file op (O_NOFOLLOW is not viable because config volumes legitimately
  contain symlinks); the bind root is contained to the compose dir and the op
  requires stack:edit.
- Docs: drop a missing screenshot reference and correct the protected-file
  delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
Anso
2026-06-21 18:16:20 -04:00
committed by GitHub
parent b611f41872
commit b9d8e9f490
24 changed files with 1986 additions and 205 deletions
@@ -723,3 +723,68 @@ describe('FileSystemService stack methods', () => {
});
});
});
// Root-scoped (bind-mount) behaviour: the file methods accept an arbitrary
// absolute root that may sit OUTSIDE the compose dir, contain paths within it,
// and disable compose/.env protection.
describe('FileSystemService root-scoped methods', () => {
let tmpBase: string;
let rootDir: string;
beforeEach(async () => {
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-fsr-'));
// A bind root that is deliberately not under the compose dir.
mockState.composeDir = path.join(tmpBase, 'compose');
rootDir = path.join(tmpBase, 'volume-root');
await fs.mkdir(rootDir, { recursive: true });
await fs.mkdir(mockState.composeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(tmpBase, { recursive: true, force: true });
});
it('lists, reads, and writes within an arbitrary root outside the compose dir', async () => {
await fs.writeFile(path.join(rootDir, 'app.conf'), 'listen 80;');
const scope = { rootAbsDir: rootDir, protectedEnabled: false };
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory('ignored', '', scope);
expect(entries.map(e => e.name)).toContain('app.conf');
const read = await service.readStackFile('ignored', 'app.conf', undefined, { scope });
expect(read.content).toBe('listen 80;');
const write = await service.writeStackFileIfUnchanged('ignored', 'app.conf', 'listen 8080;', read.mtimeMs, scope);
expect(write.ok).toBe(true);
expect(await fs.readFile(path.join(rootDir, 'app.conf'), 'utf-8')).toBe('listen 8080;');
});
it('rejects a path that escapes the root via ..', async () => {
const scope = { rootAbsDir: rootDir, protectedEnabled: false };
const service = FileSystemService.getInstance();
await expect(service.readStackFile('ignored', '../compose/secret', undefined, { scope }))
.rejects.toMatchObject({ code: 'INVALID_PATH' });
});
it('does not mark a volume compose.yaml/.env as protected when protection is disabled', async () => {
await fs.writeFile(path.join(rootDir, 'compose.yaml'), '');
await fs.writeFile(path.join(rootDir, '.env'), '');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory('ignored', '', { rootAbsDir: rootDir, protectedEnabled: false });
expect(entries.every(e => !e.isProtected)).toBe(true);
// A delete of a volume .env is allowed (not blocked as a protected stack file).
await service.deleteStackPath('ignored', '.env', false, { rootAbsDir: rootDir, protectedEnabled: false });
await expect(fs.lstat(path.join(rootDir, '.env'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects a symlink leaf whose target escapes the root', async () => {
if (isWindows) return; // POSIX symlink semantics
const outside = path.join(tmpBase, 'outside.txt');
await fs.writeFile(outside, 'secret');
await fs.symlink(outside, path.join(rootDir, 'escape'));
const service = FileSystemService.getInstance();
await expect(service.readStackFile('ignored', 'escape', undefined, { scope: { rootAbsDir: rootDir, protectedEnabled: false } }))
.rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
});
});
@@ -0,0 +1,244 @@
/**
* 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';
const STACK = 'app';
let baseDir: string;
let stackDir: string;
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(os.tmpdir(), 'sfr-base-')));
stackDir = path.join(baseDir, STACK);
await fs.mkdir(stackDir, { recursive: true });
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(baseDir, { 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']) {
expect(isDangerousHostPath(p)).toBe(true);
}
});
it('allows ordinary host paths', () => {
for (const p of ['/home/user/config', '/srv/app/data', 'C:\\data', '/etcetera']) {
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('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('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('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);
});
});
+4 -1
View File
@@ -1,4 +1,5 @@
import { CacheService } from '../services/CacheService';
import { StackFileRootsService } from '../services/StackFileRootsService';
export const REMOTE_META_NAMESPACE = 'remote-meta';
@@ -8,13 +9,15 @@ export const REMOTE_META_NAMESPACE = 'remote-meta';
*
* Also drops the global `project-name-map` since stack writes (create,
* delete, rename, compose edits) can reshape the on-disk layout used to
* build it.
* build it, and the file-root allowlists for the node so a stack deleted and
* recreated under the same name cannot serve the old stack's roots.
*/
export function invalidateNodeCaches(nodeId: number): void {
const cache = CacheService.getInstance();
cache.invalidate(`stats:${nodeId}`);
cache.invalidate(`stack-statuses:${nodeId}`);
cache.invalidate('project-name-map');
StackFileRootsService.invalidateNode(nodeId);
}
/**
+188 -42
View File
@@ -5,6 +5,8 @@ import { inspect } from 'node:util';
import YAML from 'yaml';
import multer from 'multer';
import { FileSystemService } from '../services/FileSystemService';
import { StackFileRootsService, STACK_SOURCE_ROOT_ID, stackSourceFileRoot, type StackFileRoot } from '../services/StackFileRootsService';
import { FileRootGateway } from '../services/FileRootGateway';
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import DockerController from '../services/DockerController';
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
@@ -480,6 +482,7 @@ stacksRouter.put('/:stackName', async (req: Request, res: Response) => {
});
}
invalidateNodeCaches(req.nodeId);
StackFileRootsService.invalidate(req.nodeId, stackName);
dlog(`[Stacks] Compose file saved: ${sanitizeForLog(stackName)}`);
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
res.json({ message: 'Stack saved successfully', mtimeMs: result.mtimeMs });
@@ -609,6 +612,7 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => {
});
}
invalidateNodeCaches(req.nodeId);
StackFileRootsService.invalidate(req.nodeId, stackName);
const envFileName = path.basename(envPath);
dlog(`[Stacks] Env file saved: ${sanitizeForLog(stackName)}/${sanitizeForLog(envFileName)}`);
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
@@ -1697,6 +1701,10 @@ stacksRouter.get('/:stackName/scan-status', (req: Request, res: Response): void
type FsErrorCode =
| 'INVALID_PATH'
| 'SYMLINK_ESCAPE'
| 'INVALID_ROOT'
| 'READONLY_ROOT'
| 'ROOT_UNAVAILABLE'
| 'UNSUPPORTED_ON_ROOT'
| 'IS_DIRECTORY'
| 'NOT_EMPTY'
| 'NOT_FOUND'
@@ -1742,10 +1750,88 @@ function sendFsError(
if (e.code === 'ENOENT') {
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
}
if (e.code === 'UNSUPPORTED_ON_ROOT') {
return res.status(400).json({ error: e.message, code: 'UNSUPPORTED_ON_ROOT' satisfies FsErrorCode });
}
if (e.code === 'FILE_EXISTS') {
return res.status(409).json({ error: e.message, code: 'FILE_EXISTS' satisfies FsErrorCode });
}
// Helper-backed (named-volume) ops throw an ExecError carrying an HTTP status;
// honour it (403 permission-denied, 409 conflict, 413 too-large, 504 timeout).
// A 4xx message is a self-explanatory client error and is forwarded as-is; a
// 5xx is a server-side failure, so log the detail and return a clean message.
const status = (err as { status?: unknown }).status;
if (typeof status === 'number' && status >= 400 && status < 600) {
if (status >= 500) {
// Constant format string + sanitized args (the status is already carried by
// the HTTP response, so it is not repeated in the log).
console.error('[files] %s (helper failure): %s', sanitizeForLog(fallback), sanitizeForLog(e.message));
return res.status(status).json({ error: fallback });
}
return res.status(status).json({ error: e.message });
}
console.error(`[files] ${fallback}:`, sanitizeForLog(e.message));
return res.status(500).json({ error: fallback });
}
const ROOT_ID_RE = /^[A-Za-z0-9:_-]{1,80}$/;
function readRootId(req: Request): string {
const raw = req.query.rootId;
return typeof raw === 'string' && raw ? raw : STACK_SOURCE_ROOT_ID;
}
/**
* Resolve the client's rootId to a server-derived root, enforcing browsability
* for reads and writability for writes. Sends the error response and returns
* null when the root is unknown, read-only, or not browsable, so the caller
* just does `if (!root) return;`. Writes resolve fresh (cache bypass) so a
* removed mount cannot be written through a stale allowlist.
*/
async function resolveRootForOp(
req: Request,
res: Response,
stackName: string,
mode: 'read' | 'write',
): Promise<StackFileRoot | null> {
const rootId = readRootId(req);
// Back-compat fast path: no rootId (or the stack-source root) is exactly the
// legacy behaviour. Return a synthetic stack-source root without touching the
// roots service, so plain stack-source ops never trigger a compose render.
if (rootId === STACK_SOURCE_ROOT_ID) {
return stackSourceFileRoot();
}
if (!ROOT_ID_RE.test(rootId)) {
res.status(400).json({ error: 'Invalid root', code: 'INVALID_ROOT' satisfies FsErrorCode });
return null;
}
let root: StackFileRoot;
try {
root = await StackFileRootsService.getInstance(req.nodeId).resolveRoot(stackName, rootId, { fresh: mode === 'write' });
} catch (err) {
if ((err as { code?: string }).code === 'INVALID_ROOT') {
res.status(400).json({ error: 'Unknown file root', code: 'INVALID_ROOT' satisfies FsErrorCode });
return null;
}
sendFsError(res, err, 'Failed to resolve file root');
return null;
}
if (mode === 'write' && !root.writable) {
res.status(403).json({ error: root.warning ?? 'This location is read-only.', code: 'READONLY_ROOT' satisfies FsErrorCode });
return null;
}
if (mode === 'read' && !root.browsable) {
res.status(400).json({ error: root.warning ?? 'This location cannot be browsed.', code: 'ROOT_UNAVAILABLE' satisfies FsErrorCode });
return null;
}
return root;
}
/** Drop the cached root allowlist after a stack-source mutation that can change declared mounts. */
function afterStackMutation(req: Request, stackName: string): void {
StackFileRootsService.invalidate(req.nodeId, stackName);
}
function logFileOperation(level: 'info' | 'warn', message: string, details: Record<string, unknown>): void {
const cleaned = Object.fromEntries(
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
@@ -1816,6 +1902,17 @@ function isSafeUploadFilename(rawName: string): boolean {
const DIR_LIST_LIMIT = 1000;
stacksRouter.get('/:stackName/file-roots', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
try {
const roots = await StackFileRootsService.getInstance(req.nodeId).listRoots(stackName);
return res.json(roots);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to list file roots');
}
});
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
@@ -1823,10 +1920,12 @@ stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const root = await resolveRootForOp(req, res, stackName, 'read');
if (!root) return;
const startedAt = Date.now();
logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId });
logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId, rootKind: root.kind });
try {
const result = await FileSystemService.getInstance(req.nodeId).listStackDirectoryPage(stackName, relPath, { limit: DIR_LIST_LIMIT });
const result = await FileRootGateway.getInstance(req.nodeId).listDir(root, stackName, relPath, DIR_LIST_LIMIT);
// Expose pagination context via headers; the JSON body stays
// FileEntry[] for backward compatibility with any direct API caller.
res.setHeader('X-Total-Count', String(result.total));
@@ -1859,14 +1958,17 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const forceText = req.query.force === 'text';
const root = await resolveRootForOp(req, res, stackName, 'read');
if (!root) return;
const startedAt = Date.now();
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId, forceText });
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId, forceText, rootKind: root.kind });
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath, undefined, { forceText });
// ETag is the integer mtimeMs the file was stat'd with, so the matching
// PUT can compare millisecond-equal even though some filesystems return
// float mtimeMs.
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
const result = await FileRootGateway.getInstance(req.nodeId).read(root, stackName, relPath, forceText);
// ETag carries the opaque version token the matching PUT compares. For fs
// roots it is the weak ETag over the integer mtimeMs (unchanged); for helper
// roots it is a composite token. The body also carries `version` so the
// client round-trips it verbatim as If-Match.
res.setHeader('ETag', result.version);
logFileDiag('read complete', {
stackName,
relPath,
@@ -1893,15 +1995,27 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const root = await resolveRootForOp(req, res, stackName, 'read');
if (!root) return;
const startedAt = Date.now();
logFileDiag('download start', { stackName, relPath, nodeId: req.nodeId });
logFileDiag('download start', { stackName, relPath, nodeId: req.nodeId, rootKind: root.kind });
try {
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
res.setHeader('Content-Type', result.mime);
res.setHeader('Content-Length', result.size);
const encodedFilename = encodeURIComponent(result.filename);
const safeFilename = result.filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
const result = await FileRootGateway.getInstance(req.nodeId).download(root, stackName, relPath);
const setDownloadHeaders = (filename: string, size: number, mime: string): void => {
res.setHeader('Content-Type', mime);
res.setHeader('Content-Length', size);
const encodedFilename = encodeURIComponent(filename);
const safeFilename = filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
};
// Helper-backed (named-volume) downloads come back as a bounded buffer; send
// it directly rather than through the file-stream lifecycle below.
if (result.kind === 'buffer') {
setDownloadHeaders(result.filename, result.size, 'application/octet-stream');
recordFileOp(req.nodeId, 'download', startedAt, true);
return res.end(result.buffer);
}
setDownloadHeaders(result.filename, result.size, result.mime);
// Track download completion off both the file stream's lifecycle and the
// response close. Under the in-process supertest transport, request close
// events can race ahead of normal stream completion. End/error are the
@@ -2018,6 +2132,8 @@ stacksRouter.post(
if (!isSafeUploadFilename(originalName)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
const overwrite = String(req.query.overwrite) === '1';
// The multer wrapper stashed the route-entry timestamp on the request so
@@ -2026,7 +2142,8 @@ stacksRouter.post(
const startedAt = (req as UploadStartedReq)._fileUploadStartedAt ?? Date.now();
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size, overwrite });
try {
const existing = await FileSystemService.getInstance(req.nodeId).pathKind(stackName, targetRelPath);
const gateway = FileRootGateway.getInstance(req.nodeId);
const existing = await gateway.pathKind(root, stackName, targetRelPath);
if (existing === 'directory') {
// A directory can never be replaced by an upload; surface a distinct code
// so the UI does not offer a useless "Replace" button.
@@ -2058,7 +2175,12 @@ stacksRouter.post(
},
});
}
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
// Use the atomic exclusive create for the non-overwrite case so a file
// created by another writer after the pathKind check above is not
// silently clobbered (a race surfaces as FILE_EXISTS -> 409, same as the
// pre-emptive check). overwrite=true intentionally allows the clobber.
await gateway.writeBuffer(root, stackName, targetRelPath, req.file.buffer, !overwrite);
afterStackMutation(req, stackName);
logFileOperation('info', 'mutate', {
nodeId: req.nodeId,
op: 'upload',
@@ -2066,6 +2188,8 @@ stacksRouter.post(
path: targetRelPath,
bytes: req.file.size,
overwrite,
rootKind: root.kind,
backend: root.backend,
});
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'upload', startedAt, true);
@@ -2097,23 +2221,19 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
if (typeof content !== 'string') {
return res.status(400).json({ error: '"content" must be a string' });
}
const expectedMtimeMs = parseIfMatchMtime(req.header('if-match'));
const expectedVersion = req.header('if-match') || undefined;
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
const startedAt = Date.now();
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedMtimeMs !== null });
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedVersion !== undefined, rootKind: root.kind });
try {
const result = await FileSystemService.getInstance(req.nodeId).writeStackFileIfUnchanged(
stackName,
relPath,
content,
expectedMtimeMs,
);
const result = await FileRootGateway.getInstance(req.nodeId).writeIfUnchanged(root, stackName, relPath, content, expectedVersion);
if (!result.ok) {
// Stale ETag: surface the current content + mtime so the client can
// show a "file changed elsewhere" diff and let the user reconcile.
// Real FS work ran (the if-unchanged stat compare); record the
// attempted-and-rejected write so operators chasing concurrent-edit
// patterns can see them in the snapshot.
res.setHeader('ETag', stackFileEtag(result.currentMtimeMs));
// Stale version: surface the current content + token so the client can
// show a "file changed elsewhere" diff and retry with the fresh version.
// Real work ran (the if-unchanged compare); record the attempted-and-
// rejected write so concurrent-edit patterns show in the snapshot.
res.setHeader('ETag', result.currentVersion);
return rejectFileMutation(req, res, {
op: 'write',
stack: stackName,
@@ -2125,16 +2245,20 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
error: 'File has been modified since you last read it. Reload to see the current version.',
currentMtimeMs: result.currentMtimeMs,
currentContent: result.currentContent,
currentVersion: result.currentVersion,
},
});
}
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
res.setHeader('ETag', result.version);
afterStackMutation(req, stackName);
logFileOperation('info', 'mutate', {
nodeId: req.nodeId,
op: 'write',
stack: stackName,
path: relPath,
bytes: Buffer.byteLength(content, 'utf-8'),
rootKind: root.kind,
backend: root.backend,
});
logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'write', startedAt, true);
@@ -2161,16 +2285,21 @@ stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) =>
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const recursive = req.query.recursive === '1';
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
const startedAt = Date.now();
logFileDiag('delete start', { stackName, relPath, recursive, nodeId: req.nodeId });
logFileDiag('delete start', { stackName, relPath, recursive, nodeId: req.nodeId, rootKind: root.kind });
try {
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
await FileRootGateway.getInstance(req.nodeId).deletePath(root, stackName, relPath, recursive);
afterStackMutation(req, stackName);
logFileOperation('info', 'mutate', {
nodeId: req.nodeId,
op: 'delete',
stack: stackName,
path: relPath,
recursive,
rootKind: root.kind,
backend: root.backend,
});
logFileDiag('delete timing', { stackName, relPath, recursive, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'delete', startedAt, true);
@@ -2197,15 +2326,20 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
const startedAt = Date.now();
logFileDiag('mkdir start', { stackName, relPath, nodeId: req.nodeId });
logFileDiag('mkdir start', { stackName, relPath, nodeId: req.nodeId, rootKind: root.kind });
try {
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
await FileRootGateway.getInstance(req.nodeId).mkdir(root, stackName, relPath);
afterStackMutation(req, stackName);
logFileOperation('info', 'mutate', {
nodeId: req.nodeId,
op: 'mkdir',
stack: stackName,
path: relPath,
rootKind: root.kind,
backend: root.backend,
});
logFileDiag('mkdir timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'mkdir', startedAt, true);
@@ -2239,16 +2373,21 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons
if (!isValidRelativeStackPath(to)) {
return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' });
}
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
const startedAt = Date.now();
logFileDiag('rename start', { stackName, from, to, nodeId: req.nodeId });
logFileDiag('rename start', { stackName, from, to, nodeId: req.nodeId, rootKind: root.kind });
try {
await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to);
await FileRootGateway.getInstance(req.nodeId).rename(root, stackName, from, to);
afterStackMutation(req, stackName);
logFileOperation('info', 'mutate', {
nodeId: req.nodeId,
op: 'rename',
stack: stackName,
path: from,
toPath: to,
rootKind: root.kind,
backend: root.backend,
});
logFileDiag('rename timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'rename', startedAt, true);
@@ -2275,10 +2414,12 @@ stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Resp
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const root = await resolveRootForOp(req, res, stackName, 'read');
if (!root) return;
const startedAt = Date.now();
logFileDiag('permissions read start', { stackName, relPath, nodeId: req.nodeId });
logFileDiag('permissions read start', { stackName, relPath, nodeId: req.nodeId, rootKind: root.kind });
try {
const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath);
const result = await FileRootGateway.getInstance(req.nodeId).getMode(root, stackName, relPath);
logFileDiag('permissions read complete', { stackName, relPath, nodeId: req.nodeId, mode: result.octal, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'permissionsRead', startedAt, true);
return res.json(result);
@@ -2301,16 +2442,21 @@ stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Resp
if (typeof mode !== 'number') {
return res.status(400).json({ error: '"mode" must be a number' });
}
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
const startedAt = Date.now();
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode });
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode, rootKind: root.kind });
try {
await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode);
await FileRootGateway.getInstance(req.nodeId).chmod(root, stackName, relPath, mode);
afterStackMutation(req, stackName);
logFileOperation('info', 'mutate', {
nodeId: req.nodeId,
op: 'chmod',
stack: stackName,
path: relPath,
mode: mode.toString(8).padStart(3, '0'),
rootKind: root.kind,
backend: root.backend,
});
logFileDiag('chmod timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
recordFileOp(req.nodeId, 'chmod', startedAt, true);
+237
View File
@@ -0,0 +1,237 @@
/**
* FileRootGateway: one uniform interface over the two storage backends behind a
* stack file root. Stack-source and bind roots run on FileSystemService (the
* `fs` backend); named-volume roots run on VolumeBrowserService (the `helper`
* backend, a hardened Alpine container). The route layer resolves a
* StackFileRoot, then calls the gateway so each handler does not re-implement
* the fs-vs-helper branch or the response-shape mapping.
*
* Optimistic concurrency is carried as an opaque, quoted `version` token that is
* a valid If-Match/ETag value end-to-end: for fs roots it is the existing weak
* ETag `W/"<mtimeMs>"` (so stack-source concurrency is unchanged); for helper
* roots it is a composite mtime+size+hash token that distinguishes two edits
* within the same (seconds-resolution) second.
*/
import type { Readable } from 'stream';
import { FileSystemService, type FileEntry, type FileRootScope } from './FileSystemService';
import { VolumeBrowserService, makeHelperVersion, type VolumeEntry } from './VolumeBrowserService';
import type { StackFileRoot } from './StackFileRootsService';
const HELPER_VIEW_MAX_BYTES = 2 * 1024 * 1024; // match the stack-source viewer cap
export interface GatewayReadResult {
content?: string;
binary: boolean;
oversized: boolean;
size: number;
mime: string;
mtimeMs: number;
version: string;
}
export type GatewayWriteResult =
| { ok: true; mtimeMs: number; version: string }
| { ok: false; currentContent: string; currentMtimeMs: number; currentVersion: string };
/** fs version token: the existing weak ETag over the integer mtimeMs. */
export function makeFsVersion(mtimeMs: number): string {
return `W/"${Math.floor(mtimeMs)}"`;
}
/** Parse the millisecond mtime out of a quoted (optionally weak) numeric ETag token. */
export function parseFsVersion(raw: string | undefined): number | null {
if (!raw) return null;
const m = /(?:W\/)?"(\d+)"/.exec(raw);
if (!m) return null;
const value = Number(m[1]);
return Number.isFinite(value) ? value : null;
}
function volumeEntryToFileEntry(e: VolumeEntry): FileEntry {
return {
name: e.name,
type: e.type === 'other' ? 'file' : e.type,
size: e.size,
mtime: e.mtime * 1000,
isProtected: false,
};
}
export class FileRootGateway {
private nodeId: number;
private constructor(nodeId: number) {
this.nodeId = nodeId;
}
static getInstance(nodeId: number): FileRootGateway {
return new FileRootGateway(nodeId);
}
private fs(): FileSystemService {
return FileSystemService.getInstance(this.nodeId);
}
private helper(): VolumeBrowserService {
return VolumeBrowserService.getInstance(this.nodeId);
}
/** fs scope for a stack-source/bind root; bind roots disable compose/.env protection. */
private scopeFor(root: StackFileRoot): FileRootScope {
return root.kind === 'stack-source'
? { protectedEnabled: true }
: { rootAbsDir: root.hostPathOrName, protectedEnabled: false };
}
/** Composite helper version token from an ms mtime + size + the file bytes. */
private helperVersion(mtimeMs: number, size: number, bytes: Buffer): string {
return makeHelperVersion(Math.floor(mtimeMs / 1000), size, bytes);
}
async listDir(
root: StackFileRoot,
stackName: string,
relPath: string,
limit: number,
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
if (root.backend === 'helper') {
const entries = (await this.helper().listDir(root.hostPathOrName, relPath)).map(volumeEntryToFileEntry);
return { entries, total: entries.length, truncated: false };
}
return this.fs().listStackDirectoryPage(stackName, relPath, { limit, scope: this.scopeFor(root) });
}
async read(
root: StackFileRoot,
stackName: string,
relPath: string,
forceText: boolean,
): Promise<GatewayReadResult> {
if (root.backend === 'helper') {
const r = await this.helper().readFile(root.hostPathOrName, relPath, { maxBytes: HELPER_VIEW_MAX_BYTES });
const bytes = Buffer.from(r.content, r.encoding);
const showContent = !r.binary && !r.truncated;
return {
content: showContent ? r.content : undefined,
binary: r.binary,
oversized: r.truncated,
size: r.size,
mime: r.mime,
mtimeMs: r.mtimeMs,
version: this.helperVersion(r.mtimeMs, r.size, bytes),
};
}
const r = await this.fs().readStackFile(stackName, relPath, undefined, { forceText, scope: this.scopeFor(root) });
return { ...r, version: makeFsVersion(r.mtimeMs) };
}
/** Optimistic-concurrency write for the editor save path. */
async writeIfUnchanged(
root: StackFileRoot,
stackName: string,
relPath: string,
content: string,
expectedVersion: string | undefined,
): Promise<GatewayWriteResult> {
if (root.backend === 'helper') {
const volume = root.hostPathOrName;
const exists = (await this.helper().pathKind(volume, relPath)) === 'file';
if (exists && expectedVersion) {
const current = await this.helper().readFile(volume, relPath, { maxBytes: HELPER_VIEW_MAX_BYTES });
const currentBytes = Buffer.from(current.content, current.encoding);
const currentVersion = this.helperVersion(current.mtimeMs, current.size, currentBytes);
if (currentVersion !== expectedVersion) {
return {
ok: false,
currentContent: current.binary ? '' : current.content,
currentMtimeMs: current.mtimeMs,
currentVersion,
};
}
}
const written = await this.helper().writeFile(volume, relPath, Buffer.from(content, 'utf-8'));
return {
ok: true,
mtimeMs: written.mtimeMs,
version: this.helperVersion(written.mtimeMs, written.size, Buffer.from(content, 'utf-8')),
};
}
const expectedMtimeMs = parseFsVersion(expectedVersion);
const result = await this.fs().writeStackFileIfUnchanged(stackName, relPath, content, expectedMtimeMs, this.scopeFor(root));
if (result.ok) return { ok: true, mtimeMs: result.mtimeMs, version: makeFsVersion(result.mtimeMs) };
return {
ok: false,
currentContent: result.currentContent,
currentMtimeMs: result.currentMtimeMs,
currentVersion: makeFsVersion(result.currentMtimeMs),
};
}
async pathKind(root: StackFileRoot, stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
if (root.backend === 'helper') return this.helper().pathKind(root.hostPathOrName, relPath);
return this.fs().pathKind(stackName, relPath, this.scopeFor(root));
}
/** Upload write. `exclusive` rejects an existing target (no overwrite). */
async writeBuffer(
root: StackFileRoot,
stackName: string,
relPath: string,
buffer: Buffer,
exclusive: boolean,
): Promise<void> {
if (root.backend === 'helper') {
if (exclusive && (await this.helper().pathKind(root.hostPathOrName, relPath)) !== null) {
throw Object.assign(new Error('File already exists'), { code: 'FILE_EXISTS' });
}
await this.helper().writeFile(root.hostPathOrName, relPath, buffer);
return;
}
await this.fs().writeStackFileBuffer(stackName, relPath, buffer, { exclusive, scope: this.scopeFor(root) });
}
async download(
root: StackFileRoot,
stackName: string,
relPath: string,
): Promise<{ kind: 'stream'; stream: Readable; size: number; filename: string; mime: string }
| { kind: 'buffer'; buffer: Buffer; size: number; filename: string }> {
if (root.backend === 'helper') {
const d = await this.helper().downloadFile(root.hostPathOrName, relPath);
return { kind: 'buffer', buffer: d.buffer, size: d.size, filename: d.filename };
}
const s = await this.fs().streamStackFile(stackName, relPath, this.scopeFor(root));
return { kind: 'stream', stream: s.stream, size: s.size, filename: s.filename, mime: s.mime };
}
async deletePath(root: StackFileRoot, stackName: string, relPath: string, recursive: boolean): Promise<void> {
if (root.backend === 'helper') return this.helper().deletePath(root.hostPathOrName, relPath, recursive);
return this.fs().deleteStackPath(stackName, relPath, recursive, this.scopeFor(root));
}
async mkdir(root: StackFileRoot, stackName: string, relPath: string): Promise<void> {
if (root.backend === 'helper') return this.helper().mkdir(root.hostPathOrName, relPath);
return this.fs().mkdirStackPath(stackName, relPath, this.scopeFor(root));
}
async rename(root: StackFileRoot, stackName: string, fromRel: string, toRel: string): Promise<void> {
if (root.backend === 'helper') return this.helper().rename(root.hostPathOrName, fromRel, toRel);
return this.fs().renameStackPath(stackName, fromRel, toRel, this.scopeFor(root));
}
async getMode(root: StackFileRoot, stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
if (root.backend === 'helper') throw unsupportedOnHelperRoot();
return this.fs().getStackEntryMode(stackName, relPath, this.scopeFor(root));
}
async chmod(root: StackFileRoot, stackName: string, relPath: string, mode: number): Promise<void> {
if (root.backend === 'helper') throw unsupportedOnHelperRoot();
return this.fs().chmodStackPath(stackName, relPath, mode, this.scopeFor(root));
}
}
/** Permissions (chmod) are not supported on a helper-backed named-volume root. */
function unsupportedOnHelperRoot(): Error & { code: string } {
return Object.assign(new Error('Permissions are not editable on a named volume'), { code: 'UNSUPPORTED_ON_ROOT' });
}
+116 -48
View File
@@ -17,6 +17,21 @@ export interface FileEntry {
isProtected: boolean;
}
/**
* Optional scope for a file-explorer operation. When `rootAbsDir` is set, the
* operation resolves and is contained within that absolute directory instead of
* the stack source dir, so the same primitives serve volume-aware bind-mount
* roots. `protectedEnabled` (compose/.env protection) defaults to true and is
* set false by the route for non-stack-source roots, where a file named
* compose.yaml/.env is just an ordinary editable file. The caller is
* responsible for pre-authorizing `rootAbsDir` (it may legitimately sit outside
* the compose base dir); this service only enforces containment within it.
*/
export interface FileRootScope {
rootAbsDir?: string;
protectedEnabled?: boolean;
}
/**
* Resolves the writable Sencho data directory (same one DatabaseService /
* CryptoService use). Recomputed lazily so test harnesses that override
@@ -1008,15 +1023,30 @@ export class FileSystemService {
return MIME_MAP[ext] ?? 'text/plain';
}
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath);
/**
* Resolve `relPath` within an arbitrary absolute root directory, applying the
* same containment + symlink-escape protection used for stack-source paths.
* Serves both the stack source dir (via resolveSafeStackPath) and volume-aware
* bind-mount roots, which may legitimately resolve outside the compose base dir
* (the caller pre-authorizes the root and passes its canonical realpath).
*
* KNOWN LIMITATION (TOCTOU): this realpath-validates the path, then the caller
* opens/streams/writes it by name, so a process that can write inside the root
* (e.g. a container writing its own bind-mounted config volume) could swap a
* validated regular file for a symlink between this check and the open and
* escape the root. Closing it fully requires per-component openat/O_RESOLVE
* traversal; plain O_NOFOLLOW is not viable because config volumes
* legitimately contain symlinks (e.g. nginx sites-enabled). This is a
* pre-existing property of every FileSystemService file op (not specific to
* volume roots); the bind root is contained to the compose dir and the op
* requires stack:edit, which already grants equivalent host access via
* compose. Tracked as a follow-up hardening, not a per-root regression.
*/
private async resolveSafePathWithin(rootAbsDir: string, relPath: string): Promise<string> {
const target = relPath === '' ? rootAbsDir : path.resolve(rootAbsDir, relPath);
if (!isPathWithinBase(target, stackDir)) {
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
if (!isPathWithinBase(target, rootAbsDir)) {
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
let realTarget: string;
@@ -1033,14 +1063,14 @@ export class FileSystemService {
const parent = path.dirname(existing);
if (parent === existing) {
// Reached filesystem root without finding an existing path.
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
suffix.unshift(path.basename(existing));
existing = parent;
try {
const realExisting = await fsPromises.realpath(existing);
if (!isPathWithinBase(realExisting, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
if (!isPathWithinBase(realExisting, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
realTarget = path.join(realExisting, ...suffix);
break;
@@ -1052,15 +1082,40 @@ export class FileSystemService {
}
}
if (!isPathWithinBase(realTarget, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
if (!isPathWithinBase(realTarget, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
return realTarget;
}
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
const page = await this.listStackDirectoryPage(stackName, relPath, {});
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
return this.resolveSafePathWithin(stackDir, relPath);
}
/**
* Resolve the effective path for an operation that may target the stack source
* dir (default) or a pre-authorized bind-mount root (`scope.rootAbsDir`).
*/
private async resolveScopedPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
return scope?.rootAbsDir !== undefined
? this.resolveSafePathWithin(scope.rootAbsDir, relPath)
: this.resolveSafeStackPath(stackName, relPath);
}
/** Leaf-path variant of resolveScopedPath (does not follow a symlink leaf). */
private async resolveScopedLeafPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
return scope?.rootAbsDir !== undefined
? this.resolveSafeLeafPathWithin(scope.rootAbsDir, relPath)
: this.resolveSafeStackLeafPath(stackName, relPath);
}
async listStackDirectory(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry[]> {
const page = await this.listStackDirectoryPage(stackName, relPath, { scope });
return page.entries;
}
@@ -1074,9 +1129,10 @@ export class FileSystemService {
async listStackDirectoryPage(
stackName: string,
relPath: string,
opts: { limit?: number },
opts: { limit?: number; scope?: FileRootScope },
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
const protectedEnabled = opts.scope?.protectedEnabled ?? true;
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
const total = dirents.length;
@@ -1102,7 +1158,7 @@ export class FileSystemService {
type,
size,
mtime,
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
isProtected: protectedEnabled && PROTECTED_STACK_FILES.has(dirent.name),
};
})
);
@@ -1123,9 +1179,9 @@ export class FileSystemService {
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024,
opts: { forceText?: boolean } = {},
opts: { forceText?: boolean; scope?: FileRootScope } = {},
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
const mime = this.guessMime(safePath);
// Open once and stat+read through the same handle so the mtime returned to
@@ -1166,9 +1222,10 @@ export class FileSystemService {
async streamStackFile(
stackName: string,
relPath: string
relPath: string,
scope?: FileRootScope,
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
const stat = await fsPromises.stat(safePath);
if (stat.isDirectory()) {
@@ -1253,9 +1310,9 @@ export class FileSystemService {
stackName: string,
relPath: string,
buffer: Buffer,
opts?: { exclusive?: boolean },
opts?: { exclusive?: boolean; scope?: FileRootScope },
): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, opts?.scope);
await this.writeStackFileAtomic(safePath, buffer, opts);
}
@@ -1265,8 +1322,8 @@ export class FileSystemService {
* so callers do not silently treat a malformed path as 'available for write'.
* Callers should validate inputs upstream before invoking this helper.
*/
async pathKind(stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async pathKind(stackName: string, relPath: string, scope?: FileRootScope): Promise<'file' | 'directory' | null> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
try {
const stat = await fsPromises.lstat(safePath);
if (stat.isDirectory()) return 'directory';
@@ -1296,11 +1353,12 @@ export class FileSystemService {
relPath: string,
content: string,
expectedMtimeMs: number | null,
scope?: FileRootScope,
): Promise<
| { ok: true; mtimeMs: number }
| { ok: false; currentMtimeMs: number; currentContent: string }
> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
if (expectedMtimeMs !== null) {
@@ -1339,22 +1397,30 @@ export class FileSystemService {
* target) for operations where following would mutate a file other than
* the one the user clicked on.
*/
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
private async resolveSafeLeafPathWithin(rootAbsDir: string, relPath: string): Promise<string> {
if (relPath === '' || relPath === '.') {
return this.resolveSafeStackPath(stackName, '');
return this.resolveSafePathWithin(rootAbsDir, '');
}
const parentRel = path.dirname(relPath);
const baseName = path.basename(relPath);
if (!baseName || baseName === '.' || baseName === '..') {
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
const safeParent = await this.resolveSafeStackPath(stackName, parentRel === '.' ? '' : parentRel);
const safeParent = await this.resolveSafePathWithin(rootAbsDir, parentRel === '.' ? '' : parentRel);
return path.join(safeParent, baseName);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath);
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
return this.resolveSafeLeafPathWithin(stackDir, relPath);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false, scope?: FileRootScope): Promise<void> {
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// Branch on whether the leaf is a symlink BEFORE following it. Deleting
// a symlink should remove the link entry the user clicked on; following
@@ -1390,8 +1456,8 @@ export class FileSystemService {
}
}
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<void> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
await fsPromises.mkdir(safePath, { recursive: true });
}
@@ -1403,11 +1469,13 @@ export class FileSystemService {
* the delete/chmod policy. fs.rename fails with EXDEV across a filesystem
* boundary (e.g. a bind-mounted subdirectory); the route surfaces that as a 409.
*/
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
const fromPath = await this.resolveSafeStackLeafPath(stackName, fromRel);
const toPath = await this.resolveSafeStackLeafPath(stackName, toRel);
async renameStackPath(stackName: string, fromRel: string, toRel: string, scope?: FileRootScope): Promise<void> {
if (scope?.protectedEnabled ?? true) {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
}
const fromPath = await this.resolveScopedLeafPath(stackName, fromRel, scope);
const toPath = await this.resolveScopedLeafPath(stackName, toRel, scope);
const toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
@@ -1437,19 +1505,19 @@ export class FileSystemService {
await fsPromises.rename(fromPath, toPath);
}
async getStackEntryMode(stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async getStackEntryMode(stackName: string, relPath: string, scope?: FileRootScope): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
const stat = await fsPromises.stat(safePath);
const mode = stat.mode & 0o777;
return { mode, octal: mode.toString(8).padStart(3, '0') };
}
async chmodStackPath(stackName: string, relPath: string, mode: number): Promise<void> {
async chmodStackPath(stackName: string, relPath: string, mode: number, scope?: FileRootScope): Promise<void> {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath);
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// chmod on a symlink is rejected. Following the link would silently
// mutate permissions on a file with a different name than the entry the
@@ -1466,8 +1534,8 @@ export class FileSystemService {
await fsPromises.chmod(leafPath, mode);
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async statStackEntry(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
const stat = await fsPromises.lstat(safePath);
const name = path.basename(safePath);
@@ -1483,7 +1551,7 @@ export class FileSystemService {
type,
size: stat.isDirectory() ? 0 : stat.size,
mtime: stat.mtimeMs,
isProtected: PROTECTED_STACK_FILES.has(name),
isProtected: (scope?.protectedEnabled ?? true) && PROTECTED_STACK_FILES.has(name),
};
}
}
+7
View File
@@ -7,6 +7,7 @@ import YAML from 'yaml';
import { CryptoService } from './CryptoService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType, type GitSourceAppliedSpec } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { StackFileRootsService } from './StackFileRootsService';
import { ComposeService } from './ComposeService';
import { StackOpLockService } from './StackOpLockService';
import { HealthGateService } from './HealthGateService';
@@ -1167,6 +1168,12 @@ export class GitSourceService {
}
}
// Materializing changes the compose/env files on disk, which can add or
// remove declared mounts, so the file-root allowlist must be recomputed.
// GitSourceService is default-node scoped (it uses the default
// FileSystemService instance), so invalidate the default node's cache.
StackFileRootsService.getInstance().invalidate(stackName);
return this.deriveAppliedSpec(composeFiles.map(f => f.path), contextDir);
}
@@ -0,0 +1,456 @@
/**
* StackFileRootsService: discovers the safe, stack-scoped file "roots" the
* Files & Volumes explorer can browse for a given stack. A root is either the
* stack source directory, a declared bind-mount host directory, or a named
* Docker volume. Discovery is the single server-side source of truth for which
* paths a file operation may touch: the route always re-derives the allowed
* roots and matches the client `rootId` against them, so a client can never
* address a path the stack itself did not declare.
*
* Reuses the rendered effective model (`parseEffectiveModel`) and
* `isDockerSocketMount` from the storage feature so it never re-implements mount
* parsing. Bind browse-accessibility uses its own `probeBindRootAccess` (NOT the
* portability-focused `probeHostPath`, which refuses to probe sources outside
* the stack dir).
*/
import path from 'path';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
import { NodeRegistry } from './NodeRegistry';
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
import DockerController from './DockerController';
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
import { isDockerSocketMount } from './storage/types';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
export interface RootMount {
service: string;
containerPath: string;
readOnly: boolean;
}
export interface StackFileRoot {
/** Opaque, server-minted id. The resolved path/name lives in metadata, never in the id. */
id: string;
kind: 'stack-source' | 'bind' | 'volume';
label: string;
/** Absolute host path (bind), resolved Docker volume name (volume), or stack dir (stack-source). */
hostPathOrName: string;
/** Every service/containerPath/readOnly declaration that maps to this resolved source. */
mounts: RootMount[];
/** Aggregate: true only when EVERY declaration is read-only. */
readonly: boolean;
/** Bind: a stat-able directory reachable by Sencho. Volume: the helper can inspect it. */
accessible: boolean;
browsable: boolean;
writable: boolean;
/** fs roots only (POSIX chmod is unsupported on helper-backed volume roots). */
chmodable: boolean;
dangerous: boolean;
/** Bind overlaps Sencho's managed compose base / a stack dir, so it is suppressed. */
managedSourceOverlap: boolean;
warning: string | null;
backend: 'fs' | 'helper';
}
export const STACK_SOURCE_ROOT_ID = 'stack-source';
/**
* The stack-source root. `hostPathOrName` is informational (the gateway scopes
* stack-source ops to the stack dir via FileSystemService, not this field), so
* the route can build a synthetic one without resolving the compose base dir.
*/
export function stackSourceFileRoot(hostPathOrName = ''): StackFileRoot {
return {
id: STACK_SOURCE_ROOT_ID,
kind: 'stack-source',
label: 'Stack source',
hostPathOrName,
mounts: [],
readonly: false,
accessible: true,
browsable: true,
writable: true,
chmodable: true,
dangerous: false,
managedSourceOverlap: false,
warning: null,
backend: 'fs',
};
}
const ROOTS_CACHE_TTL_MS = 15_000;
// Dangerous host directories: a bind equal to or under any of these grants
// node-level access and is never browsable. The docker socket is caught
// separately via isDockerSocketMount on the declared source.
const DANGEROUS_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/var/run', '/run'];
interface CacheEntry {
roots: StackFileRoot[];
expiresAt: number;
}
// Module-level cache: getInstance(nodeId) returns a fresh service each call (like
// FileSystemService), so the cache must outlive the instance. Keyed by node+stack.
const rootsCache = new Map<string, CacheEntry>();
/**
* Sencho's writable data directory (sencho.db, encryption.key, backups). Mirrors
* the resolution DatabaseService / FileSystemService use, so a bind mount that
* points at it is recognised as a managed-area overlap and never browsable.
*/
function resolveDataDir(): string {
return path.resolve(process.env.DATA_DIR || path.join(process.cwd(), 'data'));
}
/** A bind source equal to or under one of the dangerous roots (POSIX semantics). */
export function isDangerousHostPath(p: string): boolean {
const norm = p.replace(/\\/g, '/');
if (norm === '/') return true;
return DANGEROUS_ROOTS.some((d) => norm === d || norm.startsWith(`${d}/`));
}
/**
* Browse-accessibility probe for a bind root, scoped to the compose base dir.
* The realpath/stat run ONLY for a source that lexically resolves inside
* `baseDir`: in the containerized deployment that is the only host area the
* Sencho process can reach, so a source outside it is unreachable anyway and is
* reported non-accessible without touching the filesystem. The containment is
* an INLINE path.resolve + startsWith guard at each filesystem sink (the
* realpath of a within-base symlink can still escape, so the resolved canonical
* is re-checked before stat). Returns the canonical realpath so the route can
* pass it to FileSystemService as the containment root.
*/
export async function probeBindRootAccess(
absPath: string,
baseDir: string,
): Promise<{ canonical: string; accessible: boolean; isDir: boolean }> {
const base = path.resolve(baseDir);
const resolved = path.resolve(absPath);
// Out-of-base sources keep their original path for the dangerous/overlap
// classification the caller does, but are never statted.
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
return { canonical: absPath, accessible: false, isDir: false };
}
// A missing path (ENOENT) is the common, expected "not reachable" outcome and
// is left silent; any other code (e.g. EACCES on a path that exists but Sencho
// cannot read) is logged so an operator chasing "why can't I browse this bind"
// has a trail, while the root still degrades gracefully to non-browsable.
const logNonEnoent = (stage: string, err: unknown): void => {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
console.warn('[StackFileRoots] bind %s failed (%s):', stage, code ?? 'unknown', sanitizeForLog(resolved));
}
};
let canonical: string;
try {
canonical = await fsPromises.realpath(resolved);
} catch (err) {
logNonEnoent('realpath', err);
return { canonical: resolved, accessible: false, isDir: false };
}
// A within-base source can be a symlink whose target escapes the base; re-check
// the resolved canonical inline before the stat sink.
if (canonical !== base && !canonical.startsWith(base + path.sep)) {
return { canonical, accessible: false, isDir: false };
}
try {
const st = await fsPromises.stat(canonical);
return { canonical, accessible: true, isDir: st.isDirectory() };
} catch (err) {
logNonEnoent('stat', err);
return { canonical, accessible: false, isDir: false };
}
}
function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 16);
}
function debugCount(event: string, stackName: string): void {
if (isDebugEnabled()) {
console.debug('[StackFileRoots:debug] %s for %s', event, sanitizeForLog(stackName));
}
}
export class StackFileRootsService {
private nodeId: number;
private constructor(nodeId: number) {
this.nodeId = nodeId;
}
static getInstance(nodeId?: number): StackFileRootsService {
return new StackFileRootsService(nodeId ?? NodeRegistry.getInstance().getDefaultNodeId());
}
/** Drop the cached allowlist for a stack so a changed mount stops being addressable immediately. */
static invalidate(nodeId: number, stackName: string): void {
rootsCache.delete(`${nodeId}:${stackName}`);
}
/**
* Drop every cached allowlist for a node. Called on stack lifecycle changes
* (create / delete / import / from-git) so a stack deleted and recreated under
* the same name cannot serve the old stack's roots from the TTL cache.
*/
static invalidateNode(nodeId: number): void {
const prefix = `${nodeId}:`;
for (const key of rootsCache.keys()) {
if (key.startsWith(prefix)) rootsCache.delete(key);
}
}
invalidate(stackName: string): void {
StackFileRootsService.invalidate(this.nodeId, stackName);
}
private cacheKey(stackName: string): string {
return `${this.nodeId}:${stackName}`;
}
private stackSourceRoot(stackDir: string): StackFileRoot {
return stackSourceFileRoot(stackDir);
}
/**
* Render the merged effective model on the owning node. Returns null on any
* render/parse failure (so discovery degrades to stack-source only); never
* throws and never surfaces raw stderr.
*/
private async renderModel(stackName: string): Promise<EffectiveModel | null> {
try {
const result = await ComposeService.getInstance(this.nodeId).renderConfig(stackName);
if (result.rendered === null) return null;
return parseEffectiveModel(JSON.parse(result.rendered), stackName);
} catch (err) {
console.warn('[StackFileRoots] Model render failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
return null;
}
}
async listRoots(stackName: string, opts: { fresh?: boolean } = {}): Promise<StackFileRoot[]> {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const key = this.cacheKey(stackName);
if (!opts.fresh) {
const cached = rootsCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
debugCount('cache hit', stackName);
return cached.roots;
}
}
debugCount(opts.fresh ? 'fresh' : 'cache miss', stackName);
const baseDir = FileSystemService.getInstance(this.nodeId).getBaseDir();
const stackDir = path.join(baseDir, stackName);
const roots: StackFileRoot[] = [this.stackSourceRoot(stackDir)];
const model = await this.renderModel(stackName);
if (!model) {
// Render failure: only the stack-source root survives (never depends on a
// render). Cache the stack-source-only result, never a stale allowlist.
debugCount('render failure', stackName);
rootsCache.set(key, { roots, expiresAt: Date.now() + ROOTS_CACHE_TTL_MS });
return roots;
}
roots.push(...(await this.discoverVolumeRoots(model, baseDir, stackDir)));
rootsCache.set(key, { roots, expiresAt: Date.now() + ROOTS_CACHE_TTL_MS });
return roots;
}
/**
* Resolve a client-supplied rootId to a server-derived root. Throws INVALID_ROOT
* for an unknown/forged id. Writes pass `fresh: true` to bypass the cache, so a
* removed mount can never be written through a stale allowlist.
*/
async resolveRoot(stackName: string, rootId: string, opts: { fresh?: boolean } = {}): Promise<StackFileRoot> {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
// The stack-source root never depends on a compose render, so resolve it
// directly. This keeps plain stack-source file ops (the common case, and all
// existing API clients that send no rootId) off the docker-compose render
// path entirely.
if (rootId === STACK_SOURCE_ROOT_ID) {
const baseDir = FileSystemService.getInstance(this.nodeId).getBaseDir();
return this.stackSourceRoot(path.join(baseDir, stackName));
}
const roots = await this.listRoots(stackName, opts);
const root = roots.find((r) => r.id === rootId);
if (!root) {
throw Object.assign(new Error('Unknown file root'), { code: 'INVALID_ROOT' });
}
return root;
}
private async discoverVolumeRoots(
model: EffectiveModel,
baseDir: string,
stackDir: string,
): Promise<StackFileRoot[]> {
interface BindGroup {
canonical: string;
accessible: boolean;
isDir: boolean;
dockerSock: boolean;
mounts: RootMount[];
}
const probeByRaw = new Map<string, { canonical: string; accessible: boolean; isDir: boolean }>();
const bindByCanonical = new Map<string, BindGroup>();
const volByName = new Map<string, { name: string; mounts: RootMount[] }>();
for (const svc of model.services) {
for (const m of svc.storageMounts ?? []) {
const mount: RootMount = { service: svc.name, containerPath: m.target, readOnly: m.readOnly };
if (m.type === 'bind' && m.source) {
const rawAbs = path.isAbsolute(m.source) ? m.source : path.resolve(stackDir, m.source);
let probe = probeByRaw.get(rawAbs);
if (!probe) {
probe = await probeBindRootAccess(rawAbs, baseDir);
probeByRaw.set(rawAbs, probe);
}
let group = bindByCanonical.get(probe.canonical);
if (!group) {
group = {
canonical: probe.canonical,
accessible: probe.accessible,
isDir: probe.isDir,
dockerSock: false,
mounts: [],
};
bindByCanonical.set(probe.canonical, group);
}
group.mounts.push(mount);
if (isDockerSocketMount({ source: m.source, target: m.target })) group.dockerSock = true;
} else if (m.type === 'named' && m.source) {
const resolvedName = model.volumes[m.source]?.name ?? m.source;
let group = volByName.get(resolvedName);
if (!group) {
group = { name: resolvedName, mounts: [] };
volByName.set(resolvedName, group);
}
group.mounts.push(mount);
}
// anonymous / tmpfs mounts have no stable browse target and are skipped.
}
}
const roots: StackFileRoot[] = [];
for (const group of bindByCanonical.values()) {
const root = this.buildBindRoot(group, baseDir, stackDir);
if (root) roots.push(root);
}
for (const group of volByName.values()) {
roots.push(await this.buildVolumeRoot(group));
}
return roots;
}
private buildBindRoot(
group: { canonical: string; accessible: boolean; isDir: boolean; dockerSock: boolean; mounts: RootMount[] },
baseDir: string,
stackDir: string,
): StackFileRoot | null {
const { canonical } = group;
// A bind equal to the stack dir is already served (with protected-file
// enforcement) by the stack-source root; do not expose a second, unprotected
// editable root for it.
if (canonical === stackDir) return null;
const inStack = isPathWithinBase(canonical, stackDir); // strictly within (equal handled above)
// A bind that overlaps Sencho's own managed areas (the compose base dir, a
// sibling stack, or the data dir that holds sencho.db / encryption.key) must
// never become a browsable/editable root. Compare in both directions so a
// mount equal to, inside, or an ancestor of a managed dir is caught.
const dataDir = resolveDataDir();
const overlapsManaged = (dir: string): boolean => isPathWithinBase(canonical, dir) || isPathWithinBase(dir, canonical);
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir));
const dangerous = isDangerousHostPath(canonical) || group.dockerSock;
const readonly = group.mounts.every((m) => m.readOnly);
const isFile = group.accessible && !group.isDir;
let warning: string | null = null;
if (overlap) {
warning = "This mount overlaps Sencho's managed stack area. Browse the owning stack's source instead.";
} else if (dangerous) {
warning = 'This mount targets a protected host path and cannot be browsed.';
} else if (!group.accessible) {
warning = 'Sencho cannot access this host path. Bind it into the Sencho container to browse it.';
} else if (isFile) {
warning = 'This bind mount targets a single file, not a directory.';
}
const browsable = group.accessible && group.isDir && !dangerous && !overlap;
const writable = browsable && !readonly;
const label = group.mounts[0]?.containerPath || path.basename(canonical) || canonical;
return {
id: `bind:${shortHash(canonical)}`,
kind: 'bind',
label,
hostPathOrName: canonical,
mounts: group.mounts,
readonly,
accessible: group.accessible,
browsable,
writable,
chmodable: browsable,
dangerous,
managedSourceOverlap: overlap,
warning,
backend: 'fs',
};
}
private async buildVolumeRoot(group: { name: string; mounts: RootMount[] }): Promise<StackFileRoot> {
let accessible = false;
let warning: string | null = null;
try {
await DockerController.getInstance(this.nodeId).getDocker().getVolume(group.name).inspect();
accessible = true;
} catch (err) {
// Distinguish a genuine 404 (volume absent) from a transient Docker failure
// (daemon unreachable, proxy hop down) so the warning is honest and a
// non-404 leaves a server-side trail rather than silently reading as "gone".
const e = err as { statusCode?: number; message?: string };
const notFound = e.statusCode === 404 || /no such volume/i.test(e.message ?? '');
if (!notFound) {
console.warn('[StackFileRoots] volume inspect failed for %s:',
sanitizeForLog(group.name), sanitizeForLog(getErrorMessage(err, 'unknown')));
}
warning = notFound
? 'Sencho could not resolve this named volume on the owning node.'
: 'Sencho could not reach Docker to resolve this named volume; it may be temporarily unavailable.';
}
const readonly = group.mounts.every((m) => m.readOnly);
const browsable = accessible;
return {
id: `volume:${shortHash(group.name)}`,
kind: 'volume',
label: group.name,
hostPathOrName: group.name,
mounts: group.mounts,
readonly,
accessible,
browsable,
writable: browsable && !readonly,
chmodable: false,
dangerous: false,
managedSourceOverlap: false,
warning,
backend: 'helper',
};
}
}
+265 -10
View File
@@ -1,18 +1,32 @@
import { Writable } from 'stream';
import path from 'path';
import { createHash } from 'crypto';
import DockerController from './DockerController';
const HELPER_IMAGE = 'alpine:3.20';
const VOLUME_MOUNT = '/v';
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
// Named-volume downloads are bounded (not chunk-streamed): the helper output is
// buffered up to this size and a larger file is rejected rather than silently
// truncated. The bound matches the upload limit so the in-memory footprint is no
// worse than the existing multipart upload path.
const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024;
const EXEC_TIMEOUT_MS = 30_000;
// Containment guard run inside the helper after every `cd`. Even though
// sanitizeRelPath strips `..`, a symlinked directory component could redirect
// `cd` outside the mounted volume; `pwd -P` resolves symlinks so we can assert
// the working directory is still under the volume root before touching anything.
const ROOT_GUARD =
`case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "path escapes volume root" >&2; exit 7 ;; esac`;
// Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU
// coreutils alike. The user-supplied relative path arrives as $1; we cd into
// it before iterating, so user input never lands as an argv element to a
// command that might interpret it as a flag.
const LIST_SCRIPT = `set -e
cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; }
cd -- "$1" || exit 1
${ROOT_GUARD}
for entry in * .[!.]* ..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "")
@@ -25,8 +39,15 @@ for entry in * .[!.]* ..?*; do
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link"
done`;
// Contain the parent before statting the leaf: cd into the leaf's directory and
// assert (via pwd -P) it is still inside the volume, so a symlinked path
// component cannot make stat report on a file outside the mount.
const STAT_SCRIPT = `set -e
target="$1"
p="$1"
d=$(dirname -- "$p"); b=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
target="$b"
[ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; }
if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "")
elif [ -d "$target" ]; then t=d; link=""
@@ -38,6 +59,62 @@ mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0)
name=$(basename -- "$target")
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`;
// --- mutation + probe scripts (all contain the parent, then act on the leaf) ---
// $1 = relative path. New file → owned by the helper user (65534); existing file
// → in-place truncate (`>` keeps the inode, so owner/mode are preserved). A
// symlink leaf is refused so a write never follows a link out of the volume.
const WRITE_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
[ -L "$f" ] && { echo "refusing to write through a symlink" >&2; exit 8; }
[ -d "$f" ] && { echo "target is a directory" >&2; exit 9; }
cat > "$f"`;
// $1 = relative path of the new directory; its parent must already exist.
const MKDIR_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
mkdir -- "$f"`;
// $1 = relative path, $2 = "1" for recursive directory delete. Removing a symlink
// leaf is allowed (rm unlinks the link itself, never its target).
const DELETE_SCRIPT = `set -e
p="$1"; recursive="$2"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
[ -e "$f" ] || [ -L "$f" ] || { echo "no such path" >&2; exit 1; }
if [ -d "$f" ] && [ ! -L "$f" ]; then
if [ "$recursive" = "1" ]; then rm -rf -- "$f"; else rmdir -- "$f" 2>/dev/null || { echo "Directory is not empty" >&2; exit 10; }; fi
else
rm -f -- "$f"
fi`;
// $1 = from, $2 = to. Both parents are contained; the destination must not exist.
const RENAME_SCRIPT = `set -e
from="$1"; to="$2"
fd=$(dirname -- "$from"); td=$(dirname -- "$to")
( cd -- "$fd" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "source escapes volume root" >&2; exit 7; }
( cd -- "$td" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "destination escapes volume root" >&2; exit 7; }
{ [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; }
mv -- "$from" "$to"`;
// $1 = relative path. Prints directory|file|none. Used for upload-overwrite checks.
const PATHKIND_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 2
${ROOT_GUARD}
if [ -d "$f" ] && [ ! -L "$f" ]; then echo directory
elif [ -e "$f" ] || [ -L "$f" ]; then echo file
else echo none
fi`;
export interface VolumeEntry {
name: string;
type: 'file' | 'directory' | 'symlink' | 'other';
@@ -54,10 +131,30 @@ export interface VolumeFileResult {
truncated: boolean;
size: number;
mime: string;
/** mtime in milliseconds (seconds resolution from the helper `stat`, ×1000). */
mtimeMs: number;
}
/** Raw bytes of a volume file for download, bounded to DOWNLOAD_MAX_BYTES. */
export interface VolumeDownload {
buffer: Buffer;
size: number;
filename: string;
}
export type VolumeStat = VolumeEntry;
/**
* Opaque optimistic-concurrency token for an editable volume file. Seconds-
* resolution mtime alone collides for two edits within the same second, so the
* size and a content hash are folded in to keep the guarantee close to the
* millisecond-mtime fs path.
*/
export function makeHelperVersion(mtimeSeconds: number, size: number, content: Buffer): string {
const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
return `"v1:${mtimeSeconds}-${size}-${hash}"`;
}
export class PathTraversalError extends Error {
status = 400;
constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; }
@@ -149,6 +246,7 @@ export class VolumeBrowserService {
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404);
throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
@@ -181,12 +279,12 @@ export class VolumeBrowserService {
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.
// Read up to maxBytes+1 to detect truncation precisely. The parent is
// contained (cd + pwd -P) and the leaf symlink refused so the read can never
// follow a link out of the volume.
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`head -c ${maxBytes + 1} -- "$1"`,
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${maxBytes + 1} -- "$b"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
@@ -207,9 +305,151 @@ export class VolumeBrowserService {
truncated,
size: meta.size,
mime,
mtimeMs: meta.mtime * 1000,
};
}
/**
* Read a file's full bytes for download, bounded to DOWNLOAD_MAX_BYTES so a
* large file is rejected (413) rather than silently truncated. The parent is
* contained and the leaf symlink refused, like readFile.
*/
async downloadFile(volumeName: string, relPath: string): Promise<VolumeDownload> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot download volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const meta = await this.stat(volumeName, safe);
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
if (meta.size > DOWNLOAD_MAX_BYTES) {
throw new ExecError('File is too large to download from this volume', 413);
}
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${DOWNLOAD_MAX_BYTES + 1} -- "$b"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
throw new ExecError(`Download failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
if (stdout.length > DOWNLOAD_MAX_BYTES) {
throw new ExecError('File is too large to download from this volume', 413);
}
return { buffer: stdout, size: stdout.length, filename: path.posix.basename(safe) };
}
/** Probe whether a path is a directory, file, or absent (upload-overwrite check). */
async pathKind(volumeName: string, relPath: string): Promise<'file' | 'directory' | null> {
const safe = sanitizeRelPath(relPath);
if (!safe) return 'directory';
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, ['sh', '-c', PATHKIND_SCRIPT, 'sh', `./${safe}`]);
if (exitCode !== 0) {
// A permission failure on the parent must not be reported as "absent"
// (which would let an exclusive create proceed); surface it as 403. A
// genuinely missing parent (ENOENT) means nothing exists at this path.
if (/Permission denied/i.test(stderr.toString('utf-8'))) throw new ExecError('Permission denied', 403);
return null;
}
const kind = stdout.toString('utf-8').trim();
if (kind === 'directory') return 'directory';
if (kind === 'file') return 'file';
return null;
}
/**
* Write `content` to a volume file. New files are created owned by the helper
* user (65534); existing files are truncated in place so their owner/mode are
* preserved. The write is intentionally NON-ATOMIC (a helper death mid-write
* can leave a truncated file) because the helper cannot chown a renamed temp
* back to the original owner; ownership preservation is the better default for
* editing a service's config. Returns the new mtime/size for the version token.
*/
async writeFile(volumeName: string, relPath: string, content: Buffer): Promise<{ mtimeMs: number; size: number }> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot write the volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', WRITE_SCRIPT, 'sh', `./${safe}`],
{ writable: true, stdin: content },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg) || exitCode === 8) throw new ExecError('Permission denied', 403);
if (exitCode === 9) throw new ExecError('Target is a directory', 400);
throw new ExecError(`Write failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
const meta = await this.stat(volumeName, safe);
return { mtimeMs: meta.mtime * 1000, size: meta.size };
}
/** Create a single directory; its parent must already exist. */
async mkdir(volumeName: string, relPath: string): Promise<void> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Invalid directory path', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', MKDIR_SCRIPT, 'sh', `./${safe}`],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (/File exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
throw new ExecError(`Create folder failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
/** Delete a file or directory. Removing a symlink unlinks the link, not its target. */
async deletePath(volumeName: string, relPath: string, recursive: boolean): Promise<void> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot delete the volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', DELETE_SCRIPT, 'sh', `./${safe}`, recursive ? '1' : '0'],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (exitCode === 10 || /not empty/i.test(msg)) throw new ExecError('Directory is not empty', 409);
throw new ExecError(`Delete failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
/** Rename/move a file or directory within the same volume. */
async rename(volumeName: string, fromRel: string, toRel: string): Promise<void> {
const from = sanitizeRelPath(fromRel);
const to = sanitizeRelPath(toRel);
if (!from || !to) throw new ExecError('Invalid rename path', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', RENAME_SCRIPT, 'sh', `./${from}`, `./${to}`],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (exitCode === 11 || /exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
throw new ExecError(`Rename failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
// --- internals -----------------------------------------------------------
private async assertVolumeExists(volumeName: string): Promise<void> {
@@ -250,17 +490,23 @@ export class VolumeBrowserService {
}
}
private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
private async runHelper(
volumeName: string,
cmd: string[],
opts: { writable?: boolean; stdin?: Buffer } = {},
): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
const docker = DockerController.getInstance(this.nodeId).getDocker();
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } });
const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } });
const wantStdin = opts.stdin !== undefined;
// Manual lifecycle (create -> attach -> start -> wait -> remove). Using
// dockerode's docker.run() with AutoRemove races: Docker can delete the
// container before run()'s internal wait() callback fires, surfacing as
// a 404 "no such container" from docker-modem.
// a 404 "no such container" from docker-modem. Only the target volume mount
// becomes writable for mutations; every other hardening flag is unchanged.
const container = await docker.createContainer({
Image: HELPER_IMAGE,
Cmd: cmd,
@@ -269,6 +515,9 @@ export class VolumeBrowserService {
WorkingDir: VOLUME_MOUNT,
AttachStdout: true,
AttachStderr: true,
AttachStdin: wantStdin,
OpenStdin: wantStdin,
StdinOnce: wantStdin,
HostConfig: {
ReadonlyRootfs: true,
NetworkMode: 'none',
@@ -281,7 +530,7 @@ export class VolumeBrowserService {
Type: 'volume',
Source: volumeName,
Target: VOLUME_MOUNT,
ReadOnly: true,
ReadOnly: !opts.writable,
}],
},
});
@@ -292,13 +541,19 @@ export class VolumeBrowserService {
});
const runPromise = (async () => {
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
const stream = await container.attach({ stream: true, stdin: wantStdin, stdout: true, stderr: true, hijack: wantStdin });
const streamEnded = new Promise<void>((resolve) => {
stream.once('end', () => resolve());
stream.once('close', () => resolve());
});
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
await container.start();
if (wantStdin && opts.stdin) {
// Feed the file content to the container's stdin, then close it so the
// helper's `cat > file` sees EOF and exits.
stream.write(opts.stdin);
stream.end();
}
const exitInfo = await container.wait();
// Wait for the attach stream to finish flushing demuxed output.
await streamEnded;