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
@@ -0,0 +1,64 @@
/**
* Unit tests for isBinaryBuffer from utils/binaryDetect.ts.
*
* The function uses byte-range sampling to classify buffers:
* - Empty buffers are text (returns false)
* - Any NUL byte triggers immediate binary detection
* - More than 30% non-printable bytes in the sample triggers binary detection
* - The sampleBytes parameter limits how many bytes are examined
*/
import { describe, it, expect } from 'vitest';
import { isBinaryBuffer } from '../utils/binaryDetect';
describe('isBinaryBuffer', () => {
it('returns false for empty buffer', () => {
expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false);
});
it('returns false for plain ASCII text', () => {
const buf = Buffer.from('hello world\nfoo: bar\n');
expect(isBinaryBuffer(buf)).toBe(false);
});
it('returns false for YAML content', () => {
const yaml = Buffer.from('services:\n app:\n image: nginx:latest\n');
expect(isBinaryBuffer(yaml)).toBe(false);
});
it('returns true when NUL byte present', () => {
const buf = Buffer.from([0x68, 0x65, 0x6c, 0x00, 0x6c, 0x6f]);
expect(isBinaryBuffer(buf)).toBe(true);
});
it('returns true for PNG header (binary)', () => {
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(100, 0xff)]);
expect(isBinaryBuffer(png)).toBe(true);
});
it('returns true when >30% bytes are non-printable', () => {
// 101 bytes: 70 printable 'a' (0x61) + 31 non-printable (0x01)
// ratio = 31/101 > 0.30
const buf = Buffer.concat([Buffer.alloc(31, 0x01), Buffer.alloc(70, 0x61)]);
expect(isBinaryBuffer(buf)).toBe(true);
});
it('returns false when exactly 30% are non-printable', () => {
// 100 bytes: 70 printable + 30 non-printable (0x01, not NUL)
// ratio = 30/100 = 0.30, which is NOT > 0.30, so should return false
const buf = Buffer.concat([Buffer.alloc(30, 0x01), Buffer.alloc(70, 0x61)]);
expect(isBinaryBuffer(buf)).toBe(false);
});
it('respects sampleBytes=0 (empty sample returns false)', () => {
// sampleBytes=0 means subarray(0,0) which is empty, so no bytes to check
const buf = Buffer.from([0x00, 0x61, 0x61]);
expect(isBinaryBuffer(buf, 0)).toBe(false);
});
it('only samples the first N bytes', () => {
// First byte is printable 'a', second is NUL — only sample 1 byte, so no NUL detected
const buf = Buffer.from([0x61, 0x00, 0x61]);
expect(isBinaryBuffer(buf, 1)).toBe(false);
});
});
@@ -0,0 +1,332 @@
/**
* Tests for isValidRelativeStackPath (pure function) and the stack-scoped
* file methods on FileSystemService (listStackDirectory, readStackFile,
* writeStackFile, writeStackFileBuffer, deleteStackPath, mkdirStackPath).
*
* FileSystemService stack methods are tested against a real temp directory so
* that realpath, stat, and fs I/O all run with actual OS semantics.
* NodeRegistry is mocked to redirect the composeDir to our temp location.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { isValidRelativeStackPath } from '../utils/validation';
// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR.
// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS).
// Skip those specific cases on Windows.
const isWindows = process.platform === 'win32';
// Mutable state the mocked NodeRegistry reads. Each beforeEach updates it
// before any FileSystemService method runs.
const mockState = { composeDir: '' };
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => mockState.composeDir,
getDefaultNodeId: () => 1,
}),
},
}));
vi.mock('../utils/debug', () => ({ isDebugEnabled: () => false }));
import { FileSystemService } from '../services/FileSystemService';
// ── isValidRelativeStackPath ──────────────────────────────────────────────────
describe('isValidRelativeStackPath', () => {
// Accepted inputs
it('accepts empty string (stack root)', () => expect(isValidRelativeStackPath('')).toBe(true));
it('accepts simple filename', () => expect(isValidRelativeStackPath('compose.yaml')).toBe(true));
it('accepts dotfile', () => expect(isValidRelativeStackPath('.env')).toBe(true));
it('accepts nested path', () => expect(isValidRelativeStackPath('config/app.conf')).toBe(true));
it('accepts deeply nested path', () => expect(isValidRelativeStackPath('a/b/c/d.txt')).toBe(true));
// Rejected inputs
it('rejects ..', () => expect(isValidRelativeStackPath('..')).toBe(false));
it('rejects ../etc/passwd traversal', () => expect(isValidRelativeStackPath('../etc/passwd')).toBe(false));
it('rejects a/../b', () => expect(isValidRelativeStackPath('a/../b')).toBe(false));
it('rejects absolute path', () => expect(isValidRelativeStackPath('/etc/passwd')).toBe(false));
it('rejects Windows drive path', () => expect(isValidRelativeStackPath('C:/windows')).toBe(false));
it('rejects NUL byte', () => expect(isValidRelativeStackPath('file\x00name')).toBe(false));
it('rejects backslash', () => expect(isValidRelativeStackPath('path\\file')).toBe(false));
it('rejects double-slash', () => expect(isValidRelativeStackPath('a//b')).toBe(false));
it('rejects bare dot segment', () => expect(isValidRelativeStackPath('a/./b')).toBe(false));
});
// ── FileSystemService stack methods ──────────────────────────────────────────
describe('FileSystemService stack methods', () => {
const STACK = 'mystack';
let tmpBase: string;
let stackDir: string;
beforeEach(async () => {
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-fsp-'));
stackDir = path.join(tmpBase, STACK);
mockState.composeDir = tmpBase;
await fs.mkdir(stackDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(tmpBase, { recursive: true, force: true });
});
// ── listStackDirectory ──────────────────────────────────────────────────
describe('listStackDirectory', () => {
it('returns entries with directories sorted before files', async () => {
await fs.mkdir(path.join(stackDir, 'config'));
await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
await fs.writeFile(path.join(stackDir, '.env'), 'KEY=val\n');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
// Directories come first
expect(entries[0].type).toBe('directory');
expect(entries[0].name).toBe('config');
// Files follow, sorted alphabetically (case-insensitive)
const fileNames = entries.filter(e => e.type === 'file').map(e => e.name);
expect(fileNames).toEqual(['.env', 'compose.yaml']);
});
it('marks compose.yaml and .env as protected', async () => {
await fs.writeFile(path.join(stackDir, 'compose.yaml'), '');
await fs.writeFile(path.join(stackDir, '.env'), '');
await fs.writeFile(path.join(stackDir, 'custom.conf'), '');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
const byName = Object.fromEntries(entries.map(e => [e.name, e]));
expect(byName['compose.yaml'].isProtected).toBe(true);
expect(byName['.env'].isProtected).toBe(true);
expect(byName['custom.conf'].isProtected).toBe(false);
});
it('includes size and mtime for files', async () => {
await fs.writeFile(path.join(stackDir, 'test.txt'), 'hello');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
const file = entries.find(e => e.name === 'test.txt');
expect(file).toBeDefined();
expect(file!.size).toBe(5);
expect(file!.mtime).toBeGreaterThan(0);
});
it('returns empty array for an empty stack directory', async () => {
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
expect(entries).toEqual([]);
});
it('lists a subdirectory when relPath is provided', async () => {
await fs.mkdir(path.join(stackDir, 'sub'));
await fs.writeFile(path.join(stackDir, 'sub', 'child.txt'), 'data');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, 'sub');
expect(entries.length).toBe(1);
expect(entries[0].name).toBe('child.txt');
});
});
// ── readStackFile ───────────────────────────────────────────────────────
describe('readStackFile', () => {
it('returns text content for a UTF-8 file', async () => {
await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
const service = FileSystemService.getInstance();
const result = await service.readStackFile(STACK, 'compose.yaml');
expect(result.binary).toBe(false);
expect(result.oversized).toBe(false);
expect(result.content).toBe('services: {}\n');
});
it('returns binary:true and no content for a binary file', async () => {
// PNG magic bytes followed by non-printable data
const pngMagic = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(50, 0xff),
]);
await fs.writeFile(path.join(stackDir, 'icon.png'), pngMagic);
const service = FileSystemService.getInstance();
const result = await service.readStackFile(STACK, 'icon.png');
expect(result.binary).toBe(true);
expect(result.oversized).toBe(false);
expect(result.content).toBeUndefined();
});
it('returns oversized:true for files exceeding maxBytes', async () => {
// Write a text file larger than our small maxBytes limit
await fs.writeFile(path.join(stackDir, 'big.txt'), 'a'.repeat(200));
const service = FileSystemService.getInstance();
// maxBytes=100 forces the oversized path
const result = await service.readStackFile(STACK, 'big.txt', 100);
expect(result.oversized).toBe(true);
expect(result.size).toBe(200);
});
it('throws IS_DIRECTORY when path points to a directory', async () => {
await fs.mkdir(path.join(stackDir, 'subdir'));
const service = FileSystemService.getInstance();
await expect(service.readStackFile(STACK, 'subdir')).rejects.toMatchObject({ code: 'IS_DIRECTORY' });
});
});
// ── writeStackFile ──────────────────────────────────────────────────────
describe('writeStackFile', () => {
it('creates a new file with the given content', async () => {
const service = FileSystemService.getInstance();
await service.writeStackFile(STACK, 'new.txt', 'hello world');
const content = await fs.readFile(path.join(stackDir, 'new.txt'), 'utf-8');
expect(content).toBe('hello world');
});
it('overwrites an existing file', async () => {
await fs.writeFile(path.join(stackDir, 'data.txt'), 'original');
const service = FileSystemService.getInstance();
await service.writeStackFile(STACK, 'data.txt', 'updated');
const content = await fs.readFile(path.join(stackDir, 'data.txt'), 'utf-8');
expect(content).toBe('updated');
});
it('creates parent directories if they do not exist', async () => {
const service = FileSystemService.getInstance();
await service.writeStackFile(STACK, 'deep/nested/file.txt', 'content');
const content = await fs.readFile(path.join(stackDir, 'deep', 'nested', 'file.txt'), 'utf-8');
expect(content).toBe('content');
});
});
// ── writeStackFileBuffer ────────────────────────────────────────────────
describe('writeStackFileBuffer', () => {
it('writes raw bytes correctly', async () => {
const data = Buffer.from([0x01, 0x02, 0x03, 0xff]);
const service = FileSystemService.getInstance();
await service.writeStackFileBuffer(STACK, 'binary.bin', data);
const read = await fs.readFile(path.join(stackDir, 'binary.bin'));
expect(read).toEqual(data);
});
it('creates parent directories when needed', async () => {
const payload = Buffer.from([0xde, 0xad]);
const service = FileSystemService.getInstance();
await service.writeStackFileBuffer(STACK, 'sub/img.bin', payload);
const read = await fs.readFile(path.join(stackDir, 'sub', 'img.bin'));
expect(read).toEqual(payload);
});
});
// ── deleteStackPath ─────────────────────────────────────────────────────
describe('deleteStackPath', () => {
it('deletes a file', async () => {
await fs.writeFile(path.join(stackDir, 'todelete.txt'), '');
const service = FileSystemService.getInstance();
await service.deleteStackPath(STACK, 'todelete.txt');
await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => {
await fs.mkdir(path.join(stackDir, 'emptydir'));
const service = FileSystemService.getInstance();
await service.deleteStackPath(STACK, 'emptydir');
await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => {
await fs.mkdir(path.join(stackDir, 'nonempty'));
await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), '');
const service = FileSystemService.getInstance();
await expect(service.deleteStackPath(STACK, 'nonempty', false)).rejects.toMatchObject({ code: 'NOT_EMPTY' });
});
it('recursively deletes a non-empty directory when recursive=true', async () => {
await fs.mkdir(path.join(stackDir, 'tree'));
await fs.writeFile(path.join(stackDir, 'tree', 'child.txt'), '');
const service = FileSystemService.getInstance();
await service.deleteStackPath(STACK, 'tree', true);
await expect(fs.access(path.join(stackDir, 'tree'))).rejects.toMatchObject({ code: 'ENOENT' });
});
});
// ── mkdirStackPath ──────────────────────────────────────────────────────
describe('mkdirStackPath', () => {
it('creates a new directory', async () => {
const service = FileSystemService.getInstance();
await service.mkdirStackPath(STACK, 'newdir');
const stat = await fs.stat(path.join(stackDir, 'newdir'));
expect(stat.isDirectory()).toBe(true);
});
it('creates nested directories', async () => {
const service = FileSystemService.getInstance();
await service.mkdirStackPath(STACK, 'a/b/c');
const stat = await fs.stat(path.join(stackDir, 'a', 'b', 'c'));
expect(stat.isDirectory()).toBe(true);
});
it('does not throw when directory already exists', async () => {
await fs.mkdir(path.join(stackDir, 'existing'));
const service = FileSystemService.getInstance();
await expect(service.mkdirStackPath(STACK, 'existing')).resolves.toBeUndefined();
});
});
// ── path traversal ──────────────────────────────────────────────────────
describe('path traversal protection', () => {
it('throws INVALID_PATH when relPath escapes stack directory via ..', async () => {
// isValidRelativeStackPath rejects ".." before it reaches the service,
// but we also test the service-level guard with a stack name that would
// escape the compose dir (isPathWithinBase check in resolveSafeStackPath).
const service = FileSystemService.getInstance();
await expect(service.listStackDirectory('..', '')).rejects.toMatchObject({ code: 'INVALID_PATH' });
});
it('throws INVALID_PATH for a stack name with path separator', async () => {
const service = FileSystemService.getInstance();
await expect(service.readStackFile('../other', 'file.txt')).rejects.toMatchObject({ code: 'INVALID_PATH' });
});
it.skipIf(isWindows)('throws SYMLINK_ESCAPE when a symlink inside the stack points outside it (Linux/macOS only)', async () => {
const externalFile = path.join(tmpBase, 'secret.txt');
await fs.writeFile(externalFile, 'secret');
await fs.symlink(externalFile, path.join(stackDir, 'escape-link'));
const service = FileSystemService.getInstance();
await expect(service.readStackFile(STACK, 'escape-link')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
});
});
});
@@ -0,0 +1,447 @@
/**
* Route-level tests for the stack file explorer endpoints:
* GET /:stackName/files
* GET /:stackName/files/content
* GET /:stackName/files/download (Skipper+)
* POST /:stackName/files/upload (Skipper+)
* PUT /:stackName/files/content (Skipper+)
* DELETE /:stackName/files (Skipper+)
* POST /:stackName/files/folder (Skipper+)
*
* Covers: auth gating, tier gating (Community vs paid), input validation,
* upload size limit, and happy-path 204/200 responses.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { promises as fs } from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
// On Windows, fs.unlink on a directory returns EPERM instead of EISDIR so the
// NOT_EMPTY code path in deleteStackPath is never reached. Skip that test case
// on Windows; it is covered on Linux (CI).
const isWindows = process.platform === 'win32';
let tmpDir: string;
let app: import('express').Express;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
let viewerCookie: string;
let stacksDir: string;
const STACK = 'teststack';
beforeAll(async () => {
tmpDir = await setupTestDb();
stacksDir = process.env.COMPOSE_DIR!;
// Create stack directory so file operations have something to work with
await fs.mkdir(path.join(stacksDir, STACK), { recursive: true });
await fs.writeFile(path.join(stacksDir, STACK, 'compose.yaml'), 'services: {}\n');
await fs.writeFile(path.join(stacksDir, STACK, '.env'), 'KEY=val\n');
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
// Default: paid tier so most tests pass the tier gate
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'files-viewer', password_hash: viewerHash, role: 'viewer' });
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'files-viewer', password: 'viewerpass' });
const viewerCookies = viewerRes.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(viewerCookies) ? viewerCookies[0] : viewerCookies;
});
afterAll(async () => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
// Restore all spies then re-establish the paid-tier default so per-test
// overrides via mockReturnValueOnce don't accumulate across tests.
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
});
// ── GET /:stackName/files ─────────────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/files`);
expect(res.status).toBe(401);
});
it('returns 200 with entries array for authenticated admin', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('includes compose.yaml and .env in the listing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
const names = res.body.map((e: { name: string }) => e.name);
expect(names).toContain('compose.yaml');
expect(names).toContain('.env');
});
it('returns 400 for an invalid stack name containing path traversal', async () => {
const res = await request(app)
.get('/api/stacks/../evil/files')
.set('Cookie', adminCookie);
// Express may normalise the URL before it reaches the handler;
// the important thing is we never get 200
expect([400, 404]).toContain(res.status);
});
it('returns 400 for a stack name with special characters', async () => {
const res = await request(app)
.get('/api/stacks/my%20stack/files')
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
});
// ── GET /:stackName/files/content ─────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files/content', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PATH');
});
it('returns 200 with file content for an existing text file', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.binary).toBe(false);
expect(res.body.oversized).toBe(false);
expect(typeof res.body.content).toBe('string');
});
it('returns 404 for a non-existent file', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'nonexistent.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(404);
});
it('returns oversized:true for a file larger than the 2 MB read limit', async () => {
const bigPath = path.join(stacksDir, STACK, 'oversized.txt');
await fs.writeFile(bigPath, 'x'.repeat(3 * 1024 * 1024));
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'oversized.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.oversized).toBe(true);
expect(res.body.binary).toBe(false);
expect(res.body.content).toBeUndefined();
await fs.unlink(bigPath);
}, 15000);
});
// ── GET /:stackName/files/download ────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files/download', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('streams the file for a paid tier user', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toMatch(/attachment/);
expect(res.text).toContain('services');
});
});
// ── POST /:stackName/files/upload ─────────────────────────────────────────────
describe('POST /api/stacks/:stackName/files/upload', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(403);
});
it('returns 400 when no file is attached', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 413 TOO_LARGE when file exceeds 25 MB', async () => {
// 26 MB buffer
const bigFile = Buffer.alloc(26 * 1024 * 1024, 0x61);
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', bigFile, 'toobig.txt');
expect(res.status).toBe(413);
expect(res.body.code).toBe('TOO_LARGE');
}, 20000);
it('returns 204 for a valid file upload (paid tier)', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', Buffer.from('uploaded content'), 'uploaded.txt');
expect(res.status).toBe(204);
// Verify the file was written
const content = await fs.readFile(path.join(stacksDir, STACK, 'uploaded.txt'), 'utf-8');
expect(content).toBe('uploaded content');
});
it('returns 204 and writes into a subdirectory when ?path= is provided', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.query({ path: 'subdir' })
.set('Cookie', adminCookie)
.attach('file', Buffer.from('subdir content'), 'sub.txt');
expect(res.status).toBe(204);
const content = await fs.readFile(path.join(stacksDir, STACK, 'subdir', 'sub.txt'), 'utf-8');
expect(content).toBe('subdir content');
});
});
// ── PUT /:stackName/files/content ─────────────────────────────────────────────
describe('PUT /api/stacks/:stackName/files/content', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.send({ content: 'hello' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', adminCookie)
.send({ content: 'hello' });
expect(res.status).toBe(403);
});
it('returns 400 when content is not a string', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', adminCookie)
.send({ content: 42 });
expect(res.status).toBe(400);
});
it('returns 204 and writes the file for a paid tier admin', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'written.txt' })
.set('Cookie', adminCookie)
.send({ content: 'written via PUT' });
expect(res.status).toBe(204);
const content = await fs.readFile(path.join(stacksDir, STACK, 'written.txt'), 'utf-8');
expect(content).toBe('written via PUT');
});
});
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
describe('DELETE /api/stacks/:stackName/files', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('returns 400 when path is missing', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 204 on successful file deletion', async () => {
// Create a disposable file first
await fs.writeFile(path.join(stacksDir, STACK, 'todelete.txt'), 'bye');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'todelete.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
await expect(fs.access(path.join(stacksDir, STACK, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('returns 409 NOT_EMPTY when deleting a non-empty directory without recursive flag (Linux/macOS only)', async () => {
const dirPath = path.join(stacksDir, STACK, 'nonemptydir');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'child.txt'), '');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'nonemptydir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(409);
expect(res.body.code).toBe('NOT_EMPTY');
});
it.skipIf(isWindows)('returns 204 and removes a non-empty directory when recursive=1 (Linux/macOS only)', async () => {
const dirPath = path.join(stacksDir, STACK, 'recursivedir');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'child.txt'), 'data');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'recursivedir', recursive: '1' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
await expect(fs.access(dirPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
});
// ── POST /:stackName/files/folder ─────────────────────────────────────────────
describe('POST /api/stacks/:stackName/files/folder', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'newdir' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'newdir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('returns 400 when path is missing', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 204 and creates the directory for a paid tier admin', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'mynewdir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
const stat = await fs.stat(path.join(stacksDir, STACK, 'mynewdir'));
expect(stat.isDirectory()).toBe(true);
});
});
// ── permission gating ─────────────────────────────────────────────────────────
describe('permission gating', () => {
it('viewer receives 403 from PUT /files/content', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', viewerCookie)
.send({ content: 'hello' });
expect(res.status).toBe(403);
});
it('viewer receives 403 from POST /files/upload', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', viewerCookie)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(403);
});
it('viewer receives 403 from DELETE /files', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' })
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('viewer receives 403 from POST /files/folder', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'somedir' })
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
});
+194 -2
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { Router, type Request, type Response, type NextFunction } from 'express';
import path from 'path';
import YAML from 'yaml';
import multer from 'multer';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import DockerController from '../services/DockerController';
@@ -13,7 +14,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { requirePermission } from '../middleware/permissions';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { NotificationService } from '../services/NotificationService';
import { isValidStackName, isValidServiceName, isPathWithinBase } from '../utils/validation';
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sendGitSourceError } from '../utils/gitSourceHttp';
@@ -100,6 +101,15 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
}
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
});
function getRelPath(req: Request): string {
return typeof req.query.path === 'string' ? req.query.path : '';
}
export const stacksRouter = Router();
stacksRouter.get('/', async (req: Request, res: Response) => {
@@ -847,3 +857,185 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
res.status(500).json({ error: message });
}
});
// ── File explorer endpoints ──
type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE';
function sendFsError(
res: Response,
err: unknown,
fallback: string,
opts: { notFoundMessage?: string } = {},
): Response {
const e = err as NodeJS.ErrnoException & { code?: string };
if (e.code === 'INVALID_PATH' || e.code === 'SYMLINK_ESCAPE') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'IS_DIRECTORY') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'NOT_EMPTY') {
return res.status(409).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'ENOENT') {
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
}
console.error(`[files] ${fallback}:`, e.message);
return res.status(500).json({ error: fallback });
}
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
return res.json(entries);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to list directory');
}
});
stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
return res.json(result);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to read file');
}
});
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
res.setHeader('Content-Type', result.mime);
res.setHeader('Content-Length', result.size);
const encodedFilename = encodeURIComponent(result.filename);
const safeFilename = result.filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
result.stream.on('error', (streamErr) => {
console.error('[files] stream error:', streamErr);
if (!res.headersSent) res.status(500).end();
else res.destroy();
});
req.on('close', () => result.stream.destroy());
result.stream.pipe(res);
return;
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to download file');
}
});
stacksRouter.post(
'/:stackName/files/upload',
(req: Request, res: Response, next: NextFunction) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePaid(req, res)) return;
upload.single('file')(req, res, (err) => {
if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' });
}
if (err) return res.status(500).json({ error: 'Upload failed' });
next();
});
},
async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const safeName = path.basename(req.file.originalname);
if (!safeName || safeName === '.' || safeName === '..') {
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName;
try {
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
}
},
);
stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { content } = req.body as { content?: unknown };
if (typeof content !== 'string') {
return res.status(400).json({ error: '"content" must be a string' });
}
try {
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to write file');
}
});
stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required for delete' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const recursive = req.query.recursive === '1';
try {
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to delete path');
}
});
stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required to create a folder' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to create folder');
}
});
+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),
};
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Heuristic binary detection based on byte-range sampling.
* Printable bytes: 0x09-0x0D (tab, LF, VT, FF, CR) and 0x20-0x7E.
* Any NUL byte is an immediate binary signal (text editors never produce them).
* If more than 30% of sampled bytes are non-printable the buffer is treated as binary.
*/
export function isBinaryBuffer(buf: Buffer, sampleBytes = 8192): boolean {
if (buf.length === 0) return false;
const sample = buf.subarray(0, sampleBytes);
let nonPrintable = 0;
for (let i = 0; i < sample.length; i++) {
const b = sample[i];
if (b === 0x00) return true;
const isPrintable = (b >= 0x09 && b <= 0x0d) || (b >= 0x20 && b <= 0x7e);
if (!isPrintable) nonPrintable++;
}
return nonPrintable / sample.length > 0.3;
}
+15
View File
@@ -79,6 +79,21 @@ export function isValidDockerResourceId(id: string): boolean {
export const isValidServiceName = (name: string): boolean =>
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name);
/**
* Validates a relative path supplied by the client for stack file operations.
* An empty string is allowed (it means the stack root directory).
* Rejects anything that could escape the stack directory or cause OS-level issues.
*/
export function isValidRelativeStackPath(rel: string): boolean {
if (rel === '') return true;
if (rel.includes('\0')) return false;
if (rel.includes('\\')) return false;
if (/^[a-zA-Z]:/.test(rel) || rel.startsWith('/')) return false;
if (rel.includes('//')) return false;
const segments = rel.split('/');
return !segments.some(seg => seg === '..' || seg === '.');
}
/**
* Asserts that a resolved file path stays within a given base directory.
* Returns true if the path is safe, false if it escapes the base.