@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.
@@ -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++) {