From 37e6e48b40a7d2f75715929789ece4071b9ac5af Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 22 Jun 2026 19:33:06 -0400 Subject: [PATCH] feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409) * perf(files): spool uploads to disk instead of buffering in memory Switch the stack file-explorer upload from multer memoryStorage to diskStorage and stream the spooled temp file through the file-root gateway, so an upload is never held fully in RAM. Authorization and root resolution now run before multer spools, so an unauthorized or read-only-root request is rejected without writing a temp file, and the spool is removed on every exit path. The named-volume helper write verifies the written byte count, since cat cannot report a short write. * feat(files): copy and duplicate files in the explorer Add a copy capability to the stack file explorer: a same-folder Duplicate (auto-suffixed name) and a "Copy to..." destination picker, on both filesystem and named-volume roots. Copying is within-root, symlink-leaf-safe, blocks a directory copy into its own subtree, and refuses to create a protected name (compose/.env) at the stack root while still allowing a protected file to be duplicated under a new name. * feat(files): make the file tree keyboard accessible Bring the stack file explorer tree to the WCAG tree pattern: rows are treeitems carrying aria-level, aria-selected, and aria-expanded, with a single roving tabindex and full keyboard navigation (arrow keys, Home/End, Enter/Space) over a flattened visible-node list that stays in lockstep with the rendered rows. A polite live region announces the selected file. No visual change to the tree. * feat(files): bulk select, delete, move, and download files Add multi-select to the stack file explorer (checkboxes plus Shift and Ctrl/Cmd click over the visible order) driving three bulk actions: delete, move, and download as a streamed .tar.gz. All run within the active root on both filesystem and named-volume backends, report per-item results so partial failures surface (with the failed items kept selected for retry), normalize ancestor/descendant selections server-side, and cap the archive entry and byte counts before any bytes are streamed. Protected compose/.env files are excluded from bulk delete and move but may still be downloaded. * docs(files): document copy, bulk actions, and keyboard navigation Add the copy/duplicate and multi-select bulk delete/move/download sections to the Files & Volumes page, a keyboard-navigation note for the tree, an updated context-menu reference, and bulk troubleshooting entries. * fix(files): inline path-injection barriers at the new file-op sinks CodeQL js/path-injection does not credit the wrapped isPathWithinBase containment check, so the new copy/bulk/disk-upload flows tripped the gate. Inline the canonical path.resolve + startsWith barrier at the realpath sink in resolveSafePathWithin (covers every user-relPath flow) and confirm the multer spool path resolves within UPLOAD_TMP_DIR before unlinking it or streaming it onward. Behavior is unchanged; the paths were already validated. * fix(files): guard the ancestor-walk realpath sink too The first barrier covered realpath(target), but the ENOENT ancestor walk re-derives the path via path.dirname, which static analysis treats as a fresh tainted value. Add the same inline containment barrier before that realpath and resolve the root case via the untainted base, so the only tainted realpath input is one the startsWith check has cleared. Behavior is unchanged. * fix(files): resolve the root case off the taint path in the ancestor walk The compound guard on existing (the same variable as the startsWith subject) was not credited as a sanitizer. Handle the root case before the barrier by resolving the untainted base directly, leaving a plain canonical startsWith guard on the strictly-within ancestor. Behavior is unchanged. * fix(files): harden helper-backend bulk download and uploads Address three issues found in the named-volume (helper) backend: - Bulk download could send 200 headers before discovering a file the helper download path refuses, tearing the archive mid-stream. The prewalk now rejects symlinks, non-regular ("other") entries, and files over the per-file download cap before any header (400/413). FileEntry gains an 'other' type so non-regular entries stay distinct from regular files as they pass through the gateway. - The helper directory listing was fully buffered before the archive entry cap could fire. listDir now accepts a limit; the list script stops after limit+1 rows and the gateway reports truncation. - A stdin pipeline error during a helper upload masked the container's real nonzero exit code (and its 4xx mapping) as a generic 500. The nonzero exit now wins; the masked stream error is logged. * feat(files): add a New file toolbar button with server-enforced create-only The stack file explorer could create a folder from a toolbar button but a new file only from a folder's right-click menu, so a file could not be created at the stack root at all. Add a New file toolbar button beside New folder, targeting the current directory. Creating a file now routes through a new createEmptyStackFile helper that posts a zero-byte file through the existing upload endpoint with overwrite off, so the server's exclusive-create path rejects an existing name instead of clobbering it. A file collision surfaces inline in the dialog; a folder collision and other failures surface as a toast. * fix(files): widen the tree row hit area and add horizontal scroll for long names Right-clicking a file tree row only opened the Sencho context menu when the click landed on the filename; the rest of the row fell through to the native browser menu, and long names were truncated with no way to read them. Make each row span the full pane width (and grow with its content) so the whole row is the context-menu trigger, and let the tree scroll horizontally so a long name is reachable instead of clipped. A new opt-in horizontal prop on ScrollArea adds the styled horizontal scrollbar without clamping content width. * docs(files): document the New file button, full-row right-click, and long-name scrolling * test(files): cover createEmptyStackFile targeting the stack root Add an API-layer case for the empty-directory (stack root) create path, the primary reason the New file toolbar button exists, so a regression in the root-level URL would be caught at unit speed rather than only in e2e. --- backend/src/__tests__/bulk-paths.test.ts | 69 +++ .../src/__tests__/file-root-gateway.test.ts | 75 +++ .../__tests__/filesystem-stack-paths.test.ts | 141 ++++- .../src/__tests__/stack-files-routes.test.ts | 501 ++++++++++++++++++ .../__tests__/volume-browser-service.test.ts | 7 +- backend/src/routes/stacks.ts | 401 +++++++++++++- .../services/FileExplorerMetricsService.ts | 1 + backend/src/services/FileRootGateway.ts | 65 ++- backend/src/services/FileSystemService.ts | 146 ++++- backend/src/services/VolumeBrowserService.ts | 131 ++++- backend/src/utils/bulkPaths.ts | 54 ++ docs/features/stack-file-explorer.mdx | 51 +- e2e/stack-file-explorer.spec.ts | 55 ++ frontend/src/components/files/FileTree.tsx | 211 +++++++- .../components/files/FileTreeContextMenu.tsx | 24 +- .../src/components/files/FileTreeNode.tsx | 79 ++- .../src/components/files/MoveFileDialog.tsx | 55 +- .../src/components/files/NewFileDialog.tsx | 14 +- .../components/files/StackFileExplorer.tsx | 271 +++++++++- .../files/__tests__/FileTree.test.tsx | 280 +++++++++- .../files/__tests__/MoveFileDialog.test.tsx | 56 ++ .../files/__tests__/NewFileDialog.test.tsx | 109 ++++ .../__tests__/StackFileExplorer.test.tsx | 245 ++++++++- frontend/src/components/ui/scroll-area.tsx | 9 +- .../src/lib/__tests__/stackFilesApi.test.ts | 80 ++- frontend/src/lib/stackFilesApi.ts | 108 ++++ 26 files changed, 3100 insertions(+), 138 deletions(-) create mode 100644 backend/src/__tests__/bulk-paths.test.ts create mode 100644 backend/src/__tests__/file-root-gateway.test.ts create mode 100644 backend/src/utils/bulkPaths.ts create mode 100644 frontend/src/components/files/__tests__/NewFileDialog.test.tsx diff --git a/backend/src/__tests__/bulk-paths.test.ts b/backend/src/__tests__/bulk-paths.test.ts new file mode 100644 index 00000000..c58d22f9 --- /dev/null +++ b/backend/src/__tests__/bulk-paths.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for the bulk selection helpers (normalizeBulkPaths, + * destWithinAnySource) used by the bulk delete/move/download routes. + */ +import { describe, it, expect } from 'vitest'; +import { normalizeBulkPaths, destWithinAnySource } from '../utils/bulkPaths'; + +describe('normalizeBulkPaths', () => { + it('dedupes exact duplicates', () => { + expect(normalizeBulkPaths(['a.txt', 'a.txt', 'b.txt'], true)).toEqual(['a.txt', 'b.txt']); + }); + + it('drops a path whose ancestor is also selected', () => { + expect(normalizeBulkPaths(['dir', 'dir/child.txt'], true)).toEqual(['dir']); + expect(normalizeBulkPaths(['dir', 'dir/sub/deep.txt'], true)).toEqual(['dir']); + }); + + it('keeps siblings and unrelated paths', () => { + expect(normalizeBulkPaths(['a/x', 'a/y', 'b'], true)).toEqual(['a/x', 'a/y', 'b']); + }); + + it('does not treat a name-prefix sibling as a descendant', () => { + // "dir2" is not inside "dir" even though it shares the prefix. + expect(normalizeBulkPaths(['dir', 'dir2/file'], true)).toEqual(['dir', 'dir2/file']); + }); + + it('preserves the first-seen order', () => { + expect(normalizeBulkPaths(['z', 'a', 'm'], true)).toEqual(['z', 'a', 'm']); + }); + + describe('case sensitivity', () => { + it('collapses Foo and foo on a case-insensitive root', () => { + expect(normalizeBulkPaths(['Foo', 'foo'], false)).toEqual(['Foo']); + }); + + it('keeps both Foo and foo on a case-sensitive root (Linux / helper volumes)', () => { + expect(normalizeBulkPaths(['Foo', 'foo'], true)).toEqual(['Foo', 'foo']); + }); + + it('drops a case-differing descendant only when case-insensitive', () => { + expect(normalizeBulkPaths(['Foo', 'foo/bar'], false)).toEqual(['Foo']); + expect(normalizeBulkPaths(['Foo', 'foo/bar'], true)).toEqual(['Foo', 'foo/bar']); + }); + }); +}); + +describe('destWithinAnySource', () => { + it('flags a destination equal to a selected source', () => { + expect(destWithinAnySource('dir', ['dir'], true)).toBe(true); + }); + + it('flags a destination inside a selected source', () => { + expect(destWithinAnySource('dir/sub', ['dir'], true)).toBe(true); + }); + + it('allows a destination outside every source', () => { + expect(destWithinAnySource('other', ['dir'], true)).toBe(false); + expect(destWithinAnySource('', ['dir'], true)).toBe(false); + }); + + it('is case-aware', () => { + expect(destWithinAnySource('DIR/sub', ['dir'], false)).toBe(true); + expect(destWithinAnySource('DIR/sub', ['dir'], true)).toBe(false); + }); + + it('does not flag a name-prefix sibling destination', () => { + expect(destWithinAnySource('dir2', ['dir'], true)).toBe(false); + }); +}); diff --git a/backend/src/__tests__/file-root-gateway.test.ts b/backend/src/__tests__/file-root-gateway.test.ts new file mode 100644 index 00000000..6ed3d2bf --- /dev/null +++ b/backend/src/__tests__/file-root-gateway.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { FileRootGateway } from '../services/FileRootGateway'; +import { stackSourceFileRoot, type StackFileRoot } from '../services/StackFileRootsService'; +import { DOWNLOAD_MAX_BYTES, VolumeBrowserService, type VolumeEntry } from '../services/VolumeBrowserService'; +import type { FileEntry } from '../services/FileSystemService'; + +// assertArchivable is a pure guard (no docker/fs), so it is unit-testable on any +// host even though the helper backend itself only runs on Linux. +const gateway = FileRootGateway.getInstance(1); + +const fsRoot = stackSourceFileRoot(); +const helperRoot: StackFileRoot = { + ...stackSourceFileRoot('myvol'), + id: 'vol-1', + kind: 'volume', + backend: 'helper', +}; + +const entry = (over: Partial): FileEntry => ({ + name: 'f', type: 'file', size: 10, mtime: 0, isProtected: false, ...over, +}); + +describe('FileRootGateway.assertArchivable', () => { + it('never rejects on the fs backend (it streams any in-root file and follows symlinks)', () => { + expect(() => gateway.assertArchivable(fsRoot, 'a', entry({ type: 'symlink' }))).not.toThrow(); + expect(() => gateway.assertArchivable(fsRoot, 'big', entry({ size: DOWNLOAD_MAX_BYTES * 4 }))).not.toThrow(); + }); + + it('allows a normal regular file under the cap on the helper backend', () => { + expect(() => gateway.assertArchivable(helperRoot, 'a.txt', entry({ size: DOWNLOAD_MAX_BYTES }))).not.toThrow(); + }); + + it('rejects a helper symlink as unsupported (the helper download refuses to follow it)', () => { + expect(() => gateway.assertArchivable(helperRoot, 'link', entry({ type: 'symlink' }))) + .toThrowError(expect.objectContaining({ code: 'ARCHIVE_UNSUPPORTED' })); + }); + + it('rejects a helper non-regular (other) entry as unsupported', () => { + // fifo/socket/device entries the helper download path refuses as "not a + // regular file"; they must be caught here too, not just symlinks. + expect(() => gateway.assertArchivable(helperRoot, 'dev', entry({ type: 'other' }))) + .toThrowError(expect.objectContaining({ code: 'ARCHIVE_UNSUPPORTED' })); + }); + + it('rejects a helper file above the per-file download cap as too large', () => { + expect(() => gateway.assertArchivable(helperRoot, 'big.bin', entry({ size: DOWNLOAD_MAX_BYTES + 1 }))) + .toThrowError(expect.objectContaining({ code: 'ARCHIVE_TOO_LARGE' })); + }); +}); + +describe('FileRootGateway.listDir helper truncation', () => { + afterEach(() => vi.restoreAllMocks()); + + const volEntry = (name: string): VolumeEntry => ({ name, type: 'file', size: 1, mtime: 0, isProtected: false }); + + it('reports truncated when the helper returns the overflow row, trimming to the limit', async () => { + // The gateway asks the helper for limit+1; receiving limit+1 means there + // were more entries than the limit. + vi.spyOn(VolumeBrowserService.prototype, 'listDir').mockResolvedValue( + Array.from({ length: 4 }, (_, i) => volEntry(`f${i}`)), + ); + const res = await gateway.listDir(helperRoot, 'stack', '', 3); + expect(res.truncated).toBe(true); + expect(res.entries).toHaveLength(3); + }); + + it('reports not truncated when the helper returns at most the limit', async () => { + vi.spyOn(VolumeBrowserService.prototype, 'listDir').mockResolvedValue( + Array.from({ length: 3 }, (_, i) => volEntry(`f${i}`)), + ); + const res = await gateway.listDir(helperRoot, 'stack', '', 3); + expect(res.truncated).toBe(false); + expect(res.entries).toHaveLength(3); + }); +}); diff --git a/backend/src/__tests__/filesystem-stack-paths.test.ts b/backend/src/__tests__/filesystem-stack-paths.test.ts index d0dd6f42..abf7d2e7 100644 --- a/backend/src/__tests__/filesystem-stack-paths.test.ts +++ b/backend/src/__tests__/filesystem-stack-paths.test.ts @@ -1,7 +1,7 @@ /** * Tests for isValidRelativeStackPath (pure function) and the stack-scoped * file methods on FileSystemService (listStackDirectory, readStackFile, - * writeStackFile, writeStackFileBuffer, deleteStackPath, mkdirStackPath). + * writeStackFile, writeScopedFileFromTemp, 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. @@ -215,28 +215,6 @@ describe('FileSystemService stack methods', () => { }); }); - // ── 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); - }); - }); - // ── atomic write semantics ────────────────────────────────────────────── describe('atomic write semantics', () => { @@ -349,6 +327,123 @@ describe('FileSystemService stack methods', () => { }); }); + // ── writeScopedFileFromTemp (disk-backed upload path) ─────────────────── + + describe('writeScopedFileFromTemp', () => { + let tempSrcDir: string; + beforeEach(async () => { + // A separate base dir stands in for the OS upload-spool location, which on + // a real host may sit on a different filesystem than the stack dir. + tempSrcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-uptmp-')); + }); + afterEach(async () => { + await fs.rm(tempSrcDir, { recursive: true, force: true }); + }); + + it('streams a temp file into the stack and leaves the source temp for the caller', async () => { + const src = path.join(tempSrcDir, 'spool-bin'); + const payload = Buffer.from('streamed upload payload'); + await fs.writeFile(src, payload); + + const service = FileSystemService.getInstance(); + await service.writeScopedFileFromTemp(STACK, 'sub/uploaded.bin', src); + + const written = await fs.readFile(path.join(stackDir, 'sub', 'uploaded.bin')); + expect(written).toEqual(payload); + // The method copies (streams) the source; the caller still owns the temp. + await expect(fs.access(src)).resolves.toBeUndefined(); + const leftovers = (await fs.readdir(path.join(stackDir, 'sub'))).filter(n => n.includes('.sencho-tmp-')); + expect(leftovers).toEqual([]); + }); + + it('exclusive write to an existing target throws FILE_EXISTS, preserves the original, and leaks no tmp', async () => { + const target = path.join(stackDir, 'keep.txt'); + await fs.writeFile(target, 'ORIGINAL'); + const src = path.join(tempSrcDir, 'incoming'); + await fs.writeFile(src, 'INCOMING'); + + const service = FileSystemService.getInstance(); + await expect( + service.writeScopedFileFromTemp(STACK, 'keep.txt', src, { exclusive: true }), + ).rejects.toMatchObject({ code: 'FILE_EXISTS' }); + + expect(await fs.readFile(target, 'utf-8')).toBe('ORIGINAL'); + const leftovers = (await fs.readdir(stackDir)).filter(n => n.startsWith('keep.txt.sencho-tmp-')); + expect(leftovers).toEqual([]); + }); + + it('cleans up the staging sibling when the rename step throws', async () => { + const src = path.join(tempSrcDir, 'incoming2'); + await fs.writeFile(src, 'NEW'); + const fsModule = await import('fs'); + const renameSpy = vi + .spyOn(fsModule.promises, 'rename') + .mockRejectedValueOnce(Object.assign(new Error('disk yanked'), { code: 'EIO' })); + + const service = FileSystemService.getInstance(); + await expect(service.writeScopedFileFromTemp(STACK, 'rfail.txt', src)).rejects.toThrow(/disk yanked/); + + const dirEntries = await fs.readdir(stackDir); + expect(dirEntries).not.toContain('rfail.txt'); + expect(dirEntries.filter(n => n.startsWith('rfail.txt.sencho-tmp-'))).toEqual([]); + renameSpy.mockRestore(); + }); + }); + + // ── copyScopedPath ────────────────────────────────────────────────────── + + describe('copyScopedPath', () => { + it('copies a file and preserves the original', async () => { + await fs.writeFile(path.join(stackDir, 'src.txt'), 'hello'); + const service = FileSystemService.getInstance(); + await service.copyScopedPath(STACK, 'src.txt', 'dst.txt'); + expect(await fs.readFile(path.join(stackDir, 'dst.txt'), 'utf-8')).toBe('hello'); + expect(await fs.readFile(path.join(stackDir, 'src.txt'), 'utf-8')).toBe('hello'); + }); + + it('recursively copies a directory tree', async () => { + await fs.mkdir(path.join(stackDir, 'dir/inner'), { recursive: true }); + await fs.writeFile(path.join(stackDir, 'dir/inner/leaf.txt'), 'leaf'); + const service = FileSystemService.getInstance(); + await service.copyScopedPath(STACK, 'dir', 'dir-copy'); + expect(await fs.readFile(path.join(stackDir, 'dir-copy/inner/leaf.txt'), 'utf-8')).toBe('leaf'); + }); + + it('rejects an existing destination with EEXIST', async () => { + await fs.writeFile(path.join(stackDir, 'e-src.txt'), 'a'); + await fs.writeFile(path.join(stackDir, 'e-dst.txt'), 'b'); + const service = FileSystemService.getInstance(); + await expect(service.copyScopedPath(STACK, 'e-src.txt', 'e-dst.txt')).rejects.toMatchObject({ code: 'EEXIST' }); + expect(await fs.readFile(path.join(stackDir, 'e-dst.txt'), 'utf-8')).toBe('b'); + }); + + it('rejects copying a directory into its own descendant with INVALID_PATH', async () => { + await fs.mkdir(path.join(stackDir, 'sc/sub'), { recursive: true }); + const service = FileSystemService.getInstance(); + await expect(service.copyScopedPath(STACK, 'sc', 'sc/sub/sc')).rejects.toMatchObject({ code: 'INVALID_PATH' }); + }); + + it('blocks copying onto a protected root name but allows a protected source', async () => { + await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n'); + const service = FileSystemService.getInstance(); + // Source is protected, destination is not -> allowed. + await service.copyScopedPath(STACK, 'compose.yaml', 'compose.yaml.bak'); + expect(await fs.readFile(path.join(stackDir, 'compose.yaml.bak'), 'utf-8')).toBe('services: {}\n'); + // Destination is a reserved root name -> blocked. + await fs.writeFile(path.join(stackDir, 'plain.yaml'), 'x\n'); + await expect(service.copyScopedPath(STACK, 'plain.yaml', 'docker-compose.yml')).rejects.toMatchObject({ code: 'PROTECTED_FILE' }); + }); + + it.skipIf(isWindows)('copies a symlink as a link, not its target', async () => { + await fs.writeFile(path.join(stackDir, 'target.txt'), 'real'); + await fs.symlink('target.txt', path.join(stackDir, 'link.txt')); + const service = FileSystemService.getInstance(); + await service.copyScopedPath(STACK, 'link.txt', 'link-copy.txt'); + const lst = await fs.lstat(path.join(stackDir, 'link-copy.txt')); + expect(lst.isSymbolicLink()).toBe(true); + }); + }); + // ── deleteStackPath ───────────────────────────────────────────────────── describe('deleteStackPath', () => { diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index b3fb02d3..37373309 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -20,8 +20,41 @@ import request from 'supertest'; import bcrypt from 'bcrypt'; import { promises as fs } from 'fs'; import path from 'path'; +import zlib from 'zlib'; import { Readable } from 'stream'; +import * as tar from 'tar-stream'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import type { StackFileRoot } from '../services/StackFileRootsService'; + +/** Gunzip + untar a bulk-download response body into a { entryName: contents } map. */ +async function extractTarGz(buf: Buffer): Promise> { + const out: Record = {}; + const extract = tar.extract(); + await new Promise((resolve, reject) => { + extract.on('entry', (header, stream, next) => { + const chunks: Buffer[] = []; + stream.on('data', (c: Buffer) => chunks.push(c)); + stream.on('end', () => { out[header.name] = Buffer.concat(chunks).toString('utf-8'); next(); }); + stream.on('error', reject); + }); + extract.on('finish', () => resolve()); + extract.on('error', reject); + extract.end(zlib.gunzipSync(buf)); + }); + return out; +} + +/** + * supertest parser that buffers a binary response body. supertest types the + * parser's first arg as its Response while passing the raw readable stream at + * runtime, so the function is cast to supertest's own parser parameter type. + */ +const binaryParser = ((res: NodeJS.ReadableStream, cb: (err: Error | null, body: Buffer) => void): void => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(Buffer.from(c))); + res.on('end', () => cb(null, Buffer.concat(chunks))); + res.on('error', (err: Error) => cb(err, Buffer.alloc(0))); +}) as unknown as Parameters['parse']>[0]; // 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 @@ -35,8 +68,19 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic let adminCookie: string; let viewerCookie: string; let stacksDir: string; +let uploadTmpDir: string; const STACK = 'teststack'; +/** Count files left in the upload spool dir (ENOENT = dir never created = 0). */ +async function uploadTempCount(): Promise { + try { + return (await fs.readdir(uploadTmpDir)).length; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw err; + } +} + type FileExplorerMetricEntry = { op: string; count: number; @@ -90,6 +134,10 @@ async function expectDownloadMetricCounts(count: number, successCount: number, e beforeAll(async () => { tmpDir = await setupTestDb(); stacksDir = process.env.COMPOSE_DIR!; + // Isolate the upload spool dir per worker so temp-leak assertions are + // deterministic (the default is a shared os.tmpdir() subdir). + uploadTmpDir = path.join(tmpDir, 'uploads'); + process.env.SENCHO_UPLOAD_DIR = uploadTmpDir; // Create stack directory so file operations have something to work with await fs.mkdir(path.join(stacksDir, STACK), { recursive: true }); @@ -113,6 +161,7 @@ beforeAll(async () => { }); afterAll(async () => { + delete process.env.SENCHO_UPLOAD_DIR; cleanupTestDb(tmpDir); }); @@ -947,6 +996,100 @@ describe('POST /api/stacks/:stackName/files/upload', () => { expect(stat.isDirectory()).toBe(true); await fs.rm(dir, { recursive: true, force: true }); }); + + // The upload spools to a disk temp file (diskStorage); that temp must never be + // left behind, on success or on any rejection path. + it('leaves no spooled temp file after a successful upload', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('spool-success'), 'spool-success.txt'); + expect(res.status).toBe(204); + expect(await uploadTempCount()).toBe(0); + await fs.unlink(path.join(stacksDir, STACK, 'spool-success.txt')); + }); + + it('leaves no spooled temp file after a 409 conflict', async () => { + const target = path.join(stacksDir, STACK, 'spool-conflict.txt'); + await fs.writeFile(target, 'original'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('replacement'), 'spool-conflict.txt'); + expect(res.status).toBe(409); + expect(await uploadTempCount()).toBe(0); + await fs.unlink(target); + }); + + it('leaves no spooled temp file after exceeding the size limit (413)', async () => { + 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, 'spool-toobig.txt'); + expect(res.status).toBe(413); + expect(await uploadTempCount()).toBe(0); + }, 20000); + + it('rejects a viewer before any file is spooled (pre-multer auth gate)', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', viewerCookie) + .attach('file', Buffer.from('viewer-data'), 'viewer-spool.txt'); + expect(res.status).toBe(403); + expect(await uploadTempCount()).toBe(0); + // And nothing was written into the stack dir. + await expect(fs.access(path.join(stacksDir, STACK, 'viewer-spool.txt'))).rejects.toThrow(); + }); + + it('rejects an upload to a read-only root before spooling (pre-multer root gate)', async () => { + const readonlyRoot: StackFileRoot = { + id: 'bind:readonlybind', kind: 'bind', label: '/ro', hostPathOrName: path.join(stacksDir, 'ro-bind'), + mounts: [{ service: 'app', containerPath: '/ro', readOnly: true }], + readonly: true, accessible: true, browsable: true, writable: false, + chmodable: false, dangerous: false, managedSourceOverlap: false, + warning: 'This location is read-only.', backend: 'fs', + }; + const { StackFileRootsService } = await import('../services/StackFileRootsService'); + const spy = vi.spyOn(StackFileRootsService.prototype, 'resolveRoot').mockResolvedValue(readonlyRoot); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .query({ rootId: 'bind:readonlybind' }) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('blocked'), 'ro.txt'); + expect(res.status).toBe(403); + expect(res.body.code).toBe('READONLY_ROOT'); + expect(await uploadTempCount()).toBe(0); + spy.mockRestore(); + }); + + it('cleans up the spooled temp file when the write throws after spooling', async () => { + const { FileSystemService } = await import('../services/FileSystemService'); + const spy = vi.spyOn(FileSystemService.prototype, 'writeScopedFileFromTemp') + .mockRejectedValue(Object.assign(new Error('disk gone'), { code: 'EIO' })); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('boom'), 'wfail-upload.txt'); + expect(res.status).toBe(500); + expect(await uploadTempCount()).toBe(0); + await expect(fs.access(path.join(stacksDir, STACK, 'wfail-upload.txt'))).rejects.toThrow(); + spy.mockRestore(); + }); + + it('leaves no spooled temp file after an overwrite', async () => { + const target = path.join(stacksDir, STACK, 'spool-overwrite.txt'); + await fs.writeFile(target, 'before'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/upload`) + .query({ overwrite: '1' }) + .set('Cookie', adminCookie) + .attach('file', Buffer.from('after'), 'spool-overwrite.txt'); + expect(res.status).toBe(204); + expect(await fs.readFile(target, 'utf-8')).toBe('after'); + expect(await uploadTempCount()).toBe(0); + await fs.unlink(target); + }); }); // ── PUT /:stackName/files/content ───────────────────────────────────────────── @@ -1184,6 +1327,364 @@ describe('PATCH /api/stacks/:stackName/files/rename', () => { }); }); +// ── POST /:stackName/files/copy ────────────────────────────────────────────── + +describe('POST /api/stacks/:stackName/files/copy', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .send({ from: 'a.txt', to: 'b.txt' }); + expect(res.status).toBe(401); + }); + + it('viewer receives 403', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', viewerCookie) + .send({ from: 'a.txt', to: 'b.txt' }); + expect(res.status).toBe(403); + }); + + it('copies a file, preserving the original', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'copy-src.txt'), 'payload'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: 'copy-src.txt', to: 'copy-dst.txt' }); + expect(res.status).toBe(204); + expect(await fs.readFile(path.join(stacksDir, STACK, 'copy-dst.txt'), 'utf-8')).toBe('payload'); + expect(await fs.readFile(path.join(stacksDir, STACK, 'copy-src.txt'), 'utf-8')).toBe('payload'); + await fs.unlink(path.join(stacksDir, STACK, 'copy-src.txt')); + await fs.unlink(path.join(stacksDir, STACK, 'copy-dst.txt')); + }); + + it('allows duplicating a protected file under a new name', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: 'compose.yaml', to: 'compose.yaml.bak' }); + expect(res.status).toBe(204); + await fs.unlink(path.join(stacksDir, STACK, 'compose.yaml.bak')); + }); + + it('blocks copying onto a protected root name with 409 PROTECTED_FILE', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'rogue.yaml'), 'services: {}\n'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: 'rogue.yaml', to: 'docker-compose.yml' }); + expect(res.status).toBe(409); + expect(res.body.code).toBe('PROTECTED_FILE'); + await fs.unlink(path.join(stacksDir, STACK, 'rogue.yaml')); + }); + + it('returns 409 ALREADY_EXISTS when the destination exists', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'cexist-src.txt'), 'a'); + await fs.writeFile(path.join(stacksDir, STACK, 'cexist-dst.txt'), 'b'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: 'cexist-src.txt', to: 'cexist-dst.txt' }); + expect(res.status).toBe(409); + expect(res.body.code).toBe('ALREADY_EXISTS'); + // The existing destination is untouched. + expect(await fs.readFile(path.join(stacksDir, STACK, 'cexist-dst.txt'), 'utf-8')).toBe('b'); + await fs.unlink(path.join(stacksDir, STACK, 'cexist-src.txt')); + await fs.unlink(path.join(stacksDir, STACK, 'cexist-dst.txt')); + }); + + it('recursively copies a directory', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'cdir/nested'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'cdir/nested/deep.txt'), 'deep'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: 'cdir', to: 'cdir-copy' }); + expect(res.status).toBe(204); + expect(await fs.readFile(path.join(stacksDir, STACK, 'cdir-copy/nested/deep.txt'), 'utf-8')).toBe('deep'); + await fs.rm(path.join(stacksDir, STACK, 'cdir'), { recursive: true, force: true }); + await fs.rm(path.join(stacksDir, STACK, 'cdir-copy'), { recursive: true, force: true }); + }); + + it('rejects copying a directory into its own descendant with 400', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'selfcopy/sub'), { recursive: true }); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: 'selfcopy', to: 'selfcopy/sub/selfcopy' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('INVALID_PATH'); + await fs.rm(path.join(stacksDir, STACK, 'selfcopy'), { recursive: true, force: true }); + }); + + it('rejects path traversal in from/to with 400', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/copy`) + .set('Cookie', adminCookie) + .send({ from: '../escape.txt', to: 'x.txt' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('INVALID_PATH'); + }); +}); + +// ── Bulk operations: delete / move / download ──────────────────────────────── + +describe('POST /api/stacks/:stackName/files/bulk-delete', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).post(`/api/stacks/${STACK}/files/bulk-delete`).send({ paths: ['a.txt'] }); + expect(res.status).toBe(401); + }); + + it('viewer receives 403', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', viewerCookie) + .send({ paths: ['a.txt'] }); + expect(res.status).toBe(403); + }); + + it('rejects an empty or oversized selection with 400', async () => { + const empty = await request(app).post(`/api/stacks/${STACK}/files/bulk-delete`).set('Cookie', adminCookie).send({ paths: [] }); + expect(empty.status).toBe(400); + const tooMany = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', adminCookie) + .send({ paths: Array.from({ length: 101 }, (_, i) => `f${i}.txt`) }); + expect(tooMany.status).toBe(400); + expect(tooMany.body.code).toBe('TOO_MANY'); + }); + + it('rejects a selection containing an invalid path with 400', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', adminCookie) + .send({ paths: ['ok.txt', '../escape'] }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('INVALID_PATH'); + }); + + it('deletes multiple files and reports per-item results', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'bd1.txt'), '1'); + await fs.writeFile(path.join(stacksDir, STACK, 'bd2.txt'), '2'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', adminCookie) + .send({ paths: ['bd1.txt', 'bd2.txt', 'missing.txt'] }); + expect(res.status).toBe(200); + expect(res.body.deleted).toEqual(expect.arrayContaining(['bd1.txt', 'bd2.txt'])); + expect(res.body.failed).toHaveLength(1); + expect(res.body.failed[0].path).toBe('missing.txt'); + await expect(fs.access(path.join(stacksDir, STACK, 'bd1.txt'))).rejects.toThrow(); + }); + + it('deletes a non-empty folder recursively', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'bdir/sub'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'bdir/sub/x.txt'), 'x'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', adminCookie) + .send({ paths: ['bdir'] }); + expect(res.status).toBe(200); + expect(res.body.deleted).toEqual(['bdir']); + await expect(fs.access(path.join(stacksDir, STACK, 'bdir'))).rejects.toThrow(); + }); + + it('reports a protected file as a per-item failure, not a thrown request', async () => { + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', adminCookie) + .send({ paths: ['compose.yaml'] }); + expect(res.status).toBe(200); + expect(res.body.deleted).toEqual([]); + expect(res.body.failed[0].path).toBe('compose.yaml'); + // compose.yaml must survive. + expect(await fs.readFile(path.join(stacksDir, STACK, 'compose.yaml'), 'utf-8')).toContain('services'); + }); + + it('normalizes a directly-submitted ancestor+descendant selection (no double-process)', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'ndir'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'ndir/child.txt'), 'c'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .set('Cookie', adminCookie) + .send({ paths: ['ndir', 'ndir/child.txt'] }); + expect(res.status).toBe(200); + // Only the ancestor is acted on; the descendant is dropped, so no spurious failure. + expect(res.body.deleted).toEqual(['ndir']); + expect(res.body.failed).toEqual([]); + }); + + it('returns 403 for a read-only root before deleting', async () => { + const readonlyRoot: StackFileRoot = { + id: 'bind:robd', kind: 'bind', label: '/ro', hostPathOrName: path.join(stacksDir, 'ro'), + mounts: [{ service: 'app', containerPath: '/ro', readOnly: true }], + readonly: true, accessible: true, browsable: true, writable: false, + chmodable: false, dangerous: false, managedSourceOverlap: false, + warning: 'This location is read-only.', backend: 'fs', + }; + const { StackFileRootsService } = await import('../services/StackFileRootsService'); + const spy = vi.spyOn(StackFileRootsService.prototype, 'resolveRoot').mockResolvedValue(readonlyRoot); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-delete`) + .query({ rootId: 'bind:robd' }) + .set('Cookie', adminCookie) + .send({ paths: ['x.txt'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('READONLY_ROOT'); + spy.mockRestore(); + }); +}); + +describe('POST /api/stacks/:stackName/files/bulk-move', () => { + it('moves multiple entries into a destination folder', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'bmdest'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'bm1.txt'), '1'); + await fs.writeFile(path.join(stacksDir, STACK, 'bm2.txt'), '2'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-move`) + .set('Cookie', adminCookie) + .send({ from: ['bm1.txt', 'bm2.txt'], toDir: 'bmdest' }); + expect(res.status).toBe(200); + expect(res.body.moved).toEqual(expect.arrayContaining(['bm1.txt', 'bm2.txt'])); + expect(await fs.readFile(path.join(stacksDir, STACK, 'bmdest/bm1.txt'), 'utf-8')).toBe('1'); + await fs.rm(path.join(stacksDir, STACK, 'bmdest'), { recursive: true, force: true }); + }); + + it('reports a same-name collision at the destination as a per-item failure', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'bmd2'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'bmcol.txt'), 'src'); + await fs.writeFile(path.join(stacksDir, STACK, 'bmd2/bmcol.txt'), 'existing'); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-move`) + .set('Cookie', adminCookie) + .send({ from: ['bmcol.txt'], toDir: 'bmd2' }); + expect(res.status).toBe(200); + expect(res.body.moved).toEqual([]); + expect(res.body.failed[0].path).toBe('bmcol.txt'); + await fs.rm(path.join(stacksDir, STACK, 'bmd2'), { recursive: true, force: true }); + await fs.unlink(path.join(stacksDir, STACK, 'bmcol.txt')); + }); + + it('rejects the whole request when the destination is inside a selected folder (400)', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'bmself/sub'), { recursive: true }); + const res = await request(app) + .post(`/api/stacks/${STACK}/files/bulk-move`) + .set('Cookie', adminCookie) + .send({ from: ['bmself'], toDir: 'bmself/sub' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('INVALID_PATH'); + await fs.rm(path.join(stacksDir, STACK, 'bmself'), { recursive: true, force: true }); + }); +}); + +describe('GET /api/stacks/:stackName/files/bulk-download', () => { + it('streams a .tar.gz of the selection (viewer/read access), de-duplicating nested paths', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'dl/sub'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'dl/sub/deep.txt'), 'deep'); + await fs.writeFile(path.join(stacksDir, STACK, 'dl-top.txt'), 'top'); + // Select the dir, a file inside it (redundant), and a top-level file. + const res = await request(app) + .get(`/api/stacks/${STACK}/files/bulk-download`) + .query({ path: ['dl', 'dl/sub/deep.txt', 'dl-top.txt'] }) + .set('Cookie', viewerCookie) // a read-only viewer can download + .buffer(true) + .parse(binaryParser); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toContain('.tar.gz'); + const entries = await extractTarGz(res.body as Buffer); + // Nested selection de-duplicated: deep.txt appears once, under its dir path. + expect(entries['dl/sub/deep.txt']).toBe('deep'); + expect(entries['dl-top.txt']).toBe('top'); + expect(Object.keys(entries)).toHaveLength(2); + // Tar entry names are relative POSIX paths (no leading slash, no '..'). + for (const name of Object.keys(entries)) { + expect(name.startsWith('/')).toBe(false); + expect(name.includes('..')).toBe(false); + } + await fs.rm(path.join(stacksDir, STACK, 'dl'), { recursive: true, force: true }); + await fs.unlink(path.join(stacksDir, STACK, 'dl-top.txt')); + }); + + it('lets a viewer download but not bulk-delete or bulk-move', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'v.txt'), 'v'); + const dl = await request(app) + .get(`/api/stacks/${STACK}/files/bulk-download`) + .query({ path: ['v.txt'] }) + .set('Cookie', viewerCookie) + .buffer(true) + .parse(binaryParser); + expect(dl.status).toBe(200); + const del = await request(app).post(`/api/stacks/${STACK}/files/bulk-delete`).set('Cookie', viewerCookie).send({ paths: ['v.txt'] }); + expect(del.status).toBe(403); + const mv = await request(app).post(`/api/stacks/${STACK}/files/bulk-move`).set('Cookie', viewerCookie).send({ from: ['v.txt'], toDir: '' }); + expect(mv.status).toBe(403); + await fs.unlink(path.join(stacksDir, STACK, 'v.txt')); + }); + + // The helper (named-volume) bulk path (rootCaseSensitive helper branch, + // FileRootGateway.stat/download helper branch) runs an Alpine container per + // file and is validated on Linux / CI, not on this workstation. + + it('returns 413 when the total byte cap is exceeded', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'big.bin'), 'x'); + const { FileRootGateway } = await import('../services/FileRootGateway'); + const spy = vi.spyOn(FileRootGateway.prototype, 'stat').mockResolvedValue({ + name: 'big.bin', type: 'file', size: 2 * 1024 * 1024 * 1024, mtime: 0, isProtected: false, + }); + const res = await request(app) + .get(`/api/stacks/${STACK}/files/bulk-download`) + .query({ path: ['big.bin'] }) + .set('Cookie', adminCookie); + expect(res.status).toBe(413); + expect(res.body.code).toBe('TOO_LARGE'); + spy.mockRestore(); + await fs.unlink(path.join(stacksDir, STACK, 'big.bin')); + }); + + it('returns 400 UNSUPPORTED when an entry is not archivable (e.g. a helper symlink/non-regular file)', async () => { + await fs.writeFile(path.join(stacksDir, STACK, 'odd.bin'), 'x'); + // assertArchivable only rejects on helper roots; force the rejection so the + // route's mapping (which fires for any backend) is covered without a volume. + const { FileRootGateway } = await import('../services/FileRootGateway'); + const spy = vi.spyOn(FileRootGateway.prototype, 'assertArchivable').mockImplementation(() => { + throw Object.assign(new Error('"odd.bin" cannot be downloaded from this volume'), { code: 'ARCHIVE_UNSUPPORTED' }); + }); + const res = await request(app) + .get(`/api/stacks/${STACK}/files/bulk-download`) + .query({ path: ['odd.bin'] }) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + expect(res.body.code).toBe('UNSUPPORTED'); + spy.mockRestore(); + await fs.unlink(path.join(stacksDir, STACK, 'odd.bin')); + }); + + it('returns 413 (before any archive byte) when the entry cap is exceeded', async () => { + await fs.mkdir(path.join(stacksDir, STACK, 'huge'), { recursive: true }); + await fs.writeFile(path.join(stacksDir, STACK, 'huge/a.txt'), 'a'); + // A truncated directory listing means more entries than the archive cap. + const { FileRootGateway } = await import('../services/FileRootGateway'); + const spy = vi.spyOn(FileRootGateway.prototype, 'listDir').mockResolvedValue({ + entries: [{ name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false }], + total: 999999, + truncated: true, + }); + const res = await request(app) + .get(`/api/stacks/${STACK}/files/bulk-download`) + .query({ path: ['huge'] }) + .set('Cookie', adminCookie); + expect(res.status).toBe(413); + expect(res.body.code).toBe('TOO_LARGE'); + spy.mockRestore(); + await fs.rm(path.join(stacksDir, STACK, 'huge'), { recursive: true, force: true }); + }); + + it('rejects an empty selection with 400', async () => { + const res = await request(app).get(`/api/stacks/${STACK}/files/bulk-download`).set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); +}); + // ── PUT /:stackName/files/permissions ──────────────────────────────────────── describe('PUT /api/stacks/:stackName/files/permissions', () => { diff --git a/backend/src/__tests__/volume-browser-service.test.ts b/backend/src/__tests__/volume-browser-service.test.ts index 0c90ea55..f46f41e1 100644 --- a/backend/src/__tests__/volume-browser-service.test.ts +++ b/backend/src/__tests__/volume-browser-service.test.ts @@ -1,8 +1,11 @@ /** * Coverage for VolumeBrowserService pure helpers: path traversal sanitization, * volume-name validation, and binary detection. The Docker-facing exec path - * is exercised in manual E2E only — mocking dockerode.run reliably is not - * worth the brittleness for this PR. + * is exercised in manual E2E only (mocking dockerode.run reliably is not worth + * the brittleness here). That includes every helper that runs a script in the + * Alpine container: list/read/write/writeFileStream/delete/rename/copy and + * their exit-code-to-HTTP mappings (e.g. copy's 11 to 409, 12 to 400). Those + * run on Linux nodes / CI against a real named volume, not on this workstation. */ import { describe, it, expect } from 'vitest'; import { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index abad2c2f..69236515 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1,6 +1,11 @@ import { Router, type Request, type Response, type NextFunction } from 'express'; import { z } from 'zod'; import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import zlib from 'zlib'; +import { promises as fsp } from 'fs'; +import * as tar from 'tar-stream'; import { inspect } from 'node:util'; import YAML from 'yaml'; import multer from 'multer'; @@ -32,6 +37,7 @@ import { StackOpLockService, type StackOpAction } from '../services/StackOpLockS import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService'; import { FileExplorerMetricsService, type FileExplorerOp } from '../services/FileExplorerMetricsService'; import { isValidGitSourcePath, isValidStackName, isValidServiceName, isValidRelativeStackPath } from '../utils/validation'; +import { normalizeBulkPaths, destWithinAnySource } from '../utils/bulkPaths'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; @@ -157,12 +163,48 @@ export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): return dotenv ? [dotenv.resolvedPath as string] : []; } +// Uploads spool to disk (not memory) so a 25 MB upload is never held in RAM. +// The temp dir lives under the OS temp root, deliberately outside COMPOSE_DIR and +// any browsable volume, so a running container never observes a half-written +// spool. SENCHO_UPLOAD_DIR relocates it (e.g. onto a larger volume). +const UPLOAD_TMP_DIR = process.env.SENCHO_UPLOAD_DIR + ? path.resolve(process.env.SENCHO_UPLOAD_DIR) + : path.join(os.tmpdir(), 'sencho-uploads'); const upload = multer({ - storage: multer.memoryStorage(), + storage: multer.diskStorage({ + destination: (_req, _file, cb) => { + fsp.mkdir(UPLOAD_TMP_DIR, { recursive: true }) + .then(() => cb(null, UPLOAD_TMP_DIR)) + .catch((err: Error) => cb(err, UPLOAD_TMP_DIR)); + }, + filename: (_req, _file, cb) => { + cb(null, `up-${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}`); + }, + }), limits: { fileSize: 25 * 1024 * 1024, files: 1 }, preservePath: true, }); +/** + * Best-effort cleanup of a spooled upload temp file; never throws (a failed + * cleanup must not turn a successful upload into an error). A persistent failure + * would silently grow the spool dir, so log it at diagnostic level rather than + * swallowing it blind. + */ +async function cleanupUploadTemp(req: Request): Promise { + const tmp = req.file?.path; + if (!tmp) return; + // Canonical js/path-injection barrier inline with the unlink sink: the spool + // path is multer-generated within UPLOAD_TMP_DIR (a random filename), but + // static analysis taints req.file.*, so confirm containment before unlinking. + const baseResolved = path.resolve(UPLOAD_TMP_DIR); + const resolved = path.resolve(tmp); + if (!resolved.startsWith(baseResolved + path.sep)) return; + await fsp.unlink(resolved).catch((err: unknown) => { + logFileDiag('upload temp cleanup failed', { path: resolved, errorCode: fsErrorCode(err) }); + }); +} + function getRelPath(req: Request): string { return typeof req.query.path === 'string' ? req.query.path : ''; } @@ -2086,10 +2128,21 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons } }); -type UploadStartedReq = Request & { _fileUploadStartedAt?: number }; +type UploadStartedReq = Request & { _fileUploadStartedAt?: number; _fileUploadRoot?: StackFileRoot }; stacksRouter.post( '/:stackName/files/upload', + // Authorize BEFORE multer touches the body, so an unauthorized caller or a + // read-only/non-existent root is rejected without ever spooling a temp file. + // The resolved root is stashed for the handler so it is not resolved twice. + async (req: Request, res: Response, next: NextFunction) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const root = await resolveRootForOp(req, res, stackName, 'write'); + if (!root) return; + (req as UploadStartedReq)._fileUploadRoot = root; + next(); + }, (req: Request, res: Response, next: NextFunction) => { // Capture the time the upload entered the route so every downstream // metric reports the same latency window: the body-transfer + @@ -2107,7 +2160,11 @@ stacksRouter.post( errorCode: 'TOO_LARGE', }); recordFileOp(req.nodeId, 'upload', startedAt, false); - return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' }); + // diskStorage may have spooled a partial file before the limit fired. + void cleanupUploadTemp(req).finally(() => + res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' }), + ); + return; } if (err) { logFileOperation('warn', 'upload failed', { @@ -2117,27 +2174,33 @@ stacksRouter.post( errorCode: 'MULTER_ERROR', }); recordFileOp(req.nodeId, 'upload', startedAt, false); - return res.status(500).json({ error: 'Upload failed' }); + void cleanupUploadTemp(req).finally(() => res.status(500).json({ error: 'Upload failed' })); + return; } next(); }); }, async (req: Request, res: Response) => { const stackName = req.params.stackName as string; - if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + // The pre-multer middleware already authorized and resolved the root. + const root = (req as UploadStartedReq)._fileUploadRoot; + if (!root) { + await cleanupUploadTemp(req); + return res.status(500).json({ error: 'Upload failed' }); + } if (!req.file) { return res.status(400).json({ error: 'No file provided' }); } const relPath = getRelPath(req); if (relPath !== '' && !isValidRelativeStackPath(relPath)) { + await cleanupUploadTemp(req); return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } const originalName = req.file.originalname; if (!isSafeUploadFilename(originalName)) { + await cleanupUploadTemp(req); return res.status(400).json({ error: 'Invalid filename' }); } - const root = await resolveRootForOp(req, res, stackName, 'write'); - if (!root) return; const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName; const overwrite = String(req.query.overwrite) === '1'; // The multer wrapper stashed the route-entry timestamp on the request so @@ -2179,11 +2242,21 @@ stacksRouter.post( }, }); } - // Use the atomic exclusive create for the non-overwrite case so a file - // created by another writer after the pathKind check above is not + // Canonical js/path-injection barrier: the spool path is multer-generated + // within UPLOAD_TMP_DIR (a random filename), but static analysis taints + // req.file.*; confirm containment so the value handed to the gateway and + // FileSystemService streaming sinks is credited as safe. + const spoolBase = path.resolve(UPLOAD_TMP_DIR); + const tempPath = path.resolve(req.file.path); + if (!tempPath.startsWith(spoolBase + path.sep)) { + return res.status(400).json({ error: 'Upload failed' }); + } + // Copy the spooled temp file into place (the spool survives; the finally + // removes it). The atomic exclusive create for the non-overwrite case means + // a file created by another writer after the pathKind check above is not // silently clobbered (a race surfaces as FILE_EXISTS -> 409, same as the // pre-emptive check). overwrite=true intentionally allows the clobber. - await gateway.writeBuffer(root, stackName, targetRelPath, req.file.buffer, !overwrite); + await gateway.writeFromTemp(root, stackName, targetRelPath, tempPath, !overwrite); afterStackMutation(req, stackName); logFileOperation('info', 'mutate', { nodeId: req.nodeId, @@ -2209,6 +2282,10 @@ stacksRouter.post( }); recordFileOp(req.nodeId, 'upload', startedAt, false); return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' }); + } finally { + // writeFromTemp streams (copies) the spool into place, so the temp file + // always remains and must be removed on every exit (success, conflict, error). + await cleanupUploadTemp(req); } }, ); @@ -2410,6 +2487,310 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons } }); +stacksRouter.post('/:stackName/files/copy', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const { from, to } = req.body as { from?: unknown; to?: unknown }; + if (typeof from !== 'string' || !from) { + return res.status(400).json({ error: '"from" must be a non-empty string' }); + } + if (typeof to !== 'string' || !to) { + return res.status(400).json({ error: '"to" must be a non-empty string' }); + } + if (!isValidRelativeStackPath(from)) { + return res.status(400).json({ error: 'Invalid source path', code: 'INVALID_PATH' }); + } + if (!isValidRelativeStackPath(to)) { + return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' }); + } + const root = await resolveRootForOp(req, res, stackName, 'write'); + if (!root) return; + const startedAt = Date.now(); + logFileDiag('copy start', { stackName, from, to, nodeId: req.nodeId, rootKind: root.kind }); + try { + await FileRootGateway.getInstance(req.nodeId).copy(root, stackName, from, to); + afterStackMutation(req, stackName); + logFileOperation('info', 'mutate', { + nodeId: req.nodeId, + op: 'copy', + stack: stackName, + path: from, + toPath: to, + rootKind: root.kind, + backend: root.backend, + }); + logFileDiag('copy timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); + recordFileOp(req.nodeId, 'copy', startedAt, true); + return res.status(204).send(); + } catch (err: unknown) { + logFileOperation('warn', 'copy failed', { + nodeId: req.nodeId, + op: 'copy', + stack: stackName, + path: from, + toPath: to, + errorCode: fsErrorCode(err), + }); + recordFileOp(req.nodeId, 'copy', startedAt, false); + return sendFsError(res, err, 'Failed to copy'); + } +}); + +// ── Bulk file operations (delete / move / download) ───────────────────────── + +const MAX_BULK = 100; // selected paths accepted per bulk request +const MAX_ARCHIVE_ENTRIES = 5000; // files packed into one bulk-download archive +const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; // 1 GiB uncompressed cap + +/** + * Helper-backed named volumes are Linux containers (case-sensitive). Filesystem + * roots follow the host: Windows/macOS fold case, Linux does not. + */ +function rootCaseSensitive(root: StackFileRoot): boolean { + if (root.backend === 'helper') return true; + return process.platform !== 'win32' && process.platform !== 'darwin'; +} + +/** Validate a bulk path array; sends the 400 and returns null on any problem. */ +function parseBulkPaths(value: unknown, res: Response): string[] | null { + if (!Array.isArray(value) || value.length === 0) { + res.status(400).json({ error: 'A non-empty list of paths is required' }); + return null; + } + if (value.length > MAX_BULK) { + res.status(400).json({ error: `Select at most ${MAX_BULK} items at once`, code: 'TOO_MANY' }); + return null; + } + const out: string[] = []; + for (const p of value) { + if (typeof p !== 'string' || p === '' || !isValidRelativeStackPath(p)) { + res.status(400).json({ error: 'Invalid path in selection', code: 'INVALID_PATH' satisfies FsErrorCode }); + return null; + } + out.push(p); + } + return out; +} + +/** A clean per-item failure message for a bulk result, mapping the opaque + * filesystem codes that carry no friendly message of their own. */ +function bulkItemError(err: unknown): string { + const e = err as Error & { code?: string }; + switch (e.code) { + case 'EXDEV': return 'Cannot move across a storage boundary'; + case 'EEXIST': return 'A file or folder with that name already exists'; + case 'ENOENT': return 'No longer exists'; + case 'EISDIR': case 'ENOTDIR': return 'Path type changed'; + default: return e.message || e.code || 'Operation failed'; + } +} + +function archiveTooLargeError(message: string): Error & { code: string } { + return Object.assign(new Error(message), { code: 'ARCHIVE_TOO_LARGE' }); +} + +/** + * Walk the normalized selection and return every file to pack, enforcing the + * entry and byte caps. Throws ARCHIVE_TOO_LARGE if either cap is exceeded (or an + * fs directory listing is truncated), so the caller can 413 before any archive + * byte is streamed. File sizes come from listDir; every directory is stat-ed once + * (the walk recurses into each), but individual files in a listing are not re-stat-ed. + */ +async function enumerateArchiveFiles( + gateway: FileRootGateway, + root: StackFileRoot, + stackName: string, + selection: string[], +): Promise { + const files: string[] = []; + let totalBytes = 0; + const addFile = (relPath: string, size: number): void => { + files.push(relPath); + totalBytes += size; + if (files.length > MAX_ARCHIVE_ENTRIES) throw archiveTooLargeError('The selection has too many files to download'); + if (totalBytes > MAX_ARCHIVE_BYTES) throw archiveTooLargeError('The selection is too large to download'); + }; + const visit = async (relPath: string): Promise => { + const st = await gateway.stat(root, stackName, relPath); + if (st.type !== 'directory') { + gateway.assertArchivable(root, relPath, st); + addFile(relPath, st.size); + return; + } + // Request one over the remaining budget so a directory that would push us one + // entry past the cap is detected: the overflow entry reaches addFile (which + // throws on >), or for larger directories the fs listing reports truncated. + const remaining = MAX_ARCHIVE_ENTRIES - files.length + 1; + const { entries, truncated } = await gateway.listDir(root, stackName, relPath, remaining); + if (truncated) throw archiveTooLargeError('A selected folder has too many files to download'); + for (const entry of entries) { + const childRel = `${relPath}/${entry.name}`; + if (entry.type === 'directory') await visit(childRel); + else { + gateway.assertArchivable(root, childRel, entry); + addFile(childRel, entry.size); + } + } + }; + for (const p of selection) await visit(p); + return files; +} + +stacksRouter.post('/:stackName/files/bulk-delete', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const parsed = parseBulkPaths((req.body as { paths?: unknown }).paths, res); + if (!parsed) return; + const root = await resolveRootForOp(req, res, stackName, 'write'); + if (!root) return; + const normalized = normalizeBulkPaths(parsed, rootCaseSensitive(root)); + const gateway = FileRootGateway.getInstance(req.nodeId); + const deleted: string[] = []; + const failed: { path: string; error: string }[] = []; + for (const relPath of normalized) { + const startedAt = Date.now(); + try { + await gateway.deletePath(root, stackName, relPath, true); + deleted.push(relPath); + recordFileOp(req.nodeId, 'delete', startedAt, true); + } catch (err: unknown) { + failed.push({ path: relPath, error: bulkItemError(err) }); + recordFileOp(req.nodeId, 'delete', startedAt, false); + } + } + // Partial-success: invalidate the roots cache if anything actually changed. + if (deleted.length > 0) afterStackMutation(req, stackName); + logFileOperation('info', 'mutate', { nodeId: req.nodeId, op: 'bulkDelete', stack: stackName, deleted: deleted.length, failed: failed.length, rootKind: root.kind, backend: root.backend }); + return res.json({ deleted, failed }); +}); + +stacksRouter.post('/:stackName/files/bulk-move', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + const body = req.body as { from?: unknown; toDir?: unknown }; + const parsed = parseBulkPaths(body.from, res); + if (!parsed) return; + if (typeof body.toDir !== 'string') { + return res.status(400).json({ error: '"toDir" must be a string (use "" for the root)' }); + } + const toDir = body.toDir; + if (toDir !== '' && !isValidRelativeStackPath(toDir)) { + return res.status(400).json({ error: 'Invalid destination', code: 'INVALID_PATH' satisfies FsErrorCode }); + } + const root = await resolveRootForOp(req, res, stackName, 'write'); + if (!root) return; + const caseSensitive = rootCaseSensitive(root); + const normalized = normalizeBulkPaths(parsed, caseSensitive); + // Reject the whole request if the destination is one of the moved folders or + // sits inside one (which would move a folder into its own subtree). + if (destWithinAnySource(toDir, normalized, caseSensitive)) { + return res.status(400).json({ error: 'Cannot move the selection into itself', code: 'INVALID_PATH' satisfies FsErrorCode }); + } + const gateway = FileRootGateway.getInstance(req.nodeId); + const moved: string[] = []; + const failed: { path: string; error: string }[] = []; + for (const fromRel of normalized) { + const startedAt = Date.now(); + const name = fromRel.split('/').pop() as string; + const toRel = toDir ? `${toDir}/${name}` : name; + try { + await gateway.rename(root, stackName, fromRel, toRel); + moved.push(fromRel); + recordFileOp(req.nodeId, 'rename', startedAt, true); + } catch (err: unknown) { + failed.push({ path: fromRel, error: bulkItemError(err) }); + recordFileOp(req.nodeId, 'rename', startedAt, false); + } + } + if (moved.length > 0) afterStackMutation(req, stackName); + logFileOperation('info', 'mutate', { nodeId: req.nodeId, op: 'bulkMove', stack: stackName, moved: moved.length, failed: failed.length, rootKind: root.kind, backend: root.backend }); + return res.json({ moved, failed }); +}); + +stacksRouter.get('/:stackName/files/bulk-download', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + const raw = req.query.path; + const list = Array.isArray(raw) ? raw : raw !== undefined ? [raw] : []; + const parsed = parseBulkPaths(list, res); + if (!parsed) return; + const root = await resolveRootForOp(req, res, stackName, 'read'); + if (!root) return; + const gateway = FileRootGateway.getInstance(req.nodeId); + const normalized = normalizeBulkPaths(parsed, rootCaseSensitive(root)); + const startedAt = Date.now(); + + // Prewalk + cap enforcement BEFORE any response header is sent, so a too-large + // selection fails as a clean 413 rather than a truncated archive. + let files: string[]; + try { + files = await enumerateArchiveFiles(gateway, root, stackName, normalized); + } catch (err: unknown) { + recordFileOp(req.nodeId, 'download', startedAt, false); + const code = (err as { code?: string }).code; + if (code === 'ARCHIVE_TOO_LARGE') { + return res.status(413).json({ error: (err as Error).message, code: 'TOO_LARGE' }); + } + if (code === 'ARCHIVE_UNSUPPORTED') { + return res.status(400).json({ error: (err as Error).message, code: 'UNSUPPORTED' }); + } + return sendFsError(res, err, 'Failed to prepare download'); + } + if (files.length === 0) { + recordFileOp(req.nodeId, 'download', startedAt, false); + return res.status(404).json({ error: 'Nothing to download', code: 'NOT_FOUND' satisfies FsErrorCode }); + } + + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', `attachment; filename="${stackName}-files.tar.gz"`); + const pack = tar.pack(); + const gzip = zlib.createGzip(); + const onStreamError = (err: Error): void => { + logFileOperation('warn', 'bulk download stream error', { nodeId: req.nodeId, stack: stackName, errorCode: fsErrorCode(err) }); + if (!res.writableEnded) res.destroy(); + }; + pack.on('error', onStreamError); + gzip.on('error', onStreamError); + // If the client aborts mid-download, stop fetching the remaining files (each + // helper-volume read is a container exec) instead of streaming into a dead pipe. + let aborted = false; + res.on('close', () => { + if (!res.writableEnded) { + aborted = true; + pack.destroy(); + } + }); + pack.pipe(gzip).pipe(res); + + try { + for (const relPath of files) { + if (aborted) break; + const dl = await gateway.download(root, stackName, relPath); + if (dl.kind === 'buffer') { + await new Promise((resolve, reject) => { + pack.entry({ name: relPath }, dl.buffer, (err) => (err ? reject(err) : resolve())); + }); + } else { + await new Promise((resolve, reject) => { + const entry = pack.entry({ name: relPath, size: dl.size }, (err) => (err ? reject(err) : resolve())); + dl.stream.on('error', reject); + entry.on('error', reject); + dl.stream.pipe(entry); + }); + } + } + pack.finalize(); + recordFileOp(req.nodeId, 'download', startedAt, true); + } catch (err: unknown) { + // Headers are already sent, so surface the failure by tearing the stream + // down rather than trying to change the status. + logFileOperation('warn', 'bulk download failed mid-stream', { nodeId: req.nodeId, stack: stackName, errorCode: fsErrorCode(err) }); + recordFileOp(req.nodeId, 'download', startedAt, false); + pack.destroy(); + if (!res.writableEnded) res.destroy(); + } +}); + stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; diff --git a/backend/src/services/FileExplorerMetricsService.ts b/backend/src/services/FileExplorerMetricsService.ts index 5a632f6d..db6abe06 100644 --- a/backend/src/services/FileExplorerMetricsService.ts +++ b/backend/src/services/FileExplorerMetricsService.ts @@ -20,6 +20,7 @@ export type FileExplorerOp = | 'delete' | 'mkdir' | 'rename' + | 'copy' | 'chmod'; interface FileExplorerOpStats { diff --git a/backend/src/services/FileRootGateway.ts b/backend/src/services/FileRootGateway.ts index 6cbebe48..b3ebcbaf 100644 --- a/backend/src/services/FileRootGateway.ts +++ b/backend/src/services/FileRootGateway.ts @@ -13,9 +13,10 @@ * within the same (seconds-resolution) second. */ import type { Readable } from 'stream'; +import { createReadStream, promises as fsp } from 'fs'; import { FileSystemService, type FileEntry, type FileRootScope } from './FileSystemService'; -import { VolumeBrowserService, makeHelperVersion, type VolumeEntry } from './VolumeBrowserService'; +import { VolumeBrowserService, makeHelperVersion, DOWNLOAD_MAX_BYTES, type VolumeEntry } from './VolumeBrowserService'; import type { StackFileRoot } from './StackFileRootsService'; const HELPER_VIEW_MAX_BYTES = 2 * 1024 * 1024; // match the stack-source viewer cap @@ -51,7 +52,9 @@ export function parseFsVersion(raw: string | undefined): number | null { function volumeEntryToFileEntry(e: VolumeEntry): FileEntry { return { name: e.name, - type: e.type === 'other' ? 'file' : e.type, + // Preserve 'other' (non-regular entries) so the archive guard can reject + // what the helper download path would refuse; the UI renders it like a file. + type: e.type, size: e.size, mtime: e.mtime * 1000, isProtected: false, @@ -96,8 +99,12 @@ export class FileRootGateway { 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 }; + // Ask for one over the limit so a fully-listed directory is distinguishable + // from a truncated one without the helper buffering every entry. + const raw = await this.helper().listDir(root.hostPathOrName, relPath, limit); + const truncated = raw.length > limit; + const entries = raw.slice(0, limit).map(volumeEntryToFileEntry); + return { entries, total: entries.length, truncated }; } return this.fs().listStackDirectoryPage(stackName, relPath, { limit, scope: this.scopeFor(root) }); } @@ -173,22 +180,56 @@ export class FileRootGateway { return this.fs().pathKind(stackName, relPath, this.scopeFor(root)); } - /** Upload write. `exclusive` rejects an existing target (no overwrite). */ - async writeBuffer( + /** Stat a single entry (type + size). Used by bulk download to size the archive. */ + async stat(root: StackFileRoot, stackName: string, relPath: string): Promise { + if (root.backend === 'helper') { + return volumeEntryToFileEntry(await this.helper().stat(root.hostPathOrName, relPath)); + } + return this.fs().statStackEntry(stackName, relPath, this.scopeFor(root)); + } + + /** + * Reject a non-directory entry the backend's download path could not stream, + * BEFORE the archive prewalk commits to sending response headers. The fs + * backend streams any in-root file (and follows in-root symlinks), so it has + * no constraint; the helper backend's download refuses symlinks/non-regular + * files and caps each file at DOWNLOAD_MAX_BYTES, which must be enforced here + * or a bulk download would tear mid-archive when gateway.download() later + * throws. Throws ARCHIVE_UNSUPPORTED (-> 400) or ARCHIVE_TOO_LARGE (-> 413). + */ + assertArchivable(root: StackFileRoot, relPath: string, entry: FileEntry): void { + if (root.backend !== 'helper') return; + if (entry.type !== 'file') { + throw Object.assign(new Error(`"${relPath}" cannot be downloaded from this volume`), { code: 'ARCHIVE_UNSUPPORTED' }); + } + if (entry.size > DOWNLOAD_MAX_BYTES) { + throw Object.assign(new Error(`"${relPath}" is too large to download from this volume`), { code: 'ARCHIVE_TOO_LARGE' }); + } + } + + /** + * Upload write sourced from a temp file spooled to disk (multer diskStorage), + * so the upload is never buffered in memory. `exclusive` rejects an existing + * target (no overwrite). The caller owns deleting `tempPath`. + */ + async writeFromTemp( root: StackFileRoot, stackName: string, relPath: string, - buffer: Buffer, + tempPath: string, exclusive: boolean, ): Promise { 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); + // The helper writes via `cat`, which cannot report a short write; pass the + // spooled byte count so writeFileStream can verify the volume got it all. + const { size } = await fsp.stat(tempPath); + await this.helper().writeFileStream(root.hostPathOrName, relPath, createReadStream(tempPath), size); return; } - await this.fs().writeStackFileBuffer(stackName, relPath, buffer, { exclusive, scope: this.scopeFor(root) }); + await this.fs().writeScopedFileFromTemp(stackName, relPath, tempPath, { exclusive, scope: this.scopeFor(root) }); } async download( @@ -220,6 +261,12 @@ export class FileRootGateway { return this.fs().renameStackPath(stackName, fromRel, toRel, this.scopeFor(root)); } + /** Copy a file or directory within a single root (cross-root copy is rejected at the route). */ + async copy(root: StackFileRoot, stackName: string, fromRel: string, toRel: string): Promise { + if (root.backend === 'helper') return this.helper().copy(root.hostPathOrName, fromRel, toRel); + return this.fs().copyScopedPath(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)); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index d343731e..625dbcf1 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1,9 +1,10 @@ import path from 'path'; import os from 'os'; import crypto from 'crypto'; -import { promises as fsPromises, createReadStream } from 'fs'; +import { promises as fsPromises, createReadStream, createWriteStream } from 'fs'; import type { Dirent } from 'fs'; -import type { Readable } from 'stream'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; import { NodeRegistry } from './NodeRegistry'; import { isPathWithinBase, isValidStackName } from '../utils/validation'; import { isBinaryBuffer } from '../utils/binaryDetect'; @@ -11,7 +12,10 @@ import { sanitizeForLog } from '../utils/safeLog'; export interface FileEntry { name: string; - type: 'file' | 'directory' | 'symlink'; + // 'other' covers non-regular helper-volume entries (fifo/socket/device): they + // are unrepresentable on the fs backend but the helper can surface them, and + // they must stay distinct from 'file' so the archive guard can reject them. + type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtime: number; isProtected: boolean; @@ -100,6 +104,18 @@ function fsCaseKey(s: string): string { return process.platform === 'win32' || process.platform === 'darwin' ? s.toLowerCase() : s; } +/** + * True when resolved absolute path `candidate` is `parent` itself or sits inside + * it, compared case-folded so the guard stays authoritative on a case-insensitive + * filesystem. Used to block moving/copying a directory into its own subtree. + */ +function isSameOrDescendantFsPath(parent: string, candidate: string): boolean { + const parentKey = fsCaseKey(parent); + const parentKeyWithSep = parentKey.endsWith(path.sep) ? parentKey : parentKey + path.sep; + const candidateKey = fsCaseKey(candidate); + return candidateKey === parentKey || candidateKey.startsWith(parentKeyWithSep); +} + function isProtectedRelPath(relPath: string): boolean { if (!relPath) return false; const normalized = stripTrailingSlash(relPath); @@ -1043,9 +1059,14 @@ export class FileSystemService { * compose. Tracked as a follow-up hardening, not a per-root regression. */ private async resolveSafePathWithin(rootAbsDir: string, relPath: string): Promise { - const target = relPath === '' ? rootAbsDir : path.resolve(rootAbsDir, relPath); - - if (!isPathWithinBase(target, rootAbsDir)) { + // Canonical js/path-injection barrier inline with the realpath sinks below: + // isPathWithinBase performs the same containment check, but static analysis + // only credits the path.resolve + startsWith form when it sits at the sink. + // relPath === '' resolves to the (server-controlled) root itself and carries + // no user input, so it needs no containment check. + const baseResolved = path.resolve(rootAbsDir); + const target = path.resolve(baseResolved, relPath); + if (relPath !== '' && !target.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' }); } @@ -1067,6 +1088,21 @@ export class FileSystemService { } suffix.unshift(path.basename(existing)); existing = parent; + if (existing === baseResolved) { + // Reached the root: realpath the untainted base (never a tainted input) + // and reattach the not-yet-existing suffix. + const realBase = await fsPromises.realpath(baseResolved); + if (!isPathWithinBase(realBase, rootAbsDir)) { + throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' }); + } + realTarget = path.join(realBase, ...suffix); + break; + } + // Inline js/path-injection barrier: existing is now strictly below the + // root, so the canonical path.resolve + startsWith form credits the sink. + if (!existing.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' }); + } try { const realExisting = await fsPromises.realpath(existing); if (!isPathWithinBase(realExisting, rootAbsDir)) { @@ -1255,7 +1291,7 @@ export class FileSystemService { */ private async writeStackFileAtomic( safePath: string, - data: string | Buffer, + data: string | Buffer | Readable, opts: { exclusive?: boolean } = {}, ): Promise { await fsPromises.mkdir(path.dirname(safePath), { recursive: true }); @@ -1265,13 +1301,28 @@ export class FileSystemService { const tmpPath = `${safePath}.sencho-tmp-${suffix}`; let stagedTmp = false; try { - const fh = await fsPromises.open(tmpPath, 'wx'); - stagedTmp = true; - try { - await fh.writeFile(data); - await fh.sync(); - } finally { - await fh.close(); + if (data instanceof Readable) { + // Stream a temp-file source (an upload spooled to disk) into the staging + // file without buffering it in memory. 'wx' exclusively creates the + // staging file; the random suffix already guarantees a fresh name. + const ws = createWriteStream(tmpPath, { flags: 'wx' }); + stagedTmp = true; + await pipeline(data, ws); + const synced = await fsPromises.open(tmpPath, 'r+'); + try { + await synced.sync(); + } finally { + await synced.close(); + } + } else { + const fh = await fsPromises.open(tmpPath, 'wx'); + stagedTmp = true; + try { + await fh.writeFile(data); + await fh.sync(); + } finally { + await fh.close(); + } } if (opts.exclusive) { // link() is atomic against EEXIST. Tmp and target are guaranteed to live @@ -1306,14 +1357,22 @@ export class FileSystemService { await this.writeStackFileAtomic(safePath, content, opts); } - async writeStackFileBuffer( + /** + * Atomic, scoped write whose source is a temp file on disk (an upload spooled + * by multer's diskStorage). Streams the temp file into a staging sibling in the + * target's own directory, fsyncs, then links/renames into place, so a large + * upload is never buffered in memory and the temp file's filesystem can differ + * from the stack/volume filesystem (no cross-device rename). The caller owns + * deleting tempPath. + */ + async writeScopedFileFromTemp( stackName: string, relPath: string, - buffer: Buffer, + tempPath: string, opts?: { exclusive?: boolean; scope?: FileRootScope }, ): Promise { const safePath = await this.resolveScopedPath(stackName, relPath, opts?.scope); - await this.writeStackFileAtomic(safePath, buffer, opts); + await this.writeStackFileAtomic(safePath, createReadStream(tempPath), { exclusive: opts?.exclusive }); } /** @@ -1481,17 +1540,10 @@ export class FileSystemService { throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' }); } // Block moving a directory into itself or one of its own descendants; fs.rename - // would otherwise fail with an opaque EINVAL/EPERM. Compare case-folded so the - // guard stays authoritative when the source is supplied with non-disk casing on - // a case-insensitive filesystem. + // would otherwise fail with an opaque EINVAL/EPERM. const fromStat = await fsPromises.lstat(fromPath); - if (fromStat.isDirectory()) { - const fromKey = fsCaseKey(fromPath); - const fromKeyWithSep = fromKey.endsWith(path.sep) ? fromKey : fromKey + path.sep; - const toKey = fsCaseKey(toPath); - if (toKey === fromKey || toKey.startsWith(fromKeyWithSep)) { - throw Object.assign(new Error('Cannot move a folder into itself'), { code: 'INVALID_PATH' }); - } + if (fromStat.isDirectory() && isSameOrDescendantFsPath(fromPath, toPath)) { + throw Object.assign(new Error('Cannot move a folder into itself'), { code: 'INVALID_PATH' }); } // Prevent overwriting an existing path. lstat (not access) so a dangling // symlink already at the destination still counts as occupied. @@ -1505,6 +1557,46 @@ export class FileSystemService { await fsPromises.rename(fromPath, toPath); } + /** + * Copies a file or directory within a single root. The source resolves through + * the leaf helper and the copy does not dereference symlinks, so a symlink + * entry is copied as a link (matching the delete/rename leaf policy) rather + * than followed to its target. Only the destination is protection-checked: + * duplicating a protected file (e.g. compose.yaml) elsewhere is allowed, but a + * copy cannot create a reserved name at a protected root. An existing + * destination is rejected (surfaced as EEXIST, which the route maps to 409). + */ + async copyScopedPath(stackName: string, fromRel: string, toRel: string, scope?: FileRootScope): Promise { + if ((scope?.protectedEnabled ?? true) && 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' }); + } + // Block copying a directory into itself or one of its own descendants; + // fs.cp would otherwise recurse into the copy it is creating. + const fromStat = await fsPromises.lstat(fromPath); + if (fromStat.isDirectory() && isSameOrDescendantFsPath(fromPath, toPath)) { + throw Object.assign(new Error('Cannot copy a folder into itself'), { code: 'INVALID_PATH' }); + } + try { + await fsPromises.cp(fromPath, toPath, { + recursive: fromStat.isDirectory(), + dereference: false, + errorOnExist: true, + force: false, + }); + } catch (err: unknown) { + // fs.cp raises ERR_FS_CP_EEXIST when the destination already exists; remap + // to EEXIST so the route returns 409, matching rename's conflict handling. + if ((err as NodeJS.ErrnoException).code === 'ERR_FS_CP_EEXIST') { + throw Object.assign(new Error('A file or folder with that name already exists'), { code: 'EEXIST' }); + } + throw err; + } + } + 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); diff --git a/backend/src/services/VolumeBrowserService.ts b/backend/src/services/VolumeBrowserService.ts index f6bd2f19..5124002e 100644 --- a/backend/src/services/VolumeBrowserService.ts +++ b/backend/src/services/VolumeBrowserService.ts @@ -1,4 +1,5 @@ -import { Writable } from 'stream'; +import { Writable, Readable } from 'stream'; +import { pipeline } from 'stream/promises'; import path from 'path'; import { createHash } from 'crypto'; import DockerController from './DockerController'; @@ -10,7 +11,7 @@ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 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; +export const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024; const EXEC_TIMEOUT_MS = 30_000; // Containment guard run inside the helper after every `cd`. Even though @@ -27,6 +28,8 @@ const ROOT_GUARD = const LIST_SCRIPT = `set -e cd -- "$1" || exit 1 ${ROOT_GUARD} +lim="$2" +n=0 for entry in * .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "") @@ -37,6 +40,8 @@ for entry in * .[!.]* ..?*; do size=$(stat -c '%s' -- "$entry" 2>/dev/null || echo 0) mtime=$(stat -c '%Y' -- "$entry" 2>/dev/null || echo 0) printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link" + n=$((n+1)) + if [ -n "$lim" ] && [ "$n" -ge "$lim" ]; then break; fi done`; // Contain the parent before statting the leaf: cd into the leaf's directory and @@ -104,6 +109,26 @@ fd=$(dirname -- "$from"); td=$(dirname -- "$to") { [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; } mv -- "$from" "$to"`; +// $1 = from, $2 = to. Both parents are contained and the destination must not +// exist. cp -RP recurses without dereferencing symlinks (copying links as links, +// matching the fs backend) and does NOT preserve ownership, since the helper runs +// unprivileged and cannot chown; new entries are owned by the helper user, like +// the helper write. A directory may not be copied into itself or a descendant +// (cp would otherwise recurse into the copy it is creating). +const COPY_SCRIPT = `set -e +from="$1"; to="$2" +fd=$(dirname -- "$from"); td=$(dirname -- "$to") +sp=$( cd -- "$fd" 2>/dev/null && pwd -P ) || { echo "source escapes volume root" >&2; exit 7; } +case "$sp" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "source escapes volume root" >&2; exit 7 ;; esac +dp=$( cd -- "$td" 2>/dev/null && pwd -P ) || { echo "destination escapes volume root" >&2; exit 7; } +case "$dp" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "destination escapes volume root" >&2; exit 7 ;; esac +{ [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; } +src="$sp/$(basename -- "$from")" +if [ -d "$src" ] && [ ! -L "$src" ]; then + case "$dp/" in "$src/"*) echo "cannot copy a folder into itself" >&2; exit 12 ;; esac +fi +cp -RP -- "$from" "$to"`; + // $1 = relative path. Prints directory|file|none. Used for upload-overwrite checks. const PATHKIND_SCRIPT = `set -e p="$1" @@ -186,7 +211,7 @@ export class VolumeBrowserService { return new VolumeBrowserService(nodeId ?? 1); } - async listDir(volumeName: string, relPath: string): Promise { + async listDir(volumeName: string, relPath: string, limit?: number): Promise { const safe = sanitizeRelPath(relPath); await this.assertVolumeExists(volumeName); await this.ensureHelperImage(); @@ -194,10 +219,12 @@ export class VolumeBrowserService { // Portable across BusyBox (Alpine) and GNU coreutils. Lists each // direct child with a tab-separated row: typesizemtime // namesymlinkTarget. We chdir to /v/ first so user input is - // never an argv element passed to find/stat. - const script = LIST_SCRIPT; + // never an argv element passed to find/stat. When a limit is given the + // script breaks after limit+1 rows, so a huge directory is never fully + // buffered; the caller detects truncation from the overflow row. + const limArg = limit !== undefined ? String(limit + 1) : ''; const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [ - 'sh', '-c', script, 'sh', `./${safe || ''}`, + 'sh', '-c', LIST_SCRIPT, 'sh', `./${safe || ''}`, limArg, ]); if (exitCode !== 0) { @@ -392,6 +419,39 @@ export class VolumeBrowserService { return { mtimeMs: meta.mtime * 1000, size: meta.size }; } + /** + * Like writeFile but streams the content from a Readable (an upload spooled to + * a temp file) straight into the helper's stdin, so a large upload is never + * held in memory. Non-atomic in-place write (`cat > file`), matching writeFile + * (see writeFile for the ownership-preservation rationale); the caller owns the + * source stream's underlying temp file. `expectedSize` is the source byte count: + * `cat` cannot report a short write, so the post-write size is checked against + * it to catch a truncated transfer that exited cleanly. + */ + async writeFileStream(volumeName: string, relPath: string, source: Readable, expectedSize: number): 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: source }, + ); + 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); + if (meta.size !== expectedSize) { + throw new ExecError('Upload did not fully write to the volume', 500); + } + return { mtimeMs: meta.mtime * 1000, size: meta.size }; + } + /** Create a single directory; its parent must already exist. */ async mkdir(volumeName: string, relPath: string): Promise { const safe = sanitizeRelPath(relPath); @@ -450,6 +510,27 @@ export class VolumeBrowserService { } } + /** Copy a file or directory within the same volume (symlink-as-link, no chown). */ + async copy(volumeName: string, fromRel: string, toRel: string): Promise { + const from = sanitizeRelPath(fromRel); + const to = sanitizeRelPath(toRel); + if (!from || !to) throw new ExecError('Invalid copy path', 400); + await this.assertVolumeExists(volumeName); + await this.ensureHelperImage(); + const { stderr, exitCode } = await this.runHelper( + volumeName, + ['sh', '-c', COPY_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); + if (exitCode === 12) throw new ExecError('Cannot copy a folder into itself', 400); + throw new ExecError(`Copy failed: ${msg.substring(0, 200) || 'unknown error'}`); + } + } + // --- internals ----------------------------------------------------------- private async assertVolumeExists(volumeName: string): Promise { @@ -493,7 +574,7 @@ export class VolumeBrowserService { private async runHelper( volumeName: string, cmd: string[], - opts: { writable?: boolean; stdin?: Buffer } = {}, + opts: { writable?: boolean; stdin?: Buffer | Readable } = {}, ): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> { const docker = DockerController.getInstance(this.nodeId).getDocker(); const stdoutChunks: Buffer[] = []; @@ -540,6 +621,7 @@ export class VolumeBrowserService { timer = setTimeout(() => reject(new ExecError('Helper exec timed out', 504)), EXEC_TIMEOUT_MS); }); + let stdinError: unknown = null; const runPromise = (async () => { const stream = await container.attach({ stream: true, stdin: wantStdin, stdout: true, stderr: true, hijack: wantStdin }); const streamEnded = new Promise((resolve) => { @@ -551,16 +633,45 @@ export class VolumeBrowserService { 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(); + if (Buffer.isBuffer(opts.stdin)) { + stream.write(opts.stdin); + stream.end(); + } else { + // A Readable (an upload spooled to a temp file) is piped so a large + // upload is never held in memory. pipeline tears the stdin side down + // (destroys it) on a source error rather than ending it cleanly, so + // cat sees an abnormal close instead of a normal EOF and does not + // commit a truncated file as a success. Remember the error so the run + // is reported as failed after the container exits. + try { + await pipeline(opts.stdin, stream); + } catch (err) { + stdinError = err; + } + } } const exitInfo = await container.wait(); // Wait for the attach stream to finish flushing demuxed output. await streamEnded; + const exitCode = typeof exitInfo?.StatusCode === 'number' ? exitInfo.StatusCode : 0; + // A nonzero helper exit is the authoritative failure: its exit code carries + // the intended 4xx mapping (target-is-a-directory, permission denied, etc.), + // which a stdin EPIPE from the helper closing stdin early would otherwise + // mask as a generic 500. Only surface the stream error when the helper + // itself exited cleanly (or its status was unreadable). + if (stdinError) { + if (exitCode === 0) { + throw new ExecError('Upload stream failed before the volume write completed', 500); + } + // The nonzero exit's mapping is the user-facing error, but the stream + // failure is the root cause; log it so an intermittent upload failure is + // debuggable rather than being silently attributed to the exit code. + console.error('[VolumeBrowser] helper stdin stream error masked by nonzero exit', exitCode, stdinError); + } return { stdout: Buffer.concat(stdoutChunks), stderr: Buffer.concat(stderrChunks), - exitCode: typeof exitInfo?.StatusCode === 'number' ? exitInfo.StatusCode : 0, + exitCode, }; })(); diff --git a/backend/src/utils/bulkPaths.ts b/backend/src/utils/bulkPaths.ts new file mode 100644 index 00000000..202e932e --- /dev/null +++ b/backend/src/utils/bulkPaths.ts @@ -0,0 +1,54 @@ +/** + * Normalization for bulk file-operation selections (delete / move / download). + * + * A client (or a direct API caller) can submit overlapping paths, e.g. both a + * directory and a file inside it, or the same path twice. Acting on both would + * double-process a file, duplicate an archive entry, or report spurious + * per-item failures, so the route normalizes the selection before acting on it: + * it dedupes and drops any path whose ancestor is also selected. + * + * Case-awareness is per root, not global. Filesystem roots on a case-insensitive + * host (Windows/macOS) fold case so `Foo` and `foo` collapse; Linux filesystem + * roots and helper-backed (named-volume) roots are case-sensitive, so `Foo` and + * `foo` are distinct and both survive. The caller passes `caseSensitive` derived + * from the root. + */ +export function normalizeBulkPaths(paths: string[], caseSensitive: boolean): string[] { + const key = (p: string): string => (caseSensitive ? p : p.toLowerCase()); + + // Dedupe by key, keeping the first spelling seen. + const seen = new Set(); + const unique: string[] = []; + for (const p of paths) { + const k = key(p); + if (!seen.has(k)) { + seen.add(k); + unique.push(p); + } + } + + // Drop any path that has a selected ancestor. The check is key-based so a + // case-insensitive root treats `Foo` as the ancestor of `foo/bar`. + const keys = new Set(unique.map(key)); + return unique.filter((p) => { + const segments = key(p).split('/'); + for (let i = 1; i < segments.length; i++) { + if (keys.has(segments.slice(0, i).join('/'))) return false; + } + return true; + }); +} + +/** + * True when `dir` (a normalized rel path, '' = root) is equal to or inside any + * of the `sources` directories. Used to reject a bulk move whose destination is + * one of the moved folders or a descendant of one. + */ +export function destWithinAnySource(dir: string, sources: string[], caseSensitive: boolean): boolean { + const key = (p: string): string => (caseSensitive ? p : p.toLowerCase()); + const dirKey = key(dir); + return sources.some((s) => { + const sourceKey = key(s); + return dirKey === sourceKey || dirKey.startsWith(`${sourceKey}/`); + }); +} diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx index 2226c7ac..f57bfa93 100644 --- a/docs/features/stack-file-explorer.mdx +++ b/docs/features/stack-file-explorer.mdx @@ -53,7 +53,7 @@ Named-volume editing is best-effort and bound by file ownership. The helper cont ## Layout -The Files & Volumes tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the new-folder button, and the directory tree for the selected root. The right pane is the action bar plus the file viewer. +The Files & Volumes tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the **New file** and **New folder** buttons, and the directory tree for the selected root. The right pane is the action bar plus the file viewer. Files tab two-pane layout showing the upload widget and tree on the left and the file viewer on the right @@ -65,6 +65,10 @@ Folders sort before files, and entries within each group sort alphabetically. Click a folder to expand or collapse it. Click a file to open it in the viewer on the right. Symlinks render with a chain icon and behave like files when clicked. Deleting a symlink removes only the link entry; the file it points to is untouched. +The tree is fully keyboard navigable. Tab into it and use the arrow keys to move between rows: **Up** and **Down** move row to row, **Right** expands a folder (then steps into it), **Left** collapses it (or steps out to the parent), **Home** and **End** jump to the first and last visible row, and **Enter** opens a file or toggles a folder. + +Each row is fully clickable across the pane, so right-clicking anywhere on a row (not just on its name) opens that entry's context menu. Long names are never truncated: the tree scrolls horizontally so you can read the full name. + **Display cap.** Each directory render is capped at 1000 entries. A folder with more than 1000 children shows the first 1000 alphabetically with a footer noting how many entries the directory holds in total. The tree also has a filter input at the top of the list so you can narrow a large directory to the entries you care about without dropping to a shell. ## Protected files @@ -114,7 +118,7 @@ Writes are atomic at the filesystem level. Sencho stages the new content into a ## Creating files and folders -The toolbar **New folder** button at the top of the tree creates a folder in the currently selected directory (the parent of the file you have open, or the stack root if nothing is open). +The toolbar **New file** and **New folder** buttons at the top of the tree create an entry in the currently selected directory (the parent of the file you have open, or the stack root if nothing is open). Use the toolbar buttons to create an entry directly at the stack root, where there is no folder row to right-click. Right-click any folder for **New File** and **New Folder** entries that scope to the right-clicked folder. These write controls appear only when your account has stack edit permission. @@ -124,6 +128,8 @@ Right-click any folder for **New File** and **New Folder** entries that scope to Filenames cannot be empty, cannot contain `/` or `\`, and cannot be `.` or `..`. The Create button stays disabled until the input passes validation. +Creating a file never overwrites an existing one. If a file of that name already exists in the target directory, the dialog reports inline that the name is taken; if a folder of that name exists, Sencho rejects the creation with a message. Either way the existing entry is left untouched. + ## Uploading files The dashed **Upload file** affordance at the top of the tree opens a file picker. You can also drag and drop a file onto the dashed zone; the border lights up in the brand colour when a file is hovered over a valid drop target. Uploads are one file at a time. @@ -159,6 +165,33 @@ Move a file or folder into a different directory in one of two ways: Moving requires stack edit permission. The protected stack files (compose / docker-compose / `.env`) at the stack root stay put: the compose CLI reads them from the stack directory, so they are not offered as move sources or root destinations. Moving a file changes only where it lives on disk; it does not redeploy or restart the stack. +## Copying and duplicating + +Copy a file or folder without removing the original in one of two ways: + +- **Duplicate.** Right-click the entry and choose **Duplicate** to make a copy in the same folder under an auto-suffixed name (`config.yaml` becomes `config copy.yaml`, then `config copy 2.yaml`, and so on). This is the quickest way to snapshot a file before you edit it. +- **Copy to…** Right-click the entry and choose **Copy to…** to pick a destination folder, the same way Move works. The current folder stays disabled in the picker, since a same-folder copy is what Duplicate is for. + +Copying requires stack edit permission and stays within the current root. A folder is copied with its full contents, and a symlink is copied as a link rather than as the file it points to. Unlike Move, a protected stack file can be copied: duplicating `compose.yaml` to `compose.yaml.bak`, or copying it into a subfolder, is allowed because the copy is an ordinary file. Only creating a reserved name (`compose.yaml`, `.env`, and the rest) directly at the stack root is blocked. + +## Working with multiple files + +Select more than one entry to act on them together. + +- **Select.** Hover a row to reveal its checkbox, or hold **Ctrl** (**Cmd** on macOS) and click a row to add it to the selection. Hold **Shift** and click to select a contiguous range. A plain click still opens a file in the viewer and leaves the selection untouched. +- **Act.** Once anything is selected, a bar at the top of the tree shows the count and the bulk actions. + +| Bulk action | What it does | +|---|---| +| Download | Streams the whole selection as a single `.tar.gz` archive, preserving folder structure. Available to anyone who can read the stack. | +| Move | Opens the destination picker (the same one as single Move) and relocates every selected item into the chosen folder. Requires stack edit. | +| Delete | Asks for confirmation, then removes every selected item; selected folders are removed with their contents. Requires stack edit. | +| Clear | Drops the current selection. | + +Bulk Move and Delete skip the protected stack files (`compose.yaml`, `.env`, and the rest) at the stack root, which are reported as kept rather than acted on; they can still be included in a Download. + +Each item is processed independently. If some succeed and others fail (for example, a permission error on one file), Sencho reports how many succeeded and leaves the items that failed selected so you can see which they were and retry. Very large selections are capped: a Download that would exceed the archive's file-count or total-size limit is refused before it starts. + ## Permissions (chmod) Right-click any file and choose **Permissions** to inspect or edit its Unix mode bits. The dialog shows a 3 by 3 grid of `r` / `w` / `x` toggles for Owner, Group, and Other, plus the current octal value. @@ -201,10 +234,12 @@ When the entry is one of the five protected names, the modal asks you to type th Right-click menu on a file showing Rename, Permissions, and Delete entries +Right-click anywhere on a row, not only on its name, to open the menu. + | Right-click target | Admin entries (with stack edit) | Viewer entries | |---|---|---| -| Folder | New File, New Folder, Rename, Move to…, Delete | No write entries | -| File | Rename, Move to…, Permissions, Delete | Permissions (read-only) | +| Folder | New File, New Folder, Rename, Duplicate, Copy to…, Move to…, Delete | No write entries | +| File | Rename, Duplicate, Copy to…, Move to…, Permissions, Delete | Permissions (read-only) | **Move to…** is absent for the protected stack files (compose / docker-compose / `.env`) at the stack root, which cannot be moved out of the stack directory. @@ -256,6 +291,12 @@ The Permissions dialog opens for everyone; only users with stack edit permission Each directory render is capped at 1000 entries to keep the tree responsive. Use the filter input above the tree to narrow the list, or drop into a host shell with `cd` into the stack directory if you need to work with entries past the cap. - Upload, create, rename, move, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, and inspect permissions in read-only mode. + Upload, create, rename, copy, move, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, inspect permissions in read-only mode, and download (including a bulk download). + + + Bulk delete and move process each item independently and report how many succeeded. The items that failed stay selected so you can see which they were; open one to find out why (a common cause is a permission error on a named-volume file, or a name collision at the destination), fix it, and retry on the still-selected items. + + + A bulk download is packed into a single archive with a fixed cap on both the number of files and the total uncompressed size. A selection that would exceed either cap is refused before the download starts. Narrow the selection, or download large folders in smaller batches. diff --git a/e2e/stack-file-explorer.spec.ts b/e2e/stack-file-explorer.spec.ts index 630f1490..80ac7b74 100644 --- a/e2e/stack-file-explorer.spec.ts +++ b/e2e/stack-file-explorer.spec.ts @@ -430,6 +430,61 @@ test.describe('Stack file explorer: UI lifecycle', () => { page.locator('span.font-mono').filter({ hasText: /^lifecycle-new-folder$/ }).first(), ).toBeVisible({ timeout: 8_000 }); }); + + test('the New file toolbar button creates a root-level file', async ({ page }) => { + await page.getByRole('button', { name: 'New file' }).click(); + await page.getByLabel('File name').fill('ui-new-file.txt'); + await page.getByRole('button', { name: /^create$/i }).click(); + + await expect(page.getByText(/file created/i).first()).toBeVisible({ timeout: 8_000 }); + await expect( + page.locator('span.font-mono').filter({ hasText: /^ui-new-file\.txt$/ }).first(), + ).toBeVisible({ timeout: 8_000 }); + }); + + test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page }) => { + await fs.writeFile(nodePath.join(stackDir(), 'dup-guard.txt'), 'original\n'); + await page.reload(); + await openFilesTab(page); + + await page.getByRole('button', { name: 'New file' }).click(); + await page.getByLabel('File name').fill('dup-guard.txt'); + await page.getByRole('button', { name: /^create$/i }).click(); + + await expect(page.getByText(/a file with that name already exists/i)).toBeVisible({ timeout: 8_000 }); + // The server rejected the create, so the existing contents are intact. + expect(await fs.readFile(nodePath.join(stackDir(), 'dup-guard.txt'), 'utf-8')).toBe('original\n'); + }); + + test('right-clicking away from the filename still opens the Sencho context menu', async ({ page }) => { + const row = page.locator('[role="treeitem"]').filter({ hasText: 'compose.yaml' }).first(); + await expect(row).toBeVisible({ timeout: 8_000 }); + const box = await row.boundingBox(); + if (!box) throw new Error('no bounding box for the compose.yaml row'); + + // Right-click near the right edge, well past the filename text: the whole + // row is the trigger, so the Sencho menu (not the native one) must open. + await row.click({ button: 'right', position: { x: box.width - 6, y: box.height / 2 } }); + await expect(page.getByText('Rename')).toBeVisible({ timeout: 5_000 }); + }); + + test('a long filename overflows the pane horizontally instead of being clipped', async ({ page }) => { + const longName = 'a-really-long-file-name-that-should-overflow-the-narrow-file-tree-pane.txt'; + await fs.writeFile(nodePath.join(stackDir(), longName), ''); + await page.reload(); + await openFilesTab(page); + await expect( + page.locator('span.font-mono').filter({ hasText: longName }).first(), + ).toBeVisible({ timeout: 8_000 }); + + // The tree's scroll viewport overflows horizontally, so the name is reachable + // by scrolling rather than truncated. + const overflows = await page.getByTestId('file-tree-root-dropzone').evaluate((el) => { + const vp = el.closest('[data-radix-scroll-area-viewport]') as HTMLElement | null; + return vp ? vp.scrollWidth > vp.clientWidth : false; + }); + expect(overflows).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx index fe7c50b7..783bf21b 100644 --- a/frontend/src/components/files/FileTree.tsx +++ b/frontend/src/components/files/FileTree.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, Fragment } from 'react'; -import type { ReactNode, DragEvent } from 'react'; +import type { ReactNode, DragEvent, KeyboardEvent } from 'react'; import { Search, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -31,14 +31,23 @@ interface FileTreeProps { canEdit?: boolean; onContextMenuRename?: (relPath: string) => void; onContextMenuMove?: (relPath: string, entry: FileEntry) => void; + onContextMenuDuplicate?: (relPath: string, entry: FileEntry) => void; + onContextMenuCopy?: (relPath: string, entry: FileEntry) => void; onContextMenuNewFile?: (dirRelPath: string) => void; onContextMenuNewFolder?: (dirRelPath: string) => void; onContextMenuDelete?: (relPath: string, entry: FileEntry) => void; onContextMenuPermissions?: (relPath: string, entry: FileEntry) => void; /** Relocate `fromRel` into `destDir` (''=stack root) via drag-and-drop. */ onMove?: (fromRel: string, entryName: string, destDir: string) => void; + /** Current bulk selection (rel paths). Drives the per-row checkboxes. Read-only + * here; FileTree emits the next set via onSelectionChange. */ + selectedPaths?: ReadonlySet; + /** Emit the next bulk selection after a checkbox click or modifier-click. */ + onSelectionChange?: (next: Set) => void; } +const EMPTY_SELECTION: ReadonlySet = new Set(); + const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']); const ENV_NAMES = new Set(['.env']); // The server caps the response at 1000 entries and exposes the unfiltered @@ -58,11 +67,15 @@ export function FileTree({ canEdit = false, onContextMenuRename = () => undefined, onContextMenuMove = () => undefined, + onContextMenuDuplicate = () => undefined, + onContextMenuCopy = () => undefined, onContextMenuNewFile = () => undefined, onContextMenuNewFolder = () => undefined, onContextMenuDelete = () => undefined, onContextMenuPermissions = () => undefined, onMove = () => undefined, + selectedPaths = EMPTY_SELECTION, + onSelectionChange = () => undefined, }: FileTreeProps) { const [rootEntries, setRootEntries] = useState(null); const [rootLoading, setRootLoading] = useState(true); @@ -73,6 +86,23 @@ export function FileTree({ const [filter, setFilter] = useState(''); const [isRootDropTarget, setIsRootDropTarget] = useState(false); + // Roving-tabindex state for keyboard tree navigation: exactly one visible node + // is focusable at a time. `activeRelPath` is the intended focus; DOM focus is + // moved imperatively (only when a key press requested it, via shouldFocusRef) + // so a re-render never steals focus from elsewhere on the page. + const [activeRelPath, setActiveRelPath] = useState(null); + const nodeRefs = useRef>(new Map()); + const shouldFocusRef = useRef(false); + // Anchor for Shift+click range selection (the last row toggled on its own). + const selectionAnchorRef = useRef(null); + + useEffect(() => { + if (shouldFocusRef.current && activeRelPath) { + nodeRefs.current.get(activeRelPath)?.focus(); + shouldFocusRef.current = false; + } + }, [activeRelPath]); + // The scroll area is the stack-root drop target. Folder nodes stop propagation // on their own drops, so an event only reaches here when it lands on a file // row or empty space. A root-level entry dropped here is a no-op and ignored. @@ -206,32 +236,160 @@ export function FileTree({ return false; } - function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode { - // When the filter is active, keep entries that either match by name OR - // are directories with a matching loaded descendant. Without the - // ancestor-keep rule, the parent directory of a match would be filtered - // out at this level and its loaded children would never render. + // The entries kept at one level: name matches, or directories with a matching + // loaded descendant while filtering, capped at MAX_ENTRIES. + function keptEntries(entries: FileEntry[], parentRelPath: string): { visible: FileEntry[]; capped: boolean; total: number } { const filtered = filter ? entries.filter(e => { if (matchesFilter(e.name)) return true; if (e.type !== 'directory') return false; - const path = parentRelPath ? `${parentRelPath}/${e.name}` : e.name; - return hasMatchingDescendant(path); + const childPath = parentRelPath ? `${parentRelPath}/${e.name}` : e.name; + return hasMatchingDescendant(childPath); }) : entries; const capped = filtered.length > MAX_ENTRIES; - const visible = capped ? filtered.slice(0, MAX_ENTRIES) : filtered; + return { visible: capped ? filtered.slice(0, MAX_ENTRIES) : filtered, capped, total: filtered.length }; + } + + // A row renders expanded when the user expanded it, or while a filter is active + // and it has a matching descendant (auto-expanded into view). Files are never + // in expandedDirs and have no descendants, so this is safe to call for any row. + function computeExpanded(entryRelPath: string): boolean { + return expandedDirs.has(entryRelPath) || (filter !== '' && hasMatchingDescendant(entryRelPath)); + } + + // The flattened, in-order list of visible treeitems. It mirrors the recursive + // render and drives keyboard navigation and roving focus (a later bulk-select + // feature can reuse this ordering for shift-click ranges). + function buildVisibleNodes(): { relPath: string; entry: FileEntry; depth: number }[] { + const out: { relPath: string; entry: FileEntry; depth: number }[] = []; + const walk = (entries: FileEntry[], parent: string, depth: number) => { + for (const entry of keptEntries(entries, parent).visible) { + const relPath = parent ? `${parent}/${entry.name}` : entry.name; + out.push({ relPath, entry, depth }); + if (entry.type === 'directory' && computeExpanded(relPath)) { + const children = dirContents.get(relPath); + if (children) walk(children, relPath, depth + 1); + } + } + }; + if (rootEntries) walk(rootEntries, '', 0); + return out; + } + const visibleNodes = buildVisibleNodes(); + + // The single roving-focusable node: a prior keyboard target if still visible, + // else the selected file, else the first node. + function computeActiveKey(): string | null { + if (activeRelPath && visibleNodes.some(n => n.relPath === activeRelPath)) return activeRelPath; + if (visibleNodes.some(n => n.relPath === selectedPath)) return selectedPath; + return visibleNodes[0]?.relPath ?? null; + } + const activeKey = computeActiveKey(); + + const registerNodeRef = (relPath: string, el: HTMLDivElement | null) => { + if (el) nodeRefs.current.set(relPath, el); + else nodeRefs.current.delete(relPath); + }; + + // Keep the roving target in sync when a row is focused by click or Tab, + // without requesting a re-focus (which would fight the user's own click). + const handleFocusNode = (relPath: string) => setActiveRelPath(relPath); + + // Move the roving focus to `relPath` and pull DOM focus there after the render. + const moveActive = (relPath: string) => { + shouldFocusRef.current = true; + setActiveRelPath(relPath); + }; + + // Toggle one row in the bulk selection (checkbox or Ctrl/Cmd+click) and set the + // range anchor to it. + const handleToggleSelect = (relPath: string) => { + selectionAnchorRef.current = relPath; + const next = new Set(selectedPaths); + if (next.has(relPath)) next.delete(relPath); + else next.add(relPath); + onSelectionChange(next); + }; + + // Add the contiguous range from the anchor (or this row, if none) to this row, + // using the flattened visible order so it matches what the user sees. + const handleRangeSelect = (relPath: string) => { + const order = visibleNodes.map((n) => n.relPath); + const anchor = selectionAnchorRef.current && order.includes(selectionAnchorRef.current) + ? selectionAnchorRef.current + : relPath; + const from = order.indexOf(anchor); + const to = order.indexOf(relPath); + if (from === -1 || to === -1) return; + const [lo, hi] = from <= to ? [from, to] : [to, from]; + const next = new Set(selectedPaths); + for (let k = lo; k <= hi; k++) next.add(order[k]); + onSelectionChange(next); + }; + + function handleTreeKeyDown(e: KeyboardEvent) { + if (visibleNodes.length === 0) return; + const idx = visibleNodes.findIndex(n => n.relPath === activeKey); + const cur = idx >= 0 ? visibleNodes[idx] : undefined; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + moveActive(visibleNodes[Math.min(idx + 1, visibleNodes.length - 1)]?.relPath ?? visibleNodes[0].relPath); + break; + case 'ArrowUp': + e.preventDefault(); + if (idx > 0) moveActive(visibleNodes[idx - 1].relPath); + break; + case 'Home': + e.preventDefault(); + moveActive(visibleNodes[0].relPath); + break; + case 'End': + e.preventDefault(); + moveActive(visibleNodes[visibleNodes.length - 1].relPath); + break; + case 'ArrowRight': + if (!cur || cur.entry.type !== 'directory') break; + e.preventDefault(); + if (!computeExpanded(cur.relPath)) { + handleDirClick(cur.relPath); // expand in place + } else if (visibleNodes[idx + 1] && visibleNodes[idx + 1].depth > cur.depth) { + moveActive(visibleNodes[idx + 1].relPath); // step into the first child + } + break; + case 'ArrowLeft': { + if (!cur) break; + e.preventDefault(); + // Only collapse a directory the user actually expanded. A directory that + // is open only because the active filter auto-expanded it is not in + // expandedDirs, so toggling it would wrongly ADD it; fall through to + // move-to-parent instead. + if (cur.entry.type === 'directory' && expandedDirs.has(cur.relPath)) { + handleDirClick(cur.relPath); // collapse in place + break; + } + const parent = relPathParentDir(cur.relPath); + if (parent && visibleNodes.some(n => n.relPath === parent)) moveActive(parent); + break; + } + default: + break; + } + } + + function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode { + // keptEntries applies the filter (keeping ancestors of matches) and the + // per-level MAX_ENTRIES cap; it is the same logic the flat visible-node list + // uses, so the rendered rows and the keyboard order stay in lockstep. + const { visible, capped, total } = keptEntries(entries, parentRelPath); return ( <> {visible.map((entry) => { const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name; const isDir = entry.type === 'directory'; - // While a filter is active, auto-expand any directory that is being - // kept solely because it has a matching descendant. The user gets - // the match in view without manually expanding every ancestor. - const isExpanded = expandedDirs.has(entryRelPath) - || (filter !== '' && isDir && hasMatchingDescendant(entryRelPath)); + const isExpanded = computeExpanded(entryRelPath); const isLoading = loadingDirs.has(entryRelPath); const children = dirContents.get(entryRelPath); @@ -241,6 +399,13 @@ export function FileTree({ entry={entry} relPath={entryRelPath} depth={depth} + isActive={entryRelPath === activeKey} + registerRef={registerNodeRef} + onFocusNode={handleFocusNode} + isChecked={selectedPaths.has(entryRelPath)} + selectionActive={selectedPaths.size > 0} + onToggleSelect={() => handleToggleSelect(entryRelPath)} + onRangeSelect={() => handleRangeSelect(entryRelPath)} isSelected={selectedPath === entryRelPath} isExpanded={isExpanded} isLoading={isLoading} @@ -254,6 +419,8 @@ export function FileTree({ canEdit={canEdit} onContextMenuRename={onContextMenuRename} onContextMenuMove={onContextMenuMove} + onContextMenuDuplicate={onContextMenuDuplicate} + onContextMenuCopy={onContextMenuCopy} onContextMenuNewFile={onContextMenuNewFile} onContextMenuNewFolder={onContextMenuNewFolder} onContextMenuDelete={onContextMenuDelete} @@ -274,10 +441,10 @@ export function FileTree({ })} {capped && (
- Showing {MAX_ENTRIES} of {filtered.length} - refine the filter or use a shell + Showing {MAX_ENTRIES} of {total} - refine the filter or use a shell
)} - {filter && filtered.length === 0 && depth === 0 && ( + {filter && total === 0 && depth === 0 && (
No entries match “{filter}”
@@ -335,10 +502,14 @@ export function FileTree({ )} - +
setIsRootDropTarget(false)} onDrop={handleRootDrop} @@ -346,6 +517,10 @@ export function FileTree({ {renderEntries(rootEntries, '', 0)}
+ {/* Announce the selected file to assistive tech without a visual change. */} +
+ {selectedPath ? `Selected ${selectedPath.split('/').pop()}` : ''} +
); } diff --git a/frontend/src/components/files/FileTreeContextMenu.tsx b/frontend/src/components/files/FileTreeContextMenu.tsx index e960c8ab..12feee5e 100644 --- a/frontend/src/components/files/FileTreeContextMenu.tsx +++ b/frontend/src/components/files/FileTreeContextMenu.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { FilePlus, FolderPlus, Pencil, FolderInput, Lock, Trash2 } from 'lucide-react'; +import { FilePlus, FolderPlus, Pencil, FolderInput, Copy, CopyPlus, Lock, Trash2 } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -15,6 +15,8 @@ interface FileTreeContextMenuProps { canEdit: boolean; onRequestRename: (relPath: string) => void; onRequestMove: (relPath: string, entry: FileEntry) => void; + onRequestDuplicate: (relPath: string, entry: FileEntry) => void; + onRequestCopy: (relPath: string, entry: FileEntry) => void; onRequestNewFile: (dirRelPath: string) => void; onRequestNewFolder: (dirRelPath: string) => void; onRequestDelete: (relPath: string, entry: FileEntry) => void; @@ -28,6 +30,8 @@ export function FileTreeContextMenu({ canEdit, onRequestRename, onRequestMove, + onRequestDuplicate, + onRequestCopy, onRequestNewFile, onRequestNewFolder, onRequestDelete, @@ -45,6 +49,22 @@ export function FileTreeContextMenu({ Move to… ); + // Duplicate (same folder, auto-suffixed) and Copy to… are offered for any + // editable entry. Unlike Move, a protected root file may be duplicated/copied: + // the copy gets a new name, and the destination picker disables the stack root + // for a reserved name so it can only land in a subfolder. + const copyItems = canWrite && ( + <> + onRequestDuplicate(relPath, entry)}> + + Duplicate + + onRequestCopy(relPath, entry)}> + + Copy to… + + + ); return ( @@ -75,6 +95,7 @@ export function FileTreeContextMenu({ Rename )} + {copyItems} {moveItem} {canWrite && ( <> @@ -97,6 +118,7 @@ export function FileTreeContextMenu({ Rename )} + {copyItems} {moveItem} onRequestPermissions(relPath, entry)}> diff --git a/frontend/src/components/files/FileTreeNode.tsx b/frontend/src/components/files/FileTreeNode.tsx index a37dc440..2027529e 100644 --- a/frontend/src/components/files/FileTreeNode.tsx +++ b/frontend/src/components/files/FileTreeNode.tsx @@ -21,10 +21,28 @@ interface FileTreeNodeProps { isExpanded?: boolean; isLoading?: boolean; onClick: () => void; + // Accessibility / roving-tabindex wiring (the parent owns keyboard navigation). + /** The single roving-focusable node holds tabIndex 0; all others hold -1. */ + isActive: boolean; + /** Register/unregister this row's element so the parent can move DOM focus. */ + registerRef: (relPath: string, el: HTMLDivElement | null) => void; + /** Keep the parent's active node in sync when this row gains focus (click/Tab). */ + onFocusNode: (relPath: string) => void; + // Bulk selection wiring (checkbox + modifier-clicks; the parent owns the set). + /** Whether this row is in the bulk selection. */ + isChecked: boolean; + /** True while any row is selected, so checkboxes stay visible (not hover-only). */ + selectionActive: boolean; + /** Toggle this row in the selection (checkbox click or Ctrl/Cmd+click). */ + onToggleSelect: () => void; + /** Select the range from the selection anchor to this row (Shift+click). */ + onRangeSelect: () => void; // Context menu wiring canEdit: boolean; onContextMenuRename: (relPath: string) => void; onContextMenuMove: (relPath: string, entry: FileEntry) => void; + onContextMenuDuplicate: (relPath: string, entry: FileEntry) => void; + onContextMenuCopy: (relPath: string, entry: FileEntry) => void; onContextMenuNewFile: (dirRelPath: string) => void; onContextMenuNewFolder: (dirRelPath: string) => void; onContextMenuDelete: (relPath: string, entry: FileEntry) => void; @@ -41,9 +59,18 @@ export function FileTreeNode({ isExpanded, isLoading, onClick, + isActive, + registerRef, + onFocusNode, + isChecked, + selectionActive, + onToggleSelect, + onRangeSelect, canEdit, onContextMenuRename, onContextMenuMove, + onContextMenuDuplicate, + onContextMenuCopy, onContextMenuNewFile, onContextMenuNewFolder, onContextMenuDelete, @@ -101,26 +128,49 @@ export function FileTreeNode({ canEdit={canEdit} onRequestRename={onContextMenuRename} onRequestMove={onContextMenuMove} + onRequestDuplicate={onContextMenuDuplicate} + onRequestCopy={onContextMenuCopy} onRequestNewFile={onContextMenuNewFile} onRequestNewFolder={onContextMenuNewFolder} onRequestDelete={onContextMenuDelete} onRequestPermissions={onContextMenuPermissions} >
registerRef(relPath, el)} + role="treeitem" + aria-level={depth + 1} + aria-selected={isSelected || isChecked} + aria-expanded={isDir ? isExpanded : undefined} + tabIndex={isActive ? 0 : -1} draggable={canDrag} onDragStart={canDrag ? handleDragStart : undefined} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onClick={onClick} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onClick(); + onClick={(e) => { + // Shift / Ctrl / Cmd clicks drive bulk selection (never open the file); + // a plain click opens it in the viewer as before. + if (e.shiftKey) { + e.preventDefault(); + onRangeSelect(); + } else if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + onToggleSelect(); + } else { + onClick(); + } + }} + onFocus={() => onFocusNode(relPath)} + onKeyDown={(e) => { + // Enter/Space activate the row; arrow/Home/End navigation is owned by + // the parent tree (it needs the flattened visible order). + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } }} - aria-expanded={isDir ? isExpanded : undefined} className={cn( - 'flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm', + 'group flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm min-w-full w-max', isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground', @@ -128,6 +178,19 @@ export function FileTreeNode({ )} style={{ paddingLeft: depth * 16 + 8 }} > + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onChange={() => onToggleSelect()} + aria-label={`Select ${entry.name}`} + className={cn( + 'h-3 w-3 shrink-0 accent-accent-foreground cursor-pointer', + !isChecked && !selectionActive && 'opacity-0 group-hover:opacity-100 focus:opacity-100', + )} + /> {isDir && ( isLoading ? @@ -141,7 +204,7 @@ export function FileTreeNode({ ? : } - {entry.name} + {entry.name} {entry.isProtected && ( )} diff --git a/frontend/src/components/files/MoveFileDialog.tsx b/frontend/src/components/files/MoveFileDialog.tsx index 287e68ce..ae34a760 100644 --- a/frontend/src/components/files/MoveFileDialog.tsx +++ b/frontend/src/components/files/MoveFileDialog.tsx @@ -23,9 +23,19 @@ interface MoveFileDialogProps { entry: FileEntry | null; /** The selected file root; the destination tree is loaded within it. */ rootId?: string; - /** Relocate `fromRel` into `destDir` (''=stack root). Resolves true only when - * the entry actually moved, so the dialog stays open on a blocked/failed move. */ + /** 'move' relocates the entry; 'copy' duplicates it into the destination. The + * destination rules are identical (the current parent stays disabled in both, + * so a same-folder copy goes through Duplicate, not this picker). */ + mode?: 'move' | 'copy'; + /** Bulk mode: the rel paths of every selected source. When set, the dialog + * validates the destination against all of them and calls onConfirmDestination + * instead of onMove (the single relPath/entry props are ignored). */ + bulkSourcePaths?: string[]; + /** Relocate or copy `fromRel` into `destDir` (''=stack root). Resolves true only + * when the action succeeded, so the dialog stays open on a blocked/failed run. */ onMove: (fromRel: string, entryName: string, destDir: string) => boolean | Promise; + /** Bulk confirm: move the whole selection into `destDir`. Resolves true on success. */ + onConfirmDestination?: (destDir: string) => boolean | Promise; } export function MoveFileDialog({ @@ -35,8 +45,14 @@ export function MoveFileDialog({ relPath, entry, rootId, + mode = 'move', + bulkSourcePaths, onMove, + onConfirmDestination, }: MoveFileDialogProps) { + const isCopy = mode === 'copy'; + // Treat an empty bulk list as not-bulk so the picker never enables a no-op move. + const bulkSources = bulkSourcePaths && bulkSourcePaths.length > 0 ? bulkSourcePaths : null; // Loaded directory children, keyed by directory rel path ('' = stack root). const [dirChildren, setDirChildren] = useState>(new Map()); const [expanded, setExpanded] = useState>(new Set()); @@ -53,6 +69,16 @@ export function MoveFileDialog({ // (a no-op), the entry itself or a descendant (for a directory), or the stack // root when the entry's name is reserved at the root (compose/.env files). const isValidDest = (dir: string): boolean => { + if (bulkSources) { + // No destination inside or equal to any selected directory (would move a + // folder into its own subtree). + if (bulkSources.some((s) => isSameOrDescendantPath(s, dir))) return false; + // Not a no-op for the whole selection (every item already lives here). + if (bulkSources.every((s) => relPathParentDir(s) === dir)) return false; + // The stack root is reserved if any item carries a protected root name. + if (dir === '' && bulkSources.some((s) => isProtectedRootRelPath(s.split('/').pop() ?? s))) return false; + return true; + } if (!entry) return false; if (dir === currentParent) return false; if (entry.type === 'directory' && isSameOrDescendantPath(relPath, dir)) return false; @@ -123,12 +149,17 @@ export function MoveFileDialog({ }; const handleMove = async () => { - if (!entry || selectedDest === null || !isValidDest(selectedDest)) return; + if (selectedDest === null || !isValidDest(selectedDest)) return; setMoving(true); try { - // Close only when the move actually succeeded; a blocked or failed move + // Close only when the action actually succeeded; a blocked or failed run // (handled and toasted upstream) leaves the picker open to retry. - if (await onMove(relPath, entry.name, selectedDest)) onOpenChange(false); + const ok = bulkSources + ? await onConfirmDestination?.(selectedDest) + : entry + ? await onMove(relPath, entry.name, selectedDest) + : false; + if (ok) onOpenChange(false); } finally { setMoving(false); } @@ -207,9 +238,15 @@ export function MoveFileDialog({ return (
@@ -250,7 +287,7 @@ export function MoveFileDialog({ disabled={moving || selectedDest === null || !isValidDest(selectedDest)} > {moving && } - Move + {isCopy ? 'Copy' : 'Move'} } /> diff --git a/frontend/src/components/files/NewFileDialog.tsx b/frontend/src/components/files/NewFileDialog.tsx index 9a11dd16..73f4c996 100644 --- a/frontend/src/components/files/NewFileDialog.tsx +++ b/frontend/src/components/files/NewFileDialog.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { toast } from '@/components/ui/toast-store'; -import { writeStackFile } from '@/lib/stackFilesApi'; +import { createEmptyStackFile, UploadConflictError } from '@/lib/stackFilesApi'; function isValidFileName(name: string): boolean { if (!name || name === '.' || name === '..') return false; @@ -51,15 +51,21 @@ export function NewFileDialog({ } setValidationError(null); setCreating(true); - const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed; try { - await writeStackFile(stackName, relPath, '', { rootId }); + await createEmptyStackFile(stackName, currentDir, trimmed, { rootId }); toast.success('File created.'); onCreated(); onOpenChange(false); setName(''); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Failed to create file.'); + // A name already taken by a file comes back as UploadConflictError; + // surface it inline so the user can pick another. Other rejections + // (including a same-named folder) fall through to the toast below. + if (e instanceof UploadConflictError) { + setValidationError('A file with that name already exists.'); + } else { + toast.error(e instanceof Error ? e.message : 'Failed to create file.'); + } } finally { setCreating(false); } diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx index d7812127..f8fe67cf 100644 --- a/frontend/src/components/files/StackFileExplorer.tsx +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; -import { Trash2, FolderPlus, Download, Loader2, AlertTriangle } from 'lucide-react'; +import { Trash2, FilePlus, FolderPlus, FolderInput, Download, Loader2, AlertTriangle, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ConfirmModal } from '@/components/ui/modal'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui/toast-store'; -import { downloadStackFile, listStackDirectory, listFileRoots, renameStackPath, STACK_SOURCE_ROOT_ID } from '@/lib/stackFilesApi'; +import { downloadStackFile, listStackDirectory, listFileRoots, renameStackPath, copyStackFile, relPathParentDir, nextDuplicateName, isProtectedRootRelPath, normalizeSelection, bulkDeleteStackPaths, bulkMoveStackPaths, bulkDownloadStackFiles, STACK_SOURCE_ROOT_ID } from '@/lib/stackFilesApi'; +import { downloadBlob } from '@/lib/download'; import { FileTree } from './FileTree'; import { FileViewer } from './FileViewer'; import { FileUploadDropzone } from './FileUploadDropzone'; @@ -42,6 +43,13 @@ const STACK_SOURCE_FALLBACK: FileRoot = { backend: 'fs', }; +/** A short, actionable summary of bulk per-item failures for a toast. */ +function describeFailures(failed: { path: string; error: string }[]): string { + if (failed.length === 0) return ''; + const first = `${failed[0].path} (${failed[0].error})`; + return failed.length > 1 ? `${first} and ${failed.length - 1} more` : first; +} + /** Short label for a root option: container path (or volume name) + how many service mounts. */ function rootOptionLabel(root: FileRoot): string { if (root.kind === 'stack-source') return 'Stack source'; @@ -101,6 +109,17 @@ export function StackFileExplorer({ const [moveRelPath, setMoveRelPath] = useState(''); const [moveEntry, setMoveEntry] = useState(null); + // ── context menu: copy to… ── + const [copyOpen, setCopyOpen] = useState(false); + const [copyRelPath, setCopyRelPath] = useState(''); + const [copyEntry, setCopyEntry] = useState(null); + + // ── bulk selection (checkboxes + shift/ctrl) ── + const [selectedPaths, setSelectedPaths] = useState>(new Set()); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [bulkMoveOpen, setBulkMoveOpen] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); + // ── context menu: delete ── const [ctxDeleteOpen, setCtxDeleteOpen] = useState(false); const [ctxDeletePath, setCtxDeletePath] = useState(''); @@ -124,6 +143,7 @@ export function StackFileExplorer({ setRoots([STACK_SOURCE_FALLBACK]); setSelectedRootId(STACK_SOURCE_ROOT_ID); setPendingRootId(null); + setSelectedPaths(new Set()); }, [stackName]); // Discover the stack's file roots and default to the first browsable volume @@ -154,6 +174,8 @@ export function StackFileExplorer({ setSelectedPath(null); setSelectedEntry(null); setCurrentDir(''); + // Selection paths are scoped to the previous root; drop them on switch. + setSelectedPaths(new Set()); }, []); // Switch roots, guarding unsaved edits in the viewer first. @@ -242,6 +264,23 @@ export function StackFileExplorer({ } }, [stackName, selectedRootId, selectedPath, isViewerDirty, handleDeleted, refresh]); + // Copy handler for the "Copy to…" dialog. Copying never touches the open file, + // so there is no unsaved-changes guard. Returns true on success so the dialog + // closes; the current parent is disabled in the picker, so a same-folder copy + // goes through Duplicate instead. + const handleCopy = useCallback(async (fromRel: string, entryName: string, destDir: string): Promise => { + const toRel = destDir ? `${destDir}/${entryName}` : entryName; + try { + await copyStackFile(stackName, fromRel, toRel, selectedRootId); + toast.success('Copied successfully.'); + refresh(); + return true; + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Copy failed.'); + return false; + } + }, [stackName, selectedRootId, refresh]); + // ── Context menu callbacks ── const handleContextMenuMove = useCallback((relPath: string, entry: FileEntry) => { @@ -250,6 +289,118 @@ export function StackFileExplorer({ setMoveOpen(true); }, []); + const handleContextMenuCopy = useCallback((relPath: string, entry: FileEntry) => { + setCopyRelPath(relPath); + setCopyEntry(entry); + setCopyOpen(true); + }, []); + + // ── Bulk selection helpers ── + + // The selection, with descendants of a selected ancestor dropped (UX mirror of + // the backend's authoritative normalization). + const selection = useMemo(() => normalizeSelection([...selectedPaths]), [selectedPaths]); + // Protected root files (compose/.env) cannot be deleted or moved, so they are + // excluded from those bulk actions (but may still be downloaded). + const movableSelection = useMemo(() => selection.filter((p) => !isProtectedRootRelPath(p)), [selection]); + const protectedExcludedCount = selection.length - movableSelection.length; + + const clearSelection = useCallback(() => setSelectedPaths(new Set()), []); + + // True when the open file is one of `paths` (or inside a selected folder), so + // a bulk op that removed it should clear the viewer. + const openFileAffectedBy = useCallback( + (paths: string[]): boolean => + selectedPath !== null && paths.some((p) => selectedPath === p || selectedPath.startsWith(`${p}/`)), + [selectedPath], + ); + + // Keep the items that failed in the selection (clearing the rest) so a partial + // failure leaves the action bar scoped to exactly what still needs attention. + const keepFailedSelected = useCallback((failed: { path: string }[]) => { + setSelectedPaths(new Set(failed.map((f) => f.path))); + }, []); + + const handleBulkDelete = useCallback(async () => { + setBulkBusy(true); + try { + const result = await bulkDeleteStackPaths(stackName, movableSelection, selectedRootId); + const okN = result.deleted.length; + if (result.failed.length === 0) toast.success(`Deleted ${okN} ${okN === 1 ? 'item' : 'items'}.`); + else if (okN > 0) toast.error(`Deleted ${okN}, ${result.failed.length} failed: ${describeFailures(result.failed)}`); + else toast.error(`Delete failed: ${describeFailures(result.failed)}`); + setBulkDeleteOpen(false); + const affected = openFileAffectedBy(result.deleted); + keepFailedSelected(result.failed); + if (affected) handleDeleted(); + else refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Delete failed.'); + } finally { + setBulkBusy(false); + } + }, [stackName, selectedRootId, movableSelection, openFileAffectedBy, keepFailedSelected, handleDeleted, refresh]); + + // Bulk move confirm handler for the destination picker. Resolves true (closing + // the dialog) only when at least one item moved. + const handleBulkMove = useCallback(async (destDir: string): Promise => { + setBulkBusy(true); + try { + const result = await bulkMoveStackPaths(stackName, movableSelection, destDir, selectedRootId); + const okN = result.moved.length; + if (result.failed.length === 0) toast.success(`Moved ${okN} ${okN === 1 ? 'item' : 'items'}.`); + else if (okN > 0) toast.error(`Moved ${okN}, ${result.failed.length} failed: ${describeFailures(result.failed)}`); + else toast.error(`Move failed: ${describeFailures(result.failed)}`); + if (okN === 0) return false; // nothing moved: keep the dialog and selection + const affected = openFileAffectedBy(result.moved); + keepFailedSelected(result.failed); + if (affected) handleDeleted(); + else refresh(); + return true; + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Move failed.'); + return false; + } finally { + setBulkBusy(false); + } + }, [stackName, selectedRootId, movableSelection, openFileAffectedBy, keepFailedSelected, handleDeleted, refresh]); + + const handleBulkDownload = useCallback(async () => { + setBulkBusy(true); + try { + const res = await bulkDownloadStackFiles(stackName, selection, selectedRootId); + if (!res.ok) { + // Prefer the server's specific reason (e.g. a volume file that is a + // symlink or exceeds the per-file limit); fall back per status. + let msg = res.status === 413 ? 'The selection is too large to download.' : 'Download failed.'; + try { const body = await res.json(); if (body?.error) msg = body.error as string; } catch { /* keep the default */ } + toast.error(msg); + return; + } + downloadBlob(`${stackName}-files.tar.gz`, await res.blob()); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Download failed.'); + } finally { + setBulkBusy(false); + } + }, [stackName, selectedRootId, selection]); + + // Duplicate: copy the entry into its own folder under a non-colliding "copy" + // name, derived from the parent's current listing. + const handleContextMenuDuplicate = useCallback(async (relPath: string, entry: FileEntry) => { + const parent = relPathParentDir(relPath); + try { + const siblings = await listStackDirectory(stackName, parent, selectedRootId); + const newName = nextDuplicateName(entry.name, new Set(siblings.map((e) => e.name))); + const toRel = parent ? `${parent}/${newName}` : newName; + await copyStackFile(stackName, relPath, toRel, selectedRootId); + toast.success('Duplicated successfully.'); + refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Duplicate failed.'); + } + }, [stackName, selectedRootId, refresh]); + const handleContextMenuRename = useCallback((relPath: string) => { const name = relPath.split('/').pop() ?? relPath; setRenameRelPath(relPath); @@ -328,12 +479,28 @@ export function StackFileExplorer({ onUploaded={refresh} />
+ {rootCanEdit && ( + + )} {rootCanEdit && ( )}
+ {selectedPaths.size > 0 && ( +
+ {selectedPaths.size} selected + + {rootCanEdit && ( + <> + + + + )} + +
+ )}
@@ -486,6 +710,49 @@ export function StackFileExplorer({ onMove={handleMove} /> + {/* Copy to… (reuses the move picker; current parent disabled, Duplicate covers same-folder) */} + + + {/* Bulk move: a destination picker validated against every selected source. */} + + + {/* Bulk delete confirmation */} + setBulkDeleteOpen(false)} + variant="destructive" + kicker={`${stackName.toUpperCase()} · DELETE`} + title="Delete selected items?" + description={ + `Permanently delete ${movableSelection.length} ${movableSelection.length === 1 ? 'item' : 'items'}? ` + + 'Folders are removed with their contents. This cannot be undone.' + + (protectedExcludedCount > 0 ? ` ${protectedExcludedCount} protected file(s) are kept.` : '') + } + confirmLabel="Delete" + confirming={bulkBusy} + onConfirm={() => void handleBulkDelete()} + /> + {/* Permissions */} ({ }, })); -// ScrollArea just renders children so the tree nodes are accessible in jsdom. +const sa = vi.hoisted(() => ({ props: {} as Record })); +// ScrollArea just renders children so the tree nodes are accessible in jsdom; +// capture its props so the horizontal-scroll opt-in is testable. vi.mock('@/components/ui/scroll-area', () => ({ - ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, + ScrollArea: ({ children, ...props }: { children: React.ReactNode } & Record) => { + sa.props = props; + return
{children}
; + }, })); vi.mock('@/components/ui/skeleton', () => ({ @@ -251,6 +256,235 @@ describe('FileTree', () => { }); }); +// ── accessibility: tree roles + keyboard navigation ──────────────────────── + +describe('FileTree accessibility', () => { + it('exposes a tree with treeitem rows carrying level and selected state', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + + await screen.findByText('src'); + expect(screen.getByRole('tree', { name: /files/i })).toBeInTheDocument(); + const items = screen.getAllByRole('treeitem'); + expect(items.length).toBe(2); + // aria-level is 1-based at the root. + expect(rowFor('src')).toHaveAttribute('aria-level', '1'); + // The selected file reports aria-selected. + expect(rowFor('README.md')).toHaveAttribute('aria-selected', 'true'); + expect(rowFor('src')).toHaveAttribute('aria-selected', 'false'); + }); + + it('uses roving tabindex: only one row is tabbable at a time', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + + await screen.findByText('src'); + // The selected row is the roving focus, so it holds tabIndex 0; the other -1. + expect(rowFor('README.md')).toHaveAttribute('tabindex', '0'); + expect(rowFor('src')).toHaveAttribute('tabindex', '-1'); + }); + + it('moves focus with ArrowDown/ArrowUp and jumps with Home/End', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('src'); + + const tree = screen.getByRole('tree'); + // No prior selection: the first node is the roving target. + expect(rowFor('src')).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(tree, { key: 'ArrowDown' }); + await waitFor(() => expect(rowFor('README.md')).toHaveFocus()); + expect(rowFor('README.md')).toHaveAttribute('tabindex', '0'); + expect(rowFor('src')).toHaveAttribute('tabindex', '-1'); + + fireEvent.keyDown(tree, { key: 'ArrowUp' }); + await waitFor(() => expect(rowFor('src')).toHaveFocus()); + + fireEvent.keyDown(tree, { key: 'End' }); + await waitFor(() => expect(rowFor('README.md')).toHaveFocus()); + + fireEvent.keyDown(tree, { key: 'Home' }); + await waitFor(() => expect(rowFor('src')).toHaveFocus()); + }); + + it('expands a collapsed directory with ArrowRight and collapses it with ArrowLeft', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + render(); + await screen.findByText('src'); + + const tree = screen.getByRole('tree'); + // src is the first (roving) node; ArrowRight expands it. + fireEvent.keyDown(tree, { key: 'ArrowRight' }); + expect(await screen.findByText('index.ts')).toBeInTheDocument(); + expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true'); + + // ArrowLeft on the expanded directory collapses it. + fireEvent.keyDown(tree, { key: 'ArrowLeft' }); + await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument()); + expect(rowFor('src')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('activates the focused row with Enter', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('src'); + + const tree = screen.getByRole('tree'); + // Move focus to the file, then Enter selects it. + fireEvent.keyDown(tree, { key: 'ArrowDown' }); + await waitFor(() => expect(rowFor('README.md')).toHaveFocus()); + fireEvent.keyDown(rowFor('README.md'), { key: 'Enter' }); + expect(onSelectFile).toHaveBeenCalledWith('README.md', expect.objectContaining({ name: 'README.md' })); + }); + + it('steps into the first child with ArrowRight on an already-expanded directory', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + render(); + await screen.findByText('src'); + const tree = screen.getByRole('tree'); + + fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src in place + await screen.findByText('index.ts'); + expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.keyDown(tree, { key: 'ArrowRight' }); // step into the first child + await waitFor(() => expect(rowFor('index.ts')).toHaveFocus()); + expect(rowFor('index.ts')).toHaveAttribute('tabindex', '0'); + }); + + it('moves to the parent directory with ArrowLeft from a child, without collapsing it', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + render(); + await screen.findByText('src'); + const tree = screen.getByRole('tree'); + + fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src + await screen.findByText('index.ts'); + fireEvent.keyDown(tree, { key: 'ArrowRight' }); // focus index.ts + await waitFor(() => expect(rowFor('index.ts')).toHaveFocus()); + + fireEvent.keyDown(tree, { key: 'ArrowLeft' }); // back up to the parent + await waitFor(() => expect(rowFor('src')).toHaveFocus()); + // Moving to the parent must NOT collapse it. + expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('keeps exactly one tabbable row when the active node is filtered out', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + const user = userEvent.setup(); + render(); + await screen.findByText('src'); + const tree = screen.getByRole('tree'); + + fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src + await screen.findByText('index.ts'); + fireEvent.keyDown(tree, { key: 'ArrowRight' }); // active = src/index.ts + await waitFor(() => expect(rowFor('index.ts')).toHaveFocus()); + + // Filter to "src": the active child is removed from view, so the roving + // target must fall back rather than leave the tree with no tabbable row. + await user.type(screen.getByLabelText(/filter files/i), 'src'); + await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument()); + const tabbable = screen.getAllByRole('treeitem').filter(r => r.getAttribute('tabindex') === '0'); + expect(tabbable).toHaveLength(1); + }); + + it('announces the selected file basename in the live region', async () => { + mockLoadDir.mockReturnValue(fakeOk(SRC_ENTRIES)); + const { container } = render(); + await screen.findByText('index.ts'); + const live = container.querySelector('[aria-live="polite"]'); + expect(live).toHaveTextContent('Selected index.ts'); + }); + + it('clamps focus at the ends (ArrowUp at top, ArrowDown at bottom are no-ops)', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('src'); + const tree = screen.getByRole('tree'); + + // src is the first node; ArrowUp at the top stays on src. + fireEvent.keyDown(tree, { key: 'ArrowUp' }); + expect(rowFor('src')).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(tree, { key: 'End' }); + await waitFor(() => expect(rowFor('README.md')).toHaveFocus()); + // ArrowDown at the bottom stays on the last row. + fireEvent.keyDown(tree, { key: 'ArrowDown' }); + expect(rowFor('README.md')).toHaveAttribute('tabindex', '0'); + }); +}); + +// ── bulk selection (checkbox + modifier clicks) ───────────────────────────── + +describe('FileTree bulk selection', () => { + it('Ctrl/Cmd+click toggles a row into the selection without opening it', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + const onSelectionChange = vi.fn(); + render(); + await screen.findByText('README.md'); + + fireEvent.click(rowFor('README.md'), { ctrlKey: true }); + expect(onSelectionChange).toHaveBeenCalledWith(new Set(['README.md'])); + // Modifier-click must not open the file in the viewer. + expect(onSelectFile).not.toHaveBeenCalled(); + }); + + it('a checkbox click toggles selection without opening the row', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + const onSelectionChange = vi.fn(); + const user = userEvent.setup(); + render(); + await screen.findByText('README.md'); + + await user.click(screen.getByLabelText('Select README.md')); + expect(onSelectionChange).toHaveBeenCalledWith(new Set(['README.md'])); + expect(onSelectFile).not.toHaveBeenCalled(); + }); + + it('Shift+click selects the contiguous range from the anchor in visible order', async () => { + mockLoadDir.mockReturnValue(fakeOk([makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')])); + const onSelectionChange = vi.fn(); + render(); + await screen.findByText('a.txt'); + + // Ctrl+click sets the anchor on the first row. + fireEvent.click(rowFor('a.txt'), { ctrlKey: true }); + // Shift+click the third row selects the whole a..c range. + fireEvent.click(rowFor('c.txt'), { shiftKey: true }); + expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['a.txt', 'b.txt', 'c.txt'])); + expect(onSelectFile).not.toHaveBeenCalled(); + }); + + it('a plain click still opens the file and does not change the selection', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + const onSelectionChange = vi.fn(); + render(); + await screen.findByText('README.md'); + + fireEvent.click(rowFor('README.md')); + expect(onSelectFile).toHaveBeenCalledWith('README.md', expect.objectContaining({ name: 'README.md' })); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('reflects checkbox membership via aria-selected', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('README.md'); + expect(rowFor('README.md')).toHaveAttribute('aria-selected', 'true'); + expect(rowFor('src')).toHaveAttribute('aria-selected', 'false'); + }); +}); + // ── drag-and-drop move ────────────────────────────────────────────────────── /** A minimal DataTransfer stand-in carrying our custom move payload (or an OS file drag). */ @@ -266,7 +500,7 @@ function makeDataTransfer(payload: FileEntryDragPayload | null): DataTransfer { } function rowFor(name: string): HTMLElement { - const el = screen.getByText(name).closest('[role="button"]'); + const el = screen.getByText(name).closest('[role="treeitem"]'); if (!el) throw new Error(`no row for ${name}`); return el as HTMLElement; } @@ -365,3 +599,43 @@ describe('FileTree drag-and-drop move', () => { expect(rowFor('compose.yaml').draggable).toBe(false); }); }); + +// ── layout: full-row hit area + horizontal scroll for long names ──────────── + +describe('FileTree layout', () => { + it('opts the tree ScrollArea into a horizontal scrollbar', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('src'); + expect(sa.props.horizontal).toBe(true); + }); + + it('spans the tree and rows to the full pane width and stops clipping names', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('src'); + + // The container and each row fill the pane while growing to content, so the + // right-click hit area covers the whole row at any horizontal scroll offset. + const tree = screen.getByRole('tree'); + expect(tree.className).toMatch(/\bmin-w-full\b/); + expect(tree.className).toMatch(/\bw-max\b/); + expect(rowFor('src').className).toMatch(/\bmin-w-full\b/); + expect(rowFor('src').className).toMatch(/\bw-max\b/); + + // The name renders in full (no truncation), it just does not wrap. + const nameSpan = screen.getByText('README.md'); + expect(nameSpan.className).toContain('whitespace-nowrap'); + expect(nameSpan.className).not.toContain('truncate'); + }); + + it('opens the Sencho context menu when a directory row is right-clicked', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + render(); + await screen.findByText('src'); + + fireEvent.contextMenu(rowFor('src')); + expect(await screen.findByText('New File')).toBeInTheDocument(); + expect(screen.getByText('Rename')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx b/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx index 82eff0e5..b427f98b 100644 --- a/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx +++ b/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx @@ -145,4 +145,60 @@ describe('MoveFileDialog', () => { // Root is the source's current parent here, so it is also a no-op. expect(labelButton('Stack root')).toBeDisabled(); }); + + it('in copy mode shows a Copy button and keeps the current parent disabled', async () => { + listMock.mockResolvedValue([dir('configs'), dir('services')]); + const onMove = vi.fn().mockResolvedValue(true); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await screen.findByText('services'); + // The confirm button is labelled Copy, and there is no Move button. + const copyBtn = screen.getByRole('button', { name: /^copy$/i }); + expect(copyBtn).toBeDisabled(); + expect(screen.queryByRole('button', { name: /^move$/i })).toBeNull(); + // Same destination gating as move: the current parent stays disabled, so a + // same-folder copy must go through Duplicate, not this picker. + expect(labelButton('configs')).toBeDisabled(); + + await user.click(labelButton('services')); + expect(copyBtn).toBeEnabled(); + await user.click(copyBtn); + expect(onMove).toHaveBeenCalledWith('configs/app.conf', 'app.conf', 'services'); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it('in copy mode disables the stack root for a reserved name so a protected file can only land in a subfolder', async () => { + listMock.mockResolvedValue([dir('backups')]); + + render( + , + ); + + await screen.findByText('backups'); + // The stack root is disabled for a reserved name; a subfolder stays valid, + // so copying compose.yaml is allowed but only into a subfolder. + expect(labelButton('Stack root')).toBeDisabled(); + expect(labelButton('backups')).toBeEnabled(); + }); }); diff --git a/frontend/src/components/files/__tests__/NewFileDialog.test.tsx b/frontend/src/components/files/__tests__/NewFileDialog.test.tsx new file mode 100644 index 00000000..f7ef0747 --- /dev/null +++ b/frontend/src/components/files/__tests__/NewFileDialog.test.tsx @@ -0,0 +1,109 @@ +/** + * Coverage for NewFileDialog create semantics. + * + * Creating a file routes through createEmptyStackFile (a zero-byte upload with + * no overwrite), so the server decides collisions. A fresh name creates and + * fires onCreated; an existing name comes back as UploadConflictError and must + * surface inline without clobbering the existing file or closing the dialog. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const h = vi.hoisted(() => ({ + createMock: vi.fn<(stack: string, dir: string, name: string, opts?: { rootId?: string }) => Promise>(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +// Keep the real module (UploadConflictError must stay a real class for +// instanceof to hold) and swap only the create call. +vi.mock('@/lib/stackFilesApi', async (orig) => ({ + ...(await orig()), + createEmptyStackFile: h.createMock, +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: h.toastError, success: h.toastSuccess, loading: vi.fn(() => 'id'), dismiss: vi.fn() }, +})); + +import { NewFileDialog } from '../NewFileDialog'; +import { UploadConflictError } from '@/lib/stackFilesApi'; + +function setup(currentDir = 'configs') { + const onCreated = vi.fn(); + const onOpenChange = vi.fn(); + render( + , + ); + return { onCreated, onOpenChange }; +} + +beforeEach(() => { + h.createMock.mockReset(); + h.toastError.mockReset(); + h.toastSuccess.mockReset(); +}); + +describe('NewFileDialog', () => { + it('creates a blank file in the current dir and reports success', async () => { + h.createMock.mockResolvedValue(undefined); + const user = userEvent.setup(); + const { onCreated, onOpenChange } = setup('configs'); + + await user.type(screen.getByLabelText(/file name/i), 'app.conf'); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + await waitFor(() => + expect(h.createMock).toHaveBeenCalledWith('my-stack', 'configs', 'app.conf', { rootId: 'stack-source' }), + ); + expect(onCreated).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(h.toastSuccess).toHaveBeenCalledWith('File created.'); + }); + + it('surfaces an inline error and does not overwrite when the name already exists', async () => { + h.createMock.mockRejectedValueOnce(new UploadConflictError('app.conf already exists.')); + const user = userEvent.setup(); + const { onCreated, onOpenChange } = setup('configs'); + + await user.type(screen.getByLabelText(/file name/i), 'app.conf'); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + expect(await screen.findByText(/a file with that name already exists/i)).toBeInTheDocument(); + // The dialog stays open and the create is not treated as a success. + expect(onCreated).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + expect(h.toastSuccess).not.toHaveBeenCalled(); + }); + + it('rejects an invalid name client-side without calling the server', async () => { + const user = userEvent.setup(); + setup('configs'); + + await user.type(screen.getByLabelText(/file name/i), 'bad/name'); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + expect(await screen.findByText(/must not be empty/i)).toBeInTheDocument(); + expect(h.createMock).not.toHaveBeenCalled(); + }); + + it('routes an unexpected failure to a toast', async () => { + h.createMock.mockRejectedValueOnce(new Error('disk full')); + const user = userEvent.setup(); + const { onCreated } = setup('configs'); + + await user.type(screen.getByLabelText(/file name/i), 'app.conf'); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('disk full')); + expect(onCreated).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx b/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx index b48024fa..d551a4bd 100644 --- a/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx +++ b/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx @@ -11,26 +11,54 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { FileEntry } from '@/lib/stackFilesApi'; +import { downloadBlob } from '@/lib/download'; // Holder for the renameStackPath mock and the captured onMove callback, so tests // can drive the shared move handler directly (the DnD path passes it as onMove). const h = vi.hoisted(() => ({ renameMock: vi.fn<(stack: string, from: string, to: string) => Promise>(), + copyMock: vi.fn<(stack: string, from: string, to: string, rootId?: string) => Promise>(), + listMock: vi.fn<(stack: string, dir: string, rootId?: string) => Promise>(), + bulkDeleteMock: vi.fn<(stack: string, paths: string[], rootId?: string) => Promise<{ deleted: string[]; failed: { path: string; error: string }[] }>>(), + bulkMoveMock: vi.fn<(stack: string, from: string[], toDir: string, rootId?: string) => Promise<{ moved: string[]; failed: { path: string; error: string }[] }>>(), + bulkDownloadMock: vi.fn<(stack: string, paths: string[], rootId?: string) => Promise>(), onMove: null as null | ((fromRel: string, entryName: string, destDir: string) => void), + onCopy: null as null | ((fromRel: string, entryName: string, destDir: string) => boolean | Promise), + onConfirmDestination: null as null | ((destDir: string) => boolean | Promise), + onSelectionChange: null as null | ((next: Set) => void), + newFileProps: null as null | { open: boolean; currentDir: string; rootId?: string }, toastError: vi.fn(), toastSuccess: vi.fn(), })); vi.mock('@/lib/stackFilesApi', () => ({ STACK_SOURCE_ROOT_ID: 'stack-source', - listStackDirectory: vi.fn().mockResolvedValue([]), + listStackDirectory: h.listMock, listFileRoots: vi.fn().mockResolvedValue([]), downloadStackFile: vi.fn(), readStackFile: vi.fn(), writeStackFile: vi.fn(), renameStackPath: h.renameMock, + copyStackFile: h.copyMock, + bulkDeleteStackPaths: h.bulkDeleteMock, + bulkMoveStackPaths: h.bulkMoveMock, + bulkDownloadStackFiles: h.bulkDownloadMock, + relPathParentDir: (p: string) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''), + nextDuplicateName: (n: string) => `${n} copy`, + isProtectedRootRelPath: (rel: string) => + ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml', '.env'].includes(rel), + normalizeSelection: (paths: string[]) => { + const set = new Set(paths); + return [...set].filter((p) => { + const seg = p.split('/'); + for (let i = 1; i < seg.length; i++) if (set.has(seg.slice(0, i).join('/'))) return false; + return true; + }); + }, })); +vi.mock('@/lib/download', () => ({ downloadBlob: vi.fn() })); + vi.mock('@/components/ui/toast-store', () => ({ toast: { error: h.toastError, success: h.toastSuccess, loading: vi.fn(() => 'id'), dismiss: vi.fn() }, })); @@ -40,22 +68,45 @@ vi.mock('../FileUploadDropzone', () => ({ })); vi.mock('../NewFolderDialog', () => ({ NewFolderDialog: () => null })); -vi.mock('../NewFileDialog', () => ({ NewFileDialog: () => null })); +// Capture the dialog props so the toolbar button's open/dir/root wiring is testable. +vi.mock('../NewFileDialog', () => ({ + NewFileDialog: (props: { open: boolean; currentDir: string; rootId?: string }) => { + h.newFileProps = { open: props.open, currentDir: props.currentDir, rootId: props.rootId }; + return null; + }, +})); vi.mock('../DeleteFileConfirm', () => ({ DeleteFileConfirm: () => null })); vi.mock('../RenameDialog', () => ({ RenameDialog: () => null })); -vi.mock('../MoveFileDialog', () => ({ MoveFileDialog: () => null })); +// Capture the copy-mode dialog's confirm callback so handleCopy can be driven +// directly (the move-mode instance is exercised via the drag-and-drop onMove). +vi.mock('../MoveFileDialog', () => ({ + MoveFileDialog: ({ mode, onMove, onConfirmDestination }: { + mode?: 'move' | 'copy'; + onMove?: (fromRel: string, entryName: string, destDir: string) => boolean | Promise; + onConfirmDestination?: (destDir: string) => boolean | Promise; + }) => { + if (mode === 'copy') h.onCopy = onMove ?? null; + if (onConfirmDestination) h.onConfirmDestination = onConfirmDestination; // the bulk-move instance + return null; + }, +})); vi.mock('../FilePermissionsDialog', () => ({ FilePermissionsDialog: () => null })); // FileTree mock: selection buttons (two siblings + one nested file) plus capture // of the onMove callback so move-handler behaviour can be driven directly. vi.mock('../FileTree', () => ({ - FileTree: ({ onSelectFile, onMove }: { + FileTree: ({ onSelectFile, onMove, onContextMenuDuplicate, onSelectionChange }: { onSelectFile: (rel: string, entry: FileEntry) => void; onMove?: (fromRel: string, entryName: string, destDir: string) => void; + onContextMenuDuplicate?: (relPath: string, entry: FileEntry) => void; + onSelectionChange?: (next: Set) => void; }) => { h.onMove = onMove ?? null; + h.onSelectionChange = onSelectionChange ?? null; return (
+ + @@ -65,6 +116,9 @@ vi.mock('../FileTree', () => ({ +
); }, @@ -217,3 +271,186 @@ describe('StackFileExplorer move handling', () => { expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt'); }); }); + +describe('StackFileExplorer copy and duplicate handling', () => { + beforeEach(() => { + h.copyMock.mockReset().mockResolvedValue(undefined); + h.listMock.mockReset().mockResolvedValue([]); + h.toastError.mockReset(); + h.toastSuccess.mockReset(); + }); + + it('duplicates into the same folder under a non-colliding "copy" name', async () => { + h.listMock.mockResolvedValue([ + { name: 'app.conf', type: 'file', size: 1, mtime: 0, isProtected: false }, + ]); + const user = userEvent.setup(); + setup(); + + await user.click(screen.getByText('ctx-duplicate')); + + // Siblings are listed from the entry's parent dir, then copied to the derived name. + await waitFor(() => expect(h.listMock).toHaveBeenCalledWith('my-stack', 'configs', 'stack-source')); + await waitFor(() => expect(h.copyMock).toHaveBeenCalledWith('my-stack', 'configs/app.conf', 'configs/app.conf copy', 'stack-source')); + await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Duplicated successfully.')); + }); + + it('surfaces an error toast when the sibling listing for duplicate fails', async () => { + h.listMock.mockRejectedValueOnce(new Error('Failed to load folders.')); + const user = userEvent.setup(); + setup(); + + await user.click(screen.getByText('ctx-duplicate')); + + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('Failed to load folders.')); + expect(h.copyMock).not.toHaveBeenCalled(); + }); + + it('copies via the copy-to dialog handler and reports success', async () => { + setup(); + await waitFor(() => expect(h.onCopy).not.toBeNull()); + + const result = await h.onCopy?.('a.txt', 'a.txt', 'sub'); + expect(result).toBe(true); + expect(h.copyMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt', 'stack-source'); + expect(h.toastSuccess).toHaveBeenCalledWith('Copied successfully.'); + }); + + it('surfaces an error toast and stays open when the copy fails', async () => { + h.copyMock.mockRejectedValueOnce(new Error('already exists')); + setup(); + await waitFor(() => expect(h.onCopy).not.toBeNull()); + + const result = await h.onCopy?.('a.txt', 'a.txt', 'sub'); + expect(result).toBe(false); + expect(h.toastError).toHaveBeenCalledWith('already exists'); + }); +}); + +describe('StackFileExplorer bulk selection', () => { + beforeEach(() => { + h.bulkDeleteMock.mockReset().mockResolvedValue({ deleted: [], failed: [] }); + h.bulkMoveMock.mockReset().mockResolvedValue({ moved: [], failed: [] }); + h.bulkDownloadMock.mockReset(); + h.onConfirmDestination = null; + h.toastError.mockReset(); + h.toastSuccess.mockReset(); + vi.mocked(downloadBlob).mockReset(); + }); + + it('shows the bulk action bar with a count once files are selected', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download selection' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Move selection' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete selection' })).toBeInTheDocument(); + }); + + it('downloads the selection as an archive', async () => { + h.bulkDownloadMock.mockResolvedValue({ ok: true, blob: async () => new Blob(['x']) } as unknown as Response); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + await user.click(screen.getByRole('button', { name: 'Download selection' })); + await waitFor(() => expect(h.bulkDownloadMock).toHaveBeenCalledWith('my-stack', ['a.txt', 'b.txt'], 'stack-source')); + await waitFor(() => expect(vi.mocked(downloadBlob)).toHaveBeenCalled()); + }); + + it('moves the selection through the destination picker', async () => { + h.bulkMoveMock.mockResolvedValue({ moved: ['a.txt', 'b.txt'], failed: [] }); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + expect(h.onConfirmDestination).not.toBeNull(); + + const ok = await h.onConfirmDestination?.('dest'); + expect(ok).toBe(true); + expect(h.bulkMoveMock).toHaveBeenCalledWith('my-stack', ['a.txt', 'b.txt'], 'dest', 'stack-source'); + expect(h.toastSuccess).toHaveBeenCalledWith('Moved 2 items.'); + }); + + it('deletes the selection but excludes protected files from the request', async () => { + h.bulkDeleteMock.mockResolvedValue({ deleted: ['a.txt'], failed: [] }); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-protected')); // compose.yaml + a.txt + expect(screen.getByText('2 selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Delete selection' })); + const confirm = await screen.findByRole('button', { name: /^delete$/i }); + await user.click(confirm); + + // compose.yaml (protected) is excluded; only a.txt is sent. + await waitFor(() => expect(h.bulkDeleteMock).toHaveBeenCalledWith('my-stack', ['a.txt'], 'stack-source')); + await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Deleted 1 item.')); + }); + + it('reports a partial delete failure with detail and keeps the failed item selected', async () => { + h.bulkDeleteMock.mockResolvedValue({ deleted: ['a.txt'], failed: [{ path: 'b.txt', error: 'locked' }] }); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + await user.click(screen.getByRole('button', { name: 'Delete selection' })); + await user.click(await screen.findByRole('button', { name: /^delete$/i })); + + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith(expect.stringContaining('b.txt (locked)'))); + // The failed item stays selected so the user can retry it. + await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument()); + }); + + it('surfaces the server message when the bulk download is rejected (e.g. a volume symlink)', async () => { + h.bulkDownloadMock.mockResolvedValue({ + ok: false, status: 400, json: async () => ({ error: '"x" cannot be downloaded from this volume' }), + } as unknown as Response); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + await user.click(screen.getByRole('button', { name: 'Download selection' })); + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('"x" cannot be downloaded from this volume')); + }); + + it('falls back to a too-large message when a 413 body cannot be parsed', async () => { + // No json() on the response: the parse fails and the per-status default shows. + h.bulkDownloadMock.mockResolvedValue({ ok: false, status: 413 } as unknown as Response); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + await user.click(screen.getByRole('button', { name: 'Download selection' })); + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith(expect.stringMatching(/too large/i))); + }); + + it('clears the selection with the Clear button', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('bulk-select-two')); + expect(screen.getByText('2 selected')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Clear selection' })); + expect(screen.queryByText('2 selected')).not.toBeInTheDocument(); + }); +}); + +describe('StackFileExplorer new file affordance', () => { + beforeEach(() => { h.newFileProps = null; }); + + it('opens the New file dialog at the stack root from the toolbar button', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole('button', { name: 'New file' })); + expect(h.newFileProps).toMatchObject({ open: true, currentDir: '', rootId: 'stack-source' }); + }); + + it('targets the current directory once a nested file is selected', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('select-nested')); // dir/a.txt -> currentDir 'dir' + await user.click(screen.getByRole('button', { name: 'New file' })); + expect(h.newFileProps).toMatchObject({ open: true, currentDir: 'dir' }); + }); + + it('hides the New file button on a non-editable root', () => { + render(); + expect(screen.queryByRole('button', { name: 'New file' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx index 92ea5d70..884e457f 100644 --- a/frontend/src/components/ui/scroll-area.tsx +++ b/frontend/src/components/ui/scroll-area.tsx @@ -13,8 +13,13 @@ const ScrollArea = React.forwardRef< // viewport width and trigger its own scroll. Also renders a horizontal // ScrollBar for viewports that overflow directly. block?: boolean; + // Opt in to a horizontal ScrollBar while keeping Radix's default + // content-sizing wrapper, so content wider than the viewport (e.g. a file + // tree with long names) scrolls horizontally with the styled thumb. Unlike + // `block`, this does not clamp the content to the viewport width. + horizontal?: boolean; } ->(({ className, children, viewportRef, block, ...props }, ref) => ( +>(({ className, children, viewportRef, block, horizontal, ...props }, ref) => ( - {block && } + {(block || horizontal) && } )) diff --git a/frontend/src/lib/__tests__/stackFilesApi.test.ts b/frontend/src/lib/__tests__/stackFilesApi.test.ts index 4d64764e..6530830f 100644 --- a/frontend/src/lib/__tests__/stackFilesApi.test.ts +++ b/frontend/src/lib/__tests__/stackFilesApi.test.ts @@ -5,12 +5,15 @@ * malicious or buggy caller cannot slip a `..` segment past the * client before it would otherwise be caught by the server. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isClientSafeRelPath, isProtectedRootRelPath, isSameOrDescendantPath, relPathParentDir, + nextDuplicateName, + createEmptyStackFile, + UploadConflictError, } from '../stackFilesApi'; describe('isClientSafeRelPath', () => { @@ -115,3 +118,78 @@ describe('relPathParentDir', () => { expect(relPathParentDir('configs/redis/redis.conf')).toBe('configs/redis'); }); }); + +describe('nextDuplicateName', () => { + it('inserts " copy" before the extension', () => { + expect(nextDuplicateName('app.conf', new Set())).toBe('app copy.conf'); + }); + + it('increments the suffix when the copy name already exists', () => { + expect(nextDuplicateName('app.conf', new Set(['app copy.conf']))).toBe('app copy 2.conf'); + expect(nextDuplicateName('app.conf', new Set(['app copy.conf', 'app copy 2.conf']))).toBe('app copy 3.conf'); + }); + + it('treats a leading-dot file as having no extension', () => { + expect(nextDuplicateName('.env', new Set())).toBe('.env copy'); + }); + + it('appends to a name with no extension', () => { + expect(nextDuplicateName('Dockerfile', new Set())).toBe('Dockerfile copy'); + }); +}); + +describe('createEmptyStackFile', () => { + function stubFetch(status: number, body?: object) { + const res = { + status, + ok: status >= 200 && status < 300, + headers: { get: () => null }, + clone() { return this; }, + json: async () => body ?? {}, + }; + const fetchMock = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + + beforeEach(() => localStorage.clear()); + afterEach(() => vi.unstubAllGlobals()); + + it('posts a zero-byte file to the upload endpoint without overwrite', async () => { + const fetchMock = stubFetch(204); + await createEmptyStackFile('my-stack', 'configs', 'app.conf', { rootId: 'stack-source' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new URL(url, 'http://x').searchParams.get('path')).toBe('configs'); + expect(url).not.toContain('overwrite=1'); // never clobbers + const file = (init.body as FormData).get('file') as File; + expect(file.name).toBe('app.conf'); + expect(file.size).toBe(0); + }); + + it('targets the stack root when the directory is empty', async () => { + const fetchMock = stubFetch(204); + await createEmptyStackFile('my-stack', '', 'root-file.txt'); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + // An empty directory means the stack root: the path query carries no segment. + expect(new URL(url, 'http://x').searchParams.get('path')).toBe(''); + expect(url).not.toContain('overwrite=1'); + expect((init.body as FormData).get('file') as File).toMatchObject({ name: 'root-file.txt', size: 0 }); + }); + + it('throws UploadConflictError when a file of that name already exists', async () => { + stubFetch(409, { code: 'FILE_EXISTS', error: 'app.conf already exists.' }); + await expect(createEmptyStackFile('my-stack', 'configs', 'app.conf')).rejects.toBeInstanceOf( + UploadConflictError, + ); + }); + + it('throws a generic error (not UploadConflictError) when a folder of that name exists', async () => { + stubFetch(409, { code: 'DIR_EXISTS', error: 'A folder named app already exists in this folder.' }); + const err = await createEmptyStackFile('my-stack', 'configs', 'app').catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(UploadConflictError); + }); +}); diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts index c404b6e5..35141ecc 100644 --- a/frontend/src/lib/stackFilesApi.ts +++ b/frontend/src/lib/stackFilesApi.ts @@ -60,6 +60,26 @@ export function relPathParentDir(rel: string): string { return rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''; } +/** + * Pick a non-colliding " copy" sibling name for Duplicate. A leading-dot + * file (e.g. .env) is treated as having no extension, so the suffix lands before + * any real extension (`app copy.conf`) but as a plain suffix for dotfiles + * (`.env copy`). + */ +export function nextDuplicateName(original: string, existing: Set): string { + const dot = original.lastIndexOf('.'); + const hasExt = dot > 0; + const base = hasExt ? original.slice(0, dot) : original; + const ext = hasExt ? original.slice(dot) : ''; + let candidate = `${base} copy${ext}`; + let n = 2; + while (existing.has(candidate)) { + candidate = `${base} copy ${n}${ext}`; + n += 1; + } + return candidate; +} + /** Custom drag MIME so move drops are told apart from OS file drags (`Files`). */ export const FILE_ENTRY_DND_MIME = 'application/x-sencho-file-entry'; @@ -300,6 +320,24 @@ export async function uploadStackFile( } } +/** + * Create a new empty file at `dirRelPath/fileName`. Routes through the upload + * endpoint with a zero-byte file and no overwrite, so the server's exclusive + * create path is authoritative and an existing entry is never clobbered: an + * existing file of that name is rejected as FILE_EXISTS (thrown as + * UploadConflictError), and an existing folder is rejected as DIR_EXISTS (a + * generic Error). Works on both the filesystem and named-volume backends. + */ +export async function createEmptyStackFile( + stackName: string, + dirRelPath: string, + fileName: string, + options?: { rootId?: string } +): Promise { + const blank = new File([new Uint8Array(0)], fileName, { type: 'text/plain' }); + await uploadStackFile(stackName, dirRelPath, blank, { rootId: options?.rootId, overwrite: false }); +} + export async function writeStackFile( stackName: string, relPath: string, @@ -385,6 +423,76 @@ export async function renameStackPath( if (!res.ok) throw new Error(await parseApiError(res)); } +export async function copyStackFile( + stackName: string, + fromRel: string, + toRel: string, + rootId?: string, +): Promise { + assertSafeRelPath(fromRel, 'source path'); + assertSafeRelPath(toRel, 'destination path'); + const res = await apiFetch( + stackFilesUrl(stackName, `/copy${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`), + { method: 'POST', body: JSON.stringify({ from: fromRel, to: toRel }) } + ); + if (!res.ok) throw new Error(await parseApiError(res)); +} + +/** A failed item in a partial-success bulk operation. */ +export interface BulkFailure { + path: string; + error: string; +} +export interface BulkDeleteResult { + deleted: string[]; + failed: BulkFailure[]; +} +export interface BulkMoveResult { + moved: string[]; + failed: BulkFailure[]; +} + +export async function bulkDeleteStackPaths(stackName: string, paths: string[], rootId?: string): Promise { + const res = await apiFetch( + stackFilesUrl(stackName, `/bulk-delete${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`), + { method: 'POST', body: JSON.stringify({ paths }) } + ); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function bulkMoveStackPaths(stackName: string, from: string[], toDir: string, rootId?: string): Promise { + const res = await apiFetch( + stackFilesUrl(stackName, `/bulk-move${rootId ? `?rootId=${encodeURIComponent(rootId)}` : ''}`), + { method: 'POST', body: JSON.stringify({ from, toDir }) } + ); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +/** GET the bulk-download archive (repeated ?path= so read-only API tokens work). */ +export async function bulkDownloadStackFiles(stackName: string, paths: string[], rootId?: string): Promise { + const query = paths.map((p) => `path=${encodeURIComponent(p)}`).join('&'); + const suffix = rootId ? `&rootId=${encodeURIComponent(rootId)}` : ''; + return apiFetch(stackFilesUrl(stackName, `/bulk-download?${query}${suffix}`)); +} + +/** + * Drop any selected path whose ancestor is also selected (so a folder and a file + * inside it are not both acted on). A UX convenience; the backend re-normalizes + * authoritatively (with per-root case-awareness), so this is not the boundary. + */ +export function normalizeSelection(paths: string[]): string[] { + const set = new Set(paths); + return [...set].filter((p) => { + const segments = p.split('/'); + for (let i = 1; i < segments.length; i++) { + if (set.has(segments.slice(0, i).join('/'))) return false; + } + return true; + }); +} + export interface EntryPermissions { mode: number; octal: string;