From b9d8e9f490d423603fb15bd6c11b44a0ec13d48c Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 21 Jun 2026 18:16:20 -0400 Subject: [PATCH] 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). --- .../__tests__/filesystem-stack-paths.test.ts | 65 +++ .../stack-file-roots-service.test.ts | 244 ++++++++++ backend/src/helpers/cacheInvalidation.ts | 5 +- backend/src/routes/stacks.ts | 230 +++++++-- backend/src/services/FileRootGateway.ts | 237 +++++++++ backend/src/services/FileSystemService.ts | 164 +++++-- backend/src/services/GitSourceService.ts | 7 + backend/src/services/StackFileRootsService.ts | 456 ++++++++++++++++++ backend/src/services/VolumeBrowserService.ts | 275 ++++++++++- docs/features/stack-file-explorer.mdx | 68 ++- .../components/EditorLayout/EditorView.tsx | 2 +- .../components/files/DeleteFileConfirm.tsx | 4 +- .../files/FilePermissionsDialog.tsx | 8 +- frontend/src/components/files/FileTree.tsx | 11 +- .../components/files/FileUploadDropzone.tsx | 4 +- frontend/src/components/files/FileViewer.tsx | 35 +- .../src/components/files/MoveFileDialog.tsx | 5 +- .../src/components/files/NewFileDialog.tsx | 4 +- .../src/components/files/NewFolderDialog.tsx | 4 +- .../src/components/files/RenameDialog.tsx | 4 +- .../components/files/StackFileExplorer.tsx | 189 +++++++- .../files/__tests__/FileViewer.test.tsx | 24 +- .../__tests__/StackFileExplorer.test.tsx | 8 +- frontend/src/lib/stackFilesApi.ts | 138 ++++-- 24 files changed, 1986 insertions(+), 205 deletions(-) create mode 100644 backend/src/__tests__/stack-file-roots-service.test.ts create mode 100644 backend/src/services/FileRootGateway.ts create mode 100644 backend/src/services/StackFileRootsService.ts diff --git a/backend/src/__tests__/filesystem-stack-paths.test.ts b/backend/src/__tests__/filesystem-stack-paths.test.ts index 3ef08e92..d0dd6f42 100644 --- a/backend/src/__tests__/filesystem-stack-paths.test.ts +++ b/backend/src/__tests__/filesystem-stack-paths.test.ts @@ -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' }); + }); +}); diff --git a/backend/src/__tests__/stack-file-roots-service.test.ts b/backend/src/__tests__/stack-file-roots-service.test.ts new file mode 100644 index 00000000..05fe4dc0 --- /dev/null +++ b/backend/src/__tests__/stack-file-roots-service.test.ts @@ -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, volumes: Record = {}): string { + const services: Record = {}; + for (const [svc, vols] of Object.entries(servicesVolumes)) services[svc] = { volumes: vols }; + return JSON.stringify({ services, volumes }); +} + +/** Stub the three singletons the service depends on. */ +function stub(opts: { rendered: string | null; volumeInspect?: (name: string) => Promise }): void { + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ rendered: opts.rendered, stderr: '', timedOut: false }), + } as unknown as ReturnType); + + vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getBaseDir: () => baseDir, + } as unknown as ReturnType); + + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDocker: () => ({ + getVolume: (name: string) => ({ + inspect: () => (opts.volumeInspect ?? (async () => ({ Name: name })))(name), + }), + }), + } as unknown as ReturnType); +} + +beforeEach(async () => { + baseDir = await fs.realpath(await fs.mkdtemp(path.join(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); + }); +}); diff --git a/backend/src/helpers/cacheInvalidation.ts b/backend/src/helpers/cacheInvalidation.ts index dab87578..e78f28f9 100644 --- a/backend/src/helpers/cacheInvalidation.ts +++ b/backend/src/helpers/cacheInvalidation.ts @@ -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); } /** diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index e58ce3d3..976d5e8d 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 { + 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): 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); diff --git a/backend/src/services/FileRootGateway.ts b/backend/src/services/FileRootGateway.ts new file mode 100644 index 00000000..6cbebe48 --- /dev/null +++ b/backend/src/services/FileRootGateway.ts @@ -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/""` (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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' }); +} diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index dd896ae4..d343731e 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -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 { - 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 { + 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 { - const page = await this.listStackDirectoryPage(stackName, relPath, {}); + private async resolveSafeStackPath(stackName: string, relPath: string): Promise { + 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 { + 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 { + return scope?.rootAbsDir !== undefined + ? this.resolveSafeLeafPathWithin(scope.rootAbsDir, relPath) + : this.resolveSafeStackLeafPath(stackName, relPath); + } + + async listStackDirectory(stackName: string, relPath: string, scope?: FileRootScope): Promise { + 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 { - 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 { + private async resolveSafeLeafPathWithin(rootAbsDir: string, relPath: string): Promise { 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 { - if (isProtectedRelPath(relPath)) throw protectedFileError(relPath); - const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath); + private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise { + 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 { + 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 { - const safePath = await this.resolveSafeStackPath(stackName, relPath); + async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise { + 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 { - 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 { + 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 { + async chmodStackPath(stackName: string, relPath: string, mode: number, scope?: FileRootScope): Promise { 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 { - const safePath = await this.resolveSafeStackPath(stackName, relPath); + async statStackEntry(stackName: string, relPath: string, scope?: FileRootScope): Promise { + 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), }; } } diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index c266e126..e504375e 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -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); } diff --git a/backend/src/services/StackFileRootsService.ts b/backend/src/services/StackFileRootsService.ts new file mode 100644 index 00000000..0b08b617 --- /dev/null +++ b/backend/src/services/StackFileRootsService.ts @@ -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(); + +/** + * 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 { + 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 { + 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 { + 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 { + interface BindGroup { + canonical: string; + accessible: boolean; + isDir: boolean; + dockerSock: boolean; + mounts: RootMount[]; + } + const probeByRaw = new Map(); + const bindByCanonical = new Map(); + const volByName = new Map(); + + 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 { + 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', + }; + } +} diff --git a/backend/src/services/VolumeBrowserService.ts b/backend/src/services/VolumeBrowserService.ts index 177a8e12..f6bd2f19 100644 --- a/backend/src/services/VolumeBrowserService.ts +++ b/backend/src/services/VolumeBrowserService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { @@ -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((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; diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx index 78c18a12..2226c7ac 100644 --- a/docs/features/stack-file-explorer.mdx +++ b/docs/features/stack-file-explorer.mdx @@ -1,31 +1,59 @@ --- -title: Stack File Explorer -description: Browse, view, edit, upload, rename, and chmod every file inside a stack's directory from the dashboard. +title: Files & Volumes +description: Browse and safely edit the config files inside a stack's mounted volumes, plus the files in the stack's own directory, from the dashboard. --- -The file explorer gives you direct access to everything inside a stack's directory: configuration files, certificates, scripts, static assets, and any other files your containers depend on. It lives on the **Files** tab inside the stack editor, alongside the dedicated `compose.yaml` and `.env` editors. +Files & Volumes gives you direct access to the files your containers actually read at runtime: the configuration inside a stack's **mounted volumes** (bind mounts and named Docker volumes), as well as the files in the stack's own source directory. A typical use is editing the Nginx and proxy configuration inside a reverse proxy's `/config` volume without leaving the dashboard. It lives on the **Files & Volumes** tab inside the stack editor, alongside the dedicated `compose.yaml` and `.env` editors. - Files tab open with a populated tree on the left, a YAML file open in the editor on the right, and the Anatomy panel in the background + Files & Volumes tab open with the root switcher and a populated tree on the left and a config file open in the editor on the right - The explorer is scoped to the stack's own directory. You cannot browse other stacks or navigate above the stack root. + Browsing is limited to the roots the stack itself declares: its source directory and the volumes mounted by its services. You cannot browse other stacks or arbitrary locations on the host. -## Opening the file explorer +## Opening Files & Volumes 1. Click any stack in the left sidebar. 2. Click **edit** in the right-hand Anatomy panel header to open the editor. -3. Switch to the **Files** tab. +3. Switch to the **Files & Volumes** tab. -The **files** shortcut button next to **edit** in the Anatomy header opens the editor and selects the Files tab in one click. +The **files** shortcut button next to **edit** in the Anatomy header opens the editor and selects the Files & Volumes tab in one click. -The file explorer is available on every Sencho tier. Read actions (browse, preview, download, inspect permissions) require **stack read** permission, which every signed-in role has by default. Write actions (upload, edit, create, rename, move, change permissions, delete) require **stack edit** permission on your account. +Files & Volumes is available on every Sencho tier. Read actions (browse, preview, download, inspect permissions) require **stack read** permission, which every signed-in role has by default. Write actions (upload, edit, create, rename, move, change permissions, delete) require **stack edit** permission on your account. + +## File roots: Volumes and stack source + +The left pane opens with a **Browsing** selector that lists the roots Sencho discovered for the stack, grouped into **Volumes** and **Stack source**. When the stack declares at least one browsable volume, the explorer defaults to it; otherwise it opens on the stack source. + +Each volume root is labelled by its container path (for example `/config`) and the type of mount behind it: + +- **Bind mounts** map a host directory into a service (for example `./config:/config`). When the directory is reachable by Sencho, the root is fully browsable and editable. +- **Named volumes** are Docker-managed volumes. Sencho browses and edits them through a short-lived, locked-down helper container, so they work even though their on-disk location is not directly visible to the dashboard. + +The same source mounted by more than one service is shown as a single root, summarised as `N mounts`. A root that any service mounts read/write is editable; a root that every service mounts read-only is browse-only. + + + Editing a config file does not restart the service that reads it. After you save, restart the relevant service so it picks up the new content. + + +### When a volume cannot be browsed + +A volume root is shown with a short explanation and is not browsable when: + +- the bind mount points at a host path Sencho cannot reach (mount that path into the Sencho container to browse it), +- the mount targets a protected host location such as `/`, `/etc`, `/proc`, `/sys`, `/dev`, or the Docker socket, +- the mount overlaps Sencho's own managed stack area (browse the owning stack's source instead), or +- a named volume cannot be resolved on the node that owns the stack. + +### Named-volume editing details + +Named-volume editing is best-effort and bound by file ownership. The helper container writes as an unprivileged user, so a file owned by `root` or a specific application user may not be writable; Sencho returns a clear permission error in that case. New files created in a named volume are owned by the helper user, while edits to an existing file keep that file's owner and mode. Named-volume saves are written in place and are not atomic, so a save interrupted mid-write can leave the file partially written; the files involved are small config files, which keeps the window tiny. ## Layout -The Files tab splits into two panes. The left pane holds the upload affordance, the new-folder button, and the directory tree. The right pane is the action bar plus the file viewer. +The Files & Volumes tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the new-folder button, and the directory tree for the selected root. The right pane is the action bar plus the file viewer. Files tab two-pane layout showing the upload widget and tree on the left and the file viewer on the right @@ -41,7 +69,9 @@ Click a folder to expand or collapse it. Click a file to open it in the viewer o ## Protected files -The amber dot in the tree marks the five canonical stack files: `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, and `.env`. +Protection applies to the **Stack source** root. On a volume root a file named `compose.yaml` or `.env` is an ordinary config file: it opens directly in the viewer and has no delete restriction, because it is the application's own file rather than the stack's compose definition. + +On the stack source root, the amber dot in the tree marks the five canonical stack files: `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, and `.env`. File tree with amber dot markers next to compose.yaml and .env @@ -50,7 +80,7 @@ The amber dot in the tree marks the five canonical stack files: `compose.yaml`, Two behaviours follow from the marker: - **Dedicated tab redirect.** Clicking `compose.yaml`, `compose.yml`, or `.env` jumps you to the matching **compose.yaml** or **.env** tab so the save-and-deploy controls stay in front of you. `docker-compose.yaml` and `docker-compose.yml` are still flagged as protected, but they open in the regular file viewer because they are not the canonical Sencho file. -- **Type-to-confirm delete.** All five protected names require typing the filename to confirm a delete. See the Deleting section below. +- **Delete is blocked at the stack root.** The five canonical files at the stack root cannot be deleted through the explorer: the delete is rejected. Remove the whole stack via Stack Actions instead. A same-named file nested in a subdirectory is an ordinary file and can be deleted after the type-to-confirm step. ## Viewing files @@ -183,9 +213,21 @@ The Permissions dialog opens for everyone; only users with stack edit permission ## Troubleshooting - + The signed-in role lacks **stack read** permission for this stack. Ask an admin to grant the permission on your account, or sign in with a role that already has it. + + The stack declares no volume Sencho can reach, so the explorer opens on the stack source. This happens when the stack uses only anonymous or `tmpfs` mounts, when its bind mounts point at host paths that are not mounted into the Sencho container, or when its named volumes have not been created yet. Deploy the stack so its named volumes exist, or mount the relevant host path into the Sencho container, then reopen the tab. + + + The root carries a short explanation. A bind mount to a host path Sencho cannot reach needs that path mounted into the Sencho container. A mount that targets a protected host location (`/`, `/etc`, `/proc`, `/sys`, `/dev`, the Docker socket) or that overlaps Sencho's managed stack area is intentionally not browsable. A named volume that cannot be resolved on the owning node is not browsable until it exists. + + + Sencho writes to named volumes as an unprivileged helper user. A file owned by `root` or a specific application user may not be writable by that helper. Adjust the file's ownership or permissions on the host, or edit the file from a context that owns it, then try again. + + + Every service that mounts the volume declares it read-only (for example `./config:/config:ro`). The explorer browses it but disables the edit, upload, delete, and rename controls. Change the mount to read/write in the compose file and redeploy if you need to edit its contents from Sencho. + The five canonical stack files (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, `.env`) are protected because removing them mid-life breaks the stack. To delete a stack entirely, use **Delete stack** in the stack toolbar's overflow menu rather than removing these files individually. diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index fb03abc5..3113463f 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -415,7 +415,7 @@ export function EditorView(props: EditorViewProps) { - Files + Files & Volumes )} diff --git a/frontend/src/components/files/DeleteFileConfirm.tsx b/frontend/src/components/files/DeleteFileConfirm.tsx index 111b7e05..23a79dd5 100644 --- a/frontend/src/components/files/DeleteFileConfirm.tsx +++ b/frontend/src/components/files/DeleteFileConfirm.tsx @@ -14,6 +14,7 @@ interface DeleteFileConfirmProps { stackName: string; relPath: string; entry: FileEntry | null; + rootId?: string; onDeleted: () => void; } @@ -23,6 +24,7 @@ export function DeleteFileConfirm({ stackName, relPath, entry, + rootId, onDeleted, }: DeleteFileConfirmProps) { const [deleting, setDeleting] = useState(false); @@ -47,7 +49,7 @@ export function DeleteFileConfirm({ const executeDelete = async (recursive: boolean) => { setDeleting(true); try { - await deleteStackPath(stackName, relPath, recursive || undefined); + await deleteStackPath(stackName, relPath, recursive || undefined, rootId); onDeleted(); onOpenChange(false); } catch (e: unknown) { diff --git a/frontend/src/components/files/FilePermissionsDialog.tsx b/frontend/src/components/files/FilePermissionsDialog.tsx index 8e5eb1fa..9526c4a3 100644 --- a/frontend/src/components/files/FilePermissionsDialog.tsx +++ b/frontend/src/components/files/FilePermissionsDialog.tsx @@ -49,6 +49,7 @@ interface FilePermissionsDialogProps { stackName: string; relPath: string; entryName: string; + rootId?: string; canEdit: boolean; } @@ -58,6 +59,7 @@ export function FilePermissionsDialog({ stackName, relPath, entryName, + rootId, canEdit, }: FilePermissionsDialogProps) { const [mode, setMode] = useState(0o644); @@ -69,14 +71,14 @@ export function FilePermissionsDialog({ setLoading(true); setError(null); try { - const result = await getStackEntryPermissions(stackName, relPath); + const result = await getStackEntryPermissions(stackName, relPath, rootId); setMode(result.mode); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load permissions.'); } finally { setLoading(false); } - }, [stackName, relPath]); + }, [stackName, relPath, rootId]); useEffect(() => { if (open) void load(); @@ -90,7 +92,7 @@ export function FilePermissionsDialog({ const handleSave = async () => { setSaving(true); try { - await setStackEntryPermissions(stackName, relPath, mode); + await setStackEntryPermissions(stackName, relPath, mode, rootId); toast.success('Permissions updated.'); onOpenChange(false); } catch (e) { diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx index f6701312..fe7c50b7 100644 --- a/frontend/src/components/files/FileTree.tsx +++ b/frontend/src/components/files/FileTree.tsx @@ -23,6 +23,10 @@ interface FileTreeProps { onSelectFile: (relPath: string, entry: FileEntry) => void; onNavigateToCompose?: () => void; onNavigateToEnv?: () => void; + /** When true (stack source only), clicking compose/.env redirects to their + * dedicated editors. For volume roots a file named .env is just an ordinary + * file and opens in the viewer. */ + redirectProtected?: boolean; // Context menu wiring canEdit?: boolean; onContextMenuRename?: (relPath: string) => void; @@ -50,6 +54,7 @@ export function FileTree({ onSelectFile, onNavigateToCompose, onNavigateToEnv, + redirectProtected = true, canEdit = false, onContextMenuRename = () => undefined, onContextMenuMove = () => undefined, @@ -166,12 +171,14 @@ export function FileTree({ } function handleFileClick(relPath: string, entry: FileEntry) { - if (COMPOSE_NAMES.has(entry.name)) { + // Only the stack source root redirects compose/.env to their dedicated + // editors; on a volume root these are ordinary files opened in the viewer. + if (redirectProtected && relPath === entry.name && COMPOSE_NAMES.has(entry.name)) { if (onNavigateToCompose) onNavigateToCompose(); else toast.info('Open the Compose tab to edit this file.'); return; } - if (ENV_NAMES.has(entry.name)) { + if (redirectProtected && relPath === entry.name && ENV_NAMES.has(entry.name)) { if (onNavigateToEnv) onNavigateToEnv(); else toast.info('Open the Env tab to edit this file.'); return; diff --git a/frontend/src/components/files/FileUploadDropzone.tsx b/frontend/src/components/files/FileUploadDropzone.tsx index 2463500b..755290e1 100644 --- a/frontend/src/components/files/FileUploadDropzone.tsx +++ b/frontend/src/components/files/FileUploadDropzone.tsx @@ -11,6 +11,7 @@ interface FileUploadDropzoneProps { stackName: string; currentDir: string; canEdit: boolean; + rootId?: string; onUploaded: () => void; } @@ -18,6 +19,7 @@ export function FileUploadDropzone({ stackName, currentDir, canEdit, + rootId, onUploaded, }: FileUploadDropzoneProps) { const inputRef = useRef(null); @@ -29,7 +31,7 @@ export function FileUploadDropzone({ const runUpload = async (file: File, overwrite: boolean): Promise => { const loadingId = toast.loading(`Uploading ${file.name}...`); try { - await uploadStackFile(stackName, currentDir, file, { overwrite }); + await uploadStackFile(stackName, currentDir, file, { overwrite, rootId }); toast.success(overwrite ? 'Replaced.' : 'Uploaded.'); onUploaded(); } catch (e: unknown) { diff --git a/frontend/src/components/files/FileViewer.tsx b/frontend/src/components/files/FileViewer.tsx index bb301286..507701f6 100644 --- a/frontend/src/components/files/FileViewer.tsx +++ b/frontend/src/components/files/FileViewer.tsx @@ -13,6 +13,8 @@ interface FileViewerProps { selectedPath: string | null; canEdit: boolean; isDarkMode: boolean; + /** The selected file root; undefined/`stack-source` is the legacy behaviour. */ + rootId?: string; onSaved?: () => void; onDirtyChange?: (dirty: boolean) => void; } @@ -21,12 +23,18 @@ function getFilename(path: string): string { return path.split('/').pop() ?? path; } +/** Build the fs version token from a millisecond mtime, for a server response that omits `version`. */ +function fsVersionFromMtime(mtimeMs: number | undefined): string | undefined { + return typeof mtimeMs === 'number' ? `W/"${Math.floor(mtimeMs)}"` : undefined; +} + interface SpecialFilePanelProps { filename: string; size: number; label: string; stackName: string; relPath: string; + rootId?: string; extraAction?: { label: string; onClick: () => void; disabled?: boolean }; } @@ -36,6 +44,7 @@ function SpecialFilePanel({ label, stackName, relPath, + rootId, extraAction, }: SpecialFilePanelProps) { const [downloading, setDownloading] = useState(false); @@ -43,7 +52,7 @@ function SpecialFilePanel({ const handleDownload = async () => { setDownloading(true); try { - const res = await downloadStackFile(stackName, relPath); + const res = await downloadStackFile(stackName, relPath, rootId); if (!res.ok) { toast.error('Download failed.'); return; @@ -105,6 +114,7 @@ export function FileViewer({ selectedPath, canEdit, isDarkMode, + rootId, onSaved, onDirtyChange, }: FileViewerProps) { @@ -116,7 +126,7 @@ export function FileViewer({ const [isBinary, setIsBinary] = useState(false); const [isOversized, setIsOversized] = useState(false); const [size, setSize] = useState(0); - const [loadedMtimeMs, setLoadedMtimeMs] = useState(null); + const [loadedVersion, setLoadedVersion] = useState(null); const readOnly = !canEdit; const hasChanges = content !== originalContent; @@ -174,11 +184,11 @@ export function FileViewer({ setIsBinary(false); setIsOversized(false); - readStackFile(stackName, selectedPath) + readStackFile(stackName, selectedPath, { rootId }) .then((result) => { if (cancelled) return; setSize(result.size); - setLoadedMtimeMs(result.mtimeMs); + setLoadedVersion(result.version ?? fsVersionFromMtime(result.mtimeMs) ?? null); // Check oversized BEFORE binary: the backend returns oversized:true // for files past the 2 MB inline-preview cap regardless of the binary // probe, and the body intentionally carries no content for those @@ -207,7 +217,7 @@ export function FileViewer({ return () => { cancelled = true; }; - }, [stackName, selectedPath]); + }, [stackName, selectedPath, rootId]); const handleSave = async () => { if (!selectedPath) return; @@ -215,22 +225,23 @@ export function FileViewer({ const loadingId = toast.loading('Saving...'); try { const result = await writeStackFile(stackName, selectedPath, content, { - ifMatchMtimeMs: loadedMtimeMs ?? undefined, + ifMatchVersion: loadedVersion ?? undefined, + rootId, }); setOriginalContent(content); - if (result.mtimeMs !== null) setLoadedMtimeMs(result.mtimeMs); + if (result.version !== null) setLoadedVersion(result.version); toast.success('Saved.'); onSaved?.(); } catch (e) { if (e instanceof FileConflictError) { // The server-side content has moved on. Update the baseline (so the - // next save sends the fresh mtime and stops looping on the same + // next save sends the fresh version token and stops looping on the same // precondition) but leave the user's typed buffer untouched. Their // edits remain in the editor, Save stays enabled, and a follow-up // click will apply their changes on top of the new server content // without silently destroying what they typed. setOriginalContent(e.currentContent); - setLoadedMtimeMs(e.currentMtimeMs); + setLoadedVersion(e.currentVersion); toast.error('File changed elsewhere. Review your edits then save again to apply them on top of the current version.'); } else { toast.error(e instanceof Error ? e.message : 'Save failed.'); @@ -276,13 +287,13 @@ export function FileViewer({ setLoading(true); setError(null); try { - const result = await readStackFile(stackName, requestedPath, { forceText: true }); + const result = await readStackFile(stackName, requestedPath, { forceText: true, rootId }); // Stale-request guard: the user may have navigated to a different // file while the override request was in flight. Drop the response // rather than stomp on the new file's state. if (selectedPathRef.current !== requestedPath) return; setSize(result.size); - setLoadedMtimeMs(result.mtimeMs); + setLoadedVersion(result.version ?? fsVersionFromMtime(result.mtimeMs) ?? null); if (result.oversized) { // Backend keeps oversized files out of the inline editor even with // force=text set; the body has no content. Surface the Download @@ -312,6 +323,7 @@ export function FileViewer({ label="Binary file" stackName={stackName} relPath={selectedPath} + rootId={rootId} extraAction={{ label: 'Open as text anyway', onClick: () => void handleForceText(), @@ -328,6 +340,7 @@ export function FileViewer({ label="File too large to preview" stackName={stackName} relPath={selectedPath} + rootId={rootId} /> ); } diff --git a/frontend/src/components/files/MoveFileDialog.tsx b/frontend/src/components/files/MoveFileDialog.tsx index 3e3bbc22..287e68ce 100644 --- a/frontend/src/components/files/MoveFileDialog.tsx +++ b/frontend/src/components/files/MoveFileDialog.tsx @@ -21,6 +21,8 @@ interface MoveFileDialogProps { relPath: string; /** The entry being moved (null until a source is chosen). */ entry: FileEntry | null; + /** The selected file root; the destination tree is loaded within it. */ + rootId?: string; /** Relocate `fromRel` into `destDir` (''=stack root). Resolves true only when * the entry actually moved, so the dialog stays open on a blocked/failed move. */ onMove: (fromRel: string, entryName: string, destDir: string) => boolean | Promise; @@ -32,6 +34,7 @@ export function MoveFileDialog({ stackName, relPath, entry, + rootId, onMove, }: MoveFileDialogProps) { // Loaded directory children, keyed by directory rel path ('' = stack root). @@ -66,7 +69,7 @@ export function MoveFileDialog({ next.delete(dir); return next; }); - listStackDirectory(stackName, dir) + listStackDirectory(stackName, dir, rootId) .then((entries) => { if (requestSeqRef.current !== seq) return; setDirChildren((prev) => new Map(prev).set(dir, entries.filter((e) => e.type === 'directory'))); diff --git a/frontend/src/components/files/NewFileDialog.tsx b/frontend/src/components/files/NewFileDialog.tsx index 41139a48..9a11dd16 100644 --- a/frontend/src/components/files/NewFileDialog.tsx +++ b/frontend/src/components/files/NewFileDialog.tsx @@ -18,6 +18,7 @@ interface NewFileDialogProps { stackName: string; /** Directory within the stack where the file will be created */ currentDir: string; + rootId?: string; onCreated: () => void; } @@ -26,6 +27,7 @@ export function NewFileDialog({ onOpenChange, stackName, currentDir, + rootId, onCreated, }: NewFileDialogProps) { const [name, setName] = useState(''); @@ -51,7 +53,7 @@ export function NewFileDialog({ setCreating(true); const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed; try { - await writeStackFile(stackName, relPath, ''); + await writeStackFile(stackName, relPath, '', { rootId }); toast.success('File created.'); onCreated(); onOpenChange(false); diff --git a/frontend/src/components/files/NewFolderDialog.tsx b/frontend/src/components/files/NewFolderDialog.tsx index cab33124..9f3985d1 100644 --- a/frontend/src/components/files/NewFolderDialog.tsx +++ b/frontend/src/components/files/NewFolderDialog.tsx @@ -12,6 +12,7 @@ interface NewFolderDialogProps { onOpenChange: (open: boolean) => void; stackName: string; currentDir: string; + rootId?: string; onCreated: () => void; } @@ -25,6 +26,7 @@ export function NewFolderDialog({ onOpenChange, stackName, currentDir, + rootId, onCreated, }: NewFolderDialogProps) { const [name, setName] = useState(''); @@ -50,7 +52,7 @@ export function NewFolderDialog({ setCreating(true); const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed; try { - await mkdirStackPath(stackName, relPath); + await mkdirStackPath(stackName, relPath, rootId); toast.success('Folder created.'); onCreated(); onOpenChange(false); diff --git a/frontend/src/components/files/RenameDialog.tsx b/frontend/src/components/files/RenameDialog.tsx index 5cebf6d5..842559c1 100644 --- a/frontend/src/components/files/RenameDialog.tsx +++ b/frontend/src/components/files/RenameDialog.tsx @@ -20,6 +20,7 @@ interface RenameDialogProps { relPath: string; /** Current basename of the entry */ currentName: string; + rootId?: string; onRenamed: () => void; } @@ -29,6 +30,7 @@ export function RenameDialog({ stackName, relPath, currentName, + rootId, onRenamed, }: RenameDialogProps) { const [name, setName] = useState(''); @@ -62,7 +64,7 @@ export function RenameDialog({ const parentDir = relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : ''; const toRel = parentDir ? `${parentDir}/${trimmed}` : trimmed; try { - await renameStackPath(stackName, relPath, toRel); + await renameStackPath(stackName, relPath, toRel, rootId); toast.success('Renamed successfully.'); onRenamed(); onOpenChange(false); diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx index 220fa788..d7812127 100644 --- a/frontend/src/components/files/StackFileExplorer.tsx +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Trash2, FolderPlus, Download, Loader2, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ConfirmModal } from '@/components/ui/modal'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui/toast-store'; -import { downloadStackFile, listStackDirectory, renameStackPath } from '@/lib/stackFilesApi'; +import { downloadStackFile, listStackDirectory, listFileRoots, renameStackPath, STACK_SOURCE_ROOT_ID } from '@/lib/stackFilesApi'; import { FileTree } from './FileTree'; import { FileViewer } from './FileViewer'; import { FileUploadDropzone } from './FileUploadDropzone'; @@ -13,7 +14,7 @@ import { DeleteFileConfirm } from './DeleteFileConfirm'; import { RenameDialog } from './RenameDialog'; import { MoveFileDialog } from './MoveFileDialog'; import { FilePermissionsDialog } from './FilePermissionsDialog'; -import type { FileEntry } from '@/lib/stackFilesApi'; +import type { FileEntry, FileRoot } from '@/lib/stackFilesApi'; interface StackFileExplorerProps { stackName: string; @@ -23,6 +24,33 @@ interface StackFileExplorerProps { onNavigateToEnv?: () => void; } +/** The synthetic stack-source root used before roots load or if discovery fails. */ +const STACK_SOURCE_FALLBACK: FileRoot = { + 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', +}; + +/** Short label for a root option: container path (or volume name) + how many service mounts. */ +function rootOptionLabel(root: FileRoot): string { + if (root.kind === 'stack-source') return 'Stack source'; + const primary = root.mounts[0]?.containerPath || root.label; + const count = root.mounts.length > 1 ? ` · ${root.mounts.length} mounts` : ''; + const ro = root.readonly ? ' · read-only' : ''; + return `${primary}${count}${ro}`; +} + export function StackFileExplorer({ stackName, canEdit, @@ -36,6 +64,22 @@ export function StackFileExplorer({ const [refreshKey, setRefreshKey] = useState(0); const [isDownloading, setIsDownloading] = useState(false); + // ── file roots (Volumes + Stack source) ── + const [roots, setRoots] = useState([STACK_SOURCE_FALLBACK]); + const [selectedRootId, setSelectedRootId] = useState(STACK_SOURCE_ROOT_ID); + // When a root switch is requested while the viewer has unsaved edits, hold it + // here until the user confirms or cancels in the guard modal. + const [pendingRootId, setPendingRootId] = useState(null); + + const selectedRoot = useMemo( + () => roots.find((r) => r.id === selectedRootId) ?? STACK_SOURCE_FALLBACK, + [roots, selectedRootId], + ); + const volumeRoots = useMemo(() => roots.filter((r) => r.kind !== 'stack-source'), [roots]); + const isStackSource = selectedRoot.kind === 'stack-source'; + // Edits are allowed only when the user can edit AND the selected root is writable. + const rootCanEdit = canEdit && selectedRoot.writable; + // ── toolbar delete (existing behaviour) ── const [deleteOpen, setDeleteOpen] = useState(false); @@ -77,10 +121,51 @@ export function StackFileExplorer({ setCurrentDir(''); setIsViewerDirty(false); setPendingSelection(null); + setRoots([STACK_SOURCE_FALLBACK]); + setSelectedRootId(STACK_SOURCE_ROOT_ID); + setPendingRootId(null); + }, [stackName]); + + // Discover the stack's file roots and default to the first browsable volume + // root when one exists, otherwise the stack source. + useEffect(() => { + let cancelled = false; + listFileRoots(stackName) + .then((fetched) => { + if (cancelled) return; + const list = fetched.length ? fetched : [STACK_SOURCE_FALLBACK]; + setRoots(list); + const defaultVolume = list.find((r) => r.kind !== 'stack-source' && r.browsable); + setSelectedRootId(defaultVolume?.id ?? STACK_SOURCE_ROOT_ID); + }) + .catch(() => { + if (cancelled) return; + setRoots([STACK_SOURCE_FALLBACK]); + setSelectedRootId(STACK_SOURCE_ROOT_ID); + }); + return () => { cancelled = true; }; }, [stackName]); const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); + // Apply a root switch: reset the open file/tree to the new root's contents. + const applyRootSwitch = useCallback((rootId: string) => { + setSelectedRootId(rootId); + setSelectedPath(null); + setSelectedEntry(null); + setCurrentDir(''); + }, []); + + // Switch roots, guarding unsaved edits in the viewer first. + const handleRootChange = useCallback((rootId: string) => { + if (rootId === selectedRootId) return; + if (isViewerDirty) { + setPendingRootId(rootId); + return; + } + applyRootSwitch(rootId); + }, [selectedRootId, isViewerDirty, applyRootSwitch]); + const applySelection = useCallback((relPath: string, entry: FileEntry) => { setSelectedPath(relPath); setSelectedEntry(entry); @@ -108,7 +193,7 @@ export function StackFileExplorer({ if (!selectedPath) return; setIsDownloading(true); try { - const res = await downloadStackFile(stackName, selectedPath); + const res = await downloadStackFile(stackName, selectedPath, selectedRootId); if (!res.ok) { toast.error('Download failed.'); return; @@ -146,7 +231,7 @@ export function StackFileExplorer({ return false; } try { - await renameStackPath(stackName, fromRel, toRel); + await renameStackPath(stackName, fromRel, toRel, selectedRootId); toast.success('Moved successfully.'); if (affectsOpen) handleDeleted(); else refresh(); @@ -155,7 +240,7 @@ export function StackFileExplorer({ toast.error(e instanceof Error ? e.message : 'Move failed.'); return false; } - }, [stackName, selectedPath, isViewerDirty, handleDeleted, refresh]); + }, [stackName, selectedRootId, selectedPath, isViewerDirty, handleDeleted, refresh]); // ── Context menu callbacks ── @@ -196,18 +281,54 @@ export function StackFileExplorer({ return (
- {/* Left pane: tree + upload + new folder */} + {/* Left pane: root switcher + tree + upload + new folder */}
+
+ Browsing + + {selectedRoot.warning && ( +

+ + {selectedRoot.warning} +

+ )} + {volumeRoots.length === 0 && ( +

+ No browsable stack volumes detected. Sencho can only browse mounted folders declared by this stack. +

+ )} +
- {canEdit && ( + {rootCanEdit && ( - {canEdit && ( + {rootCanEdit && (
); } diff --git a/frontend/src/components/files/__tests__/FileViewer.test.tsx b/frontend/src/components/files/__tests__/FileViewer.test.tsx index 9e1db843..eaf4abd8 100644 --- a/frontend/src/components/files/__tests__/FileViewer.test.tsx +++ b/frontend/src/components/files/__tests__/FileViewer.test.tsx @@ -34,11 +34,13 @@ vi.mock('@/lib/stackFilesApi', () => { readonly code = 'PRECONDITION_FAILED' as const; readonly currentContent: string; readonly currentMtimeMs: number; - constructor(message: string, currentContent: string, currentMtimeMs: number) { + readonly currentVersion: string | null; + constructor(message: string, currentContent: string, currentMtimeMs: number, currentVersion: string | null) { super(message); this.name = 'FileConflictError'; this.currentContent = currentContent; this.currentMtimeMs = currentMtimeMs; + this.currentVersion = currentVersion; } } return { @@ -96,7 +98,7 @@ const mockReadFile = readStackFile as unknown as ReturnType; const mockWriteFile = writeStackFile as unknown as ReturnType; function textResult(content = 'hello world'): FileContentResult { - return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain', mtimeMs: 1_700_000_000_000 }; + return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain', mtimeMs: 1_700_000_000_000, version: 'W/"1700000000000"' }; } function binaryResult(): FileContentResult { @@ -142,7 +144,7 @@ describe('FileViewer', () => { render(); - await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts')); + await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts', { rootId: undefined })); }); it('renders binary panel (not Monaco) for a binary file', async () => { @@ -190,7 +192,7 @@ describe('FileViewer', () => { rerender(); await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2)); - expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt'); + expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt', { rootId: undefined }); }); it('reports clean dirty state on initial load of a text file', async () => { @@ -217,9 +219,9 @@ describe('FileViewer', () => { expect(onDirtyChange).toHaveBeenCalledWith(false); }); - it('sends If-Match with the loaded mtime on save and updates the local mtime from the response', async () => { + it('sends If-Match with the loaded version on save and updates the local version from the response', async () => { mockReadFile.mockResolvedValue(textResult('hello')); - mockWriteFile.mockResolvedValue({ mtimeMs: 1_700_000_000_999 }); + mockWriteFile.mockResolvedValue({ version: 'W/"1700000000999"', mtimeMs: 1_700_000_000_999 }); render(); await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument()); @@ -235,7 +237,7 @@ describe('FileViewer', () => { expect(s).toBe('my-stack'); expect(p).toBe('config.txt'); expect(c).toBe('edited content'); - expect(opts).toEqual({ ifMatchMtimeMs: 1_700_000_000_000 }); + expect(opts).toEqual({ ifMatchVersion: 'W/"1700000000000"', rootId: undefined }); }); it('binary panel offers "Open as text anyway"; click refetches with forceText and renders Monaco', async () => { @@ -298,11 +300,11 @@ describe('FileViewer', () => { expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument(); }); - it('updates baseline on FileConflictError without discarding the user buffer; follow-up save uses new mtime', async () => { + it('updates baseline on FileConflictError without discarding the user buffer; follow-up save uses the fresh version', async () => { mockReadFile.mockResolvedValue(textResult('stale local copy')); mockWriteFile - .mockRejectedValueOnce(new FileConflictError('changed elsewhere', 'SERVER NOW', 1_700_000_999_000)) - .mockResolvedValueOnce({ mtimeMs: 1_700_001_000_000 }); + .mockRejectedValueOnce(new FileConflictError('changed elsewhere', 'SERVER NOW', 1_700_000_999_000, 'W/"1700000999000"')) + .mockResolvedValueOnce({ version: 'W/"1700001000000"', mtimeMs: 1_700_001_000_000 }); render(); await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument()); @@ -319,6 +321,6 @@ describe('FileViewer', () => { saveBtn.click(); await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(2)); expect(mockWriteFile.mock.calls[1][2]).toBe('edited content'); - expect(mockWriteFile.mock.calls[1][3]).toEqual({ ifMatchMtimeMs: 1_700_000_999_000 }); + expect(mockWriteFile.mock.calls[1][3]).toEqual({ ifMatchVersion: 'W/"1700000999000"', rootId: undefined }); }); }); diff --git a/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx b/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx index d3bb075c..b48024fa 100644 --- a/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx +++ b/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx @@ -22,7 +22,9 @@ const h = vi.hoisted(() => ({ })); vi.mock('@/lib/stackFilesApi', () => ({ + STACK_SOURCE_ROOT_ID: 'stack-source', listStackDirectory: vi.fn().mockResolvedValue([]), + listFileRoots: vi.fn().mockResolvedValue([]), downloadStackFile: vi.fn(), readStackFile: vi.fn(), writeStackFile: vi.fn(), @@ -160,7 +162,7 @@ describe('StackFileExplorer move handling', () => { h.onMove?.('other.txt', 'other.txt', 'sub'); - await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'other.txt', 'sub/other.txt')); + await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'other.txt', 'sub/other.txt', 'stack-source')); await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Moved successfully.')); // The open file was not the one moved, so the viewer keeps its selection. expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt'); @@ -186,7 +188,7 @@ describe('StackFileExplorer move handling', () => { h.onMove?.('a.txt', 'a.txt', 'sub'); - await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt')); + await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt', 'stack-source')); await waitFor(() => expect(screen.getByTestId('viewer-selected').textContent).toBe('(none)')); }); @@ -198,7 +200,7 @@ describe('StackFileExplorer move handling', () => { h.onMove?.('dir', 'dir', 'other'); - await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'dir', 'other/dir')); + await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'dir', 'other/dir', 'stack-source')); await waitFor(() => expect(screen.getByTestId('viewer-selected').textContent).toBe('(none)')); }); diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts index bea414cc..c404b6e5 100644 --- a/frontend/src/lib/stackFilesApi.ts +++ b/frontend/src/lib/stackFilesApi.ts @@ -1,5 +1,8 @@ import { apiFetch } from './api'; +/** The id of the stack source directory root (mirrors the backend constant). */ +export const STACK_SOURCE_ROOT_ID = 'stack-source'; + /** * Mirrors backend/src/utils/validation.ts::isValidRelativeStackPath. Client * defense-in-depth: the backend rejects path-traversal attempts, but catching @@ -111,22 +114,60 @@ export interface FileContentResult { size: number; mime: string; mtimeMs: number; + /** + * Opaque optimistic-concurrency token, round-tripped verbatim as If-Match on + * save. For stack-source/bind roots it is the weak ETag over the mtime; for + * named-volume roots it is a composite token. Optional for back-compat with a + * server that has not yet been upgraded (falls back to the mtime ETag). + */ + version?: string; +} + +/** + * A browsable/editable file root for a stack: the stack source, a bind mount, or + * a named volume. Wire mirror of the backend `StackFileRoot` + * (backend/src/services/StackFileRootsService.ts); keep the two shapes in sync. + */ +export interface FileRootMount { + service: string; + containerPath: string; + readOnly: boolean; +} + +export interface FileRoot { + id: string; + kind: 'stack-source' | 'bind' | 'volume'; + label: string; + hostPathOrName: string; + mounts: FileRootMount[]; + readonly: boolean; + accessible: boolean; + browsable: boolean; + writable: boolean; + chmodable: boolean; + dangerous: boolean; + managedSourceOverlap: boolean; + warning: string | null; + backend: 'fs' | 'helper'; } /** * Thrown by writeStackFile when the server reports the target file has been * modified since the caller's last read (HTTP 412). The current server-side - * content and mtime are attached so callers can prompt the user to reconcile. + * content, mtime, and version token are attached so callers can prompt the user + * to reconcile and retry with the fresh token. */ export class FileConflictError extends Error { readonly code = 'PRECONDITION_FAILED' as const; readonly currentContent: string; readonly currentMtimeMs: number; - constructor(message: string, currentContent: string, currentMtimeMs: number) { + readonly currentVersion: string | null; + constructor(message: string, currentContent: string, currentMtimeMs: number, currentVersion: string | null) { super(message); this.name = 'FileConflictError'; this.currentContent = currentContent; this.currentMtimeMs = currentMtimeMs; + this.currentVersion = currentVersion; } } @@ -143,12 +184,25 @@ function stackFilesUrl(stackName: string, suffix: string): string { return `/stacks/${encodeURIComponent(stackName)}/files${suffix}`; } +/** `&rootId=...` to append onto an existing query string, or '' for the default stack-source root. */ +function rootParam(rootId?: string): string { + return rootId ? `&rootId=${encodeURIComponent(rootId)}` : ''; +} + +/** Discover the browsable/editable file roots for a stack (Volumes + Stack source). */ +export async function listFileRoots(stackName: string): Promise { + const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/file-roots`); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + export async function listStackDirectory( stackName: string, - relPath: string + relPath: string, + rootId?: string, ): Promise { assertSafeRelPath(relPath); - const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`)); + const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`)); if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; } @@ -156,12 +210,12 @@ export async function listStackDirectory( export async function readStackFile( stackName: string, relPath: string, - options?: { forceText?: boolean } + options?: { forceText?: boolean; rootId?: string } ): Promise { assertSafeRelPath(relPath); const forceSuffix = options?.forceText ? '&force=text' : ''; const res = await apiFetch( - stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${forceSuffix}`) + stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${forceSuffix}${rootParam(options?.rootId)}`) ); if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; @@ -169,10 +223,11 @@ export async function readStackFile( export async function downloadStackFile( stackName: string, - relPath: string + relPath: string, + rootId?: string, ): Promise { assertSafeRelPath(relPath); - return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`)); + return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`)); } /** @@ -192,7 +247,7 @@ export async function uploadStackFile( stackName: string, targetDir: string, file: File, - options?: { localOnly?: boolean; overwrite?: boolean } + options?: { localOnly?: boolean; overwrite?: boolean; rootId?: string } ): Promise { assertSafeRelPath(targetDir, 'target directory'); const fd = new FormData(); @@ -209,7 +264,7 @@ export async function uploadStackFile( // which breaks multipart boundary negotiation. The 401 side-effects are // replicated manually below. const res = await fetch( - `/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}${overwriteSuffix}`)}`, + `/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}${overwriteSuffix}${rootParam(options?.rootId)}`)}`, { method: 'POST', credentials: 'include', headers, body: fd } ); @@ -249,57 +304,67 @@ export async function writeStackFile( stackName: string, relPath: string, content: string, - options?: { ifMatchMtimeMs?: number } -): Promise<{ mtimeMs: number | null }> { + options?: { ifMatchVersion?: string; rootId?: string } +): Promise<{ version: string | null; mtimeMs: number | null }> { assertSafeRelPath(relPath); const headers: Record = {}; - if (options?.ifMatchMtimeMs !== undefined) { - headers['If-Match'] = `"${Math.floor(options.ifMatchMtimeMs)}"`; + if (options?.ifMatchVersion) { + // Send the opaque version token verbatim (it is already a valid quoted + // If-Match value for both fs and helper roots). + headers['If-Match'] = options.ifMatchVersion; } const res = await apiFetch( - stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`), + stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}${rootParam(options?.rootId)}`), { method: 'PUT', headers, body: JSON.stringify({ content }) } ); if (res.status === 412) { - let body: { currentContent?: string; currentMtimeMs?: number; error?: string } = {}; + let body: { currentContent?: string; currentMtimeMs?: number; currentVersion?: string; error?: string } = {}; try { body = await res.clone().json(); } catch { /* ignore */ } throw new FileConflictError( body.error ?? 'File has been modified since you last read it.', typeof body.currentContent === 'string' ? body.currentContent : '', typeof body.currentMtimeMs === 'number' ? body.currentMtimeMs : 0, + typeof body.currentVersion === 'string' + ? body.currentVersion + : res.headers.get('ETag'), ); } if (!res.ok) throw new Error(await parseApiError(res)); - // Parse the ETag the server set so callers can update their local mtime. - const etag = res.headers.get('ETag'); - if (etag) { - const stripped = etag.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1'); + // The ETag is the opaque version token for the new content; round-trip it + // verbatim on the next save. mtimeMs is parsed for display only. + const version = res.headers.get('ETag'); + let mtimeMs: number | null = null; + if (version) { + const stripped = version.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1'); const parsed = Number(stripped); - if (Number.isFinite(parsed)) return { mtimeMs: parsed }; + if (Number.isFinite(parsed)) mtimeMs = parsed; } - return { mtimeMs: null }; + return { version, mtimeMs }; } export async function deleteStackPath( stackName: string, relPath: string, - recursive?: boolean + recursive?: boolean, + rootId?: string, ): Promise { assertSafeRelPath(relPath); - const qs = recursive - ? `path=${encodeURIComponent(relPath)}&recursive=1` - : `path=${encodeURIComponent(relPath)}`; - const res = await apiFetch(stackFilesUrl(stackName, `?${qs}`), { method: 'DELETE' }); + const recursiveSuffix = recursive ? '&recursive=1' : ''; + const res = await apiFetch( + stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}${recursiveSuffix}${rootParam(rootId)}`), + { method: 'DELETE' }, + ); if (!res.ok) throw new Error(await parseApiError(res)); } export async function mkdirStackPath( stackName: string, - relPath: string + relPath: string, + rootId?: string, ): Promise { assertSafeRelPath(relPath); const res = await apiFetch( - stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`), + stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`), { method: 'POST', body: JSON.stringify({}) } ); if (!res.ok) throw new Error(await parseApiError(res)); @@ -308,12 +373,13 @@ export async function mkdirStackPath( export async function renameStackPath( stackName: string, fromRel: string, - toRel: string + toRel: string, + rootId?: string, ): Promise { assertSafeRelPath(fromRel, 'source path'); assertSafeRelPath(toRel, 'destination path'); const res = await apiFetch( - stackFilesUrl(stackName, '/rename'), + stackFilesUrl(stackName, `/rename${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`), { method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) } ); if (!res.ok) throw new Error(await parseApiError(res)); @@ -326,10 +392,11 @@ export interface EntryPermissions { export async function getStackEntryPermissions( stackName: string, - relPath: string + relPath: string, + rootId?: string, ): Promise { assertSafeRelPath(relPath); - const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`)); + const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`)); if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; } @@ -337,11 +404,12 @@ export async function getStackEntryPermissions( export async function setStackEntryPermissions( stackName: string, relPath: string, - mode: number + mode: number, + rootId?: string, ): Promise { assertSafeRelPath(relPath); const res = await apiFetch( - stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`), + stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}${rootParam(rootId)}`), { method: 'PUT', body: JSON.stringify({ mode }) } ); if (!res.ok) throw new Error(await parseApiError(res));