mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
49d775c61f
* feat(volumes): add read-only volume browser Adds a browser for the contents of any Docker named volume. Click the folder icon on a volume row (admin only) to open a sheet with a directory tree on the left and a file viewer on the right. Backend ------- New VolumeBrowserService spawns a one-shot Alpine 3.20 helper container with the target volume mounted read-only at /v. The container runs as nobody (65534:65534) with a read-only rootfs, no network, all caps dropped, no-new-privileges, and capped at 64 PIDs and 128 MiB. The helper image is pulled on first use per node. Listing and stat use a portable busybox-compatible shell loop (find -printf is not available on Alpine). Reads use head -c with an explicit -- separator; the helper's working directory is /v so user paths are passed as ./<path> argv elements and never as flags. The container lifecycle is managed manually (create, attach, start, wait, remove) to avoid the AutoRemove race where dockerode sees a 404 on its post-exit container lookup. Path safety: relative paths are sanitized server-side, rejecting parent-escape segments, absolute paths, null bytes, and oversized input. Symlinks are listed but never followed on read. Files larger than 5 MB are truncated; binary content is detected via null-byte scan and returned base64-encoded. Non-zero helper exits map to 404, 403, or 500 by classifying stderr. Routes mounted at /api/volumes: - GET /:name/list?path= - GET /:name/stat?path= - GET /:name/read?path= All three require admin. The read endpoint always inserts an audit log row (success or failure) with the actual response status code, volume name, and relative path. Frontend -------- FileTree generalized to take a loadDir callback and a sourceKey instead of a hard-coded stackName. The single existing consumer (StackFileExplorer) was updated and its tests rewritten. The loader is read through a ref so re-creating the arrow on every parent render does not re-trigger the root fetch effect. New VolumeBrowserSheet renders the tree against the volume API, shows file content (hex view for binaries), and surfaces truncation. Rapid sheet open and reopen on different volumes is generation- checked to avoid stomping the visible result with a stale read. A persistent footnote reminds the user that file reads are recorded in the audit log, and the docs page warns about the typical contents of database volumes. Tests ----- 15 new vitest cases cover the pure helpers (path traversal, volume name validation, binary detection). The Docker-facing exec path is exercised by manual end-to-end via curl against a seeded volume. * fix(volumes): truncate long volume names in browser sheet header Wide volume names overlapped the close X. Reserve right padding on the header, set min-w-0 on the flex title, mark the icon and refresh button shrink-0, and truncate the name span. * fix(volumes): satisfy lint on volume browser additions prefer-const on sanitizeRelPath's local; drop unused FileTree entry arg from the file-select callback (variance lets the arrow take fewer params than the contract).
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
sanitizeRelPath,
|
|
isValidVolumeName,
|
|
isBinaryBuffer,
|
|
PathTraversalError,
|
|
} from '../services/VolumeBrowserService';
|
|
|
|
describe('sanitizeRelPath', () => {
|
|
it('returns empty string for the volume root', () => {
|
|
expect(sanitizeRelPath('')).toBe('');
|
|
expect(sanitizeRelPath('.')).toBe('');
|
|
expect(sanitizeRelPath('/')).toBe('');
|
|
});
|
|
|
|
it('strips leading slashes', () => {
|
|
expect(sanitizeRelPath('/etc/foo')).toBe('etc/foo');
|
|
expect(sanitizeRelPath('//etc/foo')).toBe('etc/foo');
|
|
});
|
|
|
|
it('preserves nested relative paths', () => {
|
|
expect(sanitizeRelPath('a/b/c.txt')).toBe('a/b/c.txt');
|
|
});
|
|
|
|
it('rejects parent-escape segments', () => {
|
|
expect(() => sanitizeRelPath('../etc/passwd')).toThrow(PathTraversalError);
|
|
expect(() => sanitizeRelPath('foo/../../etc/passwd')).toThrow(PathTraversalError);
|
|
expect(() => sanitizeRelPath('..')).toThrow(PathTraversalError);
|
|
});
|
|
|
|
it('rejects null bytes', () => {
|
|
expect(() => sanitizeRelPath('foo\0.txt')).toThrow(PathTraversalError);
|
|
});
|
|
|
|
it('rejects oversize paths', () => {
|
|
const huge = 'a/'.repeat(700);
|
|
expect(() => sanitizeRelPath(huge)).toThrow(PathTraversalError);
|
|
});
|
|
|
|
it('rejects non-string input', () => {
|
|
expect(() => sanitizeRelPath(undefined as unknown as string)).toThrow(PathTraversalError);
|
|
expect(() => sanitizeRelPath(null as unknown as string)).toThrow(PathTraversalError);
|
|
});
|
|
});
|
|
|
|
describe('isValidVolumeName', () => {
|
|
it('accepts typical Docker volume names', () => {
|
|
expect(isValidVolumeName('my-stack_data')).toBe(true);
|
|
expect(isValidVolumeName('volume.with.dots')).toBe(true);
|
|
expect(isValidVolumeName('a')).toBe(true);
|
|
expect(isValidVolumeName('Vol_123')).toBe(true);
|
|
});
|
|
|
|
it('rejects names with disallowed characters', () => {
|
|
expect(isValidVolumeName('vol/with/slashes')).toBe(false);
|
|
expect(isValidVolumeName('vol with space')).toBe(false);
|
|
expect(isValidVolumeName('vol;rm -rf')).toBe(false);
|
|
expect(isValidVolumeName('')).toBe(false);
|
|
});
|
|
|
|
it('rejects names that start with non-alphanumeric', () => {
|
|
expect(isValidVolumeName('-leading-dash')).toBe(false);
|
|
expect(isValidVolumeName('.leading-dot')).toBe(false);
|
|
});
|
|
|
|
it('rejects oversize names', () => {
|
|
expect(isValidVolumeName('a'.repeat(256))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isBinaryBuffer', () => {
|
|
it('flags buffers containing null bytes as binary', () => {
|
|
expect(isBinaryBuffer(Buffer.from('hello\0world'))).toBe(true);
|
|
expect(isBinaryBuffer(Buffer.from([0xff, 0x00, 0x42]))).toBe(true);
|
|
});
|
|
|
|
it('treats plain UTF-8 text as non-binary', () => {
|
|
expect(isBinaryBuffer(Buffer.from('hello world\nlet me see\n'))).toBe(false);
|
|
expect(isBinaryBuffer(Buffer.from('héllo wörld'))).toBe(false);
|
|
});
|
|
|
|
it('only inspects the first 8 KB', () => {
|
|
// Pure text in the first 8KB; null byte after.
|
|
const head = Buffer.alloc(8192, 0x41);
|
|
const tail = Buffer.from([0]);
|
|
const buf = Buffer.concat([head, tail]);
|
|
expect(isBinaryBuffer(buf)).toBe(false);
|
|
});
|
|
|
|
it('handles an empty buffer', () => {
|
|
expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false);
|
|
});
|
|
});
|