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

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

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

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

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

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

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

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

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

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

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

Address review findings on the Files & Volumes feature:

- Invalidate the file-root allowlist on stack create/delete/import/from-git
  (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a
  stack deleted and recreated under the same name cannot serve the old stack's
  roots from the 15s cache.
- Use the atomic exclusive write for a non-overwrite upload so a file created
  by another writer after the existence check is not silently clobbered.
- Let the helper's real cd errno through and map permission failures to 403
  consistently across list/stat/read/write/mkdir/delete/pathKind, instead of
  reporting EACCES as 404/500; pathKind no longer reports a permission-denied
  parent as absent.
- Document the realpath-then-open TOCTOU as a known, pre-existing limitation of
  every file op (O_NOFOLLOW is not viable because config volumes legitimately
  contain symlinks); the bind root is contained to the compose dir and the op
  requires stack:edit.
- Docs: drop a missing screenshot reference and correct the protected-file
  delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
Anso
2026-06-21 18:16:20 -04:00
committed by GitHub
parent b611f41872
commit b9d8e9f490
24 changed files with 1986 additions and 205 deletions
+116 -48
View File
@@ -17,6 +17,21 @@ export interface FileEntry {
isProtected: boolean;
}
/**
* Optional scope for a file-explorer operation. When `rootAbsDir` is set, the
* operation resolves and is contained within that absolute directory instead of
* the stack source dir, so the same primitives serve volume-aware bind-mount
* roots. `protectedEnabled` (compose/.env protection) defaults to true and is
* set false by the route for non-stack-source roots, where a file named
* compose.yaml/.env is just an ordinary editable file. The caller is
* responsible for pre-authorizing `rootAbsDir` (it may legitimately sit outside
* the compose base dir); this service only enforces containment within it.
*/
export interface FileRootScope {
rootAbsDir?: string;
protectedEnabled?: boolean;
}
/**
* Resolves the writable Sencho data directory (same one DatabaseService /
* CryptoService use). Recomputed lazily so test harnesses that override
@@ -1008,15 +1023,30 @@ export class FileSystemService {
return MIME_MAP[ext] ?? 'text/plain';
}
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath);
/**
* Resolve `relPath` within an arbitrary absolute root directory, applying the
* same containment + symlink-escape protection used for stack-source paths.
* Serves both the stack source dir (via resolveSafeStackPath) and volume-aware
* bind-mount roots, which may legitimately resolve outside the compose base dir
* (the caller pre-authorizes the root and passes its canonical realpath).
*
* KNOWN LIMITATION (TOCTOU): this realpath-validates the path, then the caller
* opens/streams/writes it by name, so a process that can write inside the root
* (e.g. a container writing its own bind-mounted config volume) could swap a
* validated regular file for a symlink between this check and the open and
* escape the root. Closing it fully requires per-component openat/O_RESOLVE
* traversal; plain O_NOFOLLOW is not viable because config volumes
* legitimately contain symlinks (e.g. nginx sites-enabled). This is a
* pre-existing property of every FileSystemService file op (not specific to
* volume roots); the bind root is contained to the compose dir and the op
* requires stack:edit, which already grants equivalent host access via
* compose. Tracked as a follow-up hardening, not a per-root regression.
*/
private async resolveSafePathWithin(rootAbsDir: string, relPath: string): Promise<string> {
const target = relPath === '' ? rootAbsDir : path.resolve(rootAbsDir, relPath);
if (!isPathWithinBase(target, stackDir)) {
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
if (!isPathWithinBase(target, rootAbsDir)) {
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
let realTarget: string;
@@ -1033,14 +1063,14 @@ export class FileSystemService {
const parent = path.dirname(existing);
if (parent === existing) {
// Reached filesystem root without finding an existing path.
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
suffix.unshift(path.basename(existing));
existing = parent;
try {
const realExisting = await fsPromises.realpath(existing);
if (!isPathWithinBase(realExisting, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
if (!isPathWithinBase(realExisting, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
realTarget = path.join(realExisting, ...suffix);
break;
@@ -1052,15 +1082,40 @@ export class FileSystemService {
}
}
if (!isPathWithinBase(realTarget, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
if (!isPathWithinBase(realTarget, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
return realTarget;
}
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
const page = await this.listStackDirectoryPage(stackName, relPath, {});
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
return this.resolveSafePathWithin(stackDir, relPath);
}
/**
* Resolve the effective path for an operation that may target the stack source
* dir (default) or a pre-authorized bind-mount root (`scope.rootAbsDir`).
*/
private async resolveScopedPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
return scope?.rootAbsDir !== undefined
? this.resolveSafePathWithin(scope.rootAbsDir, relPath)
: this.resolveSafeStackPath(stackName, relPath);
}
/** Leaf-path variant of resolveScopedPath (does not follow a symlink leaf). */
private async resolveScopedLeafPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
return scope?.rootAbsDir !== undefined
? this.resolveSafeLeafPathWithin(scope.rootAbsDir, relPath)
: this.resolveSafeStackLeafPath(stackName, relPath);
}
async listStackDirectory(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry[]> {
const page = await this.listStackDirectoryPage(stackName, relPath, { scope });
return page.entries;
}
@@ -1074,9 +1129,10 @@ export class FileSystemService {
async listStackDirectoryPage(
stackName: string,
relPath: string,
opts: { limit?: number },
opts: { limit?: number; scope?: FileRootScope },
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
const protectedEnabled = opts.scope?.protectedEnabled ?? true;
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
const total = dirents.length;
@@ -1102,7 +1158,7 @@ export class FileSystemService {
type,
size,
mtime,
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
isProtected: protectedEnabled && PROTECTED_STACK_FILES.has(dirent.name),
};
})
);
@@ -1123,9 +1179,9 @@ export class FileSystemService {
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024,
opts: { forceText?: boolean } = {},
opts: { forceText?: boolean; scope?: FileRootScope } = {},
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
const mime = this.guessMime(safePath);
// Open once and stat+read through the same handle so the mtime returned to
@@ -1166,9 +1222,10 @@ export class FileSystemService {
async streamStackFile(
stackName: string,
relPath: string
relPath: string,
scope?: FileRootScope,
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
const stat = await fsPromises.stat(safePath);
if (stat.isDirectory()) {
@@ -1253,9 +1310,9 @@ export class FileSystemService {
stackName: string,
relPath: string,
buffer: Buffer,
opts?: { exclusive?: boolean },
opts?: { exclusive?: boolean; scope?: FileRootScope },
): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, opts?.scope);
await this.writeStackFileAtomic(safePath, buffer, opts);
}
@@ -1265,8 +1322,8 @@ export class FileSystemService {
* so callers do not silently treat a malformed path as 'available for write'.
* Callers should validate inputs upstream before invoking this helper.
*/
async pathKind(stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async pathKind(stackName: string, relPath: string, scope?: FileRootScope): Promise<'file' | 'directory' | null> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
try {
const stat = await fsPromises.lstat(safePath);
if (stat.isDirectory()) return 'directory';
@@ -1296,11 +1353,12 @@ export class FileSystemService {
relPath: string,
content: string,
expectedMtimeMs: number | null,
scope?: FileRootScope,
): Promise<
| { ok: true; mtimeMs: number }
| { ok: false; currentMtimeMs: number; currentContent: string }
> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
if (expectedMtimeMs !== null) {
@@ -1339,22 +1397,30 @@ export class FileSystemService {
* target) for operations where following would mutate a file other than
* the one the user clicked on.
*/
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
private async resolveSafeLeafPathWithin(rootAbsDir: string, relPath: string): Promise<string> {
if (relPath === '' || relPath === '.') {
return this.resolveSafeStackPath(stackName, '');
return this.resolveSafePathWithin(rootAbsDir, '');
}
const parentRel = path.dirname(relPath);
const baseName = path.basename(relPath);
if (!baseName || baseName === '.' || baseName === '..') {
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
const safeParent = await this.resolveSafeStackPath(stackName, parentRel === '.' ? '' : parentRel);
const safeParent = await this.resolveSafePathWithin(rootAbsDir, parentRel === '.' ? '' : parentRel);
return path.join(safeParent, baseName);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath);
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
return this.resolveSafeLeafPathWithin(stackDir, relPath);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false, scope?: FileRootScope): Promise<void> {
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// Branch on whether the leaf is a symlink BEFORE following it. Deleting
// a symlink should remove the link entry the user clicked on; following
@@ -1390,8 +1456,8 @@ export class FileSystemService {
}
}
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<void> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
await fsPromises.mkdir(safePath, { recursive: true });
}
@@ -1403,11 +1469,13 @@ export class FileSystemService {
* the delete/chmod policy. fs.rename fails with EXDEV across a filesystem
* boundary (e.g. a bind-mounted subdirectory); the route surfaces that as a 409.
*/
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
const fromPath = await this.resolveSafeStackLeafPath(stackName, fromRel);
const toPath = await this.resolveSafeStackLeafPath(stackName, toRel);
async renameStackPath(stackName: string, fromRel: string, toRel: string, scope?: FileRootScope): Promise<void> {
if (scope?.protectedEnabled ?? true) {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
}
const fromPath = await this.resolveScopedLeafPath(stackName, fromRel, scope);
const toPath = await this.resolveScopedLeafPath(stackName, toRel, scope);
const toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
@@ -1437,19 +1505,19 @@ export class FileSystemService {
await fsPromises.rename(fromPath, toPath);
}
async getStackEntryMode(stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async getStackEntryMode(stackName: string, relPath: string, scope?: FileRootScope): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
const stat = await fsPromises.stat(safePath);
const mode = stat.mode & 0o777;
return { mode, octal: mode.toString(8).padStart(3, '0') };
}
async chmodStackPath(stackName: string, relPath: string, mode: number): Promise<void> {
async chmodStackPath(stackName: string, relPath: string, mode: number, scope?: FileRootScope): Promise<void> {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveSafeStackLeafPath(stackName, relPath);
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// chmod on a symlink is rejected. Following the link would silently
// mutate permissions on a file with a different name than the entry the
@@ -1466,8 +1534,8 @@ export class FileSystemService {
await fsPromises.chmod(leafPath, mode);
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
async statStackEntry(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
const stat = await fsPromises.lstat(safePath);
const name = path.basename(safePath);
@@ -1483,7 +1551,7 @@ export class FileSystemService {
type,
size: stat.isDirectory() ? 0 : stat.size,
mtime: stat.mtimeMs,
isProtected: PROTECTED_STACK_FILES.has(name),
isProtected: (scope?.protectedEnabled ?? true) && PROTECTED_STACK_FILES.has(name),
};
}
}