mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 16:16: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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user