SPB Git

Repository Public

fs.test.ts 23.7 KB 340 lines typescript

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
Files main
Menu

Theme