mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 22:25:30 +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,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' });
|
||||
}
|
||||
Reference in New Issue
Block a user