@nicknisi/pi-relay 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -6
- package/core.ts +314 -0
- package/dist/core.d.ts +32 -0
- package/dist/core.js +223 -0
- package/dist/core.test.d.ts +1 -0
- package/dist/core.test.js +358 -0
- package/dist/filesystem.d.ts +42 -0
- package/dist/filesystem.js +614 -0
- package/dist/format.d.ts +3 -1
- package/dist/format.js +13 -0
- package/dist/index.js +175 -46
- package/dist/mailbox.d.ts +40 -3
- package/dist/mailbox.js +655 -95
- package/dist/policy.d.ts +5 -3
- package/dist/policy.js +9 -6
- package/dist/registry.d.ts +16 -0
- package/dist/registry.js +301 -45
- package/dist/relay.test.js +349 -4
- package/filesystem.ts +635 -0
- package/format.ts +14 -1
- package/index.ts +185 -40
- package/mailbox.ts +613 -91
- package/package.json +19 -1
- package/policy.ts +9 -6
- package/registry.ts +275 -42
package/dist/relay.test.js
CHANGED
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
* specification: each case pins a guarantee from the design.
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
|
+
import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
6
7
|
import * as os from 'node:os';
|
|
7
8
|
import * as path from 'node:path';
|
|
8
9
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
-
import { BOUNDARY_PREAMBLE, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown } from './format.js';
|
|
10
|
-
import { awaitReceipt, clearAsk, deposit, drain, pendingAsks, readOutgoingAsk, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
10
|
+
import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
|
|
11
|
+
import { appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
11
12
|
import { BACKLOG_CAP, OutboundPolicy, inboundAccepts } from './policy.js';
|
|
12
|
-
import { deriveAddr, listRecords, presenceOf, sweep, writeRecord } from './registry.js';
|
|
13
|
+
import { claimAlias, clearAlias, deriveAddr, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
13
15
|
const dirs = [];
|
|
14
16
|
function tmpRoot() {
|
|
15
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-relay-test-'));
|
|
17
|
+
const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'pi-relay-test-')));
|
|
16
18
|
dirs.push(dir);
|
|
17
19
|
return dir;
|
|
18
20
|
}
|
|
@@ -20,6 +22,33 @@ afterEach(() => {
|
|
|
20
22
|
while (dirs.length > 0)
|
|
21
23
|
fs.rmSync(dirs.pop(), { recursive: true, force: true });
|
|
22
24
|
});
|
|
25
|
+
let swapSequence = 0;
|
|
26
|
+
function swapWhenPinned(target, replacement, operation) {
|
|
27
|
+
const parked = `${target}.pinned-${++swapSequence}`;
|
|
28
|
+
const identity = fs.statSync(target);
|
|
29
|
+
const mutableFs = require('node:fs');
|
|
30
|
+
const originalFstatSync = mutableFs.fstatSync;
|
|
31
|
+
let swapped = false;
|
|
32
|
+
mutableFs.fstatSync = ((fd) => {
|
|
33
|
+
const stat = originalFstatSync(fd);
|
|
34
|
+
if (!swapped && stat.dev === identity.dev && stat.ino === identity.ino) {
|
|
35
|
+
fs.renameSync(target, parked);
|
|
36
|
+
fs.renameSync(replacement, target);
|
|
37
|
+
swapped = true;
|
|
38
|
+
}
|
|
39
|
+
return stat;
|
|
40
|
+
});
|
|
41
|
+
syncBuiltinESMExports();
|
|
42
|
+
try {
|
|
43
|
+
operation();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
mutableFs.fstatSync = originalFstatSync;
|
|
47
|
+
syncBuiltinESMExports();
|
|
48
|
+
}
|
|
49
|
+
expect(swapped).toBe(true);
|
|
50
|
+
return parked;
|
|
51
|
+
}
|
|
23
52
|
function record(over = {}) {
|
|
24
53
|
const now = Date.now();
|
|
25
54
|
return {
|
|
@@ -58,6 +87,19 @@ describe('registry', () => {
|
|
|
58
87
|
expect(presenceOf(record({ offline: true }))).toBe('offline');
|
|
59
88
|
expect(presenceOf(record({ pid: 2_000_000_000 }))).toBe('offline'); // dead pid
|
|
60
89
|
});
|
|
90
|
+
it('uses random exclusive temp files without overwriting a predictable temp', () => {
|
|
91
|
+
const root = tmpRoot();
|
|
92
|
+
const value = record();
|
|
93
|
+
const outside = path.join(root, 'outside');
|
|
94
|
+
const predictable = path.join(root, `${value.addr}.json.${process.pid}.tmp`);
|
|
95
|
+
fs.writeFileSync(outside, 'outside-sentinel');
|
|
96
|
+
fs.symlinkSync(outside, predictable, 'file');
|
|
97
|
+
writeRecord(root, value);
|
|
98
|
+
writeRecord(root, { ...value, name: 'updated' });
|
|
99
|
+
expect(fs.readFileSync(outside, 'utf8')).toBe('outside-sentinel');
|
|
100
|
+
expect(fs.lstatSync(predictable).isSymbolicLink()).toBe(true);
|
|
101
|
+
expect(readRecord(root, value.addr)?.name).toBe('updated');
|
|
102
|
+
});
|
|
61
103
|
it('listing has no side effects and survives garbage files', () => {
|
|
62
104
|
const root = tmpRoot();
|
|
63
105
|
writeRecord(root, record());
|
|
@@ -104,6 +146,16 @@ describe('registry', () => {
|
|
|
104
146
|
expect(listRecords(root)).toHaveLength(1);
|
|
105
147
|
expect(unreadCount(root, record().addr)).toBe(1);
|
|
106
148
|
});
|
|
149
|
+
it('treats a recoverable durable claim as undelivered mail', () => {
|
|
150
|
+
const root = tmpRoot();
|
|
151
|
+
const crashed = record({ offline: true, pid: 2_000_000_005 });
|
|
152
|
+
writeRecord(root, crashed);
|
|
153
|
+
deposit(root, crashed.addr, letter());
|
|
154
|
+
const claim = claimInbox(root, crashed.addr);
|
|
155
|
+
sweep(root, Date.now(), () => false);
|
|
156
|
+
expect(readRecord(root, crashed.addr)).not.toBeNull();
|
|
157
|
+
expect(recoverInboxClaims(root, crashed.addr)).toEqual([claim]);
|
|
158
|
+
});
|
|
107
159
|
});
|
|
108
160
|
describe('mailbox', () => {
|
|
109
161
|
it('delivers oldest-first, exactly once, with no partial reads', () => {
|
|
@@ -179,6 +231,289 @@ describe('mailbox', () => {
|
|
|
179
231
|
expect(drain(root, addr).map((l) => l.body)).toEqual(['while you were out']);
|
|
180
232
|
});
|
|
181
233
|
});
|
|
234
|
+
describe('descriptor-relative filesystem safety', () => {
|
|
235
|
+
it('keeps record reads on a pinned root after the configured root is swapped', () => {
|
|
236
|
+
const root = tmpRoot();
|
|
237
|
+
const replacement = `${root}.replacement`;
|
|
238
|
+
fs.mkdirSync(replacement);
|
|
239
|
+
writeRecord(root, record({ name: 'pinned' }));
|
|
240
|
+
writeRecord(replacement, record({ name: 'attacker' }));
|
|
241
|
+
const result = { current: null };
|
|
242
|
+
const parked = swapWhenPinned(root, replacement, () => {
|
|
243
|
+
result.current = readRecord(root, record().addr);
|
|
244
|
+
});
|
|
245
|
+
expect(result.current?.name).toBe('pinned');
|
|
246
|
+
expect(JSON.parse(fs.readFileSync(path.join(root, `${record().addr}.json`), 'utf8')).name).toBe('attacker');
|
|
247
|
+
expect(JSON.parse(fs.readFileSync(path.join(parked, `${record().addr}.json`), 'utf8')).name).toBe('pinned');
|
|
248
|
+
});
|
|
249
|
+
it('deletes aliases through the pinned aliases descriptor after a child swap', () => {
|
|
250
|
+
const root = tmpRoot();
|
|
251
|
+
writeAlias(root, { name: 'ci', addr: 'aaaa1111bbbb', sessionId: 'pinned', claimedAt: 1 });
|
|
252
|
+
const aliases = path.join(root, 'aliases');
|
|
253
|
+
const replacement = path.join(root, 'aliases.replacement');
|
|
254
|
+
fs.mkdirSync(replacement);
|
|
255
|
+
fs.writeFileSync(path.join(replacement, 'ci.json'), '{"sessionId":"attacker"}');
|
|
256
|
+
const parked = swapWhenPinned(aliases, replacement, () => clearAlias(root, 'ci'));
|
|
257
|
+
expect(fs.existsSync(path.join(parked, 'ci.json'))).toBe(false);
|
|
258
|
+
expect(fs.readFileSync(path.join(aliases, 'ci.json'), 'utf8')).toContain('attacker');
|
|
259
|
+
});
|
|
260
|
+
it('appends audit records to the pinned root after a root swap', () => {
|
|
261
|
+
const root = tmpRoot();
|
|
262
|
+
const replacement = `${root}.replacement`;
|
|
263
|
+
fs.mkdirSync(replacement);
|
|
264
|
+
fs.writeFileSync(path.join(root, 'audit.log'), '');
|
|
265
|
+
fs.writeFileSync(path.join(replacement, 'audit.log'), 'attacker-sentinel\n');
|
|
266
|
+
const parked = swapWhenPinned(root, replacement, () => appendAudit(root, {
|
|
267
|
+
ts: 1,
|
|
268
|
+
event: 'deliver',
|
|
269
|
+
kind: 'message',
|
|
270
|
+
from: 'aaaa1111bbbb',
|
|
271
|
+
to: 'cccc2222dddd',
|
|
272
|
+
messageId: 'audit-race',
|
|
273
|
+
preview: 'safe',
|
|
274
|
+
}));
|
|
275
|
+
expect(fs.readFileSync(path.join(parked, 'audit.log'), 'utf8')).toContain('audit-race');
|
|
276
|
+
expect(fs.readFileSync(path.join(root, 'audit.log'), 'utf8')).toBe('attacker-sentinel\n');
|
|
277
|
+
});
|
|
278
|
+
it('deposits and drains through pinned inbox descriptors after child swaps', () => {
|
|
279
|
+
const depositRoot = tmpRoot();
|
|
280
|
+
const addr = 'race00000001';
|
|
281
|
+
const inbox = path.join(depositRoot, `${addr}.inbox`);
|
|
282
|
+
const replacement = `${inbox}.replacement`;
|
|
283
|
+
fs.mkdirSync(inbox);
|
|
284
|
+
fs.mkdirSync(replacement);
|
|
285
|
+
const deposited = letter({ id: 'deposit-race', ts: 1000 });
|
|
286
|
+
const parkedInbox = swapWhenPinned(inbox, replacement, () => deposit(depositRoot, addr, deposited));
|
|
287
|
+
expect(fs.readdirSync(inbox)).toEqual([]);
|
|
288
|
+
expect(fs.readdirSync(parkedInbox).some((name) => name.endsWith('.json'))).toBe(true);
|
|
289
|
+
const drainRoot = tmpRoot();
|
|
290
|
+
const drainInbox = path.join(drainRoot, `${addr}.inbox`);
|
|
291
|
+
const drainReplacement = `${drainInbox}.replacement`;
|
|
292
|
+
fs.mkdirSync(drainInbox);
|
|
293
|
+
fs.mkdirSync(drainReplacement);
|
|
294
|
+
fs.writeFileSync(path.join(drainInbox, '1000-pinned.json'), JSON.stringify(letter({ id: 'drain-race' })));
|
|
295
|
+
fs.writeFileSync(path.join(drainReplacement, '1000-attacker.json'), JSON.stringify(letter({ body: 'attacker' })));
|
|
296
|
+
let drained = [];
|
|
297
|
+
const parkedDrain = swapWhenPinned(drainInbox, drainReplacement, () => {
|
|
298
|
+
drained = drain(drainRoot, addr);
|
|
299
|
+
});
|
|
300
|
+
expect(drained.map((entry) => entry.id)).toEqual(['drain-race']);
|
|
301
|
+
expect(fs.readdirSync(parkedDrain)).toEqual([]);
|
|
302
|
+
expect(fs.readdirSync(drainInbox)).toEqual(['1000-attacker.json']);
|
|
303
|
+
});
|
|
304
|
+
it('reads and clears asks through the pinned asks descriptor after child swaps', () => {
|
|
305
|
+
const root = tmpRoot();
|
|
306
|
+
const addr = 'race00000002';
|
|
307
|
+
const ask = letter({ id: 'ask-race', kind: 'ask' });
|
|
308
|
+
trackIncomingAsk(root, addr, ask);
|
|
309
|
+
const asks = path.join(root, `${addr}.asks`);
|
|
310
|
+
const replacement = `${asks}.replacement`;
|
|
311
|
+
fs.mkdirSync(replacement);
|
|
312
|
+
fs.writeFileSync(path.join(replacement, `${ask.id}.json`), JSON.stringify(letter({ id: ask.id, body: 'attacker' })));
|
|
313
|
+
const result = { current: null };
|
|
314
|
+
const parked = swapWhenPinned(asks, replacement, () => {
|
|
315
|
+
result.current = readIncomingAsk(root, addr, ask.id);
|
|
316
|
+
});
|
|
317
|
+
expect(result.current?.body).toBe('hello');
|
|
318
|
+
const current = asks;
|
|
319
|
+
const secondReplacement = `${asks}.second-replacement`;
|
|
320
|
+
fs.mkdirSync(secondReplacement);
|
|
321
|
+
fs.writeFileSync(path.join(secondReplacement, `${ask.id}.json`), JSON.stringify(letter({ body: 'second-attacker' })));
|
|
322
|
+
const parkedReplacement = swapWhenPinned(current, secondReplacement, () => clearAsk(root, addr, ask.id));
|
|
323
|
+
expect(fs.existsSync(path.join(parkedReplacement, `${ask.id}.json`))).toBe(false);
|
|
324
|
+
expect(fs.existsSync(path.join(parked, `${ask.id}.json`))).toBe(true);
|
|
325
|
+
expect(fs.readFileSync(path.join(current, `${ask.id}.json`), 'utf8')).toContain('second-attacker');
|
|
326
|
+
});
|
|
327
|
+
it('keeps a watch on the pinned inbox and closes its descriptors', async () => {
|
|
328
|
+
const root = tmpRoot();
|
|
329
|
+
const descriptorDirectory = process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd';
|
|
330
|
+
const descriptorCount = () => fs.readdirSync(descriptorDirectory).length;
|
|
331
|
+
const addr = 'race00000003';
|
|
332
|
+
const inbox = path.join(root, `${addr}.inbox`);
|
|
333
|
+
const replacement = `${inbox}.replacement`;
|
|
334
|
+
fs.mkdirSync(inbox);
|
|
335
|
+
fs.mkdirSync(replacement);
|
|
336
|
+
let calls = 0;
|
|
337
|
+
let unwatch = () => { };
|
|
338
|
+
const before = descriptorCount();
|
|
339
|
+
const parked = swapWhenPinned(inbox, replacement, () => {
|
|
340
|
+
unwatch = watchInbox(root, addr, () => calls++);
|
|
341
|
+
});
|
|
342
|
+
expect(descriptorCount()).toBeGreaterThanOrEqual(before + 2);
|
|
343
|
+
try {
|
|
344
|
+
fs.writeFileSync(path.join(parked, 'mail.json'), '{}');
|
|
345
|
+
const deadline = Date.now() + 2000;
|
|
346
|
+
while (calls === 0 && Date.now() < deadline)
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
348
|
+
expect(calls).toBeGreaterThan(0);
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
unwatch();
|
|
352
|
+
}
|
|
353
|
+
expect(descriptorCount()).toBe(before);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
describe('aliases', () => {
|
|
357
|
+
function alias(over = {}) {
|
|
358
|
+
return { name: 'ci', addr: 'aaaa1111bbbb', sessionId: 'sid-1', claimedAt: Date.now(), ...over };
|
|
359
|
+
}
|
|
360
|
+
it('claim is last-claim-wins and persists on disk across restart', () => {
|
|
361
|
+
const root = tmpRoot();
|
|
362
|
+
writeAlias(root, alias({ addr: 'old00000000', sessionId: 'old-sid' }));
|
|
363
|
+
expect(readAlias(root, 'ci')?.addr).toBe('old00000000');
|
|
364
|
+
// a new session claims the same name — overwrites
|
|
365
|
+
claimAlias(root, 'ci', 'new00000001', 'new-sid');
|
|
366
|
+
expect(readAlias(root, 'ci')?.addr).toBe('new00000001');
|
|
367
|
+
expect(listAliases(root).map((a) => a.name)).toEqual(['ci']);
|
|
368
|
+
});
|
|
369
|
+
it('sweep reaps an alias whose owning session record is gone', () => {
|
|
370
|
+
const root = tmpRoot();
|
|
371
|
+
// live owner keeps its alias
|
|
372
|
+
const live = record({ addr: 'live00000001', pid: process.pid });
|
|
373
|
+
writeRecord(root, live);
|
|
374
|
+
claimAlias(root, 'ci', live.addr, live.sessionId);
|
|
375
|
+
sweep(root);
|
|
376
|
+
expect(readAlias(root, 'ci')?.addr).toBe(live.addr);
|
|
377
|
+
// owner record reaped (offline, empty, not resumable) → alias reaped too
|
|
378
|
+
const dead = record({ addr: 'dead00000002', offline: true, pid: 2_000_000_010, sessionId: 'gone' });
|
|
379
|
+
writeRecord(root, dead);
|
|
380
|
+
claimAlias(root, 'dotfiles', dead.addr, dead.sessionId);
|
|
381
|
+
sweep(root, Date.now(), () => false); // nothing resumable
|
|
382
|
+
expect(readAlias(root, 'ci')?.addr).toBe(live.addr); // untouched
|
|
383
|
+
expect(readAlias(root, 'dotfiles')).toBeNull();
|
|
384
|
+
});
|
|
385
|
+
it('a resumable-but-offline owner keeps its alias (deliverable while down)', () => {
|
|
386
|
+
const root = tmpRoot();
|
|
387
|
+
const down = record({ addr: 'down00000003', offline: true, pid: 2_000_000_011, sessionId: 'can-resume' });
|
|
388
|
+
writeRecord(root, down);
|
|
389
|
+
claimAlias(root, 'ci', down.addr, down.sessionId);
|
|
390
|
+
sweep(root, Date.now(), (id) => id === 'can-resume');
|
|
391
|
+
expect(readAlias(root, 'ci')?.addr).toBe(down.addr);
|
|
392
|
+
});
|
|
393
|
+
it('clearAlias is idempotent', () => {
|
|
394
|
+
const root = tmpRoot();
|
|
395
|
+
writeAlias(root, alias());
|
|
396
|
+
clearAlias(root, 'ci');
|
|
397
|
+
clearAlias(root, 'ci'); // no throw
|
|
398
|
+
expect(readAlias(root, 'ci')).toBeNull();
|
|
399
|
+
expect(readRecord(root, 'aaaa1111bbbb')).toBeNull(); // sanity: readRecord on missing → null
|
|
400
|
+
});
|
|
401
|
+
it('alias names are validated — traversal never reaches the filesystem', () => {
|
|
402
|
+
const root = tmpRoot();
|
|
403
|
+
expect(isValidAliasName('ci')).toBe(true);
|
|
404
|
+
expect(isValidAliasName('dotfiles-2')).toBe(true);
|
|
405
|
+
expect(isValidAliasName('../../tmp/x')).toBe(false);
|
|
406
|
+
expect(isValidAliasName('..')).toBe(false);
|
|
407
|
+
expect(isValidAliasName('a/b')).toBe(false);
|
|
408
|
+
expect(isValidAliasName('UPPER')).toBe(false); // case-sensitive storage, lowercase-only names
|
|
409
|
+
expect(isValidAliasName('')).toBe(false);
|
|
410
|
+
// readAlias refuses invalid names outright (defense in depth behind resolveTarget)
|
|
411
|
+
expect(readAlias(root, '../../../../etc/passwd')).toBeNull();
|
|
412
|
+
expect(readAlias(root, 'UPPER')).toBeNull();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
describe('audit log', () => {
|
|
416
|
+
it('append-only: deposit writes one line, never the full body', () => {
|
|
417
|
+
const root = tmpRoot();
|
|
418
|
+
const addr = 'aud00000001';
|
|
419
|
+
const long = 'x'.repeat(500);
|
|
420
|
+
deposit(root, addr, letter({ id: 'a-1', body: long, ts: 1000 }));
|
|
421
|
+
const entries = readAudit(root, 100);
|
|
422
|
+
expect(entries).toHaveLength(1);
|
|
423
|
+
const e = entries[0];
|
|
424
|
+
expect(e.event).toBe('deposit');
|
|
425
|
+
expect(e.kind).toBe('message');
|
|
426
|
+
expect(e.from).toBe(letter().from.addr);
|
|
427
|
+
expect(e.to).toBe(addr);
|
|
428
|
+
expect(e.messageId).toBe('a-1');
|
|
429
|
+
// preview is bounded — the full 500-char body never lands in the log
|
|
430
|
+
expect(e.preview).toBe(previewBody(long));
|
|
431
|
+
expect(e.preview.length).toBeLessThan(long.length);
|
|
432
|
+
});
|
|
433
|
+
it('append-only survives corruption and returns a bounded tail', () => {
|
|
434
|
+
const root = tmpRoot();
|
|
435
|
+
for (let i = 0; i < 3; i++)
|
|
436
|
+
deposit(root, 'aud00000002', letter({ id: `a-${i}`, ts: 1000 + i }));
|
|
437
|
+
// a corrupt line must not poison reads (append-only, skip-on-parse-fail)
|
|
438
|
+
fs.appendFileSync(path.join(root, 'audit.log'), '{not json}\n');
|
|
439
|
+
const entries = readAudit(root, 2);
|
|
440
|
+
expect(entries).toHaveLength(2);
|
|
441
|
+
expect(entries.map((e) => e.messageId)).toEqual(['a-1', 'a-2']);
|
|
442
|
+
});
|
|
443
|
+
it('deliver and deposit both record (evidence survives drain-as-receipt)', () => {
|
|
444
|
+
const root = tmpRoot();
|
|
445
|
+
const addr = 'aud00000003';
|
|
446
|
+
deposit(root, addr, letter({ id: 'd-1', kind: 'ask', body: 'can I push?' }));
|
|
447
|
+
// drain consumes (unlinks) the letter, but the audit line stays
|
|
448
|
+
drain(root, addr);
|
|
449
|
+
const entries = readAudit(root);
|
|
450
|
+
expect(entries.filter((e) => e.event === 'deposit' && e.messageId === 'd-1')).toHaveLength(1);
|
|
451
|
+
// a deliver record is appended by index.ts deliver(); simulate it here:
|
|
452
|
+
appendAudit(root, {
|
|
453
|
+
ts: Date.now(),
|
|
454
|
+
event: 'deliver',
|
|
455
|
+
kind: 'ask',
|
|
456
|
+
from: letter().from.addr,
|
|
457
|
+
to: addr,
|
|
458
|
+
messageId: 'd-1',
|
|
459
|
+
preview: previewBody('can I push?'),
|
|
460
|
+
});
|
|
461
|
+
const after = readAudit(root).filter((e) => e.messageId === 'd-1');
|
|
462
|
+
expect(after.map((e) => e.event)).toEqual(['deposit', 'deliver']);
|
|
463
|
+
});
|
|
464
|
+
it('audit log rotates at the 1 MB cap, keeping one previous generation', () => {
|
|
465
|
+
const root = tmpRoot();
|
|
466
|
+
const big = 'y'.repeat(1024);
|
|
467
|
+
// ~1.1 MB of audit records
|
|
468
|
+
for (let i = 0; i < 1100; i++) {
|
|
469
|
+
appendAudit(root, {
|
|
470
|
+
ts: 1000 + i,
|
|
471
|
+
event: 'deposit',
|
|
472
|
+
kind: 'message',
|
|
473
|
+
from: 'from-addr',
|
|
474
|
+
to: 'to-addr',
|
|
475
|
+
messageId: `m-${i}`,
|
|
476
|
+
preview: big,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const file = auditLogPath(root);
|
|
480
|
+
expect(fs.existsSync(`${file}.1`)).toBe(true); // rotated generation exists
|
|
481
|
+
expect(fs.statSync(file).size).toBeLessThan(1024 * 1024); // fresh log is small
|
|
482
|
+
expect(readAudit(root, 10).length).toBeGreaterThan(0); // still readable after rotation
|
|
483
|
+
});
|
|
484
|
+
it('previewBody strips ANSI/CSI escapes (peer-controlled text reaches raw terminals)', () => {
|
|
485
|
+
expect(previewBody('hello \x1b[31mred\x1b[0m world')).toBe('hello red world');
|
|
486
|
+
expect(previewBody('\x1b[2J\x1b[Hbye')).toBe('bye');
|
|
487
|
+
});
|
|
488
|
+
it('resolveAskByRef matches by id or unique prefix, never by inference', () => {
|
|
489
|
+
const root = tmpRoot();
|
|
490
|
+
const addr = 'ask00000001';
|
|
491
|
+
const a1 = letter({ id: 'aaaa1111-0000-0000-0000-000000000001', kind: 'ask' });
|
|
492
|
+
const a2 = letter({ id: 'bbbb2222-0000-0000-0000-000000000002', kind: 'ask' });
|
|
493
|
+
trackIncomingAsk(root, addr, a1);
|
|
494
|
+
trackIncomingAsk(root, addr, a2);
|
|
495
|
+
expect(resolveAskByRef(root, addr, a1.id)?.id).toBe(a1.id); // exact id
|
|
496
|
+
expect(resolveAskByRef(root, addr, 'bbbb2222')?.id).toBe(a2.id); // prefix
|
|
497
|
+
expect(resolveAskByRef(root, addr, 'nope')).toBeNull(); // no match
|
|
498
|
+
// one pending ask alone is NOT implicitly the reply target — caller must pass its ref
|
|
499
|
+
expect(resolveAskByRef(root, addr, '')).toBeNull();
|
|
500
|
+
});
|
|
501
|
+
it('formatAudit renders entries and handles the empty case', () => {
|
|
502
|
+
expect(formatAudit([])).toContain('No audit entries');
|
|
503
|
+
const root = tmpRoot();
|
|
504
|
+
deposit(root, 'aud00000004', letter({ id: 'f-1', kind: 'message', body: 'hello world' }));
|
|
505
|
+
const text = formatAudit(readAudit(root));
|
|
506
|
+
expect(text).toContain('deposit');
|
|
507
|
+
expect(text).toContain('f-1'.slice(0, 8));
|
|
508
|
+
expect(text).toContain('hello world'); // preview present
|
|
509
|
+
// a long body is only present as its bounded preview
|
|
510
|
+
const root2 = tmpRoot();
|
|
511
|
+
const long = 'A'.repeat(500);
|
|
512
|
+
deposit(root2, 'aud00000005', letter({ id: 'f-2', body: long }));
|
|
513
|
+
const text2 = formatAudit(readAudit(root2));
|
|
514
|
+
expect(text2).not.toContain(long);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
182
517
|
describe('inbound refuse', () => {
|
|
183
518
|
it('refused mail is never consumed, so receipts honestly read queued', async () => {
|
|
184
519
|
expect(inboundAccepts('refuse')).toBe(false);
|
|
@@ -209,6 +544,16 @@ describe('policy', () => {
|
|
|
209
544
|
now += 11_000;
|
|
210
545
|
expect(p.check('same', 0).ok).toBe(true);
|
|
211
546
|
});
|
|
547
|
+
it('dedupe is per-target: a broadcast of one body reaches distinct peers', () => {
|
|
548
|
+
const p = new OutboundPolicy();
|
|
549
|
+
expect(p.check('main moved', 0, 'peerA').ok).toBe(true);
|
|
550
|
+
p.recordSend('main moved', 'peerA');
|
|
551
|
+
// same body, different peer → not deduped (loop-breaking is per-peer)
|
|
552
|
+
expect(p.check('main moved', 0, 'peerB').ok).toBe(true);
|
|
553
|
+
p.recordSend('main moved', 'peerB');
|
|
554
|
+
// same body, same peer again → still deduped inside the window
|
|
555
|
+
expect(p.check('main moved', 0, 'peerA').ok).toBe(false);
|
|
556
|
+
});
|
|
212
557
|
it('rate limits at 8 per 30s', () => {
|
|
213
558
|
const p = new OutboundPolicy();
|
|
214
559
|
for (let i = 0; i < 8; i++) {
|