@modelprofile.com/browser-runtime 3.1.1 → 4.0.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.
@@ -0,0 +1,1393 @@
1
+ import * as plugins from './plugins.js';
2
+ import { BrowserRuntimeError } from './errors.js';
3
+ import { randomId } from './utils.js';
4
+
5
+ const metadataSchema = 1;
6
+ const maximumMetadataBytes = 4096;
7
+ const maximumProcFileBytes = 2 * 1024 * 1024;
8
+ const anchorInitializationAttempts = 200;
9
+ const generationIdPattern = /^[A-Za-z0-9_-]{32}$/u;
10
+ const hashPattern = /^[a-f0-9]{64}$/u;
11
+ const bootIdPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
12
+ const unsignedIntegerPattern = /^(?:0|[1-9][0-9]{0,24})$/u;
13
+
14
+ type TOwnershipState = 'active' | 'relinquished';
15
+ export type TOwnershipGenerationRemovalReason = 'stale' | 'startup-failure' | 'stop';
16
+
17
+ interface IRuntimeDirectoryIdentity {
18
+ uid: number;
19
+ device: string;
20
+ inode: string;
21
+ hash: string;
22
+ }
23
+
24
+ interface IOwnerMetadata {
25
+ schema: typeof metadataSchema;
26
+ state: TOwnershipState;
27
+ uid: number;
28
+ runtimeDirectoryDevice: string;
29
+ runtimeDirectoryInode: string;
30
+ runtimeDirectoryHash: string;
31
+ bootId: string;
32
+ pid: number;
33
+ processStartTicks: string;
34
+ generationId: string;
35
+ nonce: string;
36
+ }
37
+
38
+ export interface IBrowserRuntimeOwnershipTestingOptions {
39
+ procRoot?: string;
40
+ now?(): number;
41
+ beforeMetadataPublication?(): Promise<void>;
42
+ beforeGenerationRemoval?(reason: TOwnershipGenerationRemovalReason): Promise<void>;
43
+ }
44
+
45
+ export interface IBrowserRuntimeOwnershipOptions {
46
+ runtimeDirectory: string;
47
+ uid: number;
48
+ testing?: IBrowserRuntimeOwnershipTestingOptions;
49
+ onOwnershipLost?(): void;
50
+ }
51
+
52
+ export interface IBrowserRuntimeGeneration {
53
+ generationId: string;
54
+ generationDirectory: string;
55
+ profileRoot: string;
56
+ artifactRoot: string;
57
+ }
58
+
59
+ type TInspectionResult = 'clear' | 'held' | 'indeterminate';
60
+ type TProcessUidState = 'same' | 'other' | 'missing' | 'indeterminate';
61
+
62
+ const isMissingError = (errorArg: unknown): boolean => (
63
+ (errorArg as NodeJS.ErrnoException).code === 'ENOENT'
64
+ || (errorArg as NodeJS.ErrnoException).code === 'ESRCH'
65
+ );
66
+
67
+ const normalizeOwnershipError = (errorArg: unknown): BrowserRuntimeError => (
68
+ errorArg instanceof BrowserRuntimeError
69
+ ? errorArg
70
+ : new BrowserRuntimeError('FENCED', 'runtime ownership could not be verified')
71
+ );
72
+
73
+ const statIdentityMatches = (
74
+ leftArg: plugins.fs.BigIntStats,
75
+ rightArg: plugins.fs.BigIntStats,
76
+ ): boolean => leftArg.dev === rightArg.dev && leftArg.ino === rightArg.ino;
77
+
78
+ const parseProcessStartTicks = (statTextArg: string, expectedPidArg?: number): string => {
79
+ if (
80
+ plugins.Buffer.byteLength(statTextArg, 'utf8') === 0
81
+ || plugins.Buffer.byteLength(statTextArg, 'utf8') > 16 * 1024
82
+ || /[\u0000\r]/u.test(statTextArg)
83
+ ) throw new BrowserRuntimeError('FENCED', 'process identity is malformed');
84
+ const statText = statTextArg.endsWith('\n') ? statTextArg.slice(0, -1) : statTextArg;
85
+ if (statText.includes('\n')) {
86
+ throw new BrowserRuntimeError('FENCED', 'process identity is malformed');
87
+ }
88
+ const commandStart = statText.indexOf(' (');
89
+ const commandEnd = statText.lastIndexOf(') ');
90
+ if (commandStart <= 0 || commandEnd < commandStart + 2) {
91
+ throw new BrowserRuntimeError('FENCED', 'process identity is malformed');
92
+ }
93
+ const pidText = statText.slice(0, commandStart);
94
+ const fieldsAfterCommand = statText.slice(commandEnd + 2).split(' ');
95
+ const processStartTicks = fieldsAfterCommand[19];
96
+ if (
97
+ !/^[1-9][0-9]{0,9}$/u.test(pidText)
98
+ || fieldsAfterCommand.length < 20
99
+ || !processStartTicks
100
+ || !unsignedIntegerPattern.test(processStartTicks)
101
+ ) throw new BrowserRuntimeError('FENCED', 'process identity is malformed');
102
+ if (expectedPidArg !== undefined && Number(pidText) !== expectedPidArg) {
103
+ throw new BrowserRuntimeError('FENCED', 'process identity is malformed');
104
+ }
105
+ return processStartTicks;
106
+ };
107
+
108
+ export class BrowserRuntimeOwnership {
109
+ public readonly runtimeDirectory: string;
110
+ public readonly lockPath: string;
111
+ public readonly anchorDirectory: string;
112
+ private readonly generationsRoot: string;
113
+ private readonly legacyProfileRoot: string;
114
+ private readonly legacyArtifactRoot: string;
115
+ private readonly uid: number;
116
+ private readonly procRoot: string;
117
+ private readonly now: () => number;
118
+ private readonly beforeMetadataPublication?: () => Promise<void>;
119
+ private readonly beforeGenerationRemoval?: (
120
+ reason: TOwnershipGenerationRemovalReason,
121
+ ) => Promise<void>;
122
+ private readonly onOwnershipLost?: () => void;
123
+ private directoryHandle?: plugins.fsPromises.FileHandle;
124
+ private directoryIdentity?: IRuntimeDirectoryIdentity;
125
+ private anchorDirectoryHandle?: plugins.fsPromises.FileHandle;
126
+ private anchorDirectoryIdentity?: plugins.fs.BigIntStats;
127
+ private anchorIdentity?: plugins.fs.BigIntStats;
128
+ private namedMutex?: plugins.smartipc.NamedMutex;
129
+ private namedMutexLease?: plugins.smartipc.NamedMutexLease;
130
+ private lockHandle?: plugins.fsPromises.FileHandle;
131
+ private lockIdentity?: plugins.fs.BigIntStats;
132
+ private createdLock = false;
133
+ private leaseLost = false;
134
+ private metadata?: IOwnerMetadata;
135
+ private generation?: IBrowserRuntimeGeneration;
136
+ private acquirePromise?: Promise<IBrowserRuntimeGeneration>;
137
+ private releasePromise?: Promise<void>;
138
+
139
+ constructor(optionsArg: IBrowserRuntimeOwnershipOptions) {
140
+ this.runtimeDirectory = plugins.path.resolve(optionsArg.runtimeDirectory);
141
+ this.lockPath = plugins.path.join(this.runtimeDirectory, 'runtime.lock');
142
+ this.anchorDirectory = plugins.path.join(this.runtimeDirectory, 'runtime.mutex');
143
+ this.generationsRoot = plugins.path.join(this.runtimeDirectory, 'generations');
144
+ this.legacyProfileRoot = plugins.path.join(this.runtimeDirectory, 'profiles');
145
+ this.legacyArtifactRoot = plugins.path.join(this.runtimeDirectory, 'artifacts');
146
+ this.uid = optionsArg.uid;
147
+ this.procRoot = optionsArg.testing?.procRoot ?? '/proc';
148
+ this.now = optionsArg.testing?.now ?? Date.now;
149
+ this.beforeMetadataPublication = optionsArg.testing?.beforeMetadataPublication;
150
+ this.beforeGenerationRemoval = optionsArg.testing?.beforeGenerationRemoval;
151
+ this.onOwnershipLost = optionsArg.onOwnershipLost;
152
+ }
153
+
154
+ public get owned(): boolean {
155
+ return Boolean(
156
+ this.metadata?.state === 'active'
157
+ && this.generation
158
+ && this.namedMutexLease?.state === 'active'
159
+ && !this.leaseLost,
160
+ );
161
+ }
162
+
163
+ public get cleanupPending(): boolean {
164
+ return Boolean(
165
+ this.lockHandle
166
+ || this.anchorDirectoryHandle
167
+ || this.directoryHandle
168
+ || (this.namedMutexLease && this.namedMutexLease.state !== 'released'),
169
+ );
170
+ }
171
+
172
+ public acquire(): Promise<IBrowserRuntimeGeneration> {
173
+ if (this.owned) return Promise.resolve({ ...this.generation! });
174
+ if (this.acquirePromise) return this.acquirePromise;
175
+ if (this.releasePromise) return this.releasePromise.then(() => this.acquire());
176
+ const operation = this.acquireInternal().finally(() => {
177
+ if (this.acquirePromise === operation) this.acquirePromise = undefined;
178
+ });
179
+ this.acquirePromise = operation;
180
+ return operation;
181
+ }
182
+
183
+ public release(
184
+ reasonArg: Exclude<TOwnershipGenerationRemovalReason, 'stale'> = 'stop',
185
+ ): Promise<void> {
186
+ if (this.releasePromise) return this.releasePromise;
187
+ if (
188
+ !this.namedMutexLease
189
+ && !this.lockHandle
190
+ && !this.anchorDirectoryHandle
191
+ && !this.directoryHandle
192
+ ) return Promise.resolve();
193
+ const operation = this.releaseInternal(reasonArg).catch((error) => {
194
+ throw normalizeOwnershipError(error);
195
+ }).finally(() => {
196
+ if (this.releasePromise === operation) this.releasePromise = undefined;
197
+ });
198
+ this.releasePromise = operation;
199
+ return operation;
200
+ }
201
+
202
+ /** @internal */
203
+ public async assertCurrentOwnership(): Promise<void> {
204
+ if (!this.owned) throw new BrowserRuntimeError('FENCED', 'runtime ownership is unavailable');
205
+ await this.assertRuntimeDirectoryCurrent();
206
+ await this.assertNativeAnchorCurrent();
207
+ await this.assertLockPathCurrent();
208
+ }
209
+
210
+ private async acquireInternal(): Promise<IBrowserRuntimeGeneration> {
211
+ try {
212
+ await this.openAndValidateRuntimeDirectory();
213
+ await this.openAndValidateAnchorDirectory();
214
+ await this.acquireNativeLease();
215
+ const lockKind = await this.openAndClassifyLock();
216
+ if (lockKind.kind === 'new') {
217
+ await this.adoptCreatedLockLayout();
218
+ } else if (lockKind.kind === 'legacy') {
219
+ await this.adoptLegacyLock();
220
+ } else {
221
+ await this.reclaimMetadataGeneration(lockKind.metadata);
222
+ }
223
+ const generation = await this.publishGeneration();
224
+ return { ...generation };
225
+ } catch (error) {
226
+ const normalized = normalizeOwnershipError(error);
227
+ if (this.metadata?.state === 'active' && this.generation) {
228
+ try {
229
+ await this.release('startup-failure');
230
+ } catch {
231
+ throw new BrowserRuntimeError('FENCED', 'runtime startup cleanup is incomplete');
232
+ }
233
+ } else {
234
+ if (this.createdLock) {
235
+ let cleanupError: unknown;
236
+ try {
237
+ await this.unlinkCreatedLock();
238
+ } catch (error) {
239
+ cleanupError = error;
240
+ }
241
+ if (cleanupError && this.createdLock) {
242
+ throw new BrowserRuntimeError('FENCED', 'runtime startup cleanup is incomplete');
243
+ }
244
+ if (cleanupError) {
245
+ await this.closePassiveHandles().catch(() => {
246
+ throw new BrowserRuntimeError('FENCED', 'runtime startup cleanup is incomplete');
247
+ });
248
+ throw new BrowserRuntimeError('FENCED', 'runtime startup cleanup is incomplete');
249
+ }
250
+ }
251
+ await this.closePassiveHandles();
252
+ }
253
+ throw normalized;
254
+ }
255
+ }
256
+
257
+ private async openAndValidateRuntimeDirectory(): Promise<void> {
258
+ const createdPath = await plugins.fsPromises.mkdir(this.runtimeDirectory, {
259
+ recursive: true,
260
+ mode: 0o700,
261
+ });
262
+ let pathStat = await plugins.fsPromises.lstat(this.runtimeDirectory, { bigint: true });
263
+ if (
264
+ !pathStat.isDirectory()
265
+ || pathStat.isSymbolicLink()
266
+ || pathStat.uid !== BigInt(this.uid)
267
+ ) throw new BrowserRuntimeError('FENCED', 'runtime directory is not private');
268
+ if (createdPath !== undefined) {
269
+ await plugins.fsPromises.chmod(this.runtimeDirectory, 0o700);
270
+ pathStat = await plugins.fsPromises.lstat(this.runtimeDirectory, { bigint: true });
271
+ }
272
+ if ((pathStat.mode & 0o777n) !== 0o700n) {
273
+ throw new BrowserRuntimeError('FENCED', 'runtime directory is not private');
274
+ }
275
+ const realPath = await plugins.fsPromises.realpath(this.runtimeDirectory);
276
+ if (realPath !== this.runtimeDirectory) {
277
+ throw new BrowserRuntimeError('FENCED', 'runtime directory path is not canonical');
278
+ }
279
+ const handle = await plugins.fsPromises.open(
280
+ this.runtimeDirectory,
281
+ plugins.fs.constants.O_RDONLY
282
+ | plugins.fs.constants.O_DIRECTORY
283
+ | plugins.fs.constants.O_NOFOLLOW,
284
+ );
285
+ this.directoryHandle = handle;
286
+ let handleStat = await handle.stat({ bigint: true });
287
+ if (createdPath !== undefined) {
288
+ await handle.chmod(0o700);
289
+ await plugins.fsPromises.chmod(this.runtimeDirectory, 0o700);
290
+ [pathStat, handleStat] = await Promise.all([
291
+ plugins.fsPromises.lstat(this.runtimeDirectory, { bigint: true }),
292
+ handle.stat({ bigint: true }),
293
+ ]);
294
+ }
295
+ if (
296
+ !handleStat.isDirectory()
297
+ || handleStat.uid !== BigInt(this.uid)
298
+ || (handleStat.mode & 0o777n) !== 0o700n
299
+ || !statIdentityMatches(pathStat, handleStat)
300
+ ) {
301
+ throw new BrowserRuntimeError('FENCED', 'runtime directory identity changed');
302
+ }
303
+ const pathHash = plugins.crypto.createHash('sha256')
304
+ .update(this.runtimeDirectory, 'utf8')
305
+ .digest('hex');
306
+ const device = String(handleStat.dev);
307
+ const inode = String(handleStat.ino);
308
+ const hash = plugins.crypto.createHash('sha256')
309
+ .update(`${this.uid}\0${device}\0${inode}\0${pathHash}`, 'utf8')
310
+ .digest('hex');
311
+ this.directoryIdentity = { uid: this.uid, device, inode, hash };
312
+ }
313
+
314
+ private async openAndValidateAnchorDirectory(): Promise<void> {
315
+ await this.ensurePrivateDirectory(this.anchorDirectory, this.runtimeDirectory);
316
+ const handle = await plugins.fsPromises.open(
317
+ this.anchorDirectory,
318
+ plugins.fs.constants.O_RDONLY
319
+ | plugins.fs.constants.O_DIRECTORY
320
+ | plugins.fs.constants.O_NOFOLLOW,
321
+ );
322
+ try {
323
+ const [pathStat, handleStat] = await Promise.all([
324
+ plugins.fsPromises.lstat(this.anchorDirectory, { bigint: true }),
325
+ handle.stat({ bigint: true }),
326
+ ]);
327
+ if (
328
+ !pathStat.isDirectory()
329
+ || pathStat.isSymbolicLink()
330
+ || pathStat.uid !== BigInt(this.uid)
331
+ || (pathStat.mode & 0o777n) !== 0o700n
332
+ || !statIdentityMatches(pathStat, handleStat)
333
+ || String(handleStat.dev) !== this.directoryIdentity!.device
334
+ ) throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor directory is unsafe');
335
+ this.anchorDirectoryHandle = handle;
336
+ this.anchorDirectoryIdentity = handleStat;
337
+ } catch (error) {
338
+ await handle.close().catch(() => undefined);
339
+ throw error;
340
+ }
341
+ }
342
+
343
+ private async acquireNativeLease(): Promise<void> {
344
+ const identity = this.directoryIdentity!;
345
+ let mutex: plugins.smartipc.NamedMutex;
346
+ try {
347
+ mutex = new plugins.smartipc.NamedMutex(
348
+ `@modelprofile.com/browser-runtime:v1:${identity.uid}:${identity.device}:${identity.inode}:${identity.hash}`,
349
+ { directoryPath: this.anchorDirectory },
350
+ );
351
+ } catch {
352
+ throw new BrowserRuntimeError('FENCED', 'runtime native ownership backend is unavailable');
353
+ }
354
+ await this.ensurePermanentAnchor(mutex.anchorPath);
355
+ let lease: plugins.smartipc.NamedMutexLease | undefined;
356
+ try {
357
+ lease = await mutex.tryAcquire();
358
+ } catch {
359
+ throw new BrowserRuntimeError('FENCED', 'runtime native ownership could not be verified');
360
+ }
361
+ if (!lease) throw new BrowserRuntimeError('LOCKED');
362
+ this.namedMutex = mutex;
363
+ this.namedMutexLease = lease;
364
+ this.leaseLost = false;
365
+ this.anchorIdentity = await plugins.fsPromises.lstat(mutex.anchorPath, { bigint: true });
366
+ await this.assertNativeAnchorCurrent();
367
+ }
368
+
369
+ private async ensurePermanentAnchor(anchorPathArg: string): Promise<void> {
370
+ if (
371
+ !this.pathIsWithin(this.anchorDirectory, anchorPathArg)
372
+ || plugins.path.dirname(anchorPathArg) !== this.anchorDirectory
373
+ ) throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor escaped containment');
374
+ const anchorName = plugins.path.basename(anchorPathArg);
375
+ const entries = await plugins.fsPromises.readdir(this.anchorDirectory, { withFileTypes: true });
376
+ if (entries.some((entry) => entry.name !== anchorName)) {
377
+ throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor directory is ambiguous');
378
+ }
379
+
380
+ let handle: plugins.fsPromises.FileHandle;
381
+ try {
382
+ handle = await plugins.fsPromises.open(
383
+ anchorPathArg,
384
+ plugins.fs.constants.O_RDWR
385
+ | plugins.fs.constants.O_CREAT
386
+ | plugins.fs.constants.O_EXCL
387
+ | plugins.fs.constants.O_NOFOLLOW,
388
+ 0o600,
389
+ );
390
+ } catch (error) {
391
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
392
+ for (let attempt = 0; attempt < anchorInitializationAttempts; attempt += 1) {
393
+ const stat = await plugins.fsPromises.lstat(anchorPathArg, { bigint: true });
394
+ if (
395
+ stat.isFile()
396
+ && !stat.isSymbolicLink()
397
+ && stat.uid === BigInt(this.uid)
398
+ && stat.nlink === 1n
399
+ && stat.size === 0n
400
+ && String(stat.dev) === this.directoryIdentity!.device
401
+ ) {
402
+ const mode = stat.mode & 0o777n;
403
+ if (mode === 0o600n) return;
404
+ if ((mode & 0o177n) !== 0n) {
405
+ throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor is unsafe');
406
+ }
407
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
408
+ continue;
409
+ }
410
+ throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor is unsafe');
411
+ }
412
+ throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor initialization is incomplete');
413
+ }
414
+
415
+ try {
416
+ await handle.chmod(0o600);
417
+ await plugins.fsPromises.chmod(anchorPathArg, 0o600);
418
+ const [pathStat, handleStat] = await Promise.all([
419
+ plugins.fsPromises.lstat(anchorPathArg, { bigint: true }),
420
+ handle.stat({ bigint: true }),
421
+ ]);
422
+ this.assertPrivateAnchorStat(pathStat);
423
+ this.assertPrivateAnchorStat(handleStat);
424
+ if (!statIdentityMatches(pathStat, handleStat)) {
425
+ throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor identity changed');
426
+ }
427
+ await handle.sync();
428
+ await this.anchorDirectoryHandle!.sync();
429
+ } finally {
430
+ await handle.close();
431
+ }
432
+ }
433
+
434
+ private assertPrivateAnchorStat(statArg: plugins.fs.BigIntStats): void {
435
+ if (
436
+ !statArg.isFile()
437
+ || statArg.isSymbolicLink()
438
+ || statArg.uid !== BigInt(this.uid)
439
+ || (statArg.mode & 0o777n) !== 0o600n
440
+ || statArg.nlink !== 1n
441
+ || statArg.size !== 0n
442
+ || String(statArg.dev) !== this.directoryIdentity!.device
443
+ ) throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor is unsafe');
444
+ }
445
+
446
+ private async assertNativeAnchorCurrent(): Promise<void> {
447
+ if (
448
+ !this.namedMutex
449
+ || !this.namedMutexLease
450
+ || this.namedMutexLease.state !== 'active'
451
+ || !this.anchorIdentity
452
+ || !this.anchorDirectoryHandle
453
+ || !this.anchorDirectoryIdentity
454
+ || this.leaseLost
455
+ ) throw new BrowserRuntimeError('FENCED', 'runtime native ownership is unavailable');
456
+ const [directoryPathStat, directoryHandleStat, anchorStat] = await Promise.all([
457
+ plugins.fsPromises.lstat(this.anchorDirectory, { bigint: true }),
458
+ this.anchorDirectoryHandle.stat({ bigint: true }),
459
+ plugins.fsPromises.lstat(this.namedMutex.anchorPath, { bigint: true }),
460
+ ]);
461
+ if (
462
+ !directoryPathStat.isDirectory()
463
+ || directoryPathStat.isSymbolicLink()
464
+ || directoryPathStat.uid !== BigInt(this.uid)
465
+ || (directoryPathStat.mode & 0o777n) !== 0o700n
466
+ || !statIdentityMatches(this.anchorDirectoryIdentity, directoryPathStat)
467
+ || !statIdentityMatches(this.anchorDirectoryIdentity, directoryHandleStat)
468
+ || String(directoryHandleStat.dev) !== this.directoryIdentity!.device
469
+ ) throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor directory identity changed');
470
+ this.assertPrivateAnchorStat(anchorStat);
471
+ if (!statIdentityMatches(this.anchorIdentity, anchorStat)) {
472
+ throw new BrowserRuntimeError('FENCED', 'runtime mutex anchor identity changed');
473
+ }
474
+ }
475
+
476
+ /** @internal */
477
+ public async forceOwnershipLossForTesting(): Promise<void> {
478
+ if (!this.namedMutexLease || this.leaseLost) return;
479
+ let releaseError: unknown;
480
+ try {
481
+ await this.namedMutexLease.release();
482
+ } catch (error) {
483
+ releaseError = error;
484
+ }
485
+ this.leaseLost = true;
486
+ this.onOwnershipLost?.();
487
+ if (releaseError) {
488
+ throw new BrowserRuntimeError('FENCED', 'runtime native ownership release is uncertain');
489
+ }
490
+ }
491
+
492
+ private async openAndClassifyLock(): Promise<
493
+ | { kind: 'new' }
494
+ | { kind: 'legacy' }
495
+ | { kind: 'metadata'; metadata: IOwnerMetadata }
496
+ > {
497
+ let handle: plugins.fsPromises.FileHandle;
498
+ try {
499
+ handle = await plugins.fsPromises.open(
500
+ this.lockPath,
501
+ plugins.fs.constants.O_RDWR
502
+ | plugins.fs.constants.O_CREAT
503
+ | plugins.fs.constants.O_EXCL
504
+ | plugins.fs.constants.O_NOFOLLOW,
505
+ 0o600,
506
+ );
507
+ } catch (error) {
508
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
509
+ const pathStat = await this.readPrivateLockPathStat();
510
+ handle = await plugins.fsPromises.open(
511
+ this.lockPath,
512
+ plugins.fs.constants.O_RDWR | plugins.fs.constants.O_NOFOLLOW,
513
+ );
514
+ let handleStat: plugins.fs.BigIntStats;
515
+ try {
516
+ handleStat = await this.validateLockHandle(handle);
517
+ } catch (validationError) {
518
+ await handle.close().catch(() => undefined);
519
+ throw validationError;
520
+ }
521
+ if (!statIdentityMatches(pathStat, handleStat)) {
522
+ await handle.close();
523
+ throw new BrowserRuntimeError('FENCED', 'runtime lock identity changed');
524
+ }
525
+ this.lockHandle = handle;
526
+ this.lockIdentity = handleStat;
527
+ await this.assertLockPathCurrent();
528
+ if (handleStat.size === 0n) return { kind: 'legacy' };
529
+ return { kind: 'metadata', metadata: await this.readMetadata() };
530
+ }
531
+
532
+ this.lockHandle = handle;
533
+ this.createdLock = true;
534
+ await handle.chmod(0o600);
535
+ const initialHandleStat = await handle.stat({ bigint: true });
536
+ const initialPathStat = await plugins.fsPromises.lstat(this.lockPath, { bigint: true });
537
+ if (
538
+ !initialHandleStat.isFile()
539
+ || !initialPathStat.isFile()
540
+ || initialPathStat.isSymbolicLink()
541
+ || !statIdentityMatches(initialHandleStat, initialPathStat)
542
+ || initialHandleStat.uid !== BigInt(this.uid)
543
+ || initialHandleStat.nlink !== 1n
544
+ || initialHandleStat.size !== 0n
545
+ ) throw new BrowserRuntimeError('FENCED', 'runtime lock is unsafe');
546
+ this.lockIdentity = initialHandleStat;
547
+ await plugins.fsPromises.chmod(this.lockPath, 0o600);
548
+ await handle.chmod(0o600);
549
+ this.lockIdentity = await this.validateLockHandle(handle);
550
+ await this.assertLockPathCurrent();
551
+ await this.directoryHandle!.sync();
552
+ return { kind: 'new' };
553
+ }
554
+
555
+ private async readPrivateLockPathStat(): Promise<plugins.fs.BigIntStats> {
556
+ let stat: plugins.fs.BigIntStats;
557
+ try {
558
+ stat = await plugins.fsPromises.lstat(this.lockPath, { bigint: true });
559
+ } catch (error) {
560
+ if (isMissingError(error)) {
561
+ throw new BrowserRuntimeError('FENCED', 'runtime lock identity changed');
562
+ }
563
+ throw error;
564
+ }
565
+ this.assertPrivateLockStat(stat);
566
+ return stat;
567
+ }
568
+
569
+ private async validateLockHandle(
570
+ handleArg: plugins.fsPromises.FileHandle,
571
+ ): Promise<plugins.fs.BigIntStats> {
572
+ const stat = await handleArg.stat({ bigint: true });
573
+ this.assertPrivateLockStat(stat);
574
+ return stat;
575
+ }
576
+
577
+ private assertPrivateLockStat(statArg: plugins.fs.BigIntStats): void {
578
+ if (
579
+ !statArg.isFile()
580
+ || statArg.isSymbolicLink()
581
+ || statArg.uid !== BigInt(this.uid)
582
+ || (statArg.mode & 0o777n) !== 0o600n
583
+ || statArg.nlink !== 1n
584
+ || statArg.size > BigInt(maximumMetadataBytes)
585
+ ) throw new BrowserRuntimeError('FENCED', 'runtime lock is unsafe');
586
+ }
587
+
588
+ private async assertLockPathCurrent(): Promise<void> {
589
+ if (!this.lockHandle || !this.lockIdentity) {
590
+ throw new BrowserRuntimeError('FENCED', 'runtime lock is unavailable');
591
+ }
592
+ const [pathStat, handleStat] = await Promise.all([
593
+ this.readPrivateLockPathStat(),
594
+ this.validateLockHandle(this.lockHandle),
595
+ ]);
596
+ if (
597
+ !statIdentityMatches(this.lockIdentity, pathStat)
598
+ || !statIdentityMatches(this.lockIdentity, handleStat)
599
+ ) throw new BrowserRuntimeError('FENCED', 'runtime lock identity changed');
600
+ }
601
+
602
+ private async adoptCreatedLockLayout(): Promise<void> {
603
+ await this.assertRuntimeEntries(new Set([
604
+ 'runtime.lock',
605
+ 'runtime.mutex',
606
+ 'profiles',
607
+ 'artifacts',
608
+ ]));
609
+ await this.validateOptionalPrivateDirectory(this.legacyProfileRoot);
610
+ await this.validateOptionalPrivateDirectory(this.legacyArtifactRoot);
611
+ const [profileInspection, profileEntries, artifactEntries] = await Promise.all([
612
+ this.inspectProfileProcesses([this.legacyProfileRoot]),
613
+ this.readOptionalDirectoryEntries(this.legacyProfileRoot),
614
+ this.readOptionalDirectoryEntries(this.legacyArtifactRoot),
615
+ ]);
616
+ if (profileInspection === 'held') throw new BrowserRuntimeError('LOCKED');
617
+ if (profileInspection === 'indeterminate') {
618
+ throw new BrowserRuntimeError('FENCED', 'legacy runtime process ownership is indeterminate');
619
+ }
620
+ if (profileEntries.length > 0 || artifactEntries.length > 0) {
621
+ throw new BrowserRuntimeError('FENCED', 'legacy runtime roots are not empty');
622
+ }
623
+ await this.assertLockPathCurrent();
624
+ await this.assertNativeAnchorCurrent();
625
+ await this.assertRuntimeDirectoryCurrent();
626
+ const [currentProfileEntries, currentArtifactEntries] = await Promise.all([
627
+ this.readOptionalDirectoryEntries(this.legacyProfileRoot),
628
+ this.readOptionalDirectoryEntries(this.legacyArtifactRoot),
629
+ ]);
630
+ if (currentProfileEntries.length > 0 || currentArtifactEntries.length > 0) {
631
+ throw new BrowserRuntimeError('FENCED', 'legacy runtime roots changed');
632
+ }
633
+ await this.removePrivateDirectory(this.legacyProfileRoot);
634
+ await this.removePrivateDirectory(this.legacyArtifactRoot);
635
+ await this.directoryHandle!.sync();
636
+ }
637
+
638
+ private async unlinkCreatedLock(): Promise<void> {
639
+ if (!this.createdLock) return;
640
+ if (!this.lockHandle) {
641
+ throw new BrowserRuntimeError('FENCED', 'created runtime lock is unavailable');
642
+ }
643
+ await this.assertNativeAnchorCurrent();
644
+ await this.assertRuntimeDirectoryCurrent();
645
+ const [pathStat, handleStat] = await Promise.all([
646
+ plugins.fsPromises.lstat(this.lockPath, { bigint: true }),
647
+ this.lockHandle.stat({ bigint: true }),
648
+ ]);
649
+ if (
650
+ !pathStat.isFile()
651
+ || pathStat.isSymbolicLink()
652
+ || !handleStat.isFile()
653
+ || pathStat.uid !== BigInt(this.uid)
654
+ || handleStat.uid !== BigInt(this.uid)
655
+ || pathStat.nlink !== 1n
656
+ || handleStat.nlink !== 1n
657
+ || String(pathStat.dev) !== this.directoryIdentity!.device
658
+ || !statIdentityMatches(pathStat, handleStat)
659
+ || (this.lockIdentity && !statIdentityMatches(this.lockIdentity, handleStat))
660
+ ) throw new BrowserRuntimeError('FENCED', 'created runtime lock identity changed');
661
+ await plugins.fsPromises.unlink(this.lockPath);
662
+ this.createdLock = false;
663
+ await this.directoryHandle!.sync();
664
+ }
665
+
666
+ private async adoptLegacyLock(): Promise<void> {
667
+ await this.assertRuntimeEntries(new Set([
668
+ 'runtime.lock',
669
+ 'runtime.mutex',
670
+ 'profiles',
671
+ 'artifacts',
672
+ ]));
673
+ await this.validateOptionalPrivateDirectory(this.legacyProfileRoot);
674
+ await this.validateOptionalPrivateDirectory(this.legacyArtifactRoot);
675
+ const [descriptorInspection, profileInspection, predatesBoot] = await Promise.all([
676
+ this.inspectLegacyLockDescriptors(),
677
+ this.inspectProfileProcesses([this.legacyProfileRoot]),
678
+ this.legacyLockPredatesCurrentBoot(),
679
+ ]);
680
+ if (descriptorInspection === 'held' || profileInspection === 'held') {
681
+ throw new BrowserRuntimeError('LOCKED');
682
+ }
683
+ if (descriptorInspection === 'indeterminate' || profileInspection === 'indeterminate') {
684
+ throw new BrowserRuntimeError('FENCED', 'legacy runtime ownership is indeterminate');
685
+ }
686
+ if (!predatesBoot) {
687
+ throw new BrowserRuntimeError('FENCED', 'same-boot legacy runtime ownership is unsafe');
688
+ }
689
+ await this.assertLockPathCurrent();
690
+ if (!await this.legacyLockPredatesCurrentBoot()) {
691
+ throw new BrowserRuntimeError('FENCED', 'legacy runtime lock timestamp changed');
692
+ }
693
+ await this.assertNativeAnchorCurrent();
694
+ await this.assertRuntimeDirectoryCurrent();
695
+ await this.removePrivateDirectory(this.legacyProfileRoot);
696
+ await this.removePrivateDirectory(this.legacyArtifactRoot);
697
+ await this.directoryHandle!.sync();
698
+ }
699
+
700
+ private async legacyLockPredatesCurrentBoot(): Promise<boolean> {
701
+ const bootTimeNs = await this.readBootTimeNs();
702
+ const [pathStat, handleStat] = await Promise.all([
703
+ this.readPrivateLockPathStat(),
704
+ this.validateLockHandle(this.lockHandle!),
705
+ ]);
706
+ if (
707
+ !statIdentityMatches(this.lockIdentity!, pathStat)
708
+ || !statIdentityMatches(this.lockIdentity!, handleStat)
709
+ ) throw new BrowserRuntimeError('FENCED', 'legacy runtime lock identity changed');
710
+ const pathTimestamps = [pathStat.birthtimeNs, pathStat.ctimeNs, pathStat.mtimeNs];
711
+ const handleTimestamps = [handleStat.birthtimeNs, handleStat.ctimeNs, handleStat.mtimeNs];
712
+ if (pathTimestamps.some((timestamp, index) => (
713
+ timestamp <= 0n || timestamp !== handleTimestamps[index]
714
+ ))) throw new BrowserRuntimeError('FENCED', 'legacy runtime lock timestamps are ambiguous');
715
+ return pathTimestamps.every((timestamp) => timestamp < bootTimeNs);
716
+ }
717
+
718
+ private async readBootTimeNs(): Promise<bigint> {
719
+ const procStat = await this.readBoundedFile(
720
+ plugins.path.join(this.procRoot, 'stat'),
721
+ maximumProcFileBytes,
722
+ );
723
+ if (/\u0000|\r/u.test(procStat)) {
724
+ throw new BrowserRuntimeError('FENCED', 'boot timestamp is malformed');
725
+ }
726
+ const bootTimeLines = procStat.split('\n').filter((line) => line.startsWith('btime'));
727
+ if (bootTimeLines.length !== 1) {
728
+ throw new BrowserRuntimeError('FENCED', 'boot timestamp is malformed');
729
+ }
730
+ const match = /^btime ([1-9][0-9]{0,19})$/u.exec(bootTimeLines[0]!);
731
+ if (!match) throw new BrowserRuntimeError('FENCED', 'boot timestamp is malformed');
732
+ const bootTimeNs = BigInt(match[1]!) * 1_000_000_000n;
733
+ const now = this.now();
734
+ if (!Number.isSafeInteger(now) || now < 0) {
735
+ throw new BrowserRuntimeError('FENCED', 'current timestamp is unavailable');
736
+ }
737
+ const nowNs = BigInt(now) * 1_000_000n;
738
+ if (bootTimeNs > nowNs) {
739
+ throw new BrowserRuntimeError('FENCED', 'boot timestamp is in the future');
740
+ }
741
+ return bootTimeNs;
742
+ }
743
+
744
+ private async reclaimMetadataGeneration(metadataArg: IOwnerMetadata): Promise<void> {
745
+ this.assertMetadataDirectoryIdentity(metadataArg);
746
+ await this.assertRuntimeEntries(new Set([
747
+ 'runtime.lock',
748
+ 'runtime.mutex',
749
+ 'generations',
750
+ ]));
751
+ await this.validateOptionalPrivateDirectory(this.generationsRoot);
752
+ const generationDirectory = this.generationPath(metadataArg.generationId);
753
+ const entries = await this.readOptionalDirectoryEntries(this.generationsRoot);
754
+ if (entries.some((entry) => entry.name !== metadataArg.generationId)) {
755
+ throw new BrowserRuntimeError('FENCED', 'runtime generation ownership is ambiguous');
756
+ }
757
+ await this.validateOptionalGeneration(generationDirectory);
758
+ const profileInspection = await this.inspectProfileProcesses([
759
+ plugins.path.join(generationDirectory, 'profiles'),
760
+ ]);
761
+ if (profileInspection === 'held') throw new BrowserRuntimeError('LOCKED');
762
+ if (profileInspection === 'indeterminate') {
763
+ throw new BrowserRuntimeError('FENCED', 'runtime process ownership is indeterminate');
764
+ }
765
+ await this.assertLockPathCurrent();
766
+ await this.assertNativeAnchorCurrent();
767
+ await this.assertRuntimeDirectoryCurrent();
768
+ await this.beforeGenerationRemoval?.('stale');
769
+ await this.assertLockPathCurrent();
770
+ await this.assertNativeAnchorCurrent();
771
+ await this.assertRuntimeDirectoryCurrent();
772
+ await this.removePrivateDirectory(generationDirectory);
773
+ await this.syncOptionalDirectory(this.generationsRoot);
774
+ }
775
+
776
+ private async publishGeneration(): Promise<IBrowserRuntimeGeneration> {
777
+ const identity = this.directoryIdentity!;
778
+ const generationId = randomId(24);
779
+ const metadata: IOwnerMetadata = {
780
+ schema: metadataSchema,
781
+ state: 'active',
782
+ uid: this.uid,
783
+ runtimeDirectoryDevice: identity.device,
784
+ runtimeDirectoryInode: identity.inode,
785
+ runtimeDirectoryHash: identity.hash,
786
+ bootId: await this.readBootId(),
787
+ pid: process.pid,
788
+ processStartTicks: parseProcessStartTicks(
789
+ await this.readBoundedFile(plugins.path.join(this.procRoot, 'self', 'stat'), 16 * 1024),
790
+ process.pid,
791
+ ),
792
+ generationId,
793
+ nonce: randomId(24),
794
+ };
795
+ const generationDirectory = this.generationPath(generationId);
796
+ const generation: IBrowserRuntimeGeneration = {
797
+ generationId,
798
+ generationDirectory,
799
+ profileRoot: plugins.path.join(generationDirectory, 'profiles'),
800
+ artifactRoot: plugins.path.join(generationDirectory, 'artifacts'),
801
+ };
802
+ await this.beforeMetadataPublication?.();
803
+ await this.writeMetadata(metadata);
804
+ this.metadata = metadata;
805
+ this.generation = generation;
806
+ await this.ensurePrivateDirectory(this.generationsRoot, this.runtimeDirectory);
807
+ const entries = await this.readOptionalDirectoryEntries(this.generationsRoot);
808
+ if (entries.length > 0) {
809
+ throw new BrowserRuntimeError('FENCED', 'runtime generation root is not empty');
810
+ }
811
+ await this.ensurePrivateDirectory(generationDirectory, this.generationsRoot);
812
+ await this.syncOptionalDirectory(this.generationsRoot);
813
+ return generation;
814
+ }
815
+
816
+ private async releaseInternal(
817
+ reasonArg: Exclude<TOwnershipGenerationRemovalReason, 'stale'>,
818
+ ): Promise<void> {
819
+ if (this.leaseLost) {
820
+ await this.closePassiveHandles().catch(() => undefined);
821
+ throw new BrowserRuntimeError('FENCED', 'runtime ownership was lost');
822
+ }
823
+ if (this.createdLock) {
824
+ await this.unlinkCreatedLock();
825
+ await this.closeOwnershipHandles();
826
+ this.metadata = undefined;
827
+ this.generation = undefined;
828
+ return;
829
+ }
830
+ const metadata = this.metadata;
831
+ const generation = this.generation;
832
+ if (metadata?.state === 'active' && generation) {
833
+ await this.assertLockPathCurrent();
834
+ await this.assertNativeAnchorCurrent();
835
+ await this.validateOptionalGeneration(generation.generationDirectory);
836
+ await this.assertLockPathCurrent();
837
+ await this.assertNativeAnchorCurrent();
838
+ await this.assertRuntimeDirectoryCurrent();
839
+ await this.beforeGenerationRemoval?.(reasonArg);
840
+ await this.assertLockPathCurrent();
841
+ await this.assertNativeAnchorCurrent();
842
+ await this.assertRuntimeDirectoryCurrent();
843
+ await this.removePrivateDirectory(generation.generationDirectory);
844
+ await this.syncOptionalDirectory(this.generationsRoot);
845
+ const relinquished: IOwnerMetadata = { ...metadata, state: 'relinquished' };
846
+ await this.writeMetadata(relinquished);
847
+ this.metadata = relinquished;
848
+ }
849
+ await this.closeOwnershipHandles();
850
+ this.metadata = undefined;
851
+ this.generation = undefined;
852
+ }
853
+
854
+ private async closePassiveHandles(): Promise<void> {
855
+ try {
856
+ await this.closeOwnershipHandles();
857
+ } finally {
858
+ this.metadata = undefined;
859
+ this.generation = undefined;
860
+ }
861
+ }
862
+
863
+ private async closeOwnershipHandles(): Promise<void> {
864
+ const errors: unknown[] = [];
865
+ if (this.lockHandle) {
866
+ await this.lockHandle.close().then(
867
+ () => {
868
+ this.lockHandle = undefined;
869
+ this.lockIdentity = undefined;
870
+ },
871
+ (error) => errors.push(error),
872
+ );
873
+ }
874
+ if (this.anchorDirectoryHandle) {
875
+ await this.anchorDirectoryHandle.close().then(
876
+ () => {
877
+ this.anchorDirectoryHandle = undefined;
878
+ this.anchorDirectoryIdentity = undefined;
879
+ },
880
+ (error) => errors.push(error),
881
+ );
882
+ }
883
+ if (this.directoryHandle) {
884
+ await this.directoryHandle.close().then(
885
+ () => {
886
+ this.directoryHandle = undefined;
887
+ this.directoryIdentity = undefined;
888
+ },
889
+ (error) => errors.push(error),
890
+ );
891
+ }
892
+ if (this.namedMutexLease) {
893
+ await this.namedMutexLease.release().then(
894
+ () => {
895
+ this.namedMutexLease = undefined;
896
+ this.namedMutex = undefined;
897
+ this.anchorIdentity = undefined;
898
+ this.leaseLost = false;
899
+ },
900
+ (error) => errors.push(error),
901
+ );
902
+ }
903
+ if (errors.length > 0) {
904
+ throw new BrowserRuntimeError('FENCED', 'runtime ownership handles could not be closed');
905
+ }
906
+ }
907
+
908
+ private async readMetadata(): Promise<IOwnerMetadata> {
909
+ const stat = await this.lockHandle!.stat({ bigint: true });
910
+ if (stat.size <= 0n || stat.size > BigInt(maximumMetadataBytes)) {
911
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is malformed');
912
+ }
913
+ const bytes = plugins.Buffer.alloc(Number(stat.size));
914
+ let bytesRead = 0;
915
+ while (bytesRead < bytes.byteLength) {
916
+ const result = await this.lockHandle!.read(
917
+ bytes,
918
+ bytesRead,
919
+ bytes.byteLength - bytesRead,
920
+ bytesRead,
921
+ );
922
+ if (result.bytesRead <= 0) {
923
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is unreadable');
924
+ }
925
+ bytesRead += result.bytesRead;
926
+ }
927
+ let text: string;
928
+ try {
929
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
930
+ } catch {
931
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is malformed');
932
+ }
933
+ if (!text.endsWith('\n') || text.slice(0, -1).includes('\n')) {
934
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is malformed');
935
+ }
936
+ const serialized = text.slice(0, -1);
937
+ let parsed: unknown;
938
+ try {
939
+ parsed = JSON.parse(serialized);
940
+ } catch {
941
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is malformed');
942
+ }
943
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
944
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is malformed');
945
+ }
946
+ const record = parsed as Record<string, unknown>;
947
+ const expectedKeys = [
948
+ 'schema',
949
+ 'state',
950
+ 'uid',
951
+ 'runtimeDirectoryDevice',
952
+ 'runtimeDirectoryInode',
953
+ 'runtimeDirectoryHash',
954
+ 'bootId',
955
+ 'pid',
956
+ 'processStartTicks',
957
+ 'generationId',
958
+ 'nonce',
959
+ ];
960
+ if (
961
+ Object.keys(record).length !== expectedKeys.length
962
+ || expectedKeys.some((key) => !(key in record))
963
+ || record.schema !== metadataSchema
964
+ || (record.state !== 'active' && record.state !== 'relinquished')
965
+ || !Number.isSafeInteger(record.uid)
966
+ || (record.uid as number) < 1
967
+ || typeof record.runtimeDirectoryDevice !== 'string'
968
+ || !unsignedIntegerPattern.test(record.runtimeDirectoryDevice)
969
+ || typeof record.runtimeDirectoryInode !== 'string'
970
+ || !unsignedIntegerPattern.test(record.runtimeDirectoryInode)
971
+ || typeof record.runtimeDirectoryHash !== 'string'
972
+ || !hashPattern.test(record.runtimeDirectoryHash)
973
+ || typeof record.bootId !== 'string'
974
+ || !bootIdPattern.test(record.bootId)
975
+ || !Number.isSafeInteger(record.pid)
976
+ || (record.pid as number) < 1
977
+ || typeof record.processStartTicks !== 'string'
978
+ || !unsignedIntegerPattern.test(record.processStartTicks)
979
+ || typeof record.generationId !== 'string'
980
+ || !generationIdPattern.test(record.generationId)
981
+ || typeof record.nonce !== 'string'
982
+ || !generationIdPattern.test(record.nonce)
983
+ || JSON.stringify(record) !== serialized
984
+ ) throw new BrowserRuntimeError('FENCED', 'runtime lock metadata is malformed');
985
+ return record as unknown as IOwnerMetadata;
986
+ }
987
+
988
+ private async writeMetadata(metadataArg: IOwnerMetadata): Promise<void> {
989
+ await this.assertLockPathCurrent();
990
+ await this.assertNativeAnchorCurrent();
991
+ const bytes = plugins.Buffer.from(`${JSON.stringify(metadataArg)}\n`, 'utf8');
992
+ if (bytes.byteLength > maximumMetadataBytes) {
993
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata exceeds its bound');
994
+ }
995
+ let written = 0;
996
+ while (written < bytes.byteLength) {
997
+ const result = await this.lockHandle!.write(
998
+ bytes,
999
+ written,
1000
+ bytes.byteLength - written,
1001
+ written,
1002
+ );
1003
+ if (result.bytesWritten <= 0) {
1004
+ throw new BrowserRuntimeError('FENCED', 'runtime lock metadata write was incomplete');
1005
+ }
1006
+ written += result.bytesWritten;
1007
+ }
1008
+ await this.lockHandle!.truncate(bytes.byteLength);
1009
+ await this.lockHandle!.sync();
1010
+ this.createdLock = false;
1011
+ await this.assertLockPathCurrent();
1012
+ await this.assertNativeAnchorCurrent();
1013
+ }
1014
+
1015
+ private assertMetadataDirectoryIdentity(metadataArg: IOwnerMetadata): void {
1016
+ const identity = this.directoryIdentity!;
1017
+ if (
1018
+ metadataArg.uid !== identity.uid
1019
+ || metadataArg.runtimeDirectoryDevice !== identity.device
1020
+ || metadataArg.runtimeDirectoryInode !== identity.inode
1021
+ || metadataArg.runtimeDirectoryHash !== identity.hash
1022
+ ) throw new BrowserRuntimeError('FENCED', 'runtime metadata belongs to another directory');
1023
+ }
1024
+
1025
+ private async inspectLegacyLockDescriptors(): Promise<TInspectionResult> {
1026
+ const lockIdentity = this.lockIdentity!;
1027
+ let indeterminate = false;
1028
+ for (const pid of await this.listOwnedProcessIds()) {
1029
+ let descriptorNames: string[];
1030
+ try {
1031
+ descriptorNames = await plugins.fsPromises.readdir(
1032
+ plugins.path.join(this.procRoot, String(pid), 'fd'),
1033
+ );
1034
+ } catch {
1035
+ const uidState = await this.processStillExists(pid);
1036
+ if (uidState === 'missing' || uidState === 'other') continue;
1037
+ indeterminate = true;
1038
+ continue;
1039
+ }
1040
+ for (const descriptorName of descriptorNames) {
1041
+ if (!/^(?:0|[1-9][0-9]*)$/u.test(descriptorName)) {
1042
+ indeterminate = true;
1043
+ continue;
1044
+ }
1045
+ const descriptor = Number(descriptorName);
1046
+ if (pid === process.pid && descriptor === this.lockHandle!.fd) continue;
1047
+ try {
1048
+ const stat = await plugins.fsPromises.stat(
1049
+ plugins.path.join(this.procRoot, String(pid), 'fd', descriptorName),
1050
+ { bigint: true },
1051
+ );
1052
+ if (stat.dev === lockIdentity.dev && stat.ino === lockIdentity.ino) return 'held';
1053
+ } catch (error) {
1054
+ if (isMissingError(error)) continue;
1055
+ indeterminate = true;
1056
+ }
1057
+ }
1058
+ }
1059
+ return indeterminate ? 'indeterminate' : 'clear';
1060
+ }
1061
+
1062
+ private async inspectProfileProcesses(profileRootsArg: string[]): Promise<TInspectionResult> {
1063
+ const roots = profileRootsArg.map((root) => plugins.path.resolve(root));
1064
+ let indeterminate = false;
1065
+ for (const pid of await this.listOwnedProcessIds()) {
1066
+ let cmdline: plugins.Buffer;
1067
+ try {
1068
+ cmdline = await this.readBoundedBuffer(
1069
+ plugins.path.join(this.procRoot, String(pid), 'cmdline'),
1070
+ maximumProcFileBytes,
1071
+ );
1072
+ } catch {
1073
+ const uidState = await this.processStillExists(pid);
1074
+ if (uidState === 'missing' || uidState === 'other') continue;
1075
+ indeterminate = true;
1076
+ continue;
1077
+ }
1078
+ if (cmdline.byteLength === 0) {
1079
+ if (!await this.processIsZombie(pid)) indeterminate = true;
1080
+ continue;
1081
+ }
1082
+ let cmdlineText: string;
1083
+ try {
1084
+ cmdlineText = new TextDecoder('utf-8', { fatal: true }).decode(cmdline);
1085
+ } catch {
1086
+ indeterminate = true;
1087
+ continue;
1088
+ }
1089
+ const argumentsList = cmdlineText.split('\0');
1090
+ if (argumentsList.at(-1) === '') argumentsList.pop();
1091
+ for (let index = 0; index < argumentsList.length; index += 1) {
1092
+ const argument = argumentsList[index]!;
1093
+ let profilePath: string | undefined;
1094
+ if (argument === '--user-data-dir') {
1095
+ profilePath = argumentsList[index + 1];
1096
+ } else if (argument.startsWith('--user-data-dir=')) {
1097
+ profilePath = argument.slice('--user-data-dir='.length);
1098
+ }
1099
+ if (!profilePath || !plugins.path.isAbsolute(profilePath)) continue;
1100
+ const resolved = plugins.path.resolve(profilePath);
1101
+ if (roots.some((root) => resolved === root || this.pathIsWithin(root, resolved))) {
1102
+ return 'held';
1103
+ }
1104
+ }
1105
+ }
1106
+ return indeterminate ? 'indeterminate' : 'clear';
1107
+ }
1108
+
1109
+ private async listOwnedProcessIds(): Promise<number[]> {
1110
+ let entries: plugins.fs.Dirent[];
1111
+ try {
1112
+ entries = await plugins.fsPromises.readdir(this.procRoot, { withFileTypes: true });
1113
+ } catch {
1114
+ throw new BrowserRuntimeError('FENCED', 'process inspection is unavailable');
1115
+ }
1116
+ const processIds: number[] = [];
1117
+ let indeterminate = false;
1118
+ for (const entry of entries) {
1119
+ if (!/^[1-9][0-9]{0,9}$/u.test(entry.name)) continue;
1120
+ const pid = Number(entry.name);
1121
+ try {
1122
+ const stat = await plugins.fsPromises.lstat(
1123
+ plugins.path.join(this.procRoot, entry.name),
1124
+ { bigint: true },
1125
+ );
1126
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1127
+ indeterminate = true;
1128
+ continue;
1129
+ }
1130
+ } catch (error) {
1131
+ if (isMissingError(error)) continue;
1132
+ indeterminate = true;
1133
+ continue;
1134
+ }
1135
+ const uidState = await this.processStillExists(pid);
1136
+ if (uidState === 'same') processIds.push(pid);
1137
+ else if (uidState === 'indeterminate') indeterminate = true;
1138
+ }
1139
+ if (indeterminate) {
1140
+ throw new BrowserRuntimeError('FENCED', 'process inspection is indeterminate');
1141
+ }
1142
+ return processIds.sort((left, right) => {
1143
+ if (left === process.pid) return -1;
1144
+ if (right === process.pid) return 1;
1145
+ return left - right;
1146
+ });
1147
+ }
1148
+
1149
+ private async processStillExists(pidArg: number): Promise<TProcessUidState> {
1150
+ const processPath = plugins.path.join(this.procRoot, String(pidArg));
1151
+ try {
1152
+ const status = await this.readBoundedFile(
1153
+ plugins.path.join(processPath, 'status'),
1154
+ 64 * 1024,
1155
+ );
1156
+ return this.parseProcessUidState(status);
1157
+ } catch (error) {
1158
+ if (!isMissingError(error)) return 'indeterminate';
1159
+ try {
1160
+ await plugins.fsPromises.lstat(processPath, { bigint: true });
1161
+ return 'indeterminate';
1162
+ } catch (statError) {
1163
+ return isMissingError(statError) ? 'missing' : 'indeterminate';
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ private parseProcessUidState(statusArg: string): TProcessUidState {
1169
+ if (/\u0000|\r/u.test(statusArg)) return 'indeterminate';
1170
+ const uidLines = statusArg.split('\n').filter((line) => line.startsWith('Uid:'));
1171
+ if (uidLines.length !== 1) return 'indeterminate';
1172
+ const match = /^Uid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec(
1173
+ uidLines[0]!,
1174
+ );
1175
+ if (!match) return 'indeterminate';
1176
+ const ids = match.slice(1).map(Number);
1177
+ if (ids.some((id) => !Number.isSafeInteger(id) || id < 0 || id > 0xffff_ffff)) {
1178
+ return 'indeterminate';
1179
+ }
1180
+ if (ids.every((id) => id === this.uid)) return 'same';
1181
+ if (ids.every((id) => id !== this.uid)) return 'other';
1182
+ return 'indeterminate';
1183
+ }
1184
+
1185
+ private async processIsZombie(pidArg: number): Promise<boolean> {
1186
+ try {
1187
+ const status = await this.readBoundedFile(
1188
+ plugins.path.join(this.procRoot, String(pidArg), 'status'),
1189
+ 64 * 1024,
1190
+ );
1191
+ return /^State:\s+Z(?:\s|$)/mu.test(status);
1192
+ } catch {
1193
+ return false;
1194
+ }
1195
+ }
1196
+
1197
+ private async readBootId(): Promise<string> {
1198
+ const rawBootId = await this.readBoundedFile(
1199
+ plugins.path.join(this.procRoot, 'sys', 'kernel', 'random', 'boot_id'),
1200
+ 64,
1201
+ );
1202
+ const bootId = rawBootId.endsWith('\n') ? rawBootId.slice(0, -1) : rawBootId;
1203
+ if (bootId.includes('\n') || bootId.includes('\r') || !bootIdPattern.test(bootId)) {
1204
+ throw new BrowserRuntimeError('FENCED', 'boot identity is malformed');
1205
+ }
1206
+ return bootId;
1207
+ }
1208
+
1209
+ private async readBoundedFile(pathArg: string, maximumBytesArg: number): Promise<string> {
1210
+ const bytes = await this.readBoundedBuffer(pathArg, maximumBytesArg);
1211
+ try {
1212
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1213
+ } catch {
1214
+ throw new BrowserRuntimeError('FENCED', 'process metadata is malformed');
1215
+ }
1216
+ }
1217
+
1218
+ private async readBoundedBuffer(
1219
+ pathArg: string,
1220
+ maximumBytesArg: number,
1221
+ ): Promise<plugins.Buffer> {
1222
+ const bytes = await plugins.fsPromises.readFile(pathArg);
1223
+ if (bytes.byteLength > maximumBytesArg) {
1224
+ throw new BrowserRuntimeError('FENCED', 'process metadata exceeds its bound');
1225
+ }
1226
+ return bytes;
1227
+ }
1228
+
1229
+ private generationPath(generationIdArg: string): string {
1230
+ if (!generationIdPattern.test(generationIdArg)) {
1231
+ throw new BrowserRuntimeError('FENCED', 'runtime generation identity is malformed');
1232
+ }
1233
+ const generationPath = plugins.path.join(this.generationsRoot, generationIdArg);
1234
+ if (!this.pathIsWithin(this.generationsRoot, generationPath)) {
1235
+ throw new BrowserRuntimeError('FENCED', 'runtime generation escaped containment');
1236
+ }
1237
+ return generationPath;
1238
+ }
1239
+
1240
+ private pathIsWithin(rootArg: string, candidateArg: string): boolean {
1241
+ const relative = plugins.path.relative(rootArg, candidateArg);
1242
+ return relative.length > 0 && relative !== '..'
1243
+ && !relative.startsWith(`..${plugins.path.sep}`)
1244
+ && !plugins.path.isAbsolute(relative);
1245
+ }
1246
+
1247
+ private async assertRuntimeEntries(allowedNamesArg: Set<string>): Promise<void> {
1248
+ await this.assertRuntimeDirectoryCurrent();
1249
+ const entries = await plugins.fsPromises.readdir(this.runtimeDirectory, { withFileTypes: true });
1250
+ if (entries.some((entry) => !allowedNamesArg.has(entry.name))) {
1251
+ throw new BrowserRuntimeError('FENCED', 'runtime directory contains unknown state');
1252
+ }
1253
+ }
1254
+
1255
+ private async assertRuntimeDirectoryCurrent(): Promise<void> {
1256
+ if (!this.directoryHandle || !this.directoryIdentity) {
1257
+ throw new BrowserRuntimeError('FENCED', 'runtime directory is unavailable');
1258
+ }
1259
+ const [pathStat, handleStat] = await Promise.all([
1260
+ plugins.fsPromises.lstat(this.runtimeDirectory, { bigint: true }),
1261
+ this.directoryHandle.stat({ bigint: true }),
1262
+ ]);
1263
+ if (
1264
+ !pathStat.isDirectory()
1265
+ || pathStat.isSymbolicLink()
1266
+ || pathStat.uid !== BigInt(this.uid)
1267
+ || (pathStat.mode & 0o777n) !== 0o700n
1268
+ || !statIdentityMatches(pathStat, handleStat)
1269
+ || String(handleStat.dev) !== this.directoryIdentity.device
1270
+ || String(handleStat.ino) !== this.directoryIdentity.inode
1271
+ ) throw new BrowserRuntimeError('FENCED', 'runtime directory identity changed');
1272
+ }
1273
+
1274
+ private async ensurePrivateDirectory(pathArg: string, parentArg: string): Promise<void> {
1275
+ if (!this.pathIsWithin(parentArg, pathArg)) {
1276
+ throw new BrowserRuntimeError('FENCED', 'runtime path escaped containment');
1277
+ }
1278
+ let created = false;
1279
+ try {
1280
+ await plugins.fsPromises.mkdir(pathArg, { recursive: false, mode: 0o700 });
1281
+ created = true;
1282
+ } catch (error) {
1283
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
1284
+ }
1285
+ if (created) {
1286
+ await plugins.fsPromises.chmod(pathArg, 0o700);
1287
+ const handle = await plugins.fsPromises.open(
1288
+ pathArg,
1289
+ plugins.fs.constants.O_RDONLY
1290
+ | plugins.fs.constants.O_DIRECTORY
1291
+ | plugins.fs.constants.O_NOFOLLOW,
1292
+ );
1293
+ try {
1294
+ await handle.chmod(0o700);
1295
+ await plugins.fsPromises.chmod(pathArg, 0o700);
1296
+ const [pathStat, handleStat] = await Promise.all([
1297
+ plugins.fsPromises.lstat(pathArg, { bigint: true }),
1298
+ handle.stat({ bigint: true }),
1299
+ ]);
1300
+ if (
1301
+ !pathStat.isDirectory()
1302
+ || pathStat.isSymbolicLink()
1303
+ || pathStat.uid !== BigInt(this.uid)
1304
+ || (pathStat.mode & 0o777n) !== 0o700n
1305
+ || !statIdentityMatches(pathStat, handleStat)
1306
+ ) throw new BrowserRuntimeError('FENCED', 'runtime directory state is unsafe');
1307
+ await handle.sync();
1308
+ } finally {
1309
+ await handle.close();
1310
+ }
1311
+ await this.syncDirectory(parentArg);
1312
+ }
1313
+ await this.validateOptionalPrivateDirectory(pathArg, false);
1314
+ }
1315
+
1316
+ private async validateOptionalPrivateDirectory(
1317
+ pathArg: string,
1318
+ allowMissingArg = true,
1319
+ ): Promise<boolean> {
1320
+ let stat: plugins.fs.BigIntStats;
1321
+ try {
1322
+ stat = await plugins.fsPromises.lstat(pathArg, { bigint: true });
1323
+ } catch (error) {
1324
+ if (allowMissingArg && isMissingError(error)) return false;
1325
+ throw error;
1326
+ }
1327
+ if (
1328
+ !stat.isDirectory()
1329
+ || stat.isSymbolicLink()
1330
+ || stat.uid !== BigInt(this.uid)
1331
+ || (stat.mode & 0o777n) !== 0o700n
1332
+ ) throw new BrowserRuntimeError('FENCED', 'runtime directory state is unsafe');
1333
+ return true;
1334
+ }
1335
+
1336
+ private async validateOptionalGeneration(generationDirectoryArg: string): Promise<void> {
1337
+ if (!await this.validateOptionalPrivateDirectory(generationDirectoryArg)) return;
1338
+ const entries = await plugins.fsPromises.readdir(generationDirectoryArg, { withFileTypes: true });
1339
+ if (entries.some((entry) => entry.name !== 'profiles' && entry.name !== 'artifacts')) {
1340
+ throw new BrowserRuntimeError('FENCED', 'runtime generation contains unknown state');
1341
+ }
1342
+ await this.validateOptionalPrivateDirectory(
1343
+ plugins.path.join(generationDirectoryArg, 'profiles'),
1344
+ );
1345
+ await this.validateOptionalPrivateDirectory(
1346
+ plugins.path.join(generationDirectoryArg, 'artifacts'),
1347
+ );
1348
+ }
1349
+
1350
+ private async removePrivateDirectory(pathArg: string): Promise<void> {
1351
+ if (!this.pathIsWithin(this.runtimeDirectory, pathArg)) {
1352
+ throw new BrowserRuntimeError('FENCED', 'runtime cleanup escaped containment');
1353
+ }
1354
+ if (!await this.validateOptionalPrivateDirectory(pathArg)) return;
1355
+ await plugins.fsPromises.rm(pathArg, {
1356
+ recursive: true,
1357
+ force: false,
1358
+ maxRetries: 3,
1359
+ retryDelay: 50,
1360
+ });
1361
+ }
1362
+
1363
+ private async readOptionalDirectoryEntries(pathArg: string): Promise<plugins.fs.Dirent[]> {
1364
+ try {
1365
+ return await plugins.fsPromises.readdir(pathArg, { withFileTypes: true });
1366
+ } catch (error) {
1367
+ if (isMissingError(error)) return [];
1368
+ throw error;
1369
+ }
1370
+ }
1371
+
1372
+ private async syncOptionalDirectory(pathArg: string): Promise<void> {
1373
+ try {
1374
+ await this.syncDirectory(pathArg);
1375
+ } catch (error) {
1376
+ if (!isMissingError(error)) throw error;
1377
+ }
1378
+ }
1379
+
1380
+ private async syncDirectory(pathArg: string): Promise<void> {
1381
+ const handle = await plugins.fsPromises.open(
1382
+ pathArg,
1383
+ plugins.fs.constants.O_RDONLY
1384
+ | plugins.fs.constants.O_DIRECTORY
1385
+ | plugins.fs.constants.O_NOFOLLOW,
1386
+ );
1387
+ try {
1388
+ await handle.sync();
1389
+ } finally {
1390
+ await handle.close();
1391
+ }
1392
+ }
1393
+ }