@nicknisi/pi-relay 0.2.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/dist/registry.js CHANGED
@@ -15,8 +15,8 @@
15
15
  * - Listing has NO side effects: reading the directory never deletes.
16
16
  */
17
17
  import * as crypto from 'node:crypto';
18
- import * as fs from 'node:fs';
19
18
  import * as path from 'node:path';
19
+ import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
20
20
  export const HEARTBEAT_STALE_MS = 45_000;
21
21
  /** A mailbox holding undelivered mail is kept this long after last contact. */
22
22
  export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
@@ -24,73 +24,124 @@ export function deriveAddr(cwd, sessionId) {
24
24
  return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
25
25
  }
26
26
  export function ensureRoot(root) {
27
- fs.mkdirSync(root, { recursive: true, mode: 0o700 });
28
- try {
29
- fs.chmodSync(root, 0o700);
30
- }
31
- catch {
32
- // best-effort on filesystems that support modes
33
- }
27
+ const directory = openRelayRoot(root, true);
28
+ directory.close();
34
29
  }
35
30
  export function recordPath(root, addr) {
31
+ assertPathSegment(addr, 'relay address');
36
32
  return path.join(root, `${addr}.json`);
37
33
  }
38
34
  export function inboxDir(root, addr) {
35
+ assertPathSegment(addr, 'relay address');
39
36
  return path.join(root, `${addr}.inbox`);
40
37
  }
41
38
  export function asksDir(root, addr) {
39
+ assertPathSegment(addr, 'relay address');
42
40
  return path.join(root, `${addr}.asks`);
43
41
  }
44
42
  export function aliasesDir(root) {
45
43
  return path.join(root, 'aliases');
46
44
  }
47
45
  export function aliasPath(root, name) {
46
+ assertPathSegment(name, 'relay alias');
48
47
  return path.join(aliasesDir(root), `${name}.json`);
49
48
  }
50
49
  export function writeAlias(root, alias) {
51
- const file = aliasPath(root, alias.name);
52
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
53
- const tmp = `${file}.${process.pid}.tmp`;
54
- fs.writeFileSync(tmp, JSON.stringify(alias, null, 2), { mode: 0o600 });
55
- fs.renameSync(tmp, file);
50
+ if (!isValidAliasName(alias.name))
51
+ throw new TypeError(`Invalid relay alias: ${alias.name}`);
52
+ const relay = openRelayRoot(root, true);
53
+ try {
54
+ const aliases = relay.openDirectory('aliases', true);
55
+ try {
56
+ aliases.writeFileAtomic(`${alias.name}.json`, JSON.stringify(alias, null, 2));
57
+ }
58
+ finally {
59
+ aliases.close();
60
+ }
61
+ }
62
+ finally {
63
+ relay.close();
64
+ }
56
65
  }
57
66
  /** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
58
67
  export function isValidAliasName(name) {
59
68
  return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
60
69
  }
70
+ function readAliasFromDirectory(directory, name) {
71
+ const raw = directory.readFile(`${name}.json`);
72
+ if (raw === null)
73
+ return null;
74
+ const parsed = JSON.parse(raw);
75
+ if (parsed && typeof parsed.name === 'string' && typeof parsed.addr === 'string')
76
+ return parsed;
77
+ return null;
78
+ }
61
79
  export function readAlias(root, name) {
62
80
  // The name can come from LLM-controlled tool input — never let it reach
63
81
  // the filesystem unvalidated (e.g. '@../../../../tmp/x').
64
82
  if (!isValidAliasName(name))
65
83
  return null;
84
+ const relay = openRelayRoot(root);
85
+ if (relay === null)
86
+ return null;
66
87
  try {
67
- const parsed = JSON.parse(fs.readFileSync(aliasPath(root, name), 'utf8'));
68
- if (parsed && typeof parsed.name === 'string' && typeof parsed.addr === 'string') {
69
- return parsed;
88
+ const aliases = relay.openDirectory('aliases');
89
+ if (aliases === null)
90
+ return null;
91
+ try {
92
+ return readAliasFromDirectory(aliases, name);
93
+ }
94
+ catch (error) {
95
+ rethrowFilesystemError(error);
96
+ return null;
97
+ }
98
+ finally {
99
+ aliases.close();
70
100
  }
71
- return null;
72
101
  }
73
- catch {
74
- return null;
102
+ finally {
103
+ relay.close();
75
104
  }
76
105
  }
77
106
  export function listAliases(root) {
78
- let entries;
107
+ const relay = openRelayRoot(root);
108
+ if (relay === null)
109
+ return [];
79
110
  try {
80
- entries = fs.readdirSync(aliasesDir(root), { withFileTypes: true });
111
+ const aliases = relay.openDirectory('aliases');
112
+ if (aliases === null)
113
+ return [];
114
+ try {
115
+ const out = [];
116
+ for (const entry of aliases.readDirectory()) {
117
+ if (!entry.name.endsWith('.json'))
118
+ continue;
119
+ if (entry.isSymbolicLink())
120
+ throw new RelayFilesystemError(`Refusing symlinked relay alias: ${entry.name}`);
121
+ if (!entry.isFile())
122
+ continue;
123
+ try {
124
+ const alias = readAliasFromDirectory(aliases, entry.name.slice(0, -'.json'.length));
125
+ if (alias)
126
+ out.push(alias);
127
+ }
128
+ catch (error) {
129
+ rethrowFilesystemError(error);
130
+ }
131
+ }
132
+ return out.sort((a, b) => a.claimedAt - b.claimedAt);
133
+ }
134
+ finally {
135
+ aliases.close();
136
+ }
81
137
  }
82
- catch {
138
+ catch (error) {
139
+ rethrowFilesystemError(error);
83
140
  return [];
84
141
  }
85
- const out = [];
86
- for (const entry of entries) {
87
- if (!entry.isFile() || !entry.name.endsWith('.json'))
88
- continue;
89
- const alias = readAlias(root, entry.name.slice(0, -'.json'.length));
90
- if (alias)
91
- out.push(alias);
142
+ finally {
143
+ relay.close();
92
144
  }
93
- return out.sort((a, b) => a.claimedAt - b.claimedAt);
94
145
  }
95
146
  /** Last-claim-wins: overwrites any prior owner. */
96
147
  export function claimAlias(root, name, addr, sessionId) {
@@ -99,50 +150,96 @@ export function claimAlias(root, name, addr, sessionId) {
99
150
  return alias;
100
151
  }
101
152
  export function clearAlias(root, name) {
153
+ if (!isValidAliasName(name))
154
+ return;
155
+ const relay = openRelayRoot(root);
156
+ if (relay === null)
157
+ return;
102
158
  try {
103
- fs.unlinkSync(aliasPath(root, name));
159
+ const aliases = relay.openDirectory('aliases');
160
+ if (aliases === null)
161
+ return;
162
+ try {
163
+ aliases.unlinkFile(`${name}.json`);
164
+ }
165
+ catch (error) {
166
+ rethrowFilesystemError(error);
167
+ // deletion remains best-effort for ordinary I/O failures
168
+ }
169
+ finally {
170
+ aliases.close();
171
+ }
104
172
  }
105
- catch {
106
- // already gone
173
+ finally {
174
+ relay.close();
107
175
  }
108
176
  }
109
177
  export function writeRecord(root, record) {
110
- ensureRoot(root);
111
- const file = recordPath(root, record.addr);
112
- const tmp = `${file}.${process.pid}.tmp`;
113
- fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
114
- fs.renameSync(tmp, file);
178
+ const relay = openRelayRoot(root, true);
179
+ try {
180
+ relay.writeFileAtomic(`${record.addr}.json`, JSON.stringify(record, null, 2));
181
+ }
182
+ finally {
183
+ relay.close();
184
+ }
185
+ }
186
+ function readRecordFromDirectory(directory, addr) {
187
+ const raw = directory.readFile(`${addr}.json`);
188
+ if (raw === null)
189
+ return null;
190
+ const parsed = JSON.parse(raw);
191
+ if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number')
192
+ return parsed;
193
+ return null;
115
194
  }
116
195
  export function readRecord(root, addr) {
117
- try {
118
- const parsed = JSON.parse(fs.readFileSync(recordPath(root, addr), 'utf8'));
119
- if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') {
120
- return parsed;
121
- }
196
+ assertPathSegment(addr, 'relay address');
197
+ const relay = openRelayRoot(root);
198
+ if (relay === null)
122
199
  return null;
200
+ try {
201
+ return readRecordFromDirectory(relay, addr);
123
202
  }
124
- catch {
203
+ catch (error) {
204
+ rethrowFilesystemError(error);
125
205
  return null;
126
206
  }
207
+ finally {
208
+ relay.close();
209
+ }
127
210
  }
128
211
  /** Read-only listing, oldest first. Never mutates anything. */
129
212
  export function listRecords(root) {
130
- let entries;
213
+ const relay = openRelayRoot(root);
214
+ if (relay === null)
215
+ return [];
131
216
  try {
132
- entries = fs.readdirSync(root, { withFileTypes: true });
217
+ const out = [];
218
+ for (const entry of relay.readDirectory()) {
219
+ if (!entry.name.endsWith('.json'))
220
+ continue;
221
+ if (entry.isSymbolicLink())
222
+ throw new RelayFilesystemError(`Refusing symlinked relay record: ${entry.name}`);
223
+ if (!entry.isFile())
224
+ continue;
225
+ try {
226
+ const record = readRecordFromDirectory(relay, entry.name.slice(0, -'.json'.length));
227
+ if (record)
228
+ out.push(record);
229
+ }
230
+ catch (error) {
231
+ rethrowFilesystemError(error);
232
+ }
233
+ }
234
+ return out.sort((a, b) => a.startedAt - b.startedAt);
133
235
  }
134
- catch {
236
+ catch (error) {
237
+ rethrowFilesystemError(error);
135
238
  return [];
136
239
  }
137
- const out = [];
138
- for (const entry of entries) {
139
- if (!entry.isFile() || !entry.name.endsWith('.json'))
140
- continue;
141
- const record = readRecord(root, entry.name.slice(0, -'.json'.length));
142
- if (record)
143
- out.push(record);
240
+ finally {
241
+ relay.close();
144
242
  }
145
- return out.sort((a, b) => a.startedAt - b.startedAt);
146
243
  }
147
244
  function pidAlive(pid) {
148
245
  if (!Number.isInteger(pid) || pid <= 0)
@@ -163,22 +260,97 @@ export function presenceOf(record, now = Date.now()) {
163
260
  return 'offline';
164
261
  return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? 'live' : 'stalled';
165
262
  }
166
- function dirHasJson(dir) {
263
+ function dirHasJson(root, name) {
264
+ const relay = openRelayRoot(root);
265
+ if (relay === null)
266
+ return false;
167
267
  try {
168
- return fs.readdirSync(dir).some((f) => f.endsWith('.json'));
268
+ const directory = relay.openDirectory(name);
269
+ if (directory === null)
270
+ return false;
271
+ try {
272
+ return directory.readDirectory().some((entry) => {
273
+ if (!entry.name.endsWith('.json'))
274
+ return false;
275
+ if (entry.isSymbolicLink())
276
+ throw new RelayFilesystemError(`Refusing symlinked relay entry: ${entry.name}`);
277
+ return entry.isFile();
278
+ });
279
+ }
280
+ finally {
281
+ directory.close();
282
+ }
169
283
  }
170
- catch {
284
+ catch (error) {
285
+ rethrowFilesystemError(error);
171
286
  return false;
172
287
  }
288
+ finally {
289
+ relay.close();
290
+ }
173
291
  }
174
- function rmrf(target) {
292
+ function claimsHaveJson(root, addr) {
293
+ const relay = openRelayRoot(root);
294
+ if (relay === null)
295
+ return false;
175
296
  try {
176
- fs.rmSync(target, { recursive: true, force: true });
297
+ const claims = relay.openDirectory(`${addr}.claims`);
298
+ if (claims === null)
299
+ return false;
300
+ try {
301
+ for (const entry of claims.readDirectory()) {
302
+ if (entry.isSymbolicLink())
303
+ throw new RelayFilesystemError(`Refusing symlinked relay claim: ${entry.name}`);
304
+ if (!entry.isDirectory())
305
+ continue;
306
+ const claim = claims.openDirectory(entry.name);
307
+ if (claim === null)
308
+ continue;
309
+ try {
310
+ if (claim.readDirectory().some((file) => {
311
+ if (!file.name.endsWith('.json'))
312
+ return false;
313
+ if (file.isSymbolicLink()) {
314
+ throw new RelayFilesystemError(`Refusing symlinked claimed letter: ${file.name}`);
315
+ }
316
+ return file.isFile();
317
+ })) {
318
+ return true;
319
+ }
320
+ }
321
+ finally {
322
+ claim.close();
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+ finally {
328
+ claims.close();
329
+ }
177
330
  }
178
- catch {
179
- // sweep is best-effort
331
+ finally {
332
+ relay.close();
180
333
  }
181
334
  }
335
+ function removeClaims(directory, addr) {
336
+ const claimsName = `${addr}.claims`;
337
+ const claims = directory.openDirectory(claimsName);
338
+ if (claims === null)
339
+ return;
340
+ try {
341
+ for (const entry of claims.readDirectory()) {
342
+ if (entry.isSymbolicLink())
343
+ throw new RelayFilesystemError(`Refusing symlinked relay claim: ${entry.name}`);
344
+ if (!entry.isDirectory())
345
+ continue;
346
+ claims.removeDirectory(entry.name);
347
+ }
348
+ }
349
+ finally {
350
+ claims.close();
351
+ }
352
+ directory.removeDirectory(claimsName);
353
+ }
182
354
  /**
183
355
  * Reclaim dead sessions' files. Rules (mail outranks tidiness):
184
356
  * - a running session is never touched;
@@ -196,19 +368,31 @@ export function sweep(root, now = Date.now(), sessionExists) {
196
368
  for (const record of listRecords(root)) {
197
369
  if (presenceOf(record, now) !== 'offline')
198
370
  continue;
199
- const hasMail = dirHasJson(inboxDir(root, record.addr)) || dirHasJson(asksDir(root, record.addr));
371
+ const hasMail = dirHasJson(root, `${record.addr}.inbox`) ||
372
+ dirHasJson(root, `${record.addr}.asks`) ||
373
+ claimsHaveJson(root, record.addr);
200
374
  const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
201
375
  if (hasMail && !expired)
202
376
  continue;
203
377
  if (!expired && (sessionExists?.(record.sessionId) ?? true))
204
378
  continue;
205
- rmrf(inboxDir(root, record.addr));
206
- rmrf(asksDir(root, record.addr));
207
- try {
208
- fs.unlinkSync(recordPath(root, record.addr));
209
- }
210
- catch {
211
- // already gone
379
+ const relay = openRelayRoot(root);
380
+ if (relay !== null) {
381
+ try {
382
+ relay.removeDirectory(`${record.addr}.inbox`);
383
+ relay.removeDirectory(`${record.addr}.asks`);
384
+ removeClaims(relay, record.addr);
385
+ try {
386
+ relay.unlinkFile(`${record.addr}.json`);
387
+ }
388
+ catch (error) {
389
+ rethrowFilesystemError(error);
390
+ // deletion remains best-effort for ordinary I/O failures
391
+ }
392
+ }
393
+ finally {
394
+ relay.close();
395
+ }
212
396
  }
213
397
  }
214
398
  // Aliases are swept when the owning session dies: an alias whose record
@@ -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
10
  import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
10
- import { appendAudit, auditLogPath, awaitReceipt, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readOutgoingAsk, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.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
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,128 @@ 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
+ });
182
356
  describe('aliases', () => {
183
357
  function alias(over = {}) {
184
358
  return { name: 'ci', addr: 'aaaa1111bbbb', sessionId: 'sid-1', claimedAt: Date.now(), ...over };