/** * @fileoverview Folder grid — `POST /api/sessions/:id/folders` grants + lists a * folder, `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)` serves one * of its files by name. Local folders are real temp dirs; a remote case mocks only * the ssh IO, and the remote listing script itself runs on a real `sh`. * * Port: N/A (app.inject doesn't open ports) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { Readable } from 'node:stream'; import { execFileSync } from 'node:child_process'; import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync, } from 'node:fs'; import fs from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; import { folderGrants, validFolderFileName } from '../../src/folder-listing.js'; import type { SessionRemote } from '../../src/types/session.js'; vi.mock('../../src/remote-files.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, remoteListFolder: vi.fn(), remoteProbePaths: vi.fn(), remoteCreateReadStream: vi.fn(), }; }); vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, probePathKind: vi.fn(actual.probePathKind) }; }); import { probePathKind } from '../../src/utils/bounded-path-probe.js'; import { buildRemoteFolderListCommand, parseRemoteFolderListOutput, remoteCreateReadStream, remoteListFolder, remoteProbePaths, } from '../../src/remote-files.js'; const mockedProbePathKind = vi.mocked(probePathKind); const mockedListFolder = vi.mocked(remoteListFolder); const mockedProbePaths = vi.mocked(remoteProbePaths); const mockedCreateReadStream = vi.mocked(remoteCreateReadStream); function touch(path: string, body: string, mtimeSec: number) { writeFileSync(path, body); utimesSync(path, mtimeSec, mtimeSec); } describe('folder grid routes — local folder', () => { let harness: RouteTestHarness; let sessionId: string; let root: string; let dir: string; beforeEach(async () => { harness = await createRouteTestHarness(registerFileRoutes); sessionId = harness.ctx._sessionId; root = mkdtempSync(join(tmpdir(), 'codeman-folder-grid-')); dir = join(root, 'renders'); mkdirSync(dir); touch(join(dir, 'old.png'), 'old-png', 1_700_000_000); touch(join(dir, 'new.PNG'), 'new-png', 1_700_000_500); touch(join(dir, 'clip.mp4'), 'mp4', 1_700_000_100); touch(join(dir, 'notes.xyz'), 'unsupported', 1_700_000_900); touch(join(dir, '.hidden.png'), 'hidden', 1_700_000_900); mkdirSync(join(dir, 'shots')); mkdirSync(join(dir, '.cache')); harness.ctx._session.workingDir = root; }); afterEach(async () => { await harness.app.close(); folderGrants.clearSession(sessionId); rmSync(root, { recursive: true, force: true }); delete process.env.CODEMAN_ATTACHMENT_CONFINE; delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS; }); async function grant(path: string) { return harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path } }); } it('lists supported files newest first plus subfolders, skipping hidden + unsupported', async () => { const res = await grant(dir); expect(res.statusCode).toBe(200); const data = JSON.parse(res.body).data; expect(data.folderId).toMatch(/^fld_/); expect(data.path).toBe(dir); expect(data.files.map((f: { name: string }) => f.name)).toEqual(['new.PNG', 'clip.mp4', 'old.png']); expect(data.files[0]).toMatchObject({ extension: 'png', attachmentType: 'image', size: 7 }); expect(data.folders).toEqual(['shots']); expect(data.truncated).toBe(false); }); it('grants the same folder the same id', async () => { const a = JSON.parse((await grant(dir)).body).data.folderId; const b = JSON.parse((await grant(`${dir}/`)).body).data.folderId; expect(b).toBe(a); }); it('rejects a relative path, a file, and a missing folder', async () => { expect((await grant('renders')).statusCode).toBe(400); expect((await grant(join(dir, 'old.png'))).statusCode).toBe(400); expect((await grant(join(root, 'nope'))).statusCode).toBe(404); }); it('refuses a blocked folder and a folder outside a confined workspace', async () => { process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir; expect((await grant(dir)).statusCode).toBe(403); delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS; process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; harness.ctx._session.workingDir = join(dir, 'shots'); expect((await grant(dir)).statusCode).toBe(403); harness.ctx._session.workingDir = root; expect((await grant(dir)).statusCode).toBe(200); }); it('never reads a refused folder: the guard runs on the resolved folder first', async () => { const readdir = vi.spyOn(fs, 'readdir'); try { process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir; expect((await grant(dir)).statusCode).toBe(403); expect(readdir).not.toHaveBeenCalled(); } finally { readdir.mockRestore(); } }); it('answers a folder or file that does not respond with 503, never 404, and does not read it', async () => { const readdir = vi.spyOn(fs, 'readdir'); try { mockedProbePathKind.mockResolvedValueOnce('unknown'); const res = await grant(dir); expect(res.statusCode).toBe(503); expect(JSON.parse(res.body).error).toContain(`Folder is not responding or not readable: ${dir}`); expect(readdir).not.toHaveBeenCalled(); } finally { readdir.mockRestore(); } const { folderId } = JSON.parse((await grant(dir)).body).data; mockedProbePathKind.mockResolvedValueOnce('unknown'); const file = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`, }); expect(file.statusCode).toBe(503); expect(JSON.parse(file.body).error).toContain('File is not responding or not readable'); }); it('does not serve a hidden file by name, as the listing hides it', async () => { const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/.hidden.png/raw`, }); expect(res.statusCode).toBe(400); }); it('serves a granted file by name, with ranges', async () => { const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`, }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toBe('image/png'); expect(res.body).toBe('old-png'); const ranged = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`, headers: { range: 'bytes=0-2' }, }); expect(ranged.statusCode).toBe(206); expect(ranged.body).toBe('old'); }); it('redirects a non-office preview to raw', async () => { const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/preview`, }); expect(res.statusCode).toBe(302); expect(res.headers.location).toBe(`/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/raw`); }); it('never serves a name that leaves the folder or has an unsupported type', async () => { const { folderId } = JSON.parse((await grant(dir)).body).data; writeFileSync(join(root, 'outside.png'), 'outside'); for (const name of ['..%2Foutside.png', '..', '.', 'notes.xyz', 'shots%2Fx.png']) { const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`, }); expect(res.statusCode, name).not.toBe(200); } const missing = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/gone.png/raw`, }); expect(missing.statusCode).toBe(404); }); it('refuses a file in the folder that symlinks to a blocked target', async () => { const secret = join(root, 'secret'); mkdirSync(secret); writeFileSync(join(secret, 'key.png'), 'secret'); symlinkSync(join(secret, 'key.png'), join(dir, 'innocent.png')); const { folderId } = JSON.parse((await grant(dir)).body).data; process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = secret; const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/innocent.png/raw`, }); expect(res.statusCode).toBe(403); }); // The attachment gate (`registerExternalAttachment`) judges the file a symlink // RESOLVES to: its extension must be a supported one and it must be a regular // file. The folder routes only looked at the requested name, so a name that // passed (`shot.png`) could serve anything it pointed at. describe('the resolved target is held to the attachment gate', () => { const files = (folderId: string, name: string, leaf: 'raw' | 'preview' | 'thumbnail' = 'raw') => harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/${encodeURIComponent(name)}/${leaf}`, }); it('refuses a supported-looking name whose target has an unsupported extension', async () => { writeFileSync(join(root, 'secret.bin'), 'secret'); symlinkSync(join(root, 'secret.bin'), join(dir, 'shot.png')); const { folderId } = JSON.parse((await grant(dir)).body).data; for (const leaf of ['raw', 'preview', 'thumbnail'] as const) { const res = await files(folderId, 'shot.png', leaf); expect(res.statusCode, leaf).toBe(400); expect(res.body, leaf).not.toContain('secret'); } }); it('refuses a name whose target has no extension at all', async () => { writeFileSync(join(root, 'history'), 'private history'); symlinkSync(join(root, 'history'), join(dir, 'notes.txt')); const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await files(folderId, 'notes.txt'); expect(res.statusCode).toBe(400); expect(res.body).not.toContain('private history'); }); it('refuses a device node behind a supported name', async () => { // /dev/null, not /dev/zero: the same class (character device, no // extension), but it ends the stream instead of filling the test's memory // should the guard ever be missing. symlinkSync('/dev/null', join(dir, 'zero.txt')); const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await files(folderId, 'zero.txt'); expect(res.statusCode).toBe(400); }); it('refuses a FIFO without opening it', async () => { const fifo = join(dir, 'pipe.txt'); execFileSync('mkfifo', [fifo]); // Hold both ends open from here: if the route ever did open it, the open // returns at once and the read blocks, which the race below reports as a // hang instead of leaving a libuv worker parked on a writer that never comes. const fd = openSync(fifo, 'r+'); try { const { folderId } = JSON.parse((await grant(dir)).body).data; const outcome = await Promise.race([ files(folderId, 'pipe.txt').then((res) => res.statusCode), new Promise((resolve) => setTimeout(() => resolve('hung'), 2000)), ]); expect(outcome).toBe(400); } finally { closeSync(fd); } }); it('still serves a symlink whose target is a supported regular file', async () => { writeFileSync(join(root, 'real.png'), 'real-png'); symlinkSync(join(root, 'real.png'), join(dir, 'alias.png')); const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await files(folderId, 'alias.png'); expect(res.statusCode).toBe(200); expect(res.body).toBe('real-png'); }); }); it('serves a file whose name is longer than the 100-character default of a route parameter', async () => { // The listing shows it (names go up to 255), so the raw/preview/thumbnail // routes have to be able to take it back. const longName = `${'n'.repeat(146)}.png`; expect(longName.length).toBe(150); touch(join(dir, longName), 'long-png', 1_700_000_300); const { folderId } = JSON.parse((await grant(dir)).body).data; const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/${longName}/raw`, }); expect(res.statusCode).toBe(200); expect(res.body).toBe('long-png'); }); // A Docker case is a LOCAL session for the file routes, but its `~` is the // container's home: a separate, seeded directory. `~/x.png` printed inside the // container used to open the HOST's `~/x.png`. describe('~/ in a Docker case', () => { beforeEach(() => { (harness.ctx._session as { docker?: unknown }).docker = { hostId: 'local', label: 'local', engine: 'docker', image: 'codeman-sandbox', containerName: 'codeman-case-test', hostWorkspacePath: root, containerWorkdir: root, network: 'bridge', }; }); it('refuses a ~/ folder, and still lists an absolute one', async () => { const home = await grant('~/renders'); expect(home.statusCode).toBe(400); expect(JSON.parse(home.body).error).toMatch(/docker/i); // A bare `~` is this host's home as much as `~/` is. expect((await grant('~')).statusCode).toBe(400); expect((await grant(dir)).statusCode).toBe(200); }); it('refuses a ~/ attachment, and still registers an absolute one', async () => { const post = (path: string) => harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/attachments`, payload: { path } }); const home = await post('~/renders/old.png'); expect(home.statusCode).toBe(400); expect(JSON.parse(home.body).error).toMatch(/docker/i); expect((await post(join(dir, 'old.png'))).statusCode).toBe(200); }); }); it('keeps resolving ~/ for a session that is not in a Docker case', async () => { // The contrast: only the Docker case is turned away, `~/` itself still works. const res = await grant('~/this-folder-does-not-exist-codeman-test'); expect(res.statusCode).toBe(404); }); it('404s an unknown folder id and another session’s grant', async () => { const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/fld_nope/files/old.png/raw`, }); expect(res.statusCode).toBe(404); const foreign = folderGrants.grant('another-session', dir); const res2 = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${foreign}/files/old.png/raw`, }); expect(res2.statusCode).toBe(404); folderGrants.clearSession('another-session'); }); }); describe('folder grid routes — remote (SSH) case', () => { const REMOTE_DIR = '/srv/remote/case'; const remote: SessionRemote = { hostId: 'host-1', label: 'testhost', host: '192.0.2.10', username: 'j', remotePath: REMOTE_DIR, }; let harness: RouteTestHarness; let sessionId: string; beforeEach(async () => { harness = await createRouteTestHarness(registerFileRoutes); sessionId = harness.ctx._sessionId; harness.ctx._session.workingDir = REMOTE_DIR; harness.ctx._session.remote = { ...remote }; mockedListFolder.mockResolvedValue({ dir: '/mnt/raid/styleframes', entries: [ { kind: 'file', name: 'a.png', size: 3, mtimeMs: 1000 }, { kind: 'file', name: 'b.jpg', size: 4, mtimeMs: 2000 }, { kind: 'directory', name: 'old', size: 0, mtimeMs: 0 }, ], truncated: true, }); mockedProbePaths.mockResolvedValue([ { realPath: '/mnt/raid/styleframes/a.png', kind: 'file', size: 3, mtimeMs: 1000 }, { realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 }, ]); mockedCreateReadStream.mockReturnValue({ stream: Readable.from([Buffer.from('abc')]), close: vi.fn() } as never); }); afterEach(async () => { await harness.app.close(); folderGrants.clearSession(sessionId); vi.clearAllMocks(); }); it('lists the folder on the remote host and serves its files over ssh', async () => { const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path: '~/styleframes' }, }); expect(res.statusCode).toBe(200); const data = JSON.parse(res.body).data; expect(mockedListFolder).toHaveBeenCalledWith( expect.objectContaining({ host: '192.0.2.10' }), '~/styleframes', expect.arrayContaining(['png', 'pdf']), expect.any(Number) ); expect(data.path).toBe('/mnt/raid/styleframes'); expect(data.files.map((f: { name: string }) => f.name)).toEqual(['b.jpg', 'a.png']); expect(data.folders).toEqual(['old']); expect(data.truncated).toBe(true); const raw = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/raw`, }); expect(raw.statusCode).toBe(200); expect(raw.body).toBe('abc'); expect(mockedProbePaths).toHaveBeenCalledWith(expect.anything(), ['/mnt/raid/styleframes/a.png', REMOTE_DIR]); const thumb = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/thumbnail`, }); expect(thumb.statusCode).toBe(400); }); describe('the resolved remote target is held to the attachment gate', () => { async function rawOf(name: string) { const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path: '~/styleframes' }, }); const { folderId } = JSON.parse(res.body).data; return harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`, }); } it('refuses a name whose remote symlink resolves to an unsupported extension', async () => { mockedProbePaths.mockResolvedValue([ { realPath: '/mnt/raid/secret.bin', kind: 'file', size: 6, mtimeMs: 1000 }, { realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 }, ]); const res = await rawOf('a.png'); expect(res.statusCode).toBe(400); expect(mockedCreateReadStream).not.toHaveBeenCalled(); }); it('refuses a name that resolves to something that is not a regular file', async () => { mockedProbePaths.mockResolvedValue([ { realPath: '/mnt/raid/styleframes/a.png', kind: 'other', size: 0, mtimeMs: 0 }, { realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 }, ]); const res = await rawOf('a.png'); expect(res.statusCode).toBe(400); expect(mockedCreateReadStream).not.toHaveBeenCalled(); }); }); it('answers 400 "Not a folder" for a remote file, so the client falls back to the preview', async () => { mockedListFolder.mockResolvedValue('not-a-folder'); const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path: '/srv/remote/case/Makefile' }, }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).error).toMatch(/not a folder/i); }); it('answers 404 for a missing remote folder', async () => { mockedListFolder.mockResolvedValue(null); const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path: '/nope' }, }); expect(res.statusCode).toBe(404); }); }); describe('remote folder listing script (real sh)', () => { let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'codeman-folder-sh-')); }); afterEach(() => rmSync(root, { recursive: true, force: true })); function run(dir: string, max = 100, home = root) { const stdout = execFileSync('sh', ['-c', buildRemoteFolderListCommand(dir, ['png', 'pdf'], max)], { encoding: 'utf8', env: { ...process.env, HOME: home }, }); return parseRemoteFolderListOutput(stdout); } it('lists supported files with size/mtime, subfolders, odd names; resolves ~ and symlinks', () => { const dir = join(root, 'frames'); mkdirSync(dir); touch(join(dir, "it's a | frame.PNG"), '12345', 1_700_000_000); touch(join(dir, 'doc.pdf'), 'pdf', 1_700_000_100); touch(join(dir, 'shot.Png'), 'x', 1_700_000_100); touch(join(dir, 'skip.txt'), 'x', 1_700_000_100); touch(join(dir, '.hidden.png'), 'x', 1_700_000_100); mkdirSync(join(dir, 'sub dir')); symlinkSync(dir, join(root, 'link')); const listing = run('~/link'); expect(listing?.dir).toBe(dir); expect(listing?.truncated).toBe(false); const byName = Object.fromEntries((listing?.entries ?? []).map((e) => [e.name, e])); // Any case, as the local listing: `shot.Png` too. expect(Object.keys(byName).sort()).toEqual(['doc.pdf', "it's a | frame.PNG", 'shot.Png', 'sub dir']); expect(byName["it's a | frame.PNG"]).toMatchObject({ kind: 'file', size: 5, mtimeMs: 1_700_000_000_000 }); expect(byName['sub dir']).toMatchObject({ kind: 'directory' }); }); it('is not thrown off by a login shell whose `cd` prints the directory (Hufflepuff: alias cd=folderprompt)', () => { // sshd hands the command to the remote user's LOGIN shell. There `cd` is an // alias that echoes the new directory, so `r=$(cd -P "$d" && pwd -P)` captured // the path twice and the listing failed ("zsh: no matches found: /x\n/x/*"). // The script has to run under an explicit `sh`, which reads no rc file. const dir = join(root, 'frames'); mkdirSync(dir); touch(join(dir, 'a.png'), 'x', 1_700_000_000); const stdout = execFileSync( 'bash', ['-c', `cd() { builtin cd "$@" && pwd; }; ${buildRemoteFolderListCommand(dir, ['png'], 100)}`], { encoding: 'utf8', env: { ...process.env, HOME: root } } ); const listing = parseRemoteFolderListOutput(stdout); expect(listing).not.toBeNull(); expect(listing).not.toBe('not-a-folder'); expect((listing as { dir: string }).dir).toBe(dir); expect((listing as { entries: { name: string }[] }).entries.map((e) => e.name)).toEqual(['a.png']); }); it('caps entries; tells a missing folder (null) from a file', () => { for (let i = 0; i < 5; i++) touch(join(root, `f${i}.png`), 'x', 1_700_000_000); const capped = run(root, 3); expect(capped?.entries).toHaveLength(3); expect(capped?.truncated).toBe(true); expect(run(join(root, 'missing'))).toBeNull(); expect(run(join(root, 'f0.png'))).toBe('not-a-folder'); }); }); describe('validFolderFileName', () => { it('accepts bare supported names only', () => { expect(validFolderFileName('a b.png')).toBe('a b.png'); for (const bad of ['', '.', '..', '.hidden.png', 'a/b.png', 'a\\b.png', 'x.xyz', 'a\0.png', 42]) { expect(validFolderFileName(bad)).toBeNull(); } }); }); describe('server wiring', () => { // The route harness builds its own Fastify instance, so a test through it // cannot notice the real server losing the option. Same style as the other // wiring guards: read the source, not a comment. it('gives the real server the same route-parameter limit the folder routes are tested with', () => { const source = readFileSync(join(__dirname, '../../src/web/server.ts'), 'utf-8'); const instances = source.match(/Fastify\(\{[^}]*\}[^)]*\)/g) ?? []; expect(instances.length).toBeGreaterThanOrEqual(2); for (const instance of instances) { expect(instance).toContain('maxParamLength: MAX_ROUTE_PARAM_LENGTH'); } }); });