@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.14
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/CHANGELOG.md +241 -0
- package/README.md +268 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +34 -2
- package/dist/claim.js +86 -5
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +98 -33
- package/dist/commands.d.ts +70 -0
- package/dist/commands.js +257 -0
- package/dist/confine.d.ts +47 -7
- package/dist/confine.js +148 -19
- package/dist/env.d.ts +17 -26
- package/dist/env.js +73 -68
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +30 -6
- package/dist/hosts/daytona.js +15 -3
- package/dist/hosts/local-host.d.ts +7 -1
- package/dist/hosts/local-host.js +30 -16
- package/dist/index.d.ts +31 -15
- package/dist/index.js +23 -15
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +5 -2
- package/dist/lease.js +3 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +64 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +52 -10
- package/dist/run.js +42 -7
- package/dist/status.d.ts +11 -3
- package/dist/status.js +15 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +22 -3
- package/dist/supervise.js +40 -13
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +46 -13
- package/package.json +12 -7
package/dist/commands.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// The commands a run's environment starts, recorded where the next open of the same claim finds them. pi makes each
|
|
2
|
+
// command the leader of a process group of its own, so killing an instance does not reach its commands: a host's unit
|
|
3
|
+
// cgroup or a container ends them with it, and a run on a local directory has neither. While commands run, the instance
|
|
4
|
+
// walks its own process tree (`/proc/<pid>/task/<tid>/children` on Linux, one ps listing on macOS: process-table.ts) and
|
|
5
|
+
// records each of its commands' group leader and every descendant it has seen, each by pid, start and boot. Its commands
|
|
6
|
+
// are the children that lead their own group and carry its mark (COMMANDS_MARK=<its token>) in their environment:
|
|
7
|
+
// another environment's commands in the same process, or the app's own helpers, are not its to record. The next open,
|
|
8
|
+
// which holds the owner lock, kills whatever a previous incarnation recorded that is still the same process (pid, start
|
|
9
|
+
// and boot all match), waits until it is gone, and clears the records, before the Harness opens. Only the instance writes
|
|
10
|
+
// records: a command never does. Nothing stops a command from writing one too, and none needs stopping: a run's commands
|
|
11
|
+
// run as the instance's user (or, with `runAs`, as another one that cannot write the instance's 0700 directory), so a
|
|
12
|
+
// record a command forged names only what that command could already kill itself. A record grants nothing.
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { HeldError } from "./errors.js";
|
|
17
|
+
import { processTable, procTable } from "./process-table.js";
|
|
18
|
+
/** The environment variable that marks a command as one environment's: its value is that recorder's token. */
|
|
19
|
+
export const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
|
|
20
|
+
const tableOf = (options) => options.table ?? (options.proc === undefined ? processTable() : procTable(options.proc));
|
|
21
|
+
/** Whether `mark` is still the process it was: same pid, same start, not a zombie. Never pid 1 or below. */
|
|
22
|
+
function same(look, mark) {
|
|
23
|
+
return mark.pid > 1 && look.info(mark.pid)?.start === mark.start;
|
|
24
|
+
}
|
|
25
|
+
const RECORD = /^(\d+)-(\d+)\.json$/;
|
|
26
|
+
/**
|
|
27
|
+
* Records this environment's command groups under this process while commands run. `started()` and `finished()` bracket
|
|
28
|
+
* each command, which carries `COMMANDS_MARK=<token>` in its environment; while any runs, the tree is walked soon after a
|
|
29
|
+
* start and then every `everyMs`. A record is written when its group gains a process and removed once nothing in it is
|
|
30
|
+
* alive. A walk that fails (a record cannot be written: a full disk) goes to `onError` the first time, never out of a
|
|
31
|
+
* timer, and the next walk tries again.
|
|
32
|
+
*/
|
|
33
|
+
export class CommandRecorder {
|
|
34
|
+
/** The value of `COMMANDS_MARK` in this environment's commands. */
|
|
35
|
+
token = randomUUID();
|
|
36
|
+
#dir;
|
|
37
|
+
#table;
|
|
38
|
+
#instance;
|
|
39
|
+
#everyMs;
|
|
40
|
+
#boot;
|
|
41
|
+
#onError;
|
|
42
|
+
#reported = false;
|
|
43
|
+
#records = new Map();
|
|
44
|
+
/** The records on disk, by key; a group's first walk writes its record even before it has members. */
|
|
45
|
+
#written = new Set();
|
|
46
|
+
#active = 0;
|
|
47
|
+
#timer;
|
|
48
|
+
constructor(dir, options = {}) {
|
|
49
|
+
this.#dir = dir;
|
|
50
|
+
this.#table = tableOf(options);
|
|
51
|
+
this.#instance = options.instance ?? process.pid;
|
|
52
|
+
this.#everyMs = options.everyMs ?? this.#table.walkMs;
|
|
53
|
+
this.#boot = this.#table.bootId();
|
|
54
|
+
this.#onError = options.onError;
|
|
55
|
+
}
|
|
56
|
+
/** A command is about to start. */
|
|
57
|
+
started() {
|
|
58
|
+
if (this.#boot === undefined)
|
|
59
|
+
return; // nothing could verify a record on the next open
|
|
60
|
+
if (this.#active++ === 0) {
|
|
61
|
+
this.#timer = setInterval(() => this.scan(), this.#everyMs);
|
|
62
|
+
this.#timer.unref();
|
|
63
|
+
}
|
|
64
|
+
setTimeout(() => this.scan(), 20).unref();
|
|
65
|
+
}
|
|
66
|
+
/** A command ended (its result is in). The last one stops the walks after a final one. */
|
|
67
|
+
finished() {
|
|
68
|
+
if (this.#boot === undefined || this.#active === 0)
|
|
69
|
+
return;
|
|
70
|
+
if (--this.#active === 0) {
|
|
71
|
+
clearInterval(this.#timer);
|
|
72
|
+
this.#timer = undefined;
|
|
73
|
+
this.scan();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Stops recording (the environment closed); the records stay for the next open. */
|
|
77
|
+
close() {
|
|
78
|
+
clearInterval(this.#timer);
|
|
79
|
+
this.#timer = undefined;
|
|
80
|
+
this.#active = 0;
|
|
81
|
+
}
|
|
82
|
+
/** One walk. It is called from timers, so it never throws: a failure goes to `onError` once, and the next walk retries. */
|
|
83
|
+
scan() {
|
|
84
|
+
try {
|
|
85
|
+
this.#scan();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (this.#reported)
|
|
89
|
+
return;
|
|
90
|
+
this.#reported = true;
|
|
91
|
+
this.#onError?.(error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Record what is new under each of this environment's command groups, and drop the records of groups that are gone. */
|
|
95
|
+
#scan() {
|
|
96
|
+
const boot = this.#boot;
|
|
97
|
+
if (boot === undefined)
|
|
98
|
+
return;
|
|
99
|
+
const look = this.#table.snapshot();
|
|
100
|
+
const own = look.info(this.#instance);
|
|
101
|
+
if (own === undefined)
|
|
102
|
+
return;
|
|
103
|
+
// New commands: children of this process that lead their own group (pi spawns each one so) and carry this
|
|
104
|
+
// environment's mark. Once recorded, a leader stays its environment's whatever it execs next (same pid and start).
|
|
105
|
+
for (const pid of look.children(this.#instance)) {
|
|
106
|
+
const leader = look.info(pid);
|
|
107
|
+
if (leader === undefined || leader.pgrp !== pid || leader.pgrp === own.pgrp)
|
|
108
|
+
continue;
|
|
109
|
+
const key = `${pid}-${leader.start}`;
|
|
110
|
+
if (this.#records.has(key) || !look.marked(pid, COMMANDS_MARK, this.token))
|
|
111
|
+
continue;
|
|
112
|
+
this.#records.set(key, { leader: { pid, start: leader.start }, members: new Map() });
|
|
113
|
+
}
|
|
114
|
+
// Every record: walk from each of its processes still alive, the leader and every member, so a background worker whose
|
|
115
|
+
// leader (or parent) exited and was reparented away is still followed, and so are the children it starts later.
|
|
116
|
+
for (const [key, record] of this.#records) {
|
|
117
|
+
const live = [record.leader, ...record.members.values()].filter((mark) => same(look, mark));
|
|
118
|
+
if (live.length === 0) {
|
|
119
|
+
this.#records.delete(key);
|
|
120
|
+
this.#written.delete(key);
|
|
121
|
+
rmSync(join(this.#dir, `${key}.json`), { force: true });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let grew = !this.#written.has(key);
|
|
125
|
+
const walk = (parent) => {
|
|
126
|
+
for (const child of look.children(parent)) {
|
|
127
|
+
const s = look.info(child);
|
|
128
|
+
if (s === undefined || child === record.leader.pid)
|
|
129
|
+
continue;
|
|
130
|
+
const known = record.members.get(child)?.start === s.start;
|
|
131
|
+
if (!known) {
|
|
132
|
+
record.members.set(child, { pid: child, start: s.start });
|
|
133
|
+
grew = true;
|
|
134
|
+
}
|
|
135
|
+
if (!known || !live.some((mark) => mark.pid === child))
|
|
136
|
+
walk(child);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
for (const mark of live)
|
|
140
|
+
walk(mark.pid);
|
|
141
|
+
if (grew)
|
|
142
|
+
this.#write(key, { boot, leader: record.leader, members: [...record.members.values()] });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
#write(key, record) {
|
|
146
|
+
mkdirSync(this.#dir, { recursive: true, mode: 0o700 });
|
|
147
|
+
const file = join(this.#dir, `${key}.json`);
|
|
148
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(record));
|
|
149
|
+
renameSync(`${file}.tmp`, file);
|
|
150
|
+
this.#written.add(key);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Ends what a previous incarnation's commands left running: for each record of this boot, the leader's whole group when
|
|
155
|
+
* the leader is still the recorded process, and each recorded descendant that still is. Waits up to `timeoutMs` until
|
|
156
|
+
* none of them is alive. A record whose processes are all gone is removed; one that still names a live process (it did
|
|
157
|
+
* not die at the deadline: a process in an uninterruptible wait) is rewritten with just those, so the next open tries
|
|
158
|
+
* again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
|
|
159
|
+
*/
|
|
160
|
+
export async function reapCommands(dir, options = {}) {
|
|
161
|
+
const table = tableOf(options);
|
|
162
|
+
const signal = options.kill ?? ((target, name) => process.kill(target, name));
|
|
163
|
+
let files;
|
|
164
|
+
try {
|
|
165
|
+
files = readdirSync(dir).filter((name) => RECORD.test(name));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return { signalled: 0, survivors: [] };
|
|
169
|
+
}
|
|
170
|
+
if (files.length === 0) {
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
return { signalled: 0, survivors: [] };
|
|
173
|
+
}
|
|
174
|
+
const boot = table.bootId();
|
|
175
|
+
const before = table.snapshot();
|
|
176
|
+
const own = before.info(process.pid);
|
|
177
|
+
let signalled = 0;
|
|
178
|
+
/**
|
|
179
|
+
* False for what is never a target: this process, its group, and a pid of 1 or below, since `kill(0)` is the caller's
|
|
180
|
+
* group and `kill(-1)` every process the user may signal.
|
|
181
|
+
*/
|
|
182
|
+
const kill = (target, mark) => {
|
|
183
|
+
if (!(mark.pid > 1) || mark.pid === process.pid || (target < 0 && -target === own?.pgrp))
|
|
184
|
+
return false;
|
|
185
|
+
try {
|
|
186
|
+
signal(target, "SIGKILL");
|
|
187
|
+
signalled++;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// gone between the check and the kill, or not ours to signal: still a target, a survivor if it is alive
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
};
|
|
194
|
+
const pending = [];
|
|
195
|
+
for (const name of files) {
|
|
196
|
+
const file = join(dir, name);
|
|
197
|
+
let record;
|
|
198
|
+
try {
|
|
199
|
+
record = JSON.parse(readFileSync(file, "utf8"));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
rmSync(file, { force: true }); // a torn write of a record names nothing
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (boot === undefined || record.boot !== boot) {
|
|
206
|
+
rmSync(file, { force: true }); // another boot's pids mean nothing on this one
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const leader = record.leader !== null && same(before, record.leader) && kill(-record.leader.pid, record.leader) ? record.leader : null;
|
|
210
|
+
// A member that is the leader went with its group.
|
|
211
|
+
const members = (record.members ?? []).filter((member) => member.pid !== record.leader?.pid && same(before, member) && kill(member.pid, member));
|
|
212
|
+
pending.push({ file, boot, leader, members });
|
|
213
|
+
}
|
|
214
|
+
const alive = () => {
|
|
215
|
+
const marks = pending.flatMap((p) => [...(p.leader ? [p.leader] : []), ...p.members]);
|
|
216
|
+
if (marks.length === 0)
|
|
217
|
+
return marks;
|
|
218
|
+
const look = table.snapshot();
|
|
219
|
+
return marks.filter((mark) => same(look, mark));
|
|
220
|
+
};
|
|
221
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5_000);
|
|
222
|
+
while (alive().length > 0 && Date.now() < deadline)
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
224
|
+
const survivors = [];
|
|
225
|
+
const after = pending.length === 0 ? before : table.snapshot();
|
|
226
|
+
for (const record of pending) {
|
|
227
|
+
const leader = record.leader !== null && same(after, record.leader) ? record.leader : null;
|
|
228
|
+
const members = record.members.filter((member) => same(after, member));
|
|
229
|
+
if (leader === null && members.length === 0) {
|
|
230
|
+
rmSync(record.file, { force: true });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
survivors.push(...(leader ? [leader] : []), ...members);
|
|
234
|
+
writeFileSync(`${record.file}.tmp`, JSON.stringify({ boot: record.boot, leader, members }));
|
|
235
|
+
renameSync(`${record.file}.tmp`, record.file);
|
|
236
|
+
}
|
|
237
|
+
if (survivors.length === 0)
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
return { signalled, survivors };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* `reapCommands` at an open: refuses the run (`HeldError("commands")`, exit 76) when a process a killed incarnation's
|
|
243
|
+
* commands left is still alive after it, since the run must not resume beside it. Its record stays, and the next open
|
|
244
|
+
* tries again.
|
|
245
|
+
*/
|
|
246
|
+
export async function reapOrRefuse(dir, options = {}) {
|
|
247
|
+
const reaped = await reapCommands(dir, options);
|
|
248
|
+
if (reaped.survivors.length > 0) {
|
|
249
|
+
const look = tableOf(options).snapshot();
|
|
250
|
+
const pids = reaped.survivors.map((s) => s.pid);
|
|
251
|
+
throw new HeldError("commands", `${pids.length} process(es) a killed incarnation's commands started are still alive after SIGKILL: ` +
|
|
252
|
+
`${reaped.survivors.map((s) => `pid ${s.pid} (${look.commandLine(s.pid)})`).join(", ")}. ` +
|
|
253
|
+
`The run does not resume beside them. End them (kill -9 ${pids.join(" ")}, as their owner, or wait for them) and open the run again; ` +
|
|
254
|
+
`they stay recorded in ${dir} until then.`);
|
|
255
|
+
}
|
|
256
|
+
return reaped;
|
|
257
|
+
}
|
package/dist/confine.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export declare class PathOutsideWorkError extends FileError {
|
|
|
14
14
|
/** Test seam: runs after a path was resolved and checked, before anything is opened. */
|
|
15
15
|
export declare const confineHooks: {
|
|
16
16
|
afterCheck?: (canonical: string) => Promise<void> | void;
|
|
17
|
+
/** After `createTempDir` made a directory and before it is granted (a command may act in between). */
|
|
18
|
+
afterTempDir?: (dir: string) => Promise<void> | void;
|
|
17
19
|
};
|
|
18
20
|
/** pi's mapping from a Node error to its `FileError` codes (pi does not export it). */
|
|
19
21
|
export declare function fileError(error: unknown, path: string): FileError;
|
|
@@ -35,8 +37,9 @@ export interface PinSpec {
|
|
|
35
37
|
readonly strict?: boolean;
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
38
|
-
* A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths to hand to pi's
|
|
39
|
-
* path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
40
|
+
* A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths (by path, the real ones) to hand to pi's
|
|
41
|
+
* own implementation; every path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
42
|
+
* the operation is done.
|
|
40
43
|
*/
|
|
41
44
|
export declare class Pin {
|
|
42
45
|
#private;
|
|
@@ -47,24 +50,58 @@ export declare class Pin {
|
|
|
47
50
|
readonly dir: string;
|
|
48
51
|
/** The target below `dir`, for operations that do not follow a last symlink. */
|
|
49
52
|
readonly entry: string;
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
/**
|
|
54
|
+
* `held` is the walk's: whether it went by path, and then the held directory's real path; and the names of the
|
|
55
|
+
* confinement's roots (`Confinement`'s `names`).
|
|
56
|
+
*/
|
|
57
|
+
constructor(handle: FileHandle, canonical: string, name: string, dirMode: boolean, held?: {
|
|
58
|
+
byPath: boolean;
|
|
59
|
+
path: string;
|
|
60
|
+
names?: readonly RootName[];
|
|
61
|
+
});
|
|
62
|
+
/** `text` with the held paths replaced by the real ones, and a root named as the caller names it. */
|
|
52
63
|
translate(text: string): string;
|
|
53
64
|
/** An error pi or Node raised on a held path, as a FileError that names the real one. */
|
|
54
65
|
error(error: unknown, path: string): FileError;
|
|
55
66
|
restore<T>(result: Result<T, FileError>): Result<T, FileError>;
|
|
56
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Opens the target's last component, which is never followed, and returns its handle; the caller closes it. By path,
|
|
69
|
+
* the directory's path must still name the directory held once the file is open.
|
|
70
|
+
*/
|
|
57
71
|
openFile(flags: number, mode?: number): Promise<FileHandle>;
|
|
58
72
|
binaryReader(reader: BinaryReader): BinaryReader;
|
|
59
73
|
/** The reader keeps the pin open: pi's reader names each entry through `dir` as it pages. */
|
|
60
74
|
dirReader(reader: DirReader): DirReader;
|
|
61
75
|
close(): Promise<void>;
|
|
62
76
|
}
|
|
77
|
+
/** A root's canonical path, as a pattern that matches it whole in a path or a message, and the name the caller gave it. */
|
|
78
|
+
interface RootName {
|
|
79
|
+
readonly canonical: RegExp;
|
|
80
|
+
readonly named: string;
|
|
81
|
+
}
|
|
63
82
|
export declare class Confinement {
|
|
83
|
+
#private;
|
|
64
84
|
readonly work: string;
|
|
65
85
|
readonly readOnly: readonly string[];
|
|
66
|
-
/**
|
|
67
|
-
|
|
86
|
+
/** Whether the walk goes by path (macOS, which has no `/proc/self/fd`; see the top of this file). */
|
|
87
|
+
readonly byPath: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Canonical directories: `work` may be read and written, `readOnly` only read. `byPath` defaults to true on macOS only.
|
|
90
|
+
* `names` maps a canonical root to the path the caller names it by, where they differ (a root under a symlinked
|
|
91
|
+
* directory, as `/tmp` and `/var` are on macOS): results name paths under it as the caller does, as pi's own do.
|
|
92
|
+
*/
|
|
93
|
+
constructor(work: string, readOnly: readonly string[], options?: {
|
|
94
|
+
readonly byPath?: boolean;
|
|
95
|
+
readonly names?: ReadonlyMap<string, string>;
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
|
|
99
|
+
* read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
|
|
100
|
+
* directory is later swapped for a link, a path through it resolves elsewhere and is refused.
|
|
101
|
+
*/
|
|
102
|
+
allowWrites(dir: string): void;
|
|
103
|
+
/** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
|
|
104
|
+
forgetWrites(dir: string): void;
|
|
68
105
|
/** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
|
|
69
106
|
locate(abs: string, original: string, access: "read" | "write", follow: boolean): Promise<{
|
|
70
107
|
canonical: string;
|
|
@@ -78,3 +115,6 @@ export declare class Confinement {
|
|
|
78
115
|
create?: boolean;
|
|
79
116
|
}): Promise<Pin>;
|
|
80
117
|
}
|
|
118
|
+
/** Whether `path` still names the directory `held` holds (same device and inode); false when it cannot be read. */
|
|
119
|
+
export declare function namesHeld(path: string, held: FileHandle): Promise<boolean>;
|
|
120
|
+
export {};
|
package/dist/confine.js
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
// openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS) done in user space. Node has no openat, so a handle's directory is named
|
|
11
11
|
// `/proc/self/fd/<fd>`: the kernel jumps straight to the held inode there and never re-walks the directory's own path. A
|
|
12
12
|
// swapped component fails the open (ELOOP or ENOTDIR) and the call is refused as changed; nothing is retried.
|
|
13
|
+
//
|
|
14
|
+
// macOS has no /proc/self/fd, so there (`byPath`) each step is opened by its full path, still with O_NOFOLLOW, and the
|
|
15
|
+
// walk ends by checking that the path still resolves to itself and names the directory held (same device and inode). A
|
|
16
|
+
// component that is a symlink, or was swapped for one before that check, is refused as changed. The operation then
|
|
17
|
+
// names the path again, so a command that swaps a directory above the target between the check and the operation can
|
|
18
|
+
// win: by path, the confinement holds against an agent's paths and the links already in place, not a command racing it.
|
|
13
19
|
import { constants, existsSync, realpathSync } from "node:fs";
|
|
14
|
-
import { lstat, mkdir, open, readlink, realpath } from "node:fs/promises";
|
|
20
|
+
import { lstat, mkdir, open, readlink, realpath, stat } from "node:fs/promises";
|
|
15
21
|
import { basename, dirname, join } from "node:path";
|
|
16
22
|
import { err, FileError, ok } from "@earendil-works/pi-durable/env";
|
|
17
23
|
/**
|
|
@@ -119,8 +125,9 @@ export const inside = (path, root) => path === root || path.startsWith(root.ends
|
|
|
119
125
|
const FILE_INFO_KEYS = ["name", "path", "kind", "size", "mtimeMs"];
|
|
120
126
|
const isFileInfo = (value) => typeof value === "object" && value !== null && FILE_INFO_KEYS.every((key) => key in value) && typeof value.path === "string";
|
|
121
127
|
/**
|
|
122
|
-
* A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths to hand to pi's
|
|
123
|
-
* path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
128
|
+
* A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths (by path, the real ones) to hand to pi's
|
|
129
|
+
* own implementation; every path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
130
|
+
* the operation is done.
|
|
124
131
|
*/
|
|
125
132
|
export class Pin {
|
|
126
133
|
canonical;
|
|
@@ -134,17 +141,29 @@ export class Pin {
|
|
|
134
141
|
/** What `dir` stands for: the directory itself, or the directory holding the entry. */
|
|
135
142
|
#real;
|
|
136
143
|
#closed = false;
|
|
137
|
-
|
|
144
|
+
#byPath;
|
|
145
|
+
#names;
|
|
146
|
+
/**
|
|
147
|
+
* `held` is the walk's: whether it went by path, and then the held directory's real path; and the names of the
|
|
148
|
+
* confinement's roots (`Confinement`'s `names`).
|
|
149
|
+
*/
|
|
150
|
+
constructor(handle, canonical, name, dirMode, held = { byPath: false, path: "" }) {
|
|
138
151
|
this.#handle = handle;
|
|
139
152
|
this.canonical = canonical;
|
|
140
153
|
this.name = name;
|
|
141
|
-
this
|
|
154
|
+
this.#byPath = held.byPath;
|
|
155
|
+
this.#names = held.names ?? [];
|
|
156
|
+
this.dir = held.byPath ? held.path : `/proc/self/fd/${handle.fd}`;
|
|
142
157
|
this.entry = dirMode ? this.dir : name === "" ? `${this.dir}/.` : `${this.dir}/${name}`;
|
|
143
158
|
this.#real = dirMode || name === "" ? canonical : dirname(canonical);
|
|
144
159
|
}
|
|
145
|
-
/** `text` with the held paths replaced by the real ones. */
|
|
160
|
+
/** `text` with the held paths replaced by the real ones, and a root named as the caller names it. */
|
|
146
161
|
translate(text) {
|
|
147
|
-
|
|
162
|
+
let real = text.split(this.entry).join(this.canonical).split(this.dir).join(this.#real);
|
|
163
|
+
// A function, so `$&` or `$$` in the name is kept as written rather than expanded.
|
|
164
|
+
for (const { canonical, named } of this.#names)
|
|
165
|
+
real = real.replace(canonical, () => named);
|
|
166
|
+
return real;
|
|
148
167
|
}
|
|
149
168
|
/** An error pi or Node raised on a held path, as a FileError that names the real one. */
|
|
150
169
|
error(error, path) {
|
|
@@ -169,16 +188,25 @@ export class Pin {
|
|
|
169
188
|
}
|
|
170
189
|
return value;
|
|
171
190
|
}
|
|
172
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Opens the target's last component, which is never followed, and returns its handle; the caller closes it. By path,
|
|
193
|
+
* the directory's path must still name the directory held once the file is open.
|
|
194
|
+
*/
|
|
173
195
|
async openFile(flags, mode) {
|
|
196
|
+
let file;
|
|
174
197
|
try {
|
|
175
|
-
|
|
198
|
+
file = await open(this.entry, flags | FILE, mode);
|
|
176
199
|
}
|
|
177
200
|
catch (error) {
|
|
178
201
|
if (errno(error) === "ELOOP")
|
|
179
202
|
throw changed(this.canonical);
|
|
180
203
|
throw error;
|
|
181
204
|
}
|
|
205
|
+
if (this.#byPath && !(await namesHeld(this.dir, this.#handle))) {
|
|
206
|
+
await file.close().catch(() => undefined);
|
|
207
|
+
throw changed(this.canonical);
|
|
208
|
+
}
|
|
209
|
+
return file;
|
|
182
210
|
}
|
|
183
211
|
binaryReader(reader) {
|
|
184
212
|
return {
|
|
@@ -213,15 +241,44 @@ const changed = (path) => new PathOutsideWorkError(path, "changed", `${path}: a
|
|
|
213
241
|
export class Confinement {
|
|
214
242
|
work;
|
|
215
243
|
readOnly;
|
|
216
|
-
/**
|
|
217
|
-
|
|
244
|
+
/** Whether the walk goes by path (macOS, which has no `/proc/self/fd`; see the top of this file). */
|
|
245
|
+
byPath;
|
|
246
|
+
/** Directories handed out for writing after construction (the environment's temp dirs), canonical. */
|
|
247
|
+
#handed = new Set();
|
|
248
|
+
#names;
|
|
249
|
+
/**
|
|
250
|
+
* Canonical directories: `work` may be read and written, `readOnly` only read. `byPath` defaults to true on macOS only.
|
|
251
|
+
* `names` maps a canonical root to the path the caller names it by, where they differ (a root under a symlinked
|
|
252
|
+
* directory, as `/tmp` and `/var` are on macOS): results name paths under it as the caller does, as pi's own do.
|
|
253
|
+
*/
|
|
254
|
+
constructor(work, readOnly, options = {}) {
|
|
218
255
|
this.work = work;
|
|
219
256
|
this.readOnly = readOnly;
|
|
257
|
+
this.byPath = options.byPath ?? process.platform === "darwin";
|
|
258
|
+
// Longest first, so a root inside another is named before the outer one could take its prefix.
|
|
259
|
+
this.#names = [...(options.names ?? [])]
|
|
260
|
+
.sort(([a], [b]) => b.length - a.length)
|
|
261
|
+
.map(([canonical, named]) => ({ canonical: new RegExp(`${canonical.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![^/'"\\s,;:)\\]])`, "g"), named }));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
|
|
265
|
+
* read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
|
|
266
|
+
* directory is later swapped for a link, a path through it resolves elsewhere and is refused.
|
|
267
|
+
*/
|
|
268
|
+
allowWrites(dir) {
|
|
269
|
+
if (this.readOnly.some((root) => dirname(dir) === root))
|
|
270
|
+
this.#handed.add(dir);
|
|
271
|
+
}
|
|
272
|
+
/** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
|
|
273
|
+
forgetWrites(dir) {
|
|
274
|
+
for (const handed of this.#handed)
|
|
275
|
+
if (inside(handed, dir))
|
|
276
|
+
this.#handed.delete(handed);
|
|
220
277
|
}
|
|
221
278
|
/** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
|
|
222
279
|
async locate(abs, original, access, follow) {
|
|
223
280
|
const canonical = follow ? await canonicalize(abs) : join(await canonicalize(dirname(abs)), basename(abs));
|
|
224
|
-
const roots = access === "write" ? [this.work] : [this.work, ...this.readOnly];
|
|
281
|
+
const roots = access === "write" ? [this.work, ...this.#handed] : [this.work, ...this.#handed, ...this.readOnly];
|
|
225
282
|
const root = roots.find((candidate) => inside(canonical, candidate));
|
|
226
283
|
if (root === undefined) {
|
|
227
284
|
const where = canonical === abs ? "" : ` (it resolves to ${canonical})`;
|
|
@@ -235,18 +292,23 @@ export class Confinement {
|
|
|
235
292
|
await confineHooks.afterCheck?.(canonical);
|
|
236
293
|
const names = relative(root, canonical);
|
|
237
294
|
if (names.length === 0 && spec.strict === true) {
|
|
238
|
-
|
|
295
|
+
// a handed directory itself (a tool removing the temp dir it was given): pinned as an entry of the read-only root that
|
|
296
|
+
// holds it, so it can go; the workspace root never can
|
|
297
|
+
const holder = this.#handed.has(root) ? this.readOnly.find((candidate) => dirname(root) === candidate) : undefined;
|
|
298
|
+
if (holder === undefined)
|
|
299
|
+
throw new PathOutsideWorkError(original, "outside_work", `${original}: the workspace root itself cannot be removed or renamed`);
|
|
300
|
+
return new Pin(await walk(holder, [], "none", canonical, this.byPath), canonical, basename(root), false, { byPath: this.byPath, path: holder, names: this.#names });
|
|
239
301
|
}
|
|
240
302
|
const name = names.pop() ?? "";
|
|
241
|
-
const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical);
|
|
242
|
-
return new Pin(handle, canonical, name, false);
|
|
303
|
+
const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical, this.byPath);
|
|
304
|
+
return new Pin(handle, canonical, name, false, { byPath: this.byPath, path: join(root, ...names), names: this.#names });
|
|
243
305
|
}
|
|
244
306
|
/** Pins the target directory itself; `create` makes it and the directories above it when they are missing. */
|
|
245
307
|
async pinDir(abs, original, spec) {
|
|
246
308
|
const { canonical, root } = await this.locate(abs, original, spec.access, true);
|
|
247
309
|
await confineHooks.afterCheck?.(canonical);
|
|
248
|
-
const handle = await walk(root, relative(root, canonical), spec.create === true ? "all" : "none", canonical);
|
|
249
|
-
return new Pin(handle, canonical, basename(canonical), true);
|
|
310
|
+
const handle = await walk(root, relative(root, canonical), spec.create === true ? "all" : "none", canonical, this.byPath);
|
|
311
|
+
return new Pin(handle, canonical, basename(canonical), true, { byPath: this.byPath, path: canonical, names: this.#names });
|
|
250
312
|
}
|
|
251
313
|
}
|
|
252
314
|
const relative = (root, path) => (path === root ? [] : path.slice(root.length + (root.endsWith("/") ? 0 : 1)).split("/"));
|
|
@@ -264,8 +326,23 @@ async function swapped(error, path) {
|
|
|
264
326
|
return false;
|
|
265
327
|
return (await lstat(path).catch(() => undefined))?.isSymbolicLink() === true;
|
|
266
328
|
}
|
|
267
|
-
/**
|
|
268
|
-
async function
|
|
329
|
+
/** Whether `path` still names the directory `held` holds (same device and inode); false when it cannot be read. */
|
|
330
|
+
export async function namesHeld(path, held) {
|
|
331
|
+
try {
|
|
332
|
+
const [named, opened] = await Promise.all([stat(path, { bigint: true }), held.stat({ bigint: true })]);
|
|
333
|
+
return named.dev === opened.dev && named.ino === opened.ino;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Opens `root`, then each name below it, every step relative to the handle before it and without following links. By
|
|
341
|
+
* path, every step is opened by its full path instead, and the last must then resolve to itself and be the one held.
|
|
342
|
+
*/
|
|
343
|
+
async function walk(root, names, create, canonical, byPath = false) {
|
|
344
|
+
if (byPath)
|
|
345
|
+
return walkByPath(root, names, create, canonical);
|
|
269
346
|
let dir;
|
|
270
347
|
try {
|
|
271
348
|
dir = await open(root, DIRECTORY);
|
|
@@ -317,3 +394,55 @@ async function walk(root, names, create, canonical) {
|
|
|
317
394
|
throw error;
|
|
318
395
|
}
|
|
319
396
|
}
|
|
397
|
+
async function walkByPath(root, names, create, canonical) {
|
|
398
|
+
let real = root;
|
|
399
|
+
let dir;
|
|
400
|
+
try {
|
|
401
|
+
dir = await open(root, DIRECTORY);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (await swapped(error, root))
|
|
405
|
+
throw changed(canonical);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
for (const name of names) {
|
|
410
|
+
if (name === "" || name === "." || name === ".." || name.includes("\0"))
|
|
411
|
+
throw changed(canonical);
|
|
412
|
+
real = join(real, name);
|
|
413
|
+
let next;
|
|
414
|
+
try {
|
|
415
|
+
next = await open(real, DIRECTORY);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
if (create === "all" && errno(error) === "ENOENT") {
|
|
419
|
+
await mkdir(real).catch((made) => {
|
|
420
|
+
if (errno(made) !== "EEXIST")
|
|
421
|
+
throw made;
|
|
422
|
+
});
|
|
423
|
+
next = await open(real, DIRECTORY).catch(async (opened) => {
|
|
424
|
+
throw (await swapped(opened, real)) ? changed(canonical) : opened;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
else if (await swapped(error, real)) {
|
|
428
|
+
throw changed(canonical);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
await dir.close();
|
|
435
|
+
dir = next;
|
|
436
|
+
}
|
|
437
|
+
// Each step was opened by its full path, and only the last component of each open is never followed: the path must
|
|
438
|
+
// still resolve to itself (no component above is a link) and name the directory held (none was swapped and back).
|
|
439
|
+
const resolved = await realpath(real).catch(() => undefined);
|
|
440
|
+
if (resolved !== real || !(await namesHeld(real, dir)))
|
|
441
|
+
throw changed(canonical);
|
|
442
|
+
return dir;
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
await dir.close().catch(() => undefined);
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|