@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/README.md +17 -3
- 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/index.js +2 -11
- package/dist/mailbox.d.ts +19 -3
- package/dist/mailbox.js +604 -121
- package/dist/registry.js +254 -70
- package/dist/relay.test.js +176 -2
- package/filesystem.ts +635 -0
- package/index.ts +2 -9
- package/mailbox.ts +546 -117
- package/package.json +19 -1
- package/registry.ts +213 -66
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
68
|
-
if (
|
|
69
|
-
return
|
|
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
|
-
|
|
74
|
-
|
|
102
|
+
finally {
|
|
103
|
+
relay.close();
|
|
75
104
|
}
|
|
76
105
|
}
|
|
77
106
|
export function listAliases(root) {
|
|
78
|
-
|
|
107
|
+
const relay = openRelayRoot(root);
|
|
108
|
+
if (relay === null)
|
|
109
|
+
return [];
|
|
79
110
|
try {
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
173
|
+
finally {
|
|
174
|
+
relay.close();
|
|
107
175
|
}
|
|
108
176
|
}
|
|
109
177
|
export function writeRecord(root, record) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
213
|
+
const relay = openRelayRoot(root);
|
|
214
|
+
if (relay === null)
|
|
215
|
+
return [];
|
|
131
216
|
try {
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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(
|
|
263
|
+
function dirHasJson(root, name) {
|
|
264
|
+
const relay = openRelayRoot(root);
|
|
265
|
+
if (relay === null)
|
|
266
|
+
return false;
|
|
167
267
|
try {
|
|
168
|
-
|
|
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
|
|
292
|
+
function claimsHaveJson(root, addr) {
|
|
293
|
+
const relay = openRelayRoot(root);
|
|
294
|
+
if (relay === null)
|
|
295
|
+
return false;
|
|
175
296
|
try {
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
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
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 };
|