Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from 'node:fs';3import { createHash } from 'node:crypto';4import path from 'node:path';5import type { FastifyInstance } from 'fastify';6import type { FileContent, FileEntry, SearchHit } from '@cr/shared';7import { buildApp, call, errCode, makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';8import { createFsPlugin, MAX_LIST_ENTRIES } from './fs.js';9import { contentDisposition, registerTransfer } from './fsx-transfer.js';10import { detectRipgrep, nameScore } from './fsx-search.js';11import type { RuntimeConfig } from './config.js';1213let tmp: string;14let root: string;15let outside: string;16let config: RuntimeConfig;17let app: FastifyInstance; // ripgrep auto-detected18let appNoRg: FastifyInstance; // pure-Node content search19const sha = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex');20const leftovers = (dir: string): string[] => readdirSync(dir).filter((n) => n.includes('.cr-tmp-') || n.includes('.cr-upload-'));2122beforeAll(async () => {23 tmp = makeTmp('cr-fsapi-');24 root = path.join(tmp, 'ws', 'proj');25 outside = path.join(tmp, 'outside');26 mkdirSync(path.join(root, 'src', 'lib'), { recursive: true });27 mkdirSync(path.join(root, 'Zeta'), { recursive: true });28 mkdirSync(path.join(root, 'node_modules', 'pkg'), { recursive: true });29 mkdirSync(path.join(root, '.git'), { recursive: true });30 mkdirSync(path.join(outside, 'dir'), { recursive: true });31 writeFileSync(path.join(root, 'b.txt'), 'hello world\n');32 writeFileSync(path.join(root, 'A.md'), '# Title\nNeedleInHaystack here\n');33 writeFileSync(path.join(root, 'src', 'index.ts'), 'export const x = 1;\n// needleinhaystack again\n');34 writeFileSync(path.join(root, 'src', 'lib', 'util-helper.ts'), 'export {};\n');35 writeFileSync(path.join(root, 'node_modules', 'pkg', 'index.js'), 'needleinhaystack\n');36 writeFileSync(path.join(root, '.git', 'config'), 'needleinhaystack\n');37 writeFileSync(path.join(root, 'bin.dat'), Buffer.from([1, 2, 0, 3, 4]));38 writeFileSync(path.join(outside, 'secret.txt'), 'TOP-SECRET');39 writeFileSync(path.join(outside, 'dir', 'keep.txt'), 'keep');40 symlinkSync(path.join(outside, 'secret.txt'), path.join(root, 'leak'));41 symlinkSync(path.join(outside, 'dir'), path.join(root, 'leakdir'));42 config = makeConfig([path.join(tmp, 'ws')]);43 app = await buildApp(config, createFsPlugin());44 appNoRg = await buildApp(config, createFsPlugin({ rgPath: null }));45});46afterAll(async () => {47 await app.close();48 await appNoRg.close();49 rmTmp(tmp);50});5152describe('/fs/list + /fs/stat', () => {53 it('lists dirs first, then by name, with lstat types and relative paths', async () => {54 const r = await call<FileEntry[]>(app, 'POST', '/fs/list', { root, rel: '' });55 expect(r.status).toBe(200);56 expect(r.body.map((e) => e.name)).toEqual(['.git', 'node_modules', 'src', 'Zeta', 'A.md', 'b.txt', 'bin.dat', 'leak', 'leakdir']);57 const byName = Object.fromEntries(r.body.map((e) => [e.name, e]));58 expect(byName['leak']?.type).toBe('symlink');59 expect(byName['leakdir']?.type).toBe('symlink'); // not reported as dir, never followed60 expect(byName['src']).toMatchObject({ type: 'dir', path: 'src' });61 expect(byName['b.txt']).toMatchObject({ type: 'file', size: 12, path: 'b.txt' });62 expect(r.headers['x-truncated']).toBeUndefined();63 const sub = await call<FileEntry[]>(app, 'POST', '/fs/list', { root, rel: 'src' });64 expect(sub.body.map((e) => e.path)).toEqual(['src/lib', 'src/index.ts']);65 expect(JSON.stringify(r.body)).not.toContain(tmp);66 });67 it('refuses to list through a symlink leaving the root, files, traversal and bad roots', async () => {68 expect(errCode(await call(app, 'POST', '/fs/list', { root, rel: 'leakdir' }))).toBe('path_outside_root');69 expect(errCode(await call(app, 'POST', '/fs/list', { root, rel: '../..' }))).toBe('bad_path');70 expect(errCode(await call(app, 'POST', '/fs/list', { root, rel: 'b.txt' }))).toBe('not_a_directory');71 expect((await call(app, 'POST', '/fs/list', { root, rel: 'nope' })).status).toBe(404);72 expect(errCode(await call(app, 'POST', '/fs/list', { root: outside, rel: '' }))).toBe('root_not_allowed');73 expect((await call(app, 'POST', '/fs/list', { rel: '' })).status).toBe(400);74 });75 it('caps the listing and flags truncation', async () => {76 const big = path.join(root, 'big');77 mkdirSync(big);78 for (let i = 0; i < MAX_LIST_ENTRIES + 25; i++) writeFileSync(path.join(big, `f${i}`), '');79 const r = await call<FileEntry[]>(app, 'POST', '/fs/list', { root, rel: 'big' });80 expect(r.body.length).toBe(MAX_LIST_ENTRIES);81 expect(r.headers['x-truncated']).toBe('1');82 await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'big', confirm: true });83 });84 it('stats without following the final symlink', async () => {85 const f = await call<FileEntry>(app, 'POST', '/fs/stat', { root, rel: 'src/index.ts' });86 expect(f.body).toMatchObject({ name: 'index.ts', path: 'src/index.ts', type: 'file' });87 expect((await call<FileEntry>(app, 'POST', '/fs/stat', { root, rel: 'leak' })).body.type).toBe('symlink');88 expect((await call<FileEntry>(app, 'POST', '/fs/stat', { root, rel: '' })).body).toMatchObject({ type: 'dir', path: '' });89 expect((await call(app, 'POST', '/fs/stat', { root, rel: 'missing' })).status).toBe(404);90 });91});9293describe('/fs/read', () => {94 it('returns content, sha256, size and mtime', async () => {95 const r = await call<FileContent>(app, 'POST', '/fs/read', { root, rel: 'b.txt' });96 expect(r.body).toMatchObject({ path: 'b.txt', content: 'hello world\n', size: 12, encoding: 'utf8', sha256: sha('hello world\n') });97 expect(r.body.mtimeMs).toBe(Math.round(statSync(path.join(root, 'b.txt')).mtimeMs));98 });99 it('rejects binary (415), too large (413), directories, missing and escaping symlinks', async () => {100 const bin = await call(app, 'POST', '/fs/read', { root, rel: 'bin.dat' });101 expect([bin.status, errCode(bin)]).toEqual([415, 'binary']);102 writeFileSync(path.join(root, 'huge.txt'), Buffer.alloc(2 * 1024 * 1024 + 1, 0x61));103 const big = await call(app, 'POST', '/fs/read', { root, rel: 'huge.txt' });104 expect([big.status, errCode(big)]).toEqual([413, 'too_large']);105 writeFileSync(path.join(root, 'huge.txt'), Buffer.alloc(2 * 1024 * 1024, 0x61));106 expect((await call(app, 'POST', '/fs/read', { root, rel: 'huge.txt' })).status).toBe(200);107 await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'huge.txt', confirm: true });108 expect(errCode(await call(app, 'POST', '/fs/read', { root, rel: 'src' }))).toBe('is_directory');109 expect((await call(app, 'POST', '/fs/read', { root, rel: 'nope.txt' })).status).toBe(404);110 const leak = await call(app, 'POST', '/fs/read', { root, rel: 'leak' });111 expect([leak.status, errCode(leak)]).toEqual([403, 'path_outside_root']);112 expect(JSON.stringify(leak.body)).not.toContain('TOP-SECRET');113 });114});115116describe('/fs/write', () => {117 it('creates, refuses to re-create, and overwrites atomically preserving the mode', async () => {118 const c = await call<FileContent>(app, 'POST', '/fs/write', { root, rel: 'new.sh', content: '#!/bin/sh\n', create: true });119 expect(c.status).toBe(200);120 expect(c.body).toMatchObject({ path: 'new.sh', size: 10, sha256: sha('#!/bin/sh\n') });121 expect('content' in c.body).toBe(false);122 const again = await call(app, 'POST', '/fs/write', { root, rel: 'new.sh', content: 'x', create: true });123 expect([again.status, errCode(again)]).toEqual([409, 'exists']);124 chmodSync(path.join(root, 'new.sh'), 0o750);125 const inoBefore = statSync(path.join(root, 'new.sh')).ino;126 const w = await call<FileContent>(app, 'POST', '/fs/write', { root, rel: 'new.sh', content: 'echo hi\n', baseSha256: c.body.sha256 });127 expect(w.status).toBe(200);128 expect(readFileSync(path.join(root, 'new.sh'), 'utf8')).toBe('echo hi\n');129 expect(statSync(path.join(root, 'new.sh')).mode & 0o777).toBe(0o750);130 expect(statSync(path.join(root, 'new.sh')).ino).not.toBe(inoBefore); // replaced by rename, not rewritten in place131 expect(leftovers(root)).toEqual([]);132 });133 it('detects conflicts and reports the current sha + mtime', async () => {134 const base = sha('echo hi\n');135 writeFileSync(path.join(root, 'new.sh'), 'changed elsewhere\n');136 const r = await call<{ error: { code: string; details: { sha256: string; mtimeMs: number } } }>(app, 'POST', '/fs/write', {137 root,138 rel: 'new.sh',139 content: 'mine\n',140 baseSha256: base,141 });142 expect(r.status).toBe(409);143 expect(r.body.error.code).toBe('conflict');144 expect(r.body.error.details.sha256).toBe(sha('changed elsewhere\n'));145 expect(r.body.error.details.mtimeMs).toBe(Math.round(statSync(path.join(root, 'new.sh')).mtimeMs));146 expect(readFileSync(path.join(root, 'new.sh'), 'utf8')).toBe('changed elsewhere\n');147 const gone = await call<{ error: { code: string } }>(app, 'POST', '/fs/write', { root, rel: 'never-existed.txt', content: 'x', baseSha256: base });148 expect([gone.status, gone.body.error.code]).toEqual([409, 'conflict']);149 expect(existsSync(path.join(root, 'never-existed.txt'))).toBe(false);150 });151 it('never writes through symlinks leaving the root, into directories or missing parents', async () => {152 const leak = await call(app, 'POST', '/fs/write', { root, rel: 'leak', content: 'pwned' });153 expect([leak.status, errCode(leak)]).toEqual([403, 'path_outside_root']);154 expect(readFileSync(path.join(outside, 'secret.txt'), 'utf8')).toBe('TOP-SECRET');155 expect(errCode(await call(app, 'POST', '/fs/write', { root, rel: 'leakdir/new.txt', content: 'pwned' }))).toBe('path_outside_root');156 expect(existsSync(path.join(outside, 'dir', 'new.txt'))).toBe(false);157 symlinkSync(path.join(outside, 'created.txt'), path.join(root, 'dangling'));158 expect(errCode(await call(app, 'POST', '/fs/write', { root, rel: 'dangling', content: 'pwned', create: true }))).toBe('path_outside_root');159 expect(existsSync(path.join(outside, 'created.txt'))).toBe(false);160 expect(errCode(await call(app, 'POST', '/fs/write', { root, rel: 'src', content: 'x' }))).toBe('is_directory');161 expect(errCode(await call(app, 'POST', '/fs/write', { root, rel: 'no/such/dir/f.txt', content: 'x' }))).toBe('parent_not_found');162 expect(errCode(await call(app, 'POST', '/fs/write', { root, rel: '../x.txt', content: 'x' }))).toBe('bad_path');163 });164 it('writes through an in-root symlink to its target and enforces the 5 MiB cap', async () => {165 symlinkSync(path.join(root, 'b.txt'), path.join(root, 'b-link'));166 const r = await call<FileContent>(app, 'POST', '/fs/write', { root, rel: 'b-link', content: 'via link\n' });167 expect(r.body.path).toBe('b.txt');168 expect(lstatSync(path.join(root, 'b-link')).isSymbolicLink()).toBe(true);169 expect(readFileSync(path.join(root, 'b.txt'), 'utf8')).toBe('via link\n');170 writeFileSync(path.join(root, 'b.txt'), 'hello world\n');171 const ok = await call(app, 'POST', '/fs/write', { root, rel: 'five.txt', content: 'a'.repeat(5 * 1024 * 1024) });172 expect(ok.status).toBe(200);173 const big = await call(app, 'POST', '/fs/write', { root, rel: 'five.txt', content: 'a'.repeat(5 * 1024 * 1024 + 1) });174 expect([big.status, errCode(big)]).toEqual([413, 'too_large']);175 expect(statSync(path.join(root, 'five.txt')).size).toBe(5 * 1024 * 1024);176 await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'five.txt', confirm: true });177 });178});179180describe('/fs/op', () => {181 it('mkdir is recursive and idempotent', async () => {182 expect((await call(app, 'POST', '/fs/op', { root, op: 'mkdir', path: 'd1/d2/d3' })).status).toBe(200);183 expect(statSync(path.join(root, 'd1', 'd2', 'd3')).isDirectory()).toBe(true);184 expect((await call(app, 'POST', '/fs/op', { root, op: 'mkdir', path: 'd1/d2' })).status).toBe(200);185 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'mkdir', path: 'b.txt' }))).toBe('exists');186 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'mkdir', path: 'leakdir/x' }))).toBe('path_outside_root');187 expect(existsSync(path.join(outside, 'dir', 'x'))).toBe(false);188 });189 it('rename validates both ends and refuses to overwrite', async () => {190 writeFileSync(path.join(root, 'r1.txt'), 'one');191 writeFileSync(path.join(root, 'r2.txt'), 'two');192 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'r1.txt', to: 'r2.txt' }))).toBe('exists');193 expect(readFileSync(path.join(root, 'r2.txt'), 'utf8')).toBe('two');194 const ok = await call<{ path: string }>(app, 'POST', '/fs/op', { root, op: 'rename', from: 'r1.txt', to: 'd1/moved.txt' });195 expect(ok.body.path).toBe('d1/moved.txt');196 expect(readFileSync(path.join(root, 'd1', 'moved.txt'), 'utf8')).toBe('one');197 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'r2.txt', to: '../r2.txt' }))).toBe('bad_path');198 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'r2.txt', to: 'leakdir/r2.txt' }))).toBe('path_outside_root');199 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'leakdir/keep.txt', to: 'stolen.txt' }))).toBe('path_outside_root');200 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'r2.txt', to: 'leak' }))).toBe('path_outside_root');201 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'd1', to: 'd1/d2/inner' }))).toBe('bad_request');202 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: '', to: 'x' }))).toBe('root_refused');203 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'ghost', to: 'x' }))).toBe('not_found');204 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'rename', from: 'r2.txt', to: 'no/dir/x' }))).toBe('parent_not_found');205 expect(existsSync(path.join(outside, 'dir', 'keep.txt'))).toBe(true);206 });207 it('delete needs confirm, refuses the root, and never follows symlinks', async () => {208 expect((await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'r2.txt' })).status).toBe(400);209 expect((await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'r2.txt', confirm: false })).status).toBe(400);210 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'delete', path: '', confirm: true }))).toBe('root_refused');211 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'delete', path: '.', confirm: true }))).toBe('root_refused');212 expect(errCode(await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'leakdir/keep.txt', confirm: true }))).toBe('path_outside_root');213 // a directory containing a symlink to an outside directory: the link dies, the target survives214 mkdirSync(path.join(root, 'victim'));215 symlinkSync(path.join(outside, 'dir'), path.join(root, 'victim', 'out'));216 writeFileSync(path.join(root, 'victim', 'f'), 'x');217 expect((await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'victim', confirm: true })).status).toBe(200);218 expect(existsSync(path.join(root, 'victim'))).toBe(false);219 expect(readFileSync(path.join(outside, 'dir', 'keep.txt'), 'utf8')).toBe('keep');220 // deleting a symlink removes the link only221 expect((await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'dangling', confirm: true })).status).toBe(200);222 symlinkSync(path.join(outside, 'dir'), path.join(root, 'tmp-link'));223 expect((await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'tmp-link', confirm: true })).status).toBe(200);224 expect(existsSync(path.join(outside, 'dir', 'keep.txt'))).toBe(true);225 expect((await call(app, 'POST', '/fs/op', { root, op: 'delete', path: 'ghost', confirm: true })).status).toBe(404);226 expect((await call(app, 'POST', '/fs/op', { root, op: 'chmod', path: 'x' })).status).toBe(400);227 });228});229230describe('/fs/upload + /fs/download', () => {231 const q = (rel: string, extra = ''): string => `?root=${encodeURIComponent(root)}&rel=${encodeURIComponent(rel)}${extra}`;232 it('streams an upload (> default 1 MiB body limit) atomically and refuses overwrite by default', async () => {233 const payload = Buffer.alloc(3 * 1024 * 1024, 7);234 const r = await app.inject({ method: 'POST', url: '/fs/upload' + q('up/data.bin'), payload, headers: { 'content-type': 'application/octet-stream' } });235 expect(r.statusCode).toBe(200);236 expect(r.json()).toMatchObject({ path: 'up/data.bin', size: payload.length });237 expect(sha(readFileSync(path.join(root, 'up', 'data.bin')))).toBe(sha(payload));238 const dup = await app.inject({ method: 'POST', url: '/fs/upload' + q('up/data.bin'), payload: 'x', headers: { 'content-type': 'application/octet-stream' } });239 expect(dup.statusCode).toBe(409);240 const over = await app.inject({ method: 'POST', url: '/fs/upload' + q('up/data.bin', '&overwrite=1'), payload: '{"json":true}', headers: { 'content-type': 'application/json' } });241 expect(over.statusCode).toBe(200);242 expect(readFileSync(path.join(root, 'up', 'data.bin'), 'utf8')).toBe('{"json":true}'); // raw passthrough, not parsed243 expect(leftovers(path.join(root, 'up'))).toEqual([]);244 });245 it('refuses unsafe destinations', async () => {246 const h = { 'content-type': 'application/octet-stream' };247 expect((await app.inject({ method: 'POST', url: '/fs/upload' + q('leak', '&overwrite=1'), payload: 'x', headers: h })).statusCode).toBe(403);248 expect((await app.inject({ method: 'POST', url: '/fs/upload' + q('leakdir/u.bin'), payload: 'x', headers: h })).statusCode).toBe(403);249 expect((await app.inject({ method: 'POST', url: '/fs/upload' + q('../u.bin'), payload: 'x', headers: h })).statusCode).toBe(400);250 expect((await app.inject({ method: 'POST', url: '/fs/upload' + q('src', '&overwrite=1'), payload: 'x', headers: h })).statusCode).toBe(400);251 expect((await app.inject({ method: 'POST', url: '/fs/upload?rel=x', payload: 'x', headers: h })).statusCode).toBe(400);252 expect(readFileSync(path.join(outside, 'secret.txt'), 'utf8')).toBe('TOP-SECRET');253 expect(existsSync(path.join(outside, 'dir', 'u.bin'))).toBe(false);254 });255 it('enforces the cap while streaming and leaves no partial file', async () => {256 const small = await buildApp(config, async (a, o) => registerTransfer(a, o.config, 1024));257 try {258 const h = { 'content-type': 'application/octet-stream' };259 const declared = await small.inject({ method: 'POST', url: '/fs/upload' + q('cap/a.bin'), payload: Buffer.alloc(4096), headers: h });260 expect(declared.statusCode).toBe(413);261 // chunked (no Content-Length): only the streaming counter can catch it262 const { Readable } = await import('node:stream');263 const stream = Readable.from([Buffer.alloc(600), Buffer.alloc(600), Buffer.alloc(600)]);264 const chunked = await small.inject({ method: 'POST', url: '/fs/upload' + q('cap/b.bin'), payload: stream, headers: h });265 expect(chunked.statusCode).toBe(413);266 expect(chunked.json().error.code).toBe('too_large');267 expect(existsSync(path.join(root, 'cap', 'b.bin'))).toBe(false);268 expect(existsSync(path.join(root, 'cap')) ? readdirSync(path.join(root, 'cap')) : []).toEqual([]);269 const fits = await small.inject({ method: 'POST', url: '/fs/upload' + q('cap/c.bin'), payload: Buffer.alloc(1024), headers: h });270 expect(fits.statusCode).toBe(200);271 } finally {272 await small.close();273 }274 });275 it('downloads with Content-Length and a safe Content-Disposition; dirs are refused', async () => {276 const r = await app.inject({ method: 'GET', url: '/fs/download' + q('bin.dat') });277 expect(r.statusCode).toBe(200);278 expect(r.headers['content-length']).toBe('5');279 expect(r.rawPayload.equals(Buffer.from([1, 2, 0, 3, 4]))).toBe(true);280 expect(r.headers['content-disposition']).toBe(`attachment; filename="bin.dat"; filename*=UTF-8''bin.dat`);281 writeFileSync(path.join(root, 'empty'), '');282 const e = await app.inject({ method: 'GET', url: '/fs/download' + q('empty') });283 expect([e.statusCode, e.rawPayload.length]).toEqual([200, 0]);284 expect((await app.inject({ method: 'GET', url: '/fs/download' + q('src') })).statusCode).toBe(400);285 expect((await app.inject({ method: 'GET', url: '/fs/download' + q('leak') })).statusCode).toBe(403);286 expect((await app.inject({ method: 'GET', url: '/fs/download' + q('nope') })).statusCode).toBe(404);287 });288 it('builds header-injection-proof dispositions', () => {289 const d = contentDisposition('a"b\r\nX-Evil: 1;é\\.txt');290 expect(d).not.toMatch(/[\r\n]/);291 expect(d.startsWith('attachment; filename="a_b__X-Evil: 1__')).toBe(true);292 expect(d).toContain("filename*=UTF-8''a%22b%0D%0AX-Evil%3A%201%3B%C3%A9%5C.txt");293 });294});295296describe('/fs/search', () => {297 it('name mode: case-insensitive, fuzzy, skips heavy dirs, relative paths', async () => {298 const r = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: 'INDEX', mode: 'name', limit: 50 });299 expect(r.status).toBe(200);300 expect(r.body.map((h) => h.path)).toEqual(['src/index.ts']); // node_modules/pkg/index.js skipped301 expect(r.body[0]).toMatchObject({ line: null, preview: null });302 const fuzzy = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: 'utlhlp', mode: 'name', limit: 50 });303 expect(fuzzy.body.map((h) => h.path)).toContain('src/lib/util-helper.ts');304 const limited = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: 't', mode: 'name', limit: 2 });305 expect(limited.body.length).toBe(2);306 expect((await call(app, 'POST', '/fs/search', { root, query: 'x', mode: 'name', limit: 201 })).status).toBe(400);307 expect((await call(app, 'POST', '/fs/search', { root, query: '', mode: 'name' })).status).toBe(400);308 expect(errCode(await call(app, 'POST', '/fs/search', { root, rel: 'leakdir', query: 'keep', mode: 'name' }))).toBe('path_outside_root');309 const all = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: 'keep', mode: 'name' });310 expect(all.body).toEqual([]); // the symlinked outside dir is not walked311 });312 it('ranks basename matches first', () => {313 expect(nameScore('index', 'src/index.ts')).toBeGreaterThan(nameScore('index', 'index-old/readme.md'));314 expect(nameScore('zzz', 'src/index.ts')).toBe(0);315 });316 it('content mode (pure Node fallback): fixed string, case-insensitive, bounded', async () => {317 const r = await call<SearchHit[]>(appNoRg, 'POST', '/fs/search', { root, query: 'needleINhaystack', mode: 'content', limit: 50 });318 expect(r.status).toBe(200);319 expect(r.body.map((h) => `${h.path}:${h.line}`).sort()).toEqual(['A.md:2', 'src/index.ts:2']);320 expect(r.body.find((h) => h.path === 'A.md')?.preview).toBe('NeedleInHaystack here');321 const regexy = await call<SearchHit[]>(appNoRg, 'POST', '/fs/search', { root, query: '.*', mode: 'content' });322 expect(regexy.body).toEqual([]); // literal, not a regex323 const secret = await call<SearchHit[]>(appNoRg, 'POST', '/fs/search', { root, query: 'TOP-SECRET', mode: 'content' });324 expect(secret.body).toEqual([]);325 });326 it('content mode (ripgrep when installed): same results, options cannot be injected', async () => {327 const rg = await detectRipgrep();328 if (!rg) return; // covered by the fallback test above329 const r = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: 'needleINhaystack', mode: 'content', limit: 50 });330 expect(r.status).toBe(200);331 expect(r.body.map((h) => `${h.path}:${h.line}`).sort()).toEqual(['A.md:2', 'src/index.ts:2']);332 const sub = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, rel: 'src', query: 'needleinhaystack', mode: 'content' });333 expect(sub.body.map((h) => h.path)).toEqual(['src/index.ts']);334 const inj = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: '--files', mode: 'content' });335 expect(inj.body).toEqual([]);336 const secret = await call<SearchHit[]>(app, 'POST', '/fs/search', { root, query: 'TOP-SECRET', mode: 'content' });337 expect(secret.body).toEqual([]);338 });339});340