mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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,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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user