import assert from 'node:assert/strict'; import test from 'node:test'; import {mkdtemp, mkdir, writeFile, appendFile, rename, symlink, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import path from 'node:path'; import {tsImport} from 'tsx/esm/api'; const {BotFileService} = await tsImport('../bot/forward/files.ts', import.meta.url); const deadline = () => Date.now() + 20_000; const code = expected => error => error.code === expected && !error.message.includes(tmpdir()); async function fixture(t) { const root = await mkdtemp(path.join(tmpdir(), 'bot-files-')); t.after(() => rm(root, {recursive: true, force: true})); const service = new BotFileService(1, {root}); t.after(() => service.dispose()); return {root, service}; } test('sorted trees preserve empty directories and binary chunks use a fixed listed size', async t => { const {root, service} = await fixture(t); await mkdir(path.join(root, 'empty')); const binary = Buffer.alloc(300_000); for (let i = 0; i < binary.length; i++) binary[i] = i % 256; await writeFile(path.join(root, 'z.bin'), binary); await writeFile(path.join(root, 'a'), 'a'); const tree = await service.list(deadline()); assert.equal(tree.partial, false); assert.deepEqual(tree.children.map(x => x.name), ['a', 'empty', 'z.bin']); assert.deepEqual(tree.children[1], {type: 'directory', name: 'empty', children: []}); assert.equal(JSON.stringify(tree).includes(root), false); const id = tree.children[2].id; const first = await service.read({id}, deadline()); const second = await service.read({id, offset: first.next_offset}, deadline()); assert.equal(first.next_offset, 262144); assert.equal(first.eof, false); assert.equal(second.eof, true); assert.equal(second.total_bytes, binary.length); assert.deepEqual(Buffer.concat([Buffer.from(first.data, 'base64'), Buffer.from(second.data, 'base64')]), binary); const end = await service.read({id, offset: binary.length}, deadline()); assert.equal(end.data, ''); assert.equal(end.eof, true); await writeFile(path.join(root, '..legal'), 'ok'); const legal = (await service.list(deadline())).children.find(entry => entry.name === '..legal'); assert.equal(Buffer.from((await service.read({id: legal.id}, deadline())).data, 'base64').toString(), 'ok'); }); test('IDs are scoped, expiring, opaque and request ranges are strict', async t => { const {root} = await fixture(t); let now = Date.now(); const service = new BotFileService(1, {root, now: () => now}); const other = new BotFileService(1, {root}); t.after(() => { service.dispose(); other.dispose(); }); await writeFile(path.join(root, 'file'), '123'); const tree = await service.list(now + 1000); const id = tree.children[0].id; assert.equal(tree.expires_at, now + 300_000); await assert.rejects(other.read({id}, deadline()), code('INVALID_REQUEST')); for (const payload of [{id: '../file'}, {id, offset: -1}, {id, offset: 4}, {id, length: 0}, {id, length: 262145}, {id, path: 'file'}, {id, offset: 0.5}]) { await assert.rejects(service.read(payload, now + 1000), code('INVALID_REQUEST')); } now = tree.expires_at; await assert.rejects(service.read({id}, now + 1000), code('INVALID_REQUEST')); const limited = await service.list(now + 1000, now + 500); assert.equal(limited.expires_at, now + 500); now += 500; await assert.rejects(service.read({id: limited.children[0].id}, now + 1000), code('INVALID_REQUEST')); }); test('absent roots and empty files work; symlinks are excluded and unsafe roots rejected', async t => { const {root, service} = await fixture(t); const absent = new BotFileService(1, {root: path.join(root, 'absent')}); assert.deepEqual((await absent.list(deadline())).children, []); await writeFile(path.join(root, 'empty'), ''); await symlink('empty', path.join(root, 'link')); await symlink(root, path.join(root, 'loop')); const tree = await service.list(deadline()); assert.deepEqual(tree.children.map(x => x.name), ['empty']); assert.equal((await service.read({id: tree.children[0].id}, deadline())).data, ''); const unsafe = new BotFileService(1, {root: path.join(root, 'loop')}); await assert.rejects(unsafe.list(deadline()), code('RESYNC_REQUIRED')); }); test('ordinary mutation, replacement, truncation and ancestor symlinks require resync', async t => { const {root, service} = await fixture(t); await mkdir(path.join(root, 'dir')); const file = path.join(root, 'dir', 'file'); await writeFile(file, 'before'); const listedId = async () => (await service.list(deadline())).children[0].children[0].id; let id = await listedId(); await writeFile(file, 'after'); await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); id = await listedId(); await rename(file, `${file}.old`); await writeFile(file, 'after'); await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); id = await listedId(); await rename(path.join(root, 'dir'), path.join(root, 'saved')); await symlink('saved', path.join(root, 'dir')); await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); }); test('only direct log/*.log files may append, with fixed EOF; truncation and replacement reject', async t => { const {root, service} = await fixture(t); await mkdir(path.join(root, 'log')); const file = path.join(root, 'log', 'bot.log'); await writeFile(file, 'first'); const id = (await service.list(deadline())).children[0].children[0].id; await appendFile(file, 'second'); const data = await service.read({id}, deadline()); assert.equal(Buffer.from(data.data, 'base64').toString(), 'first'); assert.equal(data.total_bytes, 5); assert.equal(data.eof, true); await writeFile(file, 'x'); await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); await rename(file, `${file}.old`); await writeFile(file, 'replacement'); await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); }); test('deadline, disposal and concurrency limits are checked during asynchronous operations', async t => { const {root, service} = await fixture(t); await writeFile(path.join(root, 'file'), 'data'); await assert.rejects(service.list(Date.now() - 1), code('TIMEOUT')); const scan = service.list(deadline()); await assert.rejects(service.list(deadline()), code('RATE_LIMITED')); const id = (await scan).children[0].id; const reads = Array.from({length: 4}, () => service.read({id}, deadline())); await assert.rejects(service.read({id}, deadline()), code('RATE_LIMITED')); await Promise.all(reads); const pending = service.read({id}, deadline()); service.dispose(); await assert.rejects(pending, code('CANCELLED')); await assert.rejects(service.list(deadline()), code('CANCELLED')); await assert.rejects(service.read({id}, deadline()), code('CANCELLED')); const cancelled = new BotFileService(1, {root}); const listing = cancelled.list(deadline()); cancelled.dispose(); await assert.rejects(listing, code('CANCELLED')); }); test('listings enforce node, depth and byte limits', async t => { const {root, service} = await fixture(t); await Promise.all(Array.from({length: 2010}, (_, i) => writeFile(path.join(root, `file-${i}`), ''))); const tree = await service.list(deadline()); assert.equal(tree.partial, true); assert.ok(tree.children.length <= 2000); assert.ok(Buffer.byteLength(JSON.stringify(tree)) <= 512 * 1024); const firstId = tree.children[0].id; for (let i = 0; i < 3; i++) await service.list(deadline()); const exhausted = await service.list(deadline()); assert.equal(exhausted.partial, true); assert.deepEqual(exhausted.children, []); assert.equal((await service.read({id: firstId}, deadline())).eof, true); await rm(root, {recursive: true}); await mkdir(root); let directory = root; for (let i = 0; i < 34; i++) { directory = path.join(directory, 'd'); await mkdir(directory); } const deep = await service.list(deadline()); let nodes = deep.children; let depth = 0; while (nodes.length) { depth++; nodes = nodes[0].children; } assert.equal(depth, 32); assert.equal(deep.partial, true); }); test('byte bound truncates listings before the entry bound', async t => { const {root, service} = await fixture(t); await Promise.all(Array.from({length: 1800}, (_, i) => writeFile(path.join(root, `${String(i).padStart(4, '0')}-${'x'.repeat(240)}`), ''))); const tree = await service.list(deadline()); assert.equal(tree.partial, true); assert.ok(tree.children.length > 0 && tree.children.length < 1800); assert.ok(Buffer.byteLength(JSON.stringify(tree)) <= 512 * 1024); }); test('deadline expiry during asynchronous work rejects and releases the scan slot', async t => { const {root} = await fixture(t); let now = Date.now(); const service = new BotFileService(1, {root, now: () => now}); t.after(() => service.dispose()); const end = now + 100; const scan = service.list(end); now = end; await assert.rejects(scan, code('TIMEOUT')); assert.deepEqual((await service.list(now + 100)).children, []); await mkdir(path.join(root, 'log')); await writeFile(path.join(root, 'log', 'ordinary.txt'), 'first'); const id = (await service.list(now + 100)).children[0].children[0].id; await appendFile(path.join(root, 'log', 'ordinary.txt'), 'more'); await assert.rejects(service.read({id}, now + 100), code('RESYNC_REQUIRED')); });