chore: initial commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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'));
|
||||
});
|
||||
Reference in New Issue
Block a user