mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
feat(stacks): browse and edit mounted volume files in the explorer (#1403)
* feat(stacks): browse and edit mounted volume files in the explorer Reposition the stack file explorer around runtime configuration access: discover a stack's declared mounts and expose each as a safe, stack-scoped file root. The explorer opens on a Volumes group (bind mounts and named Docker volumes) by default, with the stack source directory as a secondary group, on a "Files & Volumes" tab. - Discover roots from the rendered effective compose model; resolve named volumes to their Docker name and browse/edit them through the hardened helper container, with bind mounts handled directly when reachable. - Re-derive the allowed roots server-side on every file operation and match the client root id against them, so a request can never address a path the stack did not declare. Block dangerous host mounts and binds that overlap Sencho's managed directories; reject writes to read-only mounts. - Thread an optional root id through the existing file endpoints and an opaque, parseable optimistic-concurrency token through read, conflict, and write, for both filesystem and helper backends. - Keep compose and env file protection on the stack source root only. * fix(stacks): theme the Files & Volumes root switcher Replace the raw native select in the file-root switcher with the design system Select component. The native control did not honour the dark theme, so the panel rendered white with unreadable text. The themed Select gives a dark popover with grouped Volumes / Stack source labels and disabled items. * fix(stacks): contain the bind-root probe and de-taint the file-op error log Gate the volume-root bind probe's realpath/stat behind a compose-base containment check (mirroring the storage host-path probe) so they never run on an unvalidated host path; a source outside the compose dir is unreachable in the containerized deployment anyway and is reported non-accessible without touching the filesystem. Log the helper-backed file-op failure through a constant format string with sanitized arguments instead of an interpolated template literal. * fix(stacks): inline the bind-probe containment guard at the fs sinks The wrapped containment predicate was not recognized as a path barrier, so the bind probe's realpath/stat still flagged as uncontrolled-data-in-path. Inline the path.resolve + startsWith check directly at each filesystem sink (and re-check the resolved canonical before stat, so a within-base symlink that resolves outside the compose dir is also rejected). * fix(stacks): harden file-root lifecycle, upload race, and helper errors Address review findings on the Files & Volumes feature: - Invalidate the file-root allowlist on stack create/delete/import/from-git (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a stack deleted and recreated under the same name cannot serve the old stack's roots from the 15s cache. - Use the atomic exclusive write for a non-overwrite upload so a file created by another writer after the existence check is not silently clobbered. - Let the helper's real cd errno through and map permission failures to 403 consistently across list/stat/read/write/mkdir/delete/pathKind, instead of reporting EACCES as 404/500; pathKind no longer reports a permission-denied parent as absent. - Document the realpath-then-open TOCTOU as a known, pre-existing limitation of every file op (O_NOFOLLOW is not viable because config volumes legitimately contain symlinks); the bind root is contained to the compose dir and the op requires stack:edit. - Docs: drop a missing screenshot reference and correct the protected-file delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* StackFileRootsService discovery: bind classification (relative + absolute),
|
||||
* file-bind and inaccessible degradation, the dangerous-mount blocklist, the
|
||||
* managed-area overlap guard (stack-dir fold + sibling/ancestor suppression),
|
||||
* named-volume Docker-name resolution and unresolvable degradation, mixed
|
||||
* read-only aggregation, render-failure keeping only the stack-source root, and
|
||||
* the no-stale-allowlist guarantee. Real temp directories back the bind probe;
|
||||
* ComposeService / DockerController / FileSystemService are stubbed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
StackFileRootsService,
|
||||
isDangerousHostPath,
|
||||
STACK_SOURCE_ROOT_ID,
|
||||
} from '../services/StackFileRootsService';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
|
||||
const STACK = 'app';
|
||||
let baseDir: string;
|
||||
let stackDir: string;
|
||||
|
||||
interface RawMount { type: 'bind' | 'volume' | 'tmpfs'; source?: string; target: string; read_only?: boolean }
|
||||
|
||||
function renderModel(servicesVolumes: Record<string, RawMount[]>, volumes: Record<string, { name: string }> = {}): string {
|
||||
const services: Record<string, unknown> = {};
|
||||
for (const [svc, vols] of Object.entries(servicesVolumes)) services[svc] = { volumes: vols };
|
||||
return JSON.stringify({ services, volumes });
|
||||
}
|
||||
|
||||
/** Stub the three singletons the service depends on. */
|
||||
function stub(opts: { rendered: string | null; volumeInspect?: (name: string) => Promise<unknown> }): void {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({ rendered: opts.rendered, stderr: '', timedOut: false }),
|
||||
} as unknown as ReturnType<typeof ComposeService.getInstance>);
|
||||
|
||||
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
|
||||
getBaseDir: () => baseDir,
|
||||
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
|
||||
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDocker: () => ({
|
||||
getVolume: (name: string) => ({
|
||||
inspect: () => (opts.volumeInspect ?? (async () => ({ Name: name })))(name),
|
||||
}),
|
||||
}),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
baseDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'sfr-base-')));
|
||||
stackDir = path.join(baseDir, STACK);
|
||||
await fs.mkdir(stackDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(baseDir, { recursive: true, force: true }).catch(() => {});
|
||||
// The service cache is module-level; clear it between cases.
|
||||
StackFileRootsService.invalidate(1, STACK);
|
||||
});
|
||||
|
||||
describe('isDangerousHostPath', () => {
|
||||
it('flags the root and protected system directories and their descendants', () => {
|
||||
for (const p of ['/', '/etc', '/etc/nginx', '/proc', '/sys/x', '/dev/sda', '/var/run', '/var/run/docker.sock', '/run/x']) {
|
||||
expect(isDangerousHostPath(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('allows ordinary host paths', () => {
|
||||
for (const p of ['/home/user/config', '/srv/app/data', 'C:\\data', '/etcetera']) {
|
||||
expect(isDangerousHostPath(p)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackFileRootsService.listRoots', () => {
|
||||
it('always includes a writable stack-source root', async () => {
|
||||
stub({ rendered: renderModel({}) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const src = roots.find((r) => r.id === STACK_SOURCE_ROOT_ID);
|
||||
expect(src).toBeDefined();
|
||||
expect(src?.browsable && src?.writable && src?.backend === 'fs').toBe(true);
|
||||
});
|
||||
|
||||
it('classifies a relative bind to a directory as a browsable, writable root', async () => {
|
||||
await fs.mkdir(path.join(stackDir, 'config'));
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: './config', target: '/config', read_only: false }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const bind = roots.find((r) => r.kind === 'bind');
|
||||
expect(bind?.browsable).toBe(true);
|
||||
expect(bind?.writable).toBe(true);
|
||||
expect(bind?.hostPathOrName).toBe(await fs.realpath(path.join(stackDir, 'config')));
|
||||
expect(bind?.mounts[0]).toMatchObject({ service: 'web', containerPath: '/config', readOnly: false });
|
||||
});
|
||||
|
||||
it('marks a bind to a single file as non-browsable', async () => {
|
||||
const file = path.join(stackDir, 'single.conf');
|
||||
await fs.writeFile(file, 'x');
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: file, target: '/c', read_only: false }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const bind = roots.find((r) => r.kind === 'bind');
|
||||
expect(bind?.browsable).toBe(false);
|
||||
expect(bind?.warning).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marks an inaccessible absolute bind as non-browsable with a warning', async () => {
|
||||
const missing = path.join(baseDir, 'does-not-exist-xyz');
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: missing, target: '/c' }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const bind = roots.find((r) => r.kind === 'bind');
|
||||
expect(bind?.accessible).toBe(false);
|
||||
expect(bind?.browsable).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks a dangerous host bind (/etc) and never exposes it as browsable', async () => {
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/etc', target: '/host-etc' }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const bind = roots.find((r) => r.kind === 'bind');
|
||||
expect(bind?.dangerous).toBe(true);
|
||||
expect(bind?.browsable).toBe(false);
|
||||
});
|
||||
|
||||
it('folds a bind equal to the stack dir into stack-source (no second editable root)', async () => {
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: stackDir, target: '/app' }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
expect(roots.some((r) => r.kind === 'bind')).toBe(false);
|
||||
expect(roots.filter((r) => r.id === STACK_SOURCE_ROOT_ID)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('suppresses a bind that points into a sibling stack as a managed-area overlap', async () => {
|
||||
const sibling = path.join(baseDir, 'other');
|
||||
await fs.mkdir(sibling);
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: sibling, target: '/x' }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const bind = roots.find((r) => r.kind === 'bind');
|
||||
expect(bind?.managedSourceOverlap).toBe(true);
|
||||
expect(bind?.browsable).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses a bind that is an ancestor of the compose base dir', async () => {
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: baseDir, target: '/x' }] }) });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const bind = roots.find((r) => r.kind === 'bind');
|
||||
expect(bind?.managedSourceOverlap).toBe(true);
|
||||
expect(bind?.browsable).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a named volume by its Docker name (not the compose key) and inspects that name', async () => {
|
||||
const inspected: string[] = [];
|
||||
stub({
|
||||
rendered: renderModel({ db: [{ type: 'volume', source: 'cache', target: '/c' }] }, { cache: { name: 'app_cache' } }),
|
||||
volumeInspect: async (name) => { inspected.push(name); return { Name: name }; },
|
||||
});
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const vol = roots.find((r) => r.kind === 'volume');
|
||||
expect(vol?.hostPathOrName).toBe('app_cache');
|
||||
expect(vol?.backend).toBe('helper');
|
||||
expect(vol?.browsable).toBe(true);
|
||||
expect(inspected).toContain('app_cache');
|
||||
});
|
||||
|
||||
it('degrades a named volume that cannot be inspected to non-browsable', async () => {
|
||||
stub({
|
||||
rendered: renderModel({ db: [{ type: 'volume', source: 'gone', target: '/c' }] }),
|
||||
volumeInspect: async () => { throw new Error('no such volume'); },
|
||||
});
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const vol = roots.find((r) => r.kind === 'volume');
|
||||
expect(vol?.accessible).toBe(false);
|
||||
expect(vol?.browsable).toBe(false);
|
||||
expect(vol?.warning).toBeTruthy();
|
||||
});
|
||||
|
||||
it('aggregates a source mounted :ro and :rw across services to a single writable root', async () => {
|
||||
await fs.mkdir(path.join(stackDir, 'shared'));
|
||||
const src = path.join(stackDir, 'shared');
|
||||
stub({
|
||||
rendered: renderModel({
|
||||
a: [{ type: 'bind', source: src, target: '/s', read_only: true }],
|
||||
b: [{ type: 'bind', source: src, target: '/s', read_only: false }],
|
||||
}),
|
||||
});
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
const binds = roots.filter((r) => r.kind === 'bind');
|
||||
expect(binds).toHaveLength(1);
|
||||
expect(binds[0].readonly).toBe(false);
|
||||
expect(binds[0].writable).toBe(true);
|
||||
expect(binds[0].mounts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns only the stack-source root when the model render fails', async () => {
|
||||
stub({ rendered: null });
|
||||
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(roots[0].id).toBe(STACK_SOURCE_ROOT_ID);
|
||||
});
|
||||
|
||||
it('never serves a stale allowlist: a later render failure drops previously-discovered roots', async () => {
|
||||
await fs.mkdir(path.join(stackDir, 'config'));
|
||||
const ok = renderModel({ web: [{ type: 'bind', source: './config', target: '/config' }] });
|
||||
|
||||
stub({ rendered: ok });
|
||||
const svc = StackFileRootsService.getInstance(1);
|
||||
expect((await svc.listRoots(STACK)).some((r) => r.kind === 'bind')).toBe(true);
|
||||
|
||||
// Mounts change such that the model no longer renders; after invalidation the
|
||||
// recompute must not fall back to the prior good allowlist.
|
||||
vi.restoreAllMocks();
|
||||
stub({ rendered: null });
|
||||
StackFileRootsService.invalidate(1, STACK);
|
||||
const after = await StackFileRootsService.getInstance(1).listRoots(STACK);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].id).toBe(STACK_SOURCE_ROOT_ID);
|
||||
});
|
||||
|
||||
it('resolveRoot rejects an unknown rootId and resolves stack-source without a render', async () => {
|
||||
stub({ rendered: renderModel({}) });
|
||||
const svc = StackFileRootsService.getInstance(1);
|
||||
await expect(svc.resolveRoot(STACK, 'bind:deadbeef', { fresh: true })).rejects.toMatchObject({ code: 'INVALID_ROOT' });
|
||||
const src = await svc.resolveRoot(STACK, STACK_SOURCE_ROOT_ID);
|
||||
expect(src.kind).toBe('stack-source');
|
||||
});
|
||||
|
||||
it('invalidateNode clears the cached allowlist so a recreated stack cannot serve old roots', async () => {
|
||||
await fs.mkdir(path.join(stackDir, 'config'));
|
||||
stub({ rendered: renderModel({ web: [{ type: 'bind', source: './config', target: '/config' }] }) });
|
||||
expect((await StackFileRootsService.getInstance(1).listRoots(STACK)).some((r) => r.kind === 'bind')).toBe(true);
|
||||
|
||||
// Stack deleted + recreated under the same name with no declared volume; a
|
||||
// node-level invalidation (as the lifecycle routes trigger) must drop the
|
||||
// cached bind root rather than serve it from the TTL cache.
|
||||
vi.restoreAllMocks();
|
||||
stub({ rendered: renderModel({}) });
|
||||
StackFileRootsService.invalidateNode(1);
|
||||
const after = await StackFileRootsService.getInstance(1).listRoots(STACK);
|
||||
expect(after.some((r) => r.kind === 'bind')).toBe(false);
|
||||
expect(after).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user