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
+237
View File
@@ -0,0 +1,237 @@
/**
* FileRootGateway: one uniform interface over the two storage backends behind a
* stack file root. Stack-source and bind roots run on FileSystemService (the
* `fs` backend); named-volume roots run on VolumeBrowserService (the `helper`
* backend, a hardened Alpine container). The route layer resolves a
* StackFileRoot, then calls the gateway so each handler does not re-implement
* the fs-vs-helper branch or the response-shape mapping.
*
* Optimistic concurrency is carried as an opaque, quoted `version` token that is
* a valid If-Match/ETag value end-to-end: for fs roots it is the existing weak
* ETag `W/"<mtimeMs>"` (so stack-source concurrency is unchanged); for helper
* roots it is a composite mtime+size+hash token that distinguishes two edits
* within the same (seconds-resolution) second.
*/
import type { Readable } from 'stream';
import { FileSystemService, type FileEntry, type FileRootScope } from './FileSystemService';
import { VolumeBrowserService, makeHelperVersion, type VolumeEntry } from './VolumeBrowserService';
import type { StackFileRoot } from './StackFileRootsService';
const HELPER_VIEW_MAX_BYTES = 2 * 1024 * 1024; // match the stack-source viewer cap
export interface GatewayReadResult {
content?: string;
binary: boolean;
oversized: boolean;
size: number;
mime: string;
mtimeMs: number;
version: string;
}
export type GatewayWriteResult =
| { ok: true; mtimeMs: number; version: string }
| { ok: false; currentContent: string; currentMtimeMs: number; currentVersion: string };
/** fs version token: the existing weak ETag over the integer mtimeMs. */
export function makeFsVersion(mtimeMs: number): string {
return `W/"${Math.floor(mtimeMs)}"`;
}
/** Parse the millisecond mtime out of a quoted (optionally weak) numeric ETag token. */
export function parseFsVersion(raw: string | undefined): number | null {
if (!raw) return null;
const m = /(?:W\/)?"(\d+)"/.exec(raw);
if (!m) return null;
const value = Number(m[1]);
return Number.isFinite(value) ? value : null;
}
function volumeEntryToFileEntry(e: VolumeEntry): FileEntry {
return {
name: e.name,
type: e.type === 'other' ? 'file' : e.type,
size: e.size,
mtime: e.mtime * 1000,
isProtected: false,
};
}
export class FileRootGateway {
private nodeId: number;
private constructor(nodeId: number) {
this.nodeId = nodeId;
}
static getInstance(nodeId: number): FileRootGateway {
return new FileRootGateway(nodeId);
}
private fs(): FileSystemService {
return FileSystemService.getInstance(this.nodeId);
}
private helper(): VolumeBrowserService {
return VolumeBrowserService.getInstance(this.nodeId);
}
/** fs scope for a stack-source/bind root; bind roots disable compose/.env protection. */
private scopeFor(root: StackFileRoot): FileRootScope {
return root.kind === 'stack-source'
? { protectedEnabled: true }
: { rootAbsDir: root.hostPathOrName, protectedEnabled: false };
}
/** Composite helper version token from an ms mtime + size + the file bytes. */
private helperVersion(mtimeMs: number, size: number, bytes: Buffer): string {
return makeHelperVersion(Math.floor(mtimeMs / 1000), size, bytes);
}
async listDir(
root: StackFileRoot,
stackName: string,
relPath: string,
limit: number,
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
if (root.backend === 'helper') {
const entries = (await this.helper().listDir(root.hostPathOrName, relPath)).map(volumeEntryToFileEntry);
return { entries, total: entries.length, truncated: false };
}
return this.fs().listStackDirectoryPage(stackName, relPath, { limit, scope: this.scopeFor(root) });
}
async read(
root: StackFileRoot,
stackName: string,
relPath: string,
forceText: boolean,
): Promise<GatewayReadResult> {
if (root.backend === 'helper') {
const r = await this.helper().readFile(root.hostPathOrName, relPath, { maxBytes: HELPER_VIEW_MAX_BYTES });
const bytes = Buffer.from(r.content, r.encoding);
const showContent = !r.binary && !r.truncated;
return {
content: showContent ? r.content : undefined,
binary: r.binary,
oversized: r.truncated,
size: r.size,
mime: r.mime,
mtimeMs: r.mtimeMs,
version: this.helperVersion(r.mtimeMs, r.size, bytes),
};
}
const r = await this.fs().readStackFile(stackName, relPath, undefined, { forceText, scope: this.scopeFor(root) });
return { ...r, version: makeFsVersion(r.mtimeMs) };
}
/** Optimistic-concurrency write for the editor save path. */
async writeIfUnchanged(
root: StackFileRoot,
stackName: string,
relPath: string,
content: string,
expectedVersion: string | undefined,
): Promise<GatewayWriteResult> {
if (root.backend === 'helper') {
const volume = root.hostPathOrName;
const exists = (await this.helper().pathKind(volume, relPath)) === 'file';
if (exists && expectedVersion) {
const current = await this.helper().readFile(volume, relPath, { maxBytes: HELPER_VIEW_MAX_BYTES });
const currentBytes = Buffer.from(current.content, current.encoding);
const currentVersion = this.helperVersion(current.mtimeMs, current.size, currentBytes);
if (currentVersion !== expectedVersion) {
return {
ok: false,
currentContent: current.binary ? '' : current.content,
currentMtimeMs: current.mtimeMs,
currentVersion,
};
}
}
const written = await this.helper().writeFile(volume, relPath, Buffer.from(content, 'utf-8'));
return {
ok: true,
mtimeMs: written.mtimeMs,
version: this.helperVersion(written.mtimeMs, written.size, Buffer.from(content, 'utf-8')),
};
}
const expectedMtimeMs = parseFsVersion(expectedVersion);
const result = await this.fs().writeStackFileIfUnchanged(stackName, relPath, content, expectedMtimeMs, this.scopeFor(root));
if (result.ok) return { ok: true, mtimeMs: result.mtimeMs, version: makeFsVersion(result.mtimeMs) };
return {
ok: false,
currentContent: result.currentContent,
currentMtimeMs: result.currentMtimeMs,
currentVersion: makeFsVersion(result.currentMtimeMs),
};
}
async pathKind(root: StackFileRoot, stackName: string, relPath: string): Promise<'file' | 'directory' | null> {
if (root.backend === 'helper') return this.helper().pathKind(root.hostPathOrName, relPath);
return this.fs().pathKind(stackName, relPath, this.scopeFor(root));
}
/** Upload write. `exclusive` rejects an existing target (no overwrite). */
async writeBuffer(
root: StackFileRoot,
stackName: string,
relPath: string,
buffer: Buffer,
exclusive: boolean,
): Promise<void> {
if (root.backend === 'helper') {
if (exclusive && (await this.helper().pathKind(root.hostPathOrName, relPath)) !== null) {
throw Object.assign(new Error('File already exists'), { code: 'FILE_EXISTS' });
}
await this.helper().writeFile(root.hostPathOrName, relPath, buffer);
return;
}
await this.fs().writeStackFileBuffer(stackName, relPath, buffer, { exclusive, scope: this.scopeFor(root) });
}
async download(
root: StackFileRoot,
stackName: string,
relPath: string,
): Promise<{ kind: 'stream'; stream: Readable; size: number; filename: string; mime: string }
| { kind: 'buffer'; buffer: Buffer; size: number; filename: string }> {
if (root.backend === 'helper') {
const d = await this.helper().downloadFile(root.hostPathOrName, relPath);
return { kind: 'buffer', buffer: d.buffer, size: d.size, filename: d.filename };
}
const s = await this.fs().streamStackFile(stackName, relPath, this.scopeFor(root));
return { kind: 'stream', stream: s.stream, size: s.size, filename: s.filename, mime: s.mime };
}
async deletePath(root: StackFileRoot, stackName: string, relPath: string, recursive: boolean): Promise<void> {
if (root.backend === 'helper') return this.helper().deletePath(root.hostPathOrName, relPath, recursive);
return this.fs().deleteStackPath(stackName, relPath, recursive, this.scopeFor(root));
}
async mkdir(root: StackFileRoot, stackName: string, relPath: string): Promise<void> {
if (root.backend === 'helper') return this.helper().mkdir(root.hostPathOrName, relPath);
return this.fs().mkdirStackPath(stackName, relPath, this.scopeFor(root));
}
async rename(root: StackFileRoot, stackName: string, fromRel: string, toRel: string): Promise<void> {
if (root.backend === 'helper') return this.helper().rename(root.hostPathOrName, fromRel, toRel);
return this.fs().renameStackPath(stackName, fromRel, toRel, this.scopeFor(root));
}
async getMode(root: StackFileRoot, stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
if (root.backend === 'helper') throw unsupportedOnHelperRoot();
return this.fs().getStackEntryMode(stackName, relPath, this.scopeFor(root));
}
async chmod(root: StackFileRoot, stackName: string, relPath: string, mode: number): Promise<void> {
if (root.backend === 'helper') throw unsupportedOnHelperRoot();
return this.fs().chmodStackPath(stackName, relPath, mode, this.scopeFor(root));
}
}
/** Permissions (chmod) are not supported on a helper-backed named-volume root. */
function unsupportedOnHelperRoot(): Error & { code: string } {
return Object.assign(new Error('Permissions are not editable on a named volume'), { code: 'UNSUPPORTED_ON_ROOT' });
}
+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),
};
}
}
+7
View File
@@ -7,6 +7,7 @@ import YAML from 'yaml';
import { CryptoService } from './CryptoService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType, type GitSourceAppliedSpec } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { StackFileRootsService } from './StackFileRootsService';
import { ComposeService } from './ComposeService';
import { StackOpLockService } from './StackOpLockService';
import { HealthGateService } from './HealthGateService';
@@ -1167,6 +1168,12 @@ export class GitSourceService {
}
}
// Materializing changes the compose/env files on disk, which can add or
// remove declared mounts, so the file-root allowlist must be recomputed.
// GitSourceService is default-node scoped (it uses the default
// FileSystemService instance), so invalidate the default node's cache.
StackFileRootsService.getInstance().invalidate(stackName);
return this.deriveAppliedSpec(composeFiles.map(f => f.path), contextDir);
}
@@ -0,0 +1,456 @@
/**
* StackFileRootsService: discovers the safe, stack-scoped file "roots" the
* Files & Volumes explorer can browse for a given stack. A root is either the
* stack source directory, a declared bind-mount host directory, or a named
* Docker volume. Discovery is the single server-side source of truth for which
* paths a file operation may touch: the route always re-derives the allowed
* roots and matches the client `rootId` against them, so a client can never
* address a path the stack itself did not declare.
*
* Reuses the rendered effective model (`parseEffectiveModel`) and
* `isDockerSocketMount` from the storage feature so it never re-implements mount
* parsing. Bind browse-accessibility uses its own `probeBindRootAccess` (NOT the
* portability-focused `probeHostPath`, which refuses to probe sources outside
* the stack dir).
*/
import path from 'path';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
import { NodeRegistry } from './NodeRegistry';
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
import DockerController from './DockerController';
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
import { isDockerSocketMount } from './storage/types';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
export interface RootMount {
service: string;
containerPath: string;
readOnly: boolean;
}
export interface StackFileRoot {
/** Opaque, server-minted id. The resolved path/name lives in metadata, never in the id. */
id: string;
kind: 'stack-source' | 'bind' | 'volume';
label: string;
/** Absolute host path (bind), resolved Docker volume name (volume), or stack dir (stack-source). */
hostPathOrName: string;
/** Every service/containerPath/readOnly declaration that maps to this resolved source. */
mounts: RootMount[];
/** Aggregate: true only when EVERY declaration is read-only. */
readonly: boolean;
/** Bind: a stat-able directory reachable by Sencho. Volume: the helper can inspect it. */
accessible: boolean;
browsable: boolean;
writable: boolean;
/** fs roots only (POSIX chmod is unsupported on helper-backed volume roots). */
chmodable: boolean;
dangerous: boolean;
/** Bind overlaps Sencho's managed compose base / a stack dir, so it is suppressed. */
managedSourceOverlap: boolean;
warning: string | null;
backend: 'fs' | 'helper';
}
export const STACK_SOURCE_ROOT_ID = 'stack-source';
/**
* The stack-source root. `hostPathOrName` is informational (the gateway scopes
* stack-source ops to the stack dir via FileSystemService, not this field), so
* the route can build a synthetic one without resolving the compose base dir.
*/
export function stackSourceFileRoot(hostPathOrName = ''): StackFileRoot {
return {
id: STACK_SOURCE_ROOT_ID,
kind: 'stack-source',
label: 'Stack source',
hostPathOrName,
mounts: [],
readonly: false,
accessible: true,
browsable: true,
writable: true,
chmodable: true,
dangerous: false,
managedSourceOverlap: false,
warning: null,
backend: 'fs',
};
}
const ROOTS_CACHE_TTL_MS = 15_000;
// Dangerous host directories: a bind equal to or under any of these grants
// node-level access and is never browsable. The docker socket is caught
// separately via isDockerSocketMount on the declared source.
const DANGEROUS_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/var/run', '/run'];
interface CacheEntry {
roots: StackFileRoot[];
expiresAt: number;
}
// Module-level cache: getInstance(nodeId) returns a fresh service each call (like
// FileSystemService), so the cache must outlive the instance. Keyed by node+stack.
const rootsCache = new Map<string, CacheEntry>();
/**
* Sencho's writable data directory (sencho.db, encryption.key, backups). Mirrors
* the resolution DatabaseService / FileSystemService use, so a bind mount that
* points at it is recognised as a managed-area overlap and never browsable.
*/
function resolveDataDir(): string {
return path.resolve(process.env.DATA_DIR || path.join(process.cwd(), 'data'));
}
/** A bind source equal to or under one of the dangerous roots (POSIX semantics). */
export function isDangerousHostPath(p: string): boolean {
const norm = p.replace(/\\/g, '/');
if (norm === '/') return true;
return DANGEROUS_ROOTS.some((d) => norm === d || norm.startsWith(`${d}/`));
}
/**
* Browse-accessibility probe for a bind root, scoped to the compose base dir.
* The realpath/stat run ONLY for a source that lexically resolves inside
* `baseDir`: in the containerized deployment that is the only host area the
* Sencho process can reach, so a source outside it is unreachable anyway and is
* reported non-accessible without touching the filesystem. The containment is
* an INLINE path.resolve + startsWith guard at each filesystem sink (the
* realpath of a within-base symlink can still escape, so the resolved canonical
* is re-checked before stat). Returns the canonical realpath so the route can
* pass it to FileSystemService as the containment root.
*/
export async function probeBindRootAccess(
absPath: string,
baseDir: string,
): Promise<{ canonical: string; accessible: boolean; isDir: boolean }> {
const base = path.resolve(baseDir);
const resolved = path.resolve(absPath);
// Out-of-base sources keep their original path for the dangerous/overlap
// classification the caller does, but are never statted.
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
return { canonical: absPath, accessible: false, isDir: false };
}
// A missing path (ENOENT) is the common, expected "not reachable" outcome and
// is left silent; any other code (e.g. EACCES on a path that exists but Sencho
// cannot read) is logged so an operator chasing "why can't I browse this bind"
// has a trail, while the root still degrades gracefully to non-browsable.
const logNonEnoent = (stage: string, err: unknown): void => {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
console.warn('[StackFileRoots] bind %s failed (%s):', stage, code ?? 'unknown', sanitizeForLog(resolved));
}
};
let canonical: string;
try {
canonical = await fsPromises.realpath(resolved);
} catch (err) {
logNonEnoent('realpath', err);
return { canonical: resolved, accessible: false, isDir: false };
}
// A within-base source can be a symlink whose target escapes the base; re-check
// the resolved canonical inline before the stat sink.
if (canonical !== base && !canonical.startsWith(base + path.sep)) {
return { canonical, accessible: false, isDir: false };
}
try {
const st = await fsPromises.stat(canonical);
return { canonical, accessible: true, isDir: st.isDirectory() };
} catch (err) {
logNonEnoent('stat', err);
return { canonical, accessible: false, isDir: false };
}
}
function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 16);
}
function debugCount(event: string, stackName: string): void {
if (isDebugEnabled()) {
console.debug('[StackFileRoots:debug] %s for %s', event, sanitizeForLog(stackName));
}
}
export class StackFileRootsService {
private nodeId: number;
private constructor(nodeId: number) {
this.nodeId = nodeId;
}
static getInstance(nodeId?: number): StackFileRootsService {
return new StackFileRootsService(nodeId ?? NodeRegistry.getInstance().getDefaultNodeId());
}
/** Drop the cached allowlist for a stack so a changed mount stops being addressable immediately. */
static invalidate(nodeId: number, stackName: string): void {
rootsCache.delete(`${nodeId}:${stackName}`);
}
/**
* Drop every cached allowlist for a node. Called on stack lifecycle changes
* (create / delete / import / from-git) so a stack deleted and recreated under
* the same name cannot serve the old stack's roots from the TTL cache.
*/
static invalidateNode(nodeId: number): void {
const prefix = `${nodeId}:`;
for (const key of rootsCache.keys()) {
if (key.startsWith(prefix)) rootsCache.delete(key);
}
}
invalidate(stackName: string): void {
StackFileRootsService.invalidate(this.nodeId, stackName);
}
private cacheKey(stackName: string): string {
return `${this.nodeId}:${stackName}`;
}
private stackSourceRoot(stackDir: string): StackFileRoot {
return stackSourceFileRoot(stackDir);
}
/**
* Render the merged effective model on the owning node. Returns null on any
* render/parse failure (so discovery degrades to stack-source only); never
* throws and never surfaces raw stderr.
*/
private async renderModel(stackName: string): Promise<EffectiveModel | null> {
try {
const result = await ComposeService.getInstance(this.nodeId).renderConfig(stackName);
if (result.rendered === null) return null;
return parseEffectiveModel(JSON.parse(result.rendered), stackName);
} catch (err) {
console.warn('[StackFileRoots] Model render failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
return null;
}
}
async listRoots(stackName: string, opts: { fresh?: boolean } = {}): Promise<StackFileRoot[]> {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const key = this.cacheKey(stackName);
if (!opts.fresh) {
const cached = rootsCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
debugCount('cache hit', stackName);
return cached.roots;
}
}
debugCount(opts.fresh ? 'fresh' : 'cache miss', stackName);
const baseDir = FileSystemService.getInstance(this.nodeId).getBaseDir();
const stackDir = path.join(baseDir, stackName);
const roots: StackFileRoot[] = [this.stackSourceRoot(stackDir)];
const model = await this.renderModel(stackName);
if (!model) {
// Render failure: only the stack-source root survives (never depends on a
// render). Cache the stack-source-only result, never a stale allowlist.
debugCount('render failure', stackName);
rootsCache.set(key, { roots, expiresAt: Date.now() + ROOTS_CACHE_TTL_MS });
return roots;
}
roots.push(...(await this.discoverVolumeRoots(model, baseDir, stackDir)));
rootsCache.set(key, { roots, expiresAt: Date.now() + ROOTS_CACHE_TTL_MS });
return roots;
}
/**
* Resolve a client-supplied rootId to a server-derived root. Throws INVALID_ROOT
* for an unknown/forged id. Writes pass `fresh: true` to bypass the cache, so a
* removed mount can never be written through a stale allowlist.
*/
async resolveRoot(stackName: string, rootId: string, opts: { fresh?: boolean } = {}): Promise<StackFileRoot> {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
// The stack-source root never depends on a compose render, so resolve it
// directly. This keeps plain stack-source file ops (the common case, and all
// existing API clients that send no rootId) off the docker-compose render
// path entirely.
if (rootId === STACK_SOURCE_ROOT_ID) {
const baseDir = FileSystemService.getInstance(this.nodeId).getBaseDir();
return this.stackSourceRoot(path.join(baseDir, stackName));
}
const roots = await this.listRoots(stackName, opts);
const root = roots.find((r) => r.id === rootId);
if (!root) {
throw Object.assign(new Error('Unknown file root'), { code: 'INVALID_ROOT' });
}
return root;
}
private async discoverVolumeRoots(
model: EffectiveModel,
baseDir: string,
stackDir: string,
): Promise<StackFileRoot[]> {
interface BindGroup {
canonical: string;
accessible: boolean;
isDir: boolean;
dockerSock: boolean;
mounts: RootMount[];
}
const probeByRaw = new Map<string, { canonical: string; accessible: boolean; isDir: boolean }>();
const bindByCanonical = new Map<string, BindGroup>();
const volByName = new Map<string, { name: string; mounts: RootMount[] }>();
for (const svc of model.services) {
for (const m of svc.storageMounts ?? []) {
const mount: RootMount = { service: svc.name, containerPath: m.target, readOnly: m.readOnly };
if (m.type === 'bind' && m.source) {
const rawAbs = path.isAbsolute(m.source) ? m.source : path.resolve(stackDir, m.source);
let probe = probeByRaw.get(rawAbs);
if (!probe) {
probe = await probeBindRootAccess(rawAbs, baseDir);
probeByRaw.set(rawAbs, probe);
}
let group = bindByCanonical.get(probe.canonical);
if (!group) {
group = {
canonical: probe.canonical,
accessible: probe.accessible,
isDir: probe.isDir,
dockerSock: false,
mounts: [],
};
bindByCanonical.set(probe.canonical, group);
}
group.mounts.push(mount);
if (isDockerSocketMount({ source: m.source, target: m.target })) group.dockerSock = true;
} else if (m.type === 'named' && m.source) {
const resolvedName = model.volumes[m.source]?.name ?? m.source;
let group = volByName.get(resolvedName);
if (!group) {
group = { name: resolvedName, mounts: [] };
volByName.set(resolvedName, group);
}
group.mounts.push(mount);
}
// anonymous / tmpfs mounts have no stable browse target and are skipped.
}
}
const roots: StackFileRoot[] = [];
for (const group of bindByCanonical.values()) {
const root = this.buildBindRoot(group, baseDir, stackDir);
if (root) roots.push(root);
}
for (const group of volByName.values()) {
roots.push(await this.buildVolumeRoot(group));
}
return roots;
}
private buildBindRoot(
group: { canonical: string; accessible: boolean; isDir: boolean; dockerSock: boolean; mounts: RootMount[] },
baseDir: string,
stackDir: string,
): StackFileRoot | null {
const { canonical } = group;
// A bind equal to the stack dir is already served (with protected-file
// enforcement) by the stack-source root; do not expose a second, unprotected
// editable root for it.
if (canonical === stackDir) return null;
const inStack = isPathWithinBase(canonical, stackDir); // strictly within (equal handled above)
// A bind that overlaps Sencho's own managed areas (the compose base dir, a
// sibling stack, or the data dir that holds sencho.db / encryption.key) must
// never become a browsable/editable root. Compare in both directions so a
// mount equal to, inside, or an ancestor of a managed dir is caught.
const dataDir = resolveDataDir();
const overlapsManaged = (dir: string): boolean => isPathWithinBase(canonical, dir) || isPathWithinBase(dir, canonical);
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir));
const dangerous = isDangerousHostPath(canonical) || group.dockerSock;
const readonly = group.mounts.every((m) => m.readOnly);
const isFile = group.accessible && !group.isDir;
let warning: string | null = null;
if (overlap) {
warning = "This mount overlaps Sencho's managed stack area. Browse the owning stack's source instead.";
} else if (dangerous) {
warning = 'This mount targets a protected host path and cannot be browsed.';
} else if (!group.accessible) {
warning = 'Sencho cannot access this host path. Bind it into the Sencho container to browse it.';
} else if (isFile) {
warning = 'This bind mount targets a single file, not a directory.';
}
const browsable = group.accessible && group.isDir && !dangerous && !overlap;
const writable = browsable && !readonly;
const label = group.mounts[0]?.containerPath || path.basename(canonical) || canonical;
return {
id: `bind:${shortHash(canonical)}`,
kind: 'bind',
label,
hostPathOrName: canonical,
mounts: group.mounts,
readonly,
accessible: group.accessible,
browsable,
writable,
chmodable: browsable,
dangerous,
managedSourceOverlap: overlap,
warning,
backend: 'fs',
};
}
private async buildVolumeRoot(group: { name: string; mounts: RootMount[] }): Promise<StackFileRoot> {
let accessible = false;
let warning: string | null = null;
try {
await DockerController.getInstance(this.nodeId).getDocker().getVolume(group.name).inspect();
accessible = true;
} catch (err) {
// Distinguish a genuine 404 (volume absent) from a transient Docker failure
// (daemon unreachable, proxy hop down) so the warning is honest and a
// non-404 leaves a server-side trail rather than silently reading as "gone".
const e = err as { statusCode?: number; message?: string };
const notFound = e.statusCode === 404 || /no such volume/i.test(e.message ?? '');
if (!notFound) {
console.warn('[StackFileRoots] volume inspect failed for %s:',
sanitizeForLog(group.name), sanitizeForLog(getErrorMessage(err, 'unknown')));
}
warning = notFound
? 'Sencho could not resolve this named volume on the owning node.'
: 'Sencho could not reach Docker to resolve this named volume; it may be temporarily unavailable.';
}
const readonly = group.mounts.every((m) => m.readOnly);
const browsable = accessible;
return {
id: `volume:${shortHash(group.name)}`,
kind: 'volume',
label: group.name,
hostPathOrName: group.name,
mounts: group.mounts,
readonly,
accessible,
browsable,
writable: browsable && !readonly,
chmodable: false,
dangerous: false,
managedSourceOverlap: false,
warning,
backend: 'helper',
};
}
}
+265 -10
View File
@@ -1,18 +1,32 @@
import { Writable } from 'stream';
import path from 'path';
import { createHash } from 'crypto';
import DockerController from './DockerController';
const HELPER_IMAGE = 'alpine:3.20';
const VOLUME_MOUNT = '/v';
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
// Named-volume downloads are bounded (not chunk-streamed): the helper output is
// buffered up to this size and a larger file is rejected rather than silently
// truncated. The bound matches the upload limit so the in-memory footprint is no
// worse than the existing multipart upload path.
const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024;
const EXEC_TIMEOUT_MS = 30_000;
// Containment guard run inside the helper after every `cd`. Even though
// sanitizeRelPath strips `..`, a symlinked directory component could redirect
// `cd` outside the mounted volume; `pwd -P` resolves symlinks so we can assert
// the working directory is still under the volume root before touching anything.
const ROOT_GUARD =
`case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "path escapes volume root" >&2; exit 7 ;; esac`;
// Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU
// coreutils alike. The user-supplied relative path arrives as $1; we cd into
// it before iterating, so user input never lands as an argv element to a
// command that might interpret it as a flag.
const LIST_SCRIPT = `set -e
cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; }
cd -- "$1" || exit 1
${ROOT_GUARD}
for entry in * .[!.]* ..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "")
@@ -25,8 +39,15 @@ for entry in * .[!.]* ..?*; do
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link"
done`;
// Contain the parent before statting the leaf: cd into the leaf's directory and
// assert (via pwd -P) it is still inside the volume, so a symlinked path
// component cannot make stat report on a file outside the mount.
const STAT_SCRIPT = `set -e
target="$1"
p="$1"
d=$(dirname -- "$p"); b=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
target="$b"
[ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; }
if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "")
elif [ -d "$target" ]; then t=d; link=""
@@ -38,6 +59,62 @@ mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0)
name=$(basename -- "$target")
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`;
// --- mutation + probe scripts (all contain the parent, then act on the leaf) ---
// $1 = relative path. New file → owned by the helper user (65534); existing file
// → in-place truncate (`>` keeps the inode, so owner/mode are preserved). A
// symlink leaf is refused so a write never follows a link out of the volume.
const WRITE_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
[ -L "$f" ] && { echo "refusing to write through a symlink" >&2; exit 8; }
[ -d "$f" ] && { echo "target is a directory" >&2; exit 9; }
cat > "$f"`;
// $1 = relative path of the new directory; its parent must already exist.
const MKDIR_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
mkdir -- "$f"`;
// $1 = relative path, $2 = "1" for recursive directory delete. Removing a symlink
// leaf is allowed (rm unlinks the link itself, never its target).
const DELETE_SCRIPT = `set -e
p="$1"; recursive="$2"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 1
${ROOT_GUARD}
[ -e "$f" ] || [ -L "$f" ] || { echo "no such path" >&2; exit 1; }
if [ -d "$f" ] && [ ! -L "$f" ]; then
if [ "$recursive" = "1" ]; then rm -rf -- "$f"; else rmdir -- "$f" 2>/dev/null || { echo "Directory is not empty" >&2; exit 10; }; fi
else
rm -f -- "$f"
fi`;
// $1 = from, $2 = to. Both parents are contained; the destination must not exist.
const RENAME_SCRIPT = `set -e
from="$1"; to="$2"
fd=$(dirname -- "$from"); td=$(dirname -- "$to")
( cd -- "$fd" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "source escapes volume root" >&2; exit 7; }
( cd -- "$td" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "destination escapes volume root" >&2; exit 7; }
{ [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; }
mv -- "$from" "$to"`;
// $1 = relative path. Prints directory|file|none. Used for upload-overwrite checks.
const PATHKIND_SCRIPT = `set -e
p="$1"
d=$(dirname -- "$p"); f=$(basename -- "$p")
cd -- "$d" || exit 2
${ROOT_GUARD}
if [ -d "$f" ] && [ ! -L "$f" ]; then echo directory
elif [ -e "$f" ] || [ -L "$f" ]; then echo file
else echo none
fi`;
export interface VolumeEntry {
name: string;
type: 'file' | 'directory' | 'symlink' | 'other';
@@ -54,10 +131,30 @@ export interface VolumeFileResult {
truncated: boolean;
size: number;
mime: string;
/** mtime in milliseconds (seconds resolution from the helper `stat`, ×1000). */
mtimeMs: number;
}
/** Raw bytes of a volume file for download, bounded to DOWNLOAD_MAX_BYTES. */
export interface VolumeDownload {
buffer: Buffer;
size: number;
filename: string;
}
export type VolumeStat = VolumeEntry;
/**
* Opaque optimistic-concurrency token for an editable volume file. Seconds-
* resolution mtime alone collides for two edits within the same second, so the
* size and a content hash are folded in to keep the guarantee close to the
* millisecond-mtime fs path.
*/
export function makeHelperVersion(mtimeSeconds: number, size: number, content: Buffer): string {
const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
return `"v1:${mtimeSeconds}-${size}-${hash}"`;
}
export class PathTraversalError extends Error {
status = 400;
constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; }
@@ -149,6 +246,7 @@ export class VolumeBrowserService {
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404);
throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
@@ -181,12 +279,12 @@ export class VolumeBrowserService {
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
// Read up to maxBytes+1 to detect truncation precisely. The path is
// passed as $1 (an argv element, never concatenated) and read with
// head -c -- "$1" so a leading-dash filename is never parsed as a flag.
// Read up to maxBytes+1 to detect truncation precisely. The parent is
// contained (cd + pwd -P) and the leaf symlink refused so the read can never
// follow a link out of the volume.
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`head -c ${maxBytes + 1} -- "$1"`,
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${maxBytes + 1} -- "$b"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
@@ -207,9 +305,151 @@ export class VolumeBrowserService {
truncated,
size: meta.size,
mime,
mtimeMs: meta.mtime * 1000,
};
}
/**
* Read a file's full bytes for download, bounded to DOWNLOAD_MAX_BYTES so a
* large file is rejected (413) rather than silently truncated. The parent is
* contained and the leaf symlink refused, like readFile.
*/
async downloadFile(volumeName: string, relPath: string): Promise<VolumeDownload> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot download volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const meta = await this.stat(volumeName, safe);
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
if (meta.size > DOWNLOAD_MAX_BYTES) {
throw new ExecError('File is too large to download from this volume', 413);
}
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${DOWNLOAD_MAX_BYTES + 1} -- "$b"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
throw new ExecError(`Download failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
if (stdout.length > DOWNLOAD_MAX_BYTES) {
throw new ExecError('File is too large to download from this volume', 413);
}
return { buffer: stdout, size: stdout.length, filename: path.posix.basename(safe) };
}
/** Probe whether a path is a directory, file, or absent (upload-overwrite check). */
async pathKind(volumeName: string, relPath: string): Promise<'file' | 'directory' | null> {
const safe = sanitizeRelPath(relPath);
if (!safe) return 'directory';
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, ['sh', '-c', PATHKIND_SCRIPT, 'sh', `./${safe}`]);
if (exitCode !== 0) {
// A permission failure on the parent must not be reported as "absent"
// (which would let an exclusive create proceed); surface it as 403. A
// genuinely missing parent (ENOENT) means nothing exists at this path.
if (/Permission denied/i.test(stderr.toString('utf-8'))) throw new ExecError('Permission denied', 403);
return null;
}
const kind = stdout.toString('utf-8').trim();
if (kind === 'directory') return 'directory';
if (kind === 'file') return 'file';
return null;
}
/**
* Write `content` to a volume file. New files are created owned by the helper
* user (65534); existing files are truncated in place so their owner/mode are
* preserved. The write is intentionally NON-ATOMIC (a helper death mid-write
* can leave a truncated file) because the helper cannot chown a renamed temp
* back to the original owner; ownership preservation is the better default for
* editing a service's config. Returns the new mtime/size for the version token.
*/
async writeFile(volumeName: string, relPath: string, content: Buffer): Promise<{ mtimeMs: number; size: number }> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot write the volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', WRITE_SCRIPT, 'sh', `./${safe}`],
{ writable: true, stdin: content },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg) || exitCode === 8) throw new ExecError('Permission denied', 403);
if (exitCode === 9) throw new ExecError('Target is a directory', 400);
throw new ExecError(`Write failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
const meta = await this.stat(volumeName, safe);
return { mtimeMs: meta.mtime * 1000, size: meta.size };
}
/** Create a single directory; its parent must already exist. */
async mkdir(volumeName: string, relPath: string): Promise<void> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Invalid directory path', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', MKDIR_SCRIPT, 'sh', `./${safe}`],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (/File exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
throw new ExecError(`Create folder failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
/** Delete a file or directory. Removing a symlink unlinks the link, not its target. */
async deletePath(volumeName: string, relPath: string, recursive: boolean): Promise<void> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot delete the volume root', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', DELETE_SCRIPT, 'sh', `./${safe}`, recursive ? '1' : '0'],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (exitCode === 10 || /not empty/i.test(msg)) throw new ExecError('Directory is not empty', 409);
throw new ExecError(`Delete failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
/** Rename/move a file or directory within the same volume. */
async rename(volumeName: string, fromRel: string, toRel: string): Promise<void> {
const from = sanitizeRelPath(fromRel);
const to = sanitizeRelPath(toRel);
if (!from || !to) throw new ExecError('Invalid rename path', 400);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stderr, exitCode } = await this.runHelper(
volumeName,
['sh', '-c', RENAME_SCRIPT, 'sh', `./${from}`, `./${to}`],
{ writable: true },
);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
if (exitCode === 11 || /exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
throw new ExecError(`Rename failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
}
// --- internals -----------------------------------------------------------
private async assertVolumeExists(volumeName: string): Promise<void> {
@@ -250,17 +490,23 @@ export class VolumeBrowserService {
}
}
private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
private async runHelper(
volumeName: string,
cmd: string[],
opts: { writable?: boolean; stdin?: Buffer } = {},
): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
const docker = DockerController.getInstance(this.nodeId).getDocker();
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } });
const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } });
const wantStdin = opts.stdin !== undefined;
// Manual lifecycle (create -> attach -> start -> wait -> remove). Using
// dockerode's docker.run() with AutoRemove races: Docker can delete the
// container before run()'s internal wait() callback fires, surfacing as
// a 404 "no such container" from docker-modem.
// a 404 "no such container" from docker-modem. Only the target volume mount
// becomes writable for mutations; every other hardening flag is unchanged.
const container = await docker.createContainer({
Image: HELPER_IMAGE,
Cmd: cmd,
@@ -269,6 +515,9 @@ export class VolumeBrowserService {
WorkingDir: VOLUME_MOUNT,
AttachStdout: true,
AttachStderr: true,
AttachStdin: wantStdin,
OpenStdin: wantStdin,
StdinOnce: wantStdin,
HostConfig: {
ReadonlyRootfs: true,
NetworkMode: 'none',
@@ -281,7 +530,7 @@ export class VolumeBrowserService {
Type: 'volume',
Source: volumeName,
Target: VOLUME_MOUNT,
ReadOnly: true,
ReadOnly: !opts.writable,
}],
},
});
@@ -292,13 +541,19 @@ export class VolumeBrowserService {
});
const runPromise = (async () => {
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
const stream = await container.attach({ stream: true, stdin: wantStdin, stdout: true, stderr: true, hijack: wantStdin });
const streamEnded = new Promise<void>((resolve) => {
stream.once('end', () => resolve());
stream.once('close', () => resolve());
});
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
await container.start();
if (wantStdin && opts.stdin) {
// Feed the file content to the container's stdin, then close it so the
// helper's `cat > file` sees EOF and exits.
stream.write(opts.stdin);
stream.end();
}
const exitInfo = await container.wait();
// Wait for the attach stream to finish flushing demuxed output.
await streamEnded;