@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
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/** Descriptor-relative, symlink-safe filesystem operations for the relay store. Pi-free. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import koffi from 'koffi';
|
|
6
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
7
|
+
throw new Error(`Relay filesystem operations are unsupported on ${process.platform}`);
|
|
8
|
+
}
|
|
9
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
10
|
+
const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
|
|
11
|
+
const CLOEXEC = process.platform === 'darwin' ? 0x01000000 : 0x00080000;
|
|
12
|
+
const READ_DIRECTORY_FLAGS = fs.constants.O_RDONLY | NOFOLLOW | DIRECTORY | CLOEXEC;
|
|
13
|
+
const AT_REMOVEDIR = 0x80;
|
|
14
|
+
function loadLibc() {
|
|
15
|
+
const candidates = process.platform === 'darwin'
|
|
16
|
+
? ['/usr/lib/libSystem.B.dylib']
|
|
17
|
+
: [
|
|
18
|
+
'libc.so.6',
|
|
19
|
+
`/lib/libc.musl-${process.arch === 'arm64' ? 'aarch64' : process.arch}.so.1`,
|
|
20
|
+
`/usr/lib/libc.musl-${process.arch === 'arm64' ? 'aarch64' : process.arch}.so.1`,
|
|
21
|
+
];
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
try {
|
|
24
|
+
return koffi.load(candidate);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// try the next libc name (glibc first, then musl)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Relay could not load libc on ${process.platform}/${process.arch}`);
|
|
31
|
+
}
|
|
32
|
+
const libc = loadLibc();
|
|
33
|
+
const nativeOpenAt = libc.func('int openat(int, const char *, int, ...)');
|
|
34
|
+
const nativeMkdirAt = libc.func('int mkdirat(int, const char *, uint32_t)');
|
|
35
|
+
const nativeUnlinkAt = libc.func('int unlinkat(int, const char *, int)');
|
|
36
|
+
const nativeRenameAt = libc.func('int renameat(int, const char *, int, const char *)');
|
|
37
|
+
const nativeDup = libc.func('int dup(int)');
|
|
38
|
+
const nativeFdOpenDir = libc.func('void *fdopendir(int)');
|
|
39
|
+
const nativeReadDir = libc.func('void *readdir(void *)');
|
|
40
|
+
const nativeCloseDir = libc.func('int closedir(void *)');
|
|
41
|
+
const nativeStrError = libc.func('const char *strerror(int)');
|
|
42
|
+
const errnoNames = new Map(Object.entries(koffi.os.errno).map(([name, value]) => [value, name]));
|
|
43
|
+
/** An existing relay path is unsafe to traverse or is not the expected type. */
|
|
44
|
+
export class RelayFilesystemError extends Error {
|
|
45
|
+
constructor(message, options) {
|
|
46
|
+
super(message, options);
|
|
47
|
+
this.name = 'RelayFilesystemError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function rethrowFilesystemError(error) {
|
|
51
|
+
if (error instanceof RelayFilesystemError)
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
export function assertPathSegment(segment, label = 'relay path component') {
|
|
55
|
+
if (segment.length === 0 ||
|
|
56
|
+
segment === '.' ||
|
|
57
|
+
segment === '..' ||
|
|
58
|
+
segment.includes('/') ||
|
|
59
|
+
segment.includes('\\') ||
|
|
60
|
+
segment.includes('\0')) {
|
|
61
|
+
throw new RelayFilesystemError(`Unsafe ${label}: ${segment}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isErrorCode(error, code) {
|
|
65
|
+
return error?.code === code;
|
|
66
|
+
}
|
|
67
|
+
function unsafe(message, cause) {
|
|
68
|
+
throw new RelayFilesystemError(message, cause === undefined ? undefined : { cause });
|
|
69
|
+
}
|
|
70
|
+
function syscallError(syscall, target) {
|
|
71
|
+
const errno = koffi.errno();
|
|
72
|
+
const code = errnoNames.get(errno) ?? `ERRNO_${errno}`;
|
|
73
|
+
const error = new Error(`${syscall} ${target}: ${nativeStrError(errno)}`);
|
|
74
|
+
error.code = code;
|
|
75
|
+
error.errno = errno;
|
|
76
|
+
error.syscall = syscall;
|
|
77
|
+
error.path = target;
|
|
78
|
+
return error;
|
|
79
|
+
}
|
|
80
|
+
function openAt(directoryFd, name, flags, mode = 0) {
|
|
81
|
+
const fd = flags & fs.constants.O_CREAT
|
|
82
|
+
? nativeOpenAt(directoryFd, name, flags, 'uint32_t', mode)
|
|
83
|
+
: nativeOpenAt(directoryFd, name, flags);
|
|
84
|
+
if (fd < 0)
|
|
85
|
+
throw syscallError('openat', name);
|
|
86
|
+
return fd;
|
|
87
|
+
}
|
|
88
|
+
function mkdirAt(directoryFd, name) {
|
|
89
|
+
if (nativeMkdirAt(directoryFd, name, 0o700) < 0)
|
|
90
|
+
throw syscallError('mkdirat', name);
|
|
91
|
+
}
|
|
92
|
+
function unlinkAt(directoryFd, name, flags = 0) {
|
|
93
|
+
if (nativeUnlinkAt(directoryFd, name, flags) < 0)
|
|
94
|
+
throw syscallError('unlinkat', name);
|
|
95
|
+
}
|
|
96
|
+
function renameAt(fromDirectoryFd, from, toDirectoryFd, to) {
|
|
97
|
+
if (nativeRenameAt(fromDirectoryFd, from, toDirectoryFd, to) < 0) {
|
|
98
|
+
throw syscallError('renameat', `${from} -> ${to}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function sameFile(left, right) {
|
|
102
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
103
|
+
}
|
|
104
|
+
function verifyDirectoryFd(fd, label) {
|
|
105
|
+
const stat = fs.fstatSync(fd);
|
|
106
|
+
if (!stat.isDirectory())
|
|
107
|
+
unsafe(`Relay path is not a directory: ${label}`);
|
|
108
|
+
return stat;
|
|
109
|
+
}
|
|
110
|
+
function isProtectedSystemPath(stat) {
|
|
111
|
+
return stat.uid === 0 && (stat.mode & 0o022) === 0;
|
|
112
|
+
}
|
|
113
|
+
function canonicalTraversalPath(absolute) {
|
|
114
|
+
let candidate = absolute;
|
|
115
|
+
for (let redirect = 0; redirect < 40; redirect++) {
|
|
116
|
+
const parsed = path.parse(candidate);
|
|
117
|
+
const components = candidate.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
118
|
+
let current = parsed.root;
|
|
119
|
+
let redirected = false;
|
|
120
|
+
for (const [index, component] of components.entries()) {
|
|
121
|
+
current = path.join(current, component);
|
|
122
|
+
let stat;
|
|
123
|
+
try {
|
|
124
|
+
stat = fs.lstatSync(current);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (isErrorCode(error, 'ENOENT'))
|
|
128
|
+
return candidate;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
if (stat.isSymbolicLink()) {
|
|
132
|
+
if (index === components.length - 1)
|
|
133
|
+
unsafe(`Refusing symlinked relay root: ${absolute}`);
|
|
134
|
+
let canonicalTarget;
|
|
135
|
+
let targetStat;
|
|
136
|
+
const parentStat = fs.statSync(path.dirname(current));
|
|
137
|
+
try {
|
|
138
|
+
canonicalTarget = fs.realpathSync.native(current);
|
|
139
|
+
targetStat = fs.lstatSync(canonicalTarget);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
unsafe(`Refusing unsafe system ancestor symlink: ${current}`, error);
|
|
143
|
+
}
|
|
144
|
+
if (!isProtectedSystemPath(parentStat) ||
|
|
145
|
+
!isProtectedSystemPath(stat) ||
|
|
146
|
+
!targetStat.isDirectory() ||
|
|
147
|
+
!isProtectedSystemPath(targetStat)) {
|
|
148
|
+
unsafe(`Refusing user-controlled ancestor symlink: ${current}`);
|
|
149
|
+
}
|
|
150
|
+
candidate = path.join(canonicalTarget, ...components.slice(index + 1));
|
|
151
|
+
redirected = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (!stat.isDirectory())
|
|
155
|
+
unsafe(`Relay path is not a directory: ${current}`);
|
|
156
|
+
}
|
|
157
|
+
if (!redirected)
|
|
158
|
+
return candidate;
|
|
159
|
+
}
|
|
160
|
+
unsafe(`Refusing relay path with too many system symlinks: ${absolute}`);
|
|
161
|
+
}
|
|
162
|
+
function openDirectoryAt(directoryFd, name, create, label, chmodExisting = false) {
|
|
163
|
+
assertPathSegment(name);
|
|
164
|
+
let created = false;
|
|
165
|
+
if (create) {
|
|
166
|
+
try {
|
|
167
|
+
mkdirAt(directoryFd, name);
|
|
168
|
+
created = true;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (!isErrorCode(error, 'EEXIST'))
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let fd;
|
|
176
|
+
try {
|
|
177
|
+
fd = openAt(directoryFd, name, READ_DIRECTORY_FLAGS);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (!create && isErrorCode(error, 'ENOENT'))
|
|
181
|
+
return null;
|
|
182
|
+
if (isErrorCode(error, 'ELOOP') || isErrorCode(error, 'ENOTDIR')) {
|
|
183
|
+
unsafe(`Refusing symlinked or non-directory relay path component: ${label}`, error);
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
verifyDirectoryFd(fd, label);
|
|
189
|
+
if (created || chmodExisting) {
|
|
190
|
+
try {
|
|
191
|
+
fs.fchmodSync(fd, 0o700);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// best-effort on filesystems that support modes
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return fd;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
fs.closeSync(fd);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const DT_DIRECTORY = 4;
|
|
205
|
+
const DT_REGULAR = 8;
|
|
206
|
+
const DT_SYMLINK = 10;
|
|
207
|
+
function readDirectoryFd(fd) {
|
|
208
|
+
const duplicate = nativeDup(fd);
|
|
209
|
+
if (duplicate < 0)
|
|
210
|
+
throw syscallError('dup', String(fd));
|
|
211
|
+
const directory = nativeFdOpenDir(duplicate);
|
|
212
|
+
if (directory === null) {
|
|
213
|
+
fs.closeSync(duplicate);
|
|
214
|
+
throw syscallError('fdopendir', String(fd));
|
|
215
|
+
}
|
|
216
|
+
const entries = [];
|
|
217
|
+
try {
|
|
218
|
+
for (;;) {
|
|
219
|
+
koffi.errno(0);
|
|
220
|
+
const pointer = nativeReadDir(directory);
|
|
221
|
+
if (pointer === null) {
|
|
222
|
+
if (koffi.errno() !== 0)
|
|
223
|
+
throw syscallError('readdir', String(fd));
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
const bytes = Buffer.from(koffi.view(pointer, process.platform === 'darwin' ? 1048 : 280));
|
|
227
|
+
const type = bytes[process.platform === 'darwin' ? 20 : 18];
|
|
228
|
+
const nameOffset = process.platform === 'darwin' ? 21 : 19;
|
|
229
|
+
const nameLength = process.platform === 'darwin'
|
|
230
|
+
? bytes.readUInt16LE(18)
|
|
231
|
+
: bytes.subarray(nameOffset).indexOf(0) === -1
|
|
232
|
+
? bytes.length - nameOffset
|
|
233
|
+
: bytes.subarray(nameOffset).indexOf(0);
|
|
234
|
+
const name = bytes.subarray(nameOffset, nameOffset + nameLength).toString();
|
|
235
|
+
if (name === '.' || name === '..')
|
|
236
|
+
continue;
|
|
237
|
+
entries.push({
|
|
238
|
+
name,
|
|
239
|
+
isFile: () => type === DT_REGULAR,
|
|
240
|
+
isDirectory: () => type === DT_DIRECTORY,
|
|
241
|
+
isSymbolicLink: () => type === DT_SYMLINK,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
nativeCloseDir(directory);
|
|
247
|
+
}
|
|
248
|
+
return entries;
|
|
249
|
+
}
|
|
250
|
+
function openRegularFileAt(directoryFd, name, flags, mode = 0) {
|
|
251
|
+
assertPathSegment(name, 'relay file name');
|
|
252
|
+
let fd;
|
|
253
|
+
try {
|
|
254
|
+
fd = openAt(directoryFd, name, flags | NOFOLLOW | CLOEXEC, mode);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (isErrorCode(error, 'ENOENT'))
|
|
258
|
+
return null;
|
|
259
|
+
if (isErrorCode(error, 'ELOOP'))
|
|
260
|
+
unsafe(`Refusing symlinked relay file: ${name}`, error);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
if (!fs.fstatSync(fd).isFile())
|
|
265
|
+
unsafe(`Relay path is not a regular file: ${name}`);
|
|
266
|
+
return fd;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
fs.closeSync(fd);
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** A pinned directory descriptor. All child and leaf access is relative to this descriptor. */
|
|
274
|
+
export class RelayDirectoryHandle {
|
|
275
|
+
#fd;
|
|
276
|
+
#label;
|
|
277
|
+
#closed = false;
|
|
278
|
+
constructor(fd, label) {
|
|
279
|
+
this.#fd = fd;
|
|
280
|
+
this.#label = label;
|
|
281
|
+
}
|
|
282
|
+
close() {
|
|
283
|
+
if (this.#closed)
|
|
284
|
+
return;
|
|
285
|
+
this.#closed = true;
|
|
286
|
+
fs.closeSync(this.#fd);
|
|
287
|
+
}
|
|
288
|
+
openDirectory(name, create = false) {
|
|
289
|
+
this.#assertOpen();
|
|
290
|
+
const label = `${this.#label}/${name}`;
|
|
291
|
+
const fd = openDirectoryAt(this.#fd, name, create, label, create);
|
|
292
|
+
return fd === null ? null : new RelayDirectoryHandle(fd, label);
|
|
293
|
+
}
|
|
294
|
+
readDirectory() {
|
|
295
|
+
this.#assertOpen();
|
|
296
|
+
return readDirectoryFd(this.#fd);
|
|
297
|
+
}
|
|
298
|
+
readFile(fileName) {
|
|
299
|
+
this.#assertOpen();
|
|
300
|
+
const fd = openRegularFileAt(this.#fd, fileName, fs.constants.O_RDONLY);
|
|
301
|
+
if (fd === null)
|
|
302
|
+
return null;
|
|
303
|
+
try {
|
|
304
|
+
return fs.readFileSync(fd, 'utf8');
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
fs.closeSync(fd);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
writeFileAtomic(fileName, contents) {
|
|
311
|
+
this.#assertOpen();
|
|
312
|
+
assertPathSegment(fileName, 'relay file name');
|
|
313
|
+
let temporary = '';
|
|
314
|
+
let fd = null;
|
|
315
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
316
|
+
temporary = `${fileName}.${randomBytes(16).toString('hex')}.tmp`;
|
|
317
|
+
try {
|
|
318
|
+
fd = openRegularFileAt(this.#fd, temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (!isErrorCode(error, 'EEXIST'))
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (fd === null)
|
|
327
|
+
throw new Error(`Could not create an exclusive relay temporary file for ${fileName}`);
|
|
328
|
+
let renamed = false;
|
|
329
|
+
try {
|
|
330
|
+
fs.writeFileSync(fd, contents, 'utf8');
|
|
331
|
+
fs.fsyncSync(fd);
|
|
332
|
+
this.verifyFile(fileName);
|
|
333
|
+
renameAt(this.#fd, temporary, this.#fd, fileName);
|
|
334
|
+
this.sync();
|
|
335
|
+
renamed = true;
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
fs.closeSync(fd);
|
|
339
|
+
if (!renamed) {
|
|
340
|
+
try {
|
|
341
|
+
unlinkAt(this.#fd, temporary);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// best-effort cleanup of the random file created by this call
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
unlinkFile(fileName) {
|
|
350
|
+
this.#assertOpen();
|
|
351
|
+
const fd = openRegularFileAt(this.#fd, fileName, fs.constants.O_RDONLY);
|
|
352
|
+
if (fd === null)
|
|
353
|
+
return false;
|
|
354
|
+
fs.closeSync(fd);
|
|
355
|
+
try {
|
|
356
|
+
unlinkAt(this.#fd, fileName);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (isErrorCode(error, 'ENOENT'))
|
|
361
|
+
return false;
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
fileExists(fileName) {
|
|
366
|
+
this.#assertOpen();
|
|
367
|
+
const fd = openRegularFileAt(this.#fd, fileName, fs.constants.O_RDONLY);
|
|
368
|
+
if (fd === null)
|
|
369
|
+
return false;
|
|
370
|
+
fs.closeSync(fd);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
verifyFile(fileName) {
|
|
374
|
+
this.#assertOpen();
|
|
375
|
+
const fd = openRegularFileAt(this.#fd, fileName, fs.constants.O_RDONLY);
|
|
376
|
+
if (fd !== null)
|
|
377
|
+
fs.closeSync(fd);
|
|
378
|
+
}
|
|
379
|
+
appendFile(fileName, contents) {
|
|
380
|
+
this.#assertOpen();
|
|
381
|
+
const fd = openRegularFileAt(this.#fd, fileName, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT, 0o600);
|
|
382
|
+
if (fd === null)
|
|
383
|
+
throw new Error(`Could not open relay file for append: ${fileName}`);
|
|
384
|
+
try {
|
|
385
|
+
fs.writeFileSync(fd, contents, 'utf8');
|
|
386
|
+
return fs.fstatSync(fd).size;
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
fs.closeSync(fd);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
renameFile(fromName, toName) {
|
|
393
|
+
this.#assertOpen();
|
|
394
|
+
const from = openRegularFileAt(this.#fd, fromName, fs.constants.O_RDONLY);
|
|
395
|
+
if (from === null)
|
|
396
|
+
return;
|
|
397
|
+
fs.closeSync(from);
|
|
398
|
+
this.verifyFile(toName);
|
|
399
|
+
renameAt(this.#fd, fromName, this.#fd, toName);
|
|
400
|
+
}
|
|
401
|
+
/** Atomically move one pinned child directory beneath another pinned directory. */
|
|
402
|
+
moveDirectoryTo(name, target, targetName) {
|
|
403
|
+
this.#assertOpen();
|
|
404
|
+
target.#assertOpen();
|
|
405
|
+
assertPathSegment(name);
|
|
406
|
+
assertPathSegment(targetName);
|
|
407
|
+
const source = this.openDirectory(name);
|
|
408
|
+
if (source === null)
|
|
409
|
+
return null;
|
|
410
|
+
try {
|
|
411
|
+
const existing = target.openDirectory(targetName);
|
|
412
|
+
if (existing !== null) {
|
|
413
|
+
existing.close();
|
|
414
|
+
unsafe(`Relay destination directory already exists: ${targetName}`);
|
|
415
|
+
}
|
|
416
|
+
const sourceStat = fs.fstatSync(source.#fd);
|
|
417
|
+
renameAt(this.#fd, name, target.#fd, targetName);
|
|
418
|
+
const moved = target.openDirectory(targetName);
|
|
419
|
+
if (moved === null)
|
|
420
|
+
unsafe(`Relay directory move disappeared: ${targetName}`);
|
|
421
|
+
try {
|
|
422
|
+
if (!sameFile(sourceStat, fs.fstatSync(moved.#fd))) {
|
|
423
|
+
unsafe(`Relay directory changed during move: ${name}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
moved.close();
|
|
428
|
+
}
|
|
429
|
+
this.sync();
|
|
430
|
+
target.sync();
|
|
431
|
+
return source;
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
source.close();
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Atomically move one regular file between pinned directories without following links. */
|
|
439
|
+
moveFileTo(fileName, target, targetName = fileName) {
|
|
440
|
+
this.#assertOpen();
|
|
441
|
+
target.#assertOpen();
|
|
442
|
+
assertPathSegment(fileName, 'relay file name');
|
|
443
|
+
assertPathSegment(targetName, 'relay file name');
|
|
444
|
+
const sourceFd = openRegularFileAt(this.#fd, fileName, fs.constants.O_RDONLY);
|
|
445
|
+
if (sourceFd === null)
|
|
446
|
+
return false;
|
|
447
|
+
try {
|
|
448
|
+
if (target.fileExists(targetName))
|
|
449
|
+
return false;
|
|
450
|
+
const sourceStat = fs.fstatSync(sourceFd);
|
|
451
|
+
renameAt(this.#fd, fileName, target.#fd, targetName);
|
|
452
|
+
const movedFd = openRegularFileAt(target.#fd, targetName, fs.constants.O_RDONLY);
|
|
453
|
+
if (movedFd === null)
|
|
454
|
+
unsafe(`Relay file move disappeared: ${targetName}`);
|
|
455
|
+
try {
|
|
456
|
+
if (!sameFile(sourceStat, fs.fstatSync(movedFd)))
|
|
457
|
+
unsafe(`Relay file changed during move: ${fileName}`);
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
fs.closeSync(movedFd);
|
|
461
|
+
}
|
|
462
|
+
this.sync();
|
|
463
|
+
target.sync();
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
fs.closeSync(sourceFd);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Remove a child directory only when it is still a real, empty directory. */
|
|
471
|
+
removeEmptyDirectory(name) {
|
|
472
|
+
this.#assertOpen();
|
|
473
|
+
const directory = this.openDirectory(name);
|
|
474
|
+
if (directory === null)
|
|
475
|
+
return false;
|
|
476
|
+
try {
|
|
477
|
+
if (directory.readDirectory().length > 0)
|
|
478
|
+
return false;
|
|
479
|
+
try {
|
|
480
|
+
unlinkAt(this.#fd, name, AT_REMOVEDIR);
|
|
481
|
+
this.sync();
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (isErrorCode(error, 'ENOENT') || isErrorCode(error, 'ENOTEMPTY') || isErrorCode(error, 'EEXIST')) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
directory.close();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
sync() {
|
|
496
|
+
this.#assertOpen();
|
|
497
|
+
fs.fsyncSync(this.#fd);
|
|
498
|
+
}
|
|
499
|
+
removeDirectory(name) {
|
|
500
|
+
this.#assertOpen();
|
|
501
|
+
const directory = this.openDirectory(name);
|
|
502
|
+
if (directory === null)
|
|
503
|
+
return;
|
|
504
|
+
try {
|
|
505
|
+
for (const entry of directory.readDirectory()) {
|
|
506
|
+
if (entry.isSymbolicLink())
|
|
507
|
+
unsafe(`Refusing symlinked relay entry: ${entry.name}`);
|
|
508
|
+
if (!entry.isFile())
|
|
509
|
+
unsafe(`Relay directory contains a non-file entry: ${entry.name}`);
|
|
510
|
+
directory.unlinkFile(entry.name);
|
|
511
|
+
}
|
|
512
|
+
unlinkAt(this.#fd, name, AT_REMOVEDIR);
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
directory.close();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
watch(listener) {
|
|
519
|
+
this.#assertOpen();
|
|
520
|
+
let previous = fs.fstatSync(this.#fd).mtimeMs;
|
|
521
|
+
const timer = setInterval(() => {
|
|
522
|
+
const current = fs.fstatSync(this.#fd).mtimeMs;
|
|
523
|
+
if (current !== previous) {
|
|
524
|
+
previous = current;
|
|
525
|
+
listener();
|
|
526
|
+
}
|
|
527
|
+
}, 250);
|
|
528
|
+
timer.unref();
|
|
529
|
+
return {
|
|
530
|
+
close: () => clearInterval(timer),
|
|
531
|
+
unref: () => timer.unref(),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
#assertOpen() {
|
|
535
|
+
if (this.#closed)
|
|
536
|
+
throw new Error('Relay directory descriptor is closed');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function ensureRootDirectory(absolute) {
|
|
540
|
+
const parsed = path.parse(absolute);
|
|
541
|
+
const components = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
542
|
+
const rootName = components.pop();
|
|
543
|
+
if (rootName === undefined)
|
|
544
|
+
return;
|
|
545
|
+
let fd = fs.openSync(parsed.root, READ_DIRECTORY_FLAGS);
|
|
546
|
+
try {
|
|
547
|
+
verifyDirectoryFd(fd, parsed.root);
|
|
548
|
+
for (const component of components) {
|
|
549
|
+
const next = openDirectoryAt(fd, component, true, absolute);
|
|
550
|
+
fs.closeSync(fd);
|
|
551
|
+
fd = next;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
mkdirAt(fd, rootName);
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
if (!isErrorCode(error, 'EEXIST'))
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
fs.closeSync(fd);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function openRootFd(absolute) {
|
|
566
|
+
const parsed = path.parse(absolute);
|
|
567
|
+
const components = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
568
|
+
let fd = fs.openSync(parsed.root, READ_DIRECTORY_FLAGS);
|
|
569
|
+
try {
|
|
570
|
+
verifyDirectoryFd(fd, parsed.root);
|
|
571
|
+
for (const component of components) {
|
|
572
|
+
const next = openDirectoryAt(fd, component, false, absolute);
|
|
573
|
+
if (next === null)
|
|
574
|
+
return null;
|
|
575
|
+
fs.closeSync(fd);
|
|
576
|
+
fd = next;
|
|
577
|
+
}
|
|
578
|
+
const rootFd = fd;
|
|
579
|
+
fd = -1;
|
|
580
|
+
return rootFd;
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
if (fd >= 0)
|
|
584
|
+
fs.closeSync(fd);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** Open every canonical configured-root component without following symlinks and return its pinned descriptor. */
|
|
588
|
+
export function openRelayRoot(root, create = false) {
|
|
589
|
+
const absolute = path.resolve(root);
|
|
590
|
+
const traversalPath = canonicalTraversalPath(absolute);
|
|
591
|
+
if (create)
|
|
592
|
+
ensureRootDirectory(traversalPath);
|
|
593
|
+
let canonicalRoot;
|
|
594
|
+
try {
|
|
595
|
+
canonicalRoot = fs.realpathSync.native(traversalPath);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (!create && isErrorCode(error, 'ENOENT'))
|
|
599
|
+
return null;
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
const fd = openRootFd(canonicalRoot);
|
|
603
|
+
if (fd === null)
|
|
604
|
+
return null;
|
|
605
|
+
if (create) {
|
|
606
|
+
try {
|
|
607
|
+
fs.fchmodSync(fd, 0o700);
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// best-effort on filesystems that support modes
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return new RelayDirectoryHandle(fd, absolute);
|
|
614
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
|
18
18
|
import { Box, getKeybindings, Text } from '@earendil-works/pi-tui';
|
|
19
19
|
import { Type } from 'typebox';
|
|
20
20
|
import { formatAudit, formatDelivery, formatListing, formatPendingAsk, refusalAmbiguous, refusalUnknown, shortAddr, } from './format.js';
|
|
21
|
-
import { appendAudit, awaitReceipt, clearAsk, drain, deposit, pendingAsks, previewBody, readAudit, readOutgoingAsk, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
21
|
+
import { appendAudit, awaitReceipt, clearAsk, drain, deposit, outgoingAskIds, pendingAsks, previewBody, readAudit, readOutgoingAsk, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
22
22
|
import { OutboundPolicy, inboundAccepts } from './policy.js';
|
|
23
23
|
import { claimAlias, deriveAddr, ensureRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
|
|
24
24
|
function toolResult(text, details = {}) {
|
|
@@ -690,16 +690,7 @@ export default function relay(pi) {
|
|
|
690
690
|
}
|
|
691
691
|
/** Find an outgoing ask id by exact id or unique prefix (for cancel ergonomics). */
|
|
692
692
|
function resolveOutAskId(root, addr, idOrPrefix) {
|
|
693
|
-
|
|
694
|
-
try {
|
|
695
|
-
names = fs.readdirSync(path.join(root, `${addr}.asks`));
|
|
696
|
-
}
|
|
697
|
-
catch {
|
|
698
|
-
return undefined;
|
|
699
|
-
}
|
|
700
|
-
const ids = names
|
|
701
|
-
.filter((f) => f.startsWith('out-') && f.endsWith('.json'))
|
|
702
|
-
.map((f) => f.slice('out-'.length, -'.json'.length));
|
|
693
|
+
const ids = outgoingAskIds(root, addr);
|
|
703
694
|
if (ids.includes(idOrPrefix))
|
|
704
695
|
return idOrPrefix;
|
|
705
696
|
const matches = ids.filter((id) => id.startsWith(idOrPrefix));
|
package/dist/mailbox.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface Letter {
|
|
|
27
27
|
ts: number;
|
|
28
28
|
}
|
|
29
29
|
export declare const MAX_BODY_CHARS: number;
|
|
30
|
-
/** Atomic deposit: write to a tmp sibling, then rename into place. */
|
|
30
|
+
/** Atomic deposit: write to a random exclusive tmp sibling, then rename into place. */
|
|
31
31
|
export declare function deposit(root: string, toAddr: string, letter: Letter): void;
|
|
32
32
|
/**
|
|
33
33
|
* Take every letter in the inbox, oldest first. Each letter is unlinked as
|
|
@@ -44,10 +44,24 @@ export declare function unreadCount(root: string, addr: string): number;
|
|
|
44
44
|
* a process alive.
|
|
45
45
|
*/
|
|
46
46
|
export declare function watchInbox(root: string, addr: string, onMail: () => void): () => void;
|
|
47
|
+
export interface InboxClaim {
|
|
48
|
+
claimToken: string;
|
|
49
|
+
fileTokens: string[];
|
|
50
|
+
}
|
|
51
|
+
/** Atomically detach the current non-empty inbox and return its durable tokens. */
|
|
52
|
+
export declare function claimInbox(root: string, addr: string): InboxClaim | null;
|
|
53
|
+
/** Enumerate durable claims left by this or a crashed consumer. */
|
|
54
|
+
export declare function recoverInboxClaims(root: string, addr: string): InboxClaim[];
|
|
55
|
+
/** Read and validate one claimed letter without exposing or following a path. */
|
|
56
|
+
export declare function readClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): Letter | null;
|
|
57
|
+
/** Permanently acknowledge/delete one claimed letter. Idempotent. */
|
|
58
|
+
export declare function ackClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
|
|
59
|
+
/** Atomically return one claimed letter to the current inbox. */
|
|
60
|
+
export declare function requeueClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
|
|
47
61
|
/**
|
|
48
62
|
* Consumption receipt: after depositing to a LIVE target, wait briefly for
|
|
49
|
-
* the letter to vanish
|
|
50
|
-
*
|
|
63
|
+
* the exact letter to vanish from both the live inbox and durable claims.
|
|
64
|
+
* 'delivered' means the receiver acknowledged it, not merely claimed it.
|
|
51
65
|
*/
|
|
52
66
|
export declare function awaitReceipt(root: string, toAddr: string, letter: Letter, timeoutMs?: number): Promise<'delivered' | 'queued'>;
|
|
53
67
|
export interface OutAsk {
|
|
@@ -82,3 +96,5 @@ export declare function readAudit(root: string, limit?: number): AuditRecord[];
|
|
|
82
96
|
export declare function resolveAskByRef(root: string, addr: string, replyTo: string): Letter | null;
|
|
83
97
|
/** Asks we have received and not yet answered, oldest first. */
|
|
84
98
|
export declare function pendingAsks(root: string, addr: string): Letter[];
|
|
99
|
+
/** Outgoing ask ids, used by the Pi entry point for prefix resolution. */
|
|
100
|
+
export declare function outgoingAskIds(root: string, addr: string): string[];
|