feat(files): per-stack file explorer (#780)

* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
This commit is contained in:
Anso
2026-04-26 13:05:19 -04:00
committed by GitHub
parent dd9d33813b
commit 801a098a5b
29 changed files with 3645 additions and 71 deletions
+242 -1
View File
@@ -1,6 +1,17 @@
import path from 'path';
import { promises as fsPromises } from 'fs';
import { promises as fsPromises, createReadStream } from 'fs';
import type { Readable } from 'stream';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
export interface FileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: number;
isProtected: boolean;
}
/**
* Resolves the writable Sencho data directory (same one DatabaseService /
@@ -14,6 +25,22 @@ function getBackupBaseDir(): string {
import { isDebugEnabled } from '../utils/debug';
const PROTECTED_STACK_FILES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
'.env',
]);
const MIME_MAP: Record<string, string> = {
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.json': 'application/json',
'.sh': 'text/x-sh',
'.env': 'text/plain',
};
/**
* FileSystemService - local-only file I/O for compose stack management.
*
@@ -320,4 +347,218 @@ export class FileSystemService {
return { exists: false, timestamp: null };
}
}
// ---------------------------------------------------------------------------
// Stack-scoped file explorer methods
// ---------------------------------------------------------------------------
private guessMime(filePath: string): string {
if (path.basename(filePath) === '.env') return 'text/plain';
const ext = path.extname(filePath).toLowerCase();
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);
if (!isPathWithinBase(target, stackDir)) {
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
}
let realTarget: string;
try {
realTarget = await fsPromises.realpath(target);
} catch (err: unknown) {
const fsErr = err as NodeJS.ErrnoException;
if (fsErr.code !== 'ENOENT') throw err;
// Walk up to the deepest existing ancestor, then reattach the suffix.
let existing = target;
const suffix: string[] = [];
while (true) {
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' });
}
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' });
}
realTarget = path.join(realExisting, ...suffix);
break;
} catch (innerErr: unknown) {
const innerFsErr = innerErr as NodeJS.ErrnoException;
if (innerFsErr.code !== 'ENOENT') throw innerErr;
// Continue walking up.
}
}
}
if (!isPathWithinBase(realTarget, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
}
return realTarget;
}
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
const entries = await Promise.all(
dirents.map(async (dirent): Promise<FileEntry> => {
const entryPath = path.join(safePath, dirent.name);
let size = 0;
let mtime = 0;
try {
const st = await fsPromises.stat(entryPath);
size = dirent.isDirectory() ? 0 : st.size;
mtime = st.mtimeMs;
} catch {
// stat can fail for broken symlinks; use defaults.
}
const type: FileEntry['type'] = dirent.isDirectory()
? 'directory'
: dirent.isSymbolicLink()
? 'symlink'
: 'file';
return {
name: dirent.name,
type,
size,
mtime,
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
};
})
);
return entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
async readStackFile(
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
const mime = this.guessMime(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
if (stat.size > maxBytes) {
const fd = await fsPromises.open(safePath, 'r');
const probe = Buffer.allocUnsafe(8192);
const { bytesRead } = await fd.read(probe, 0, 8192, 0);
await fd.close();
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
return { binary, oversized: true, size: stat.size, mime };
}
const buf = await fsPromises.readFile(safePath);
if (isBinaryBuffer(buf)) {
return { binary: true, oversized: false, size: stat.size, mime };
}
return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') };
}
async streamStackFile(
stackName: string,
relPath: string
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
return {
stream: createReadStream(safePath),
size: stat.size,
filename: path.basename(safePath),
mime: this.guessMime(safePath),
};
}
async writeStackFile(stackName: string, relPath: string, content: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
await fsPromises.writeFile(safePath, content, 'utf-8');
}
async writeStackFileBuffer(stackName: string, relPath: string, buffer: Buffer): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
await fsPromises.writeFile(safePath, buffer);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
if (recursive) {
await fsPromises.rm(safePath, { recursive: true, force: true });
return;
}
try {
await fsPromises.unlink(safePath);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EISDIR') {
try {
await fsPromises.rmdir(safePath);
} catch (inner: unknown) {
const ie = inner as NodeJS.ErrnoException;
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
}
throw inner;
}
} else {
throw err;
}
}
}
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(safePath, { recursive: true });
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
// 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);
const type: FileEntry['type'] = stat.isDirectory()
? 'directory'
: stat.isSymbolicLink()
? 'symlink'
: 'file';
return {
name,
type,
size: stat.isDirectory() ? 0 : stat.size,
mtime: stat.mtimeMs,
isProtected: PROTECTED_STACK_FILES.has(name),
};
}
}