@jeffusion/bungee-types 4.2.0 → 4.4.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,944 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { constants as fsConstants, existsSync } from 'node:fs';
3
+ import { chmod, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
4
+ import { spawn } from 'node:child_process';
5
+ import { isAbsolute, join, normalize, parse, resolve, win32 } from 'node:path';
6
+ import { decodeDaemonMetadataV1, encodeDaemonMetadataV1 } from './daemon-control.js';
7
+ const MAX_BYTES = 4 * 1024;
8
+ const METADATA_FILENAME = 'daemon.json';
9
+ const WINDOWS_RENAME_RETRIES = 3;
10
+ const WINDOWS_RENAME_RETRY_DELAY_MS = 25;
11
+ const WINDOWS_SYSTEM = 'S-1-5-18';
12
+ const WINDOWS_ADMINISTRATORS = 'S-1-5-32-544';
13
+ const WINDOWS_FULL_CONTROL = 2032127;
14
+ const WINDOWS_CONTAINER_INHERIT = 1;
15
+ const WINDOWS_OBJECT_INHERIT = 2;
16
+ const WINDOWS_ACL_EXEC_OPTIONS = {
17
+ windowsHide: true,
18
+ };
19
+ const WINDOWS_ACL_DEADLINE_MS = 10000;
20
+ const WINDOWS_ACL_MAX_OUTPUT_BYTES = 64 * 1024;
21
+ const WINDOWS_ACL_PHASE_PREFIX = '__BUNGEE_ACL_PHASE__:';
22
+ const WINDOWS_ACL_PHASES = new Set(['started', 'before_get_acl', 'after_get_acl', 'before_set_acl', 'after_set_acl']);
23
+ const WINDOWS_ACL_OPERATIONS = new Set(['read', 'set', 'ensure']);
24
+ const WINDOWS_ACL_OUTCOMES = new Set(['exit', 'timeout', 'signal', 'spawn_error']);
25
+ const WINDOWS_ACL_SIGNALS = new Set([
26
+ 'SIGABRT', 'SIGALRM', 'SIGHUP', 'SIGINT', 'SIGKILL', 'SIGPIPE', 'SIGQUIT', 'SIGTERM',
27
+ 'SIGUSR1', 'SIGUSR2', 'SIGCONT', 'SIGSTOP', 'SIGTSTP', 'SIGTTIN', 'SIGTTOU',
28
+ ]);
29
+ const SID = /^S-(?:\d+)(?:-\d+)+$/;
30
+ export class DaemonFileError extends Error {
31
+ constructor(code, message = code, cause) {
32
+ super(message, cause === undefined ? undefined : { cause });
33
+ this.code = code;
34
+ this.name = 'DaemonFileError';
35
+ }
36
+ }
37
+ const POSIX_UNSUPPORTED_FSYNC = new Set(['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS']);
38
+ const WINDOWS_UNSUPPORTED_FSYNC = new Set([...POSIX_UNSUPPORTED_FSYNC, 'EPERM', 'EISDIR']);
39
+ function fail(code, message, cause) {
40
+ throw new DaemonFileError(code, message, cause);
41
+ }
42
+ function currentPlatform(options) { return options.platform ?? process.platform; }
43
+ function testStage(options, stage) {
44
+ options?.testHooks?.onStage?.(stage);
45
+ }
46
+ function comparePath(value, platform) {
47
+ const normalized = (platform === 'win32' ? win32.normalize(value) : normalize(value)).split('\\').join('/');
48
+ return platform === 'win32' ? normalized.toLowerCase() : normalized;
49
+ }
50
+ function contained(parent, child, platform) {
51
+ const root = comparePath(parent, platform);
52
+ const target = comparePath(child, platform);
53
+ return target !== root && target.startsWith(`${root}/`);
54
+ }
55
+ function sameIdentity(left, right) {
56
+ return left.dev === right.dev && left.ino === right.ino;
57
+ }
58
+ function canonicalSid(value) {
59
+ if (typeof value !== 'string')
60
+ return false;
61
+ return SID.test(value) && value.slice(2).split('-').every((part) => part === '0' || !part.startsWith('0'));
62
+ }
63
+ function identity(stat) {
64
+ return { dev: String(stat.dev), ino: String(stat.ino) };
65
+ }
66
+ function mode(stat) { return stat.mode & 0o777; }
67
+ async function rejectSymlinkComponents(path, options, stage) {
68
+ const absolute = resolve(path);
69
+ const root = parse(absolute).root;
70
+ let current = root;
71
+ for (const part of absolute.slice(root.length).split(/[\\/]+/).filter(Boolean)) {
72
+ current = resolve(current, part);
73
+ testStage(options, stage);
74
+ try {
75
+ if ((await lstat(current)).isSymbolicLink())
76
+ fail('symlink', 'daemon runtime path contains a symlink');
77
+ }
78
+ catch (error) {
79
+ if (error.code === 'ENOENT')
80
+ return;
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+ async function canonicalProfile(options) {
86
+ const profile = process.env.USERPROFILE;
87
+ if (profile === undefined || !isAbsolute(profile))
88
+ fail('containment', 'USERPROFILE is unavailable');
89
+ testStage(options, 'profile_realpath');
90
+ try {
91
+ return await realpath(profile);
92
+ }
93
+ catch {
94
+ fail('containment', 'USERPROFILE cannot be canonicalized');
95
+ }
96
+ }
97
+ async function secureRuntimeDirectory(options, allowCreate) {
98
+ const requested = options.runtimeDirectory;
99
+ if (!isAbsolute(requested) || requested.includes('\0'))
100
+ fail('path', 'runtime directory must be absolute and NUL-free');
101
+ await rejectSymlinkComponents(requested, options, 'runtime_components');
102
+ const platform = currentPlatform(options);
103
+ let runtimePath = requested;
104
+ let canonicalRuntimePath;
105
+ let before;
106
+ testStage(options, 'runtime_lstat');
107
+ try {
108
+ before = await lstat(runtimePath);
109
+ }
110
+ catch (error) {
111
+ if (error.code !== 'ENOENT')
112
+ throw error;
113
+ if (platform === 'win32') {
114
+ testStage(options, 'runtime_realpath');
115
+ try {
116
+ canonicalRuntimePath = await realpath(requested);
117
+ }
118
+ catch (realpathError) {
119
+ if (realpathError.code !== 'ENOENT')
120
+ throw realpathError;
121
+ }
122
+ }
123
+ if (canonicalRuntimePath !== undefined) {
124
+ runtimePath = canonicalRuntimePath;
125
+ await rejectSymlinkComponents(runtimePath, options, 'runtime_components');
126
+ testStage(options, 'runtime_lstat');
127
+ before = await lstat(runtimePath);
128
+ }
129
+ else {
130
+ if (!allowCreate)
131
+ throw error;
132
+ testStage(options, 'runtime_mkdir');
133
+ await mkdir(requested, { recursive: true, mode: 0o700 });
134
+ testStage(options, 'runtime_created_lstat');
135
+ before = await lstat(requested);
136
+ }
137
+ }
138
+ if (!before.isDirectory() || before.isSymbolicLink())
139
+ fail('directory', 'runtime directory is not a real directory');
140
+ let root = canonicalRuntimePath;
141
+ if (root === undefined) {
142
+ testStage(options, 'runtime_realpath');
143
+ root = await realpath(runtimePath);
144
+ }
145
+ if (platform === 'win32') {
146
+ const profile = await canonicalProfile(options);
147
+ if (!contained(profile, root, platform))
148
+ fail('containment', 'runtime directory must be a strict child of USERPROFILE');
149
+ // Profile containment is deliberately before any ACL adapter call.
150
+ await ensureWindowsAcl(root, options.windowsAcl ?? defaultWindowsAclAdapter(), 'directory', options);
151
+ }
152
+ else {
153
+ if (typeof process.geteuid === 'function' && before.uid !== process.geteuid())
154
+ fail('owner', 'runtime directory owner is invalid');
155
+ if (mode(before) !== 0o700)
156
+ await chmod(runtimePath, 0o700);
157
+ const after = await lstat(runtimePath);
158
+ if (!after.isDirectory() || after.uid !== before.uid || mode(after) !== 0o700)
159
+ fail('permissions', 'runtime directory permissions are not 0700');
160
+ }
161
+ return root;
162
+ }
163
+ async function secureTarget(targetPath, options, mustExist) {
164
+ const platform = currentPlatform(options);
165
+ if (!isAbsolute(targetPath) || targetPath.includes('\0'))
166
+ fail('path', 'metadata target must be absolute and NUL-free');
167
+ const requestedTarget = resolve(targetPath);
168
+ const requestedExpected = resolve(options.runtimeDirectory, METADATA_FILENAME);
169
+ if (comparePath(requestedTarget, platform) !== comparePath(requestedExpected, platform)) {
170
+ // Do this before touching the trusted root so an untrusted path cannot cause ACL work.
171
+ fail('containment', 'metadata target is not the fixed runtime target');
172
+ }
173
+ const canonicalRoot = await secureRuntimeDirectory(options, !mustExist);
174
+ await rejectSymlinkComponents(targetPath, options, 'target_components');
175
+ const target = resolve(canonicalRoot, METADATA_FILENAME);
176
+ testStage(options, 'target_lstat');
177
+ try {
178
+ const item = await lstat(target);
179
+ if (item.isSymbolicLink())
180
+ fail('symlink', 'metadata file must not be a symlink');
181
+ if (!item.isFile() || item.nlink > 1)
182
+ fail('file', 'metadata file must be a single regular file');
183
+ // nlink === 0 is a transient metadata state (e.g. APFS lstat racing an atomic rename),
184
+ // not a hardlink: classify it as a retryable race so the read retry loop re-verifies.
185
+ if (item.nlink === 0)
186
+ fail('race', 'metadata file was detached by rename');
187
+ if (platform !== 'win32' && typeof process.geteuid === 'function' && item.uid !== process.geteuid())
188
+ fail('owner', 'metadata owner is invalid');
189
+ if (platform === 'win32')
190
+ await ensureWindowsAcl(target, options.windowsAcl ?? defaultWindowsAclAdapter(), 'file', options);
191
+ testStage(options, 'target_realpath');
192
+ const canonical = await realpath(target);
193
+ if (!contained(canonicalRoot, canonical, platform))
194
+ fail('containment', 'metadata file escaped the runtime root');
195
+ return { root: canonicalRoot, target, initial: identity(item) };
196
+ }
197
+ catch (error) {
198
+ if (error.code !== 'ENOENT' || mustExist)
199
+ throw error;
200
+ return { root: canonicalRoot, target };
201
+ }
202
+ }
203
+ function assertDescriptor(item, expected, platform) {
204
+ if (!item.isFile() || item.nlink > 1)
205
+ fail('file', 'opened metadata descriptor is not a single regular file');
206
+ if (item.nlink === 0)
207
+ fail('race', 'opened metadata descriptor was detached by rename');
208
+ if (expected !== undefined && !sameIdentity(identity(item), expected))
209
+ fail('race', 'metadata file identity changed');
210
+ if (platform !== 'win32' && typeof process.geteuid === 'function' && item.uid !== process.geteuid())
211
+ fail('owner', 'metadata owner changed');
212
+ }
213
+ async function verifyLstat(target, expected, platform, expectedMode, options) {
214
+ testStage(options, 'verify_lstat');
215
+ const item = await lstat(target);
216
+ if (!item.isFile() || item.isSymbolicLink() || item.nlink > 1)
217
+ fail('file', 'metadata file is not a single regular file');
218
+ if (item.nlink === 0 || !sameIdentity(identity(item), expected))
219
+ fail('race', 'metadata file changed');
220
+ if (platform !== 'win32' && typeof process.geteuid === 'function' && item.uid !== process.geteuid())
221
+ fail('owner', 'metadata owner changed');
222
+ if (platform !== 'win32' && expectedMode !== undefined && mode(item) !== expectedMode)
223
+ fail('permissions', 'metadata file permissions are not exact');
224
+ return identity(item);
225
+ }
226
+ async function removeOwnedFile(path, expected) {
227
+ try {
228
+ const item = await lstat(path);
229
+ if (!item.isFile() || item.isSymbolicLink() || item.nlink !== 1 || !sameIdentity(identity(item), expected))
230
+ return;
231
+ await unlink(path);
232
+ }
233
+ catch { /* fail closed: never unlink an unproven replacement */ }
234
+ }
235
+ function bytesEqual(left, right) {
236
+ return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]);
237
+ }
238
+ async function readEvidence(path, options) {
239
+ const safe = await secureTarget(path, options, true);
240
+ const platform = currentPlatform(options);
241
+ if (safe.initial === undefined)
242
+ fail('file', 'metadata file disappeared');
243
+ const noFollow = platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW;
244
+ const handle = await open(safe.target, fsConstants.O_RDONLY | noFollow);
245
+ try {
246
+ await options.testHooks?.afterOpen?.(safe.target);
247
+ testStage(options, 'descriptor_stat');
248
+ const opened = await handle.stat();
249
+ assertDescriptor(opened, safe.initial, platform);
250
+ if (opened.size > MAX_BYTES)
251
+ fail('limit', 'daemon metadata file exceeds 4 KiB');
252
+ if (platform !== 'win32' && mode(opened) !== 0o600)
253
+ await handle.chmod(0o600);
254
+ testStage(options, 'descriptor_stat');
255
+ const secured = await handle.stat();
256
+ assertDescriptor(secured, safe.initial, platform);
257
+ if (platform !== 'win32' && mode(secured) !== 0o600)
258
+ fail('permissions', 'metadata file permissions are not exact');
259
+ await verifyLstat(safe.target, safe.initial, platform, 0o600, options);
260
+ await options.testHooks?.afterInitialStat?.(safe.target);
261
+ const chunks = [];
262
+ let total = 0;
263
+ while (true) {
264
+ const chunk = new Uint8Array(MAX_BYTES + 1);
265
+ const result = await handle.read(chunk, 0, chunk.byteLength, null);
266
+ if (result.bytesRead === 0)
267
+ break;
268
+ total += result.bytesRead;
269
+ if (total > MAX_BYTES)
270
+ fail('race', 'daemon metadata file grew while reading');
271
+ chunks.push(chunk.slice(0, result.bytesRead));
272
+ }
273
+ testStage(options, 'descriptor_stat');
274
+ const finalStat = await handle.stat();
275
+ assertDescriptor(finalStat, safe.initial, platform);
276
+ if (finalStat.size !== total || finalStat.size !== opened.size)
277
+ fail('race', 'metadata file size changed while reading');
278
+ await verifyLstat(safe.target, safe.initial, platform, 0o600, options);
279
+ const bytes = new Uint8Array(total);
280
+ let offset = 0;
281
+ for (const chunk of chunks) {
282
+ bytes.set(chunk, offset);
283
+ offset += chunk.byteLength;
284
+ }
285
+ const metadata = decodeDaemonMetadataV1(bytes);
286
+ const canonical = new TextEncoder().encode(encodeDaemonMetadataV1(metadata));
287
+ if (!bytesEqual(bytes, canonical))
288
+ fail('invalid', 'metadata bytes are not canonical');
289
+ return { target: safe.target, root: safe.root, bytes: canonical, identity: safe.initial, metadata };
290
+ }
291
+ finally {
292
+ testStage(options, 'file_close');
293
+ await handle.close();
294
+ }
295
+ }
296
+ async function ensureWindowsAcl(path, adapter, kind, options) {
297
+ testStage(options, kind === 'directory' ? 'directory_acl' : 'file_acl');
298
+ const optimized = adapter;
299
+ if (optimized.ensure !== undefined) {
300
+ await optimized.ensure(path, kind);
301
+ return;
302
+ }
303
+ let snapshot = await adapter.read(path);
304
+ let validation = windowsAclSecure(snapshot, kind);
305
+ if (validation?.reason === 'invalid_current_sid')
306
+ throw new WindowsAclValidationError(validation);
307
+ if (validation !== null) {
308
+ testStage(options, kind === 'directory' ? 'directory_acl' : 'file_acl');
309
+ await adapter.set(path, snapshot.currentSid, kind);
310
+ }
311
+ testStage(options, kind === 'directory' ? 'directory_acl' : 'file_acl');
312
+ snapshot = await adapter.read(path);
313
+ validation = windowsAclSecure(snapshot, kind);
314
+ if (validation !== null)
315
+ throw new WindowsAclValidationError(validation);
316
+ }
317
+ const WINDOWS_ACL_VALIDATION_REASONS = new Set([
318
+ 'invalid_current_sid', 'unexpected_sid', 'missing_sid', 'access_type',
319
+ 'rights', 'inheritance', 'propagation', 'inherited',
320
+ ]);
321
+ const WINDOWS_ACL_TARGET_KINDS = new Set(['directory', 'file']);
322
+ const MAX_WINDOWS_ACL_EVIDENCE_COUNT = 1024;
323
+ function boundedAclCount(value) {
324
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isSafeInteger(value))
325
+ return 0;
326
+ return Math.min(MAX_WINDOWS_ACL_EVIDENCE_COUNT, Math.max(0, value));
327
+ }
328
+ function windowsAclSecure(snapshot, kind) {
329
+ const entries = Array.isArray(snapshot?.entries) ? snapshot.entries : [];
330
+ const currentSid = snapshot?.currentSid;
331
+ const validCurrentSid = canonicalSid(currentSid);
332
+ const allowed = new Set(validCurrentSid ? [currentSid, WINDOWS_SYSTEM, WINDOWS_ADMINISTRATORS] : [WINDOWS_SYSTEM, WINDOWS_ADMINISTRATORS]);
333
+ const missing = new Set(allowed);
334
+ const inheritance = kind === 'directory' ? WINDOWS_CONTAINER_INHERIT | WINDOWS_OBJECT_INHERIT : 0;
335
+ let reason = validCurrentSid ? undefined : 'invalid_current_sid';
336
+ let unexpectedCount = 0;
337
+ let inheritedCount = 0;
338
+ for (const entry of entries) {
339
+ if (entry?.inherited === true)
340
+ inheritedCount += 1;
341
+ if (!canonicalSid(entry?.sid) || !allowed.has(entry.sid)) {
342
+ unexpectedCount += 1;
343
+ reason ?? (reason = 'unexpected_sid');
344
+ continue;
345
+ }
346
+ missing.delete(entry.sid);
347
+ if (entry.access !== 'allow')
348
+ reason ?? (reason = 'access_type');
349
+ else if (entry.rights !== WINDOWS_FULL_CONTROL)
350
+ reason ?? (reason = 'rights');
351
+ else if (entry.inheritance !== inheritance)
352
+ reason ?? (reason = 'inheritance');
353
+ else if (entry.propagation !== 0)
354
+ reason ?? (reason = 'propagation');
355
+ else if (entry.inherited)
356
+ reason ?? (reason = 'inherited');
357
+ }
358
+ if (reason === undefined && missing.size !== 0)
359
+ reason = 'missing_sid';
360
+ if (reason === undefined)
361
+ return null;
362
+ return {
363
+ reason,
364
+ targetKind: kind,
365
+ entriesCount: boundedAclCount(entries.length),
366
+ unexpectedCount: boundedAclCount(unexpectedCount),
367
+ inheritedCount: boundedAclCount(inheritedCount),
368
+ missingCount: boundedAclCount(missing.size),
369
+ };
370
+ }
371
+ function encodedPowerShell(script) {
372
+ return Buffer.from(script, 'utf16le').toString('base64');
373
+ }
374
+ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); }
375
+ class WindowsAclProcessError extends Error {
376
+ constructor(diagnostic) {
377
+ super(JSON.stringify(diagnostic));
378
+ this.diagnostic = diagnostic;
379
+ this.code = 'BUNGEE_WINDOWS_ACL_PROCESS';
380
+ this.name = 'WindowsAclProcessError';
381
+ }
382
+ }
383
+ class WindowsAclValidationError extends DaemonFileError {
384
+ constructor(validation) {
385
+ super('acl', 'Windows ACL validation failed');
386
+ this.reason = validation.reason;
387
+ this.targetKind = validation.targetKind;
388
+ this.entriesCount = validation.entriesCount;
389
+ this.unexpectedCount = validation.unexpectedCount;
390
+ this.inheritedCount = validation.inheritedCount;
391
+ this.missingCount = validation.missingCount;
392
+ }
393
+ }
394
+ /** Formats only the bounded, allowlisted fields safe for exposing a Windows ACL failure. */
395
+ export function formatDaemonFileAclError(error) {
396
+ if (!(error instanceof DaemonFileError) || error.code !== 'acl')
397
+ return null;
398
+ if (error instanceof WindowsAclValidationError) {
399
+ const reason = WINDOWS_ACL_VALIDATION_REASONS.has(error.reason) ? error.reason : 'unknown';
400
+ const targetKind = WINDOWS_ACL_TARGET_KINDS.has(error.targetKind) ? error.targetKind : 'unknown';
401
+ return `acl_reason=${reason} target_kind=${targetKind}`
402
+ + ` entries_count=${boundedAclCount(error.entriesCount)}`
403
+ + ` unexpected_count=${boundedAclCount(error.unexpectedCount)}`
404
+ + ` inherited_count=${boundedAclCount(error.inheritedCount)}`
405
+ + ` missing_count=${boundedAclCount(error.missingCount)}`;
406
+ }
407
+ const cause = error.cause;
408
+ if (!(cause instanceof WindowsAclProcessError))
409
+ return null;
410
+ const diagnostic = cause.diagnostic;
411
+ const operation = WINDOWS_ACL_OPERATIONS.has(diagnostic.operation) ? diagnostic.operation : 'unknown';
412
+ const outcome = WINDOWS_ACL_OUTCOMES.has(diagnostic.outcome) ? diagnostic.outcome : 'unknown';
413
+ const lastPhase = diagnostic.last_phase === null ? 'null' : WINDOWS_ACL_PHASES.has(diagnostic.last_phase) ? diagnostic.last_phase : 'unknown';
414
+ return `acl_operation=${operation} outcome=${outcome}`
415
+ + ` last_phase=${lastPhase} kill_returned_true=${diagnostic.kill_returned_true === true}`
416
+ + ` spawn_event=${diagnostic.spawn_event === true} exit_event=${diagnostic.exit_event === true}`
417
+ + ` close_event=${diagnostic.close_event === true}`;
418
+ }
419
+ function boundedElapsed(startedAt, deadlineMs) {
420
+ return Math.min(deadlineMs, Math.max(0, Date.now() - startedAt));
421
+ }
422
+ function selectWindowsAclExecutable(environment) {
423
+ const programFiles = environment.ProgramFiles;
424
+ if (programFiles !== undefined && isAbsolute(programFiles)) {
425
+ const pwsh = join(programFiles, 'PowerShell', '7', 'pwsh.exe');
426
+ if (existsSync(pwsh))
427
+ return pwsh;
428
+ }
429
+ return 'powershell.exe';
430
+ }
431
+ function allowedSignal(signal) {
432
+ return signal !== null && WINDOWS_ACL_SIGNALS.has(signal) ? signal : null;
433
+ }
434
+ function processDiagnostic(operation, startedAt, phase, environment, deadlineMs, result) {
435
+ return {
436
+ operation,
437
+ outcome: result.outcome,
438
+ elapsed_ms: boundedElapsed(startedAt, deadlineMs),
439
+ exit_code: typeof result.exitCode === 'number' ? result.exitCode : null,
440
+ signal: allowedSignal(result.signal ?? null),
441
+ spawn_event: result.spawnEvent,
442
+ exit_event: result.exitEvent,
443
+ close_event: result.closeEvent,
444
+ kill_returned_true: result.killReturnedTrue,
445
+ stdout_bytes: Math.min(WINDOWS_ACL_MAX_OUTPUT_BYTES, result.stdoutBytes),
446
+ stderr_bytes: Math.min(WINDOWS_ACL_MAX_OUTPUT_BYTES, result.stderrBytes),
447
+ last_phase: phase,
448
+ psmodulepath_present: false,
449
+ systemroot_present: Object.keys(environment).some((key) => key.toLowerCase() === 'systemroot'),
450
+ };
451
+ }
452
+ async function runPowerShell(operation, script, environment, deadlineMs) {
453
+ const startedAt = Date.now();
454
+ let phase = null;
455
+ let child;
456
+ try {
457
+ child = spawn(selectWindowsAclExecutable(process.env), ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodedPowerShell(script)], {
458
+ ...WINDOWS_ACL_EXEC_OPTIONS,
459
+ env: environment,
460
+ });
461
+ }
462
+ catch {
463
+ const diagnostic = processDiagnostic(operation, startedAt, phase, environment, deadlineMs, {
464
+ outcome: 'spawn_error', spawnEvent: false, exitEvent: false, closeEvent: false, killReturnedTrue: false,
465
+ stdoutBytes: 0, stderrBytes: 0,
466
+ });
467
+ throw new WindowsAclProcessError(diagnostic);
468
+ }
469
+ let stdout = '';
470
+ let stdoutBytes = 0;
471
+ let stderrBytes = 0;
472
+ let phaseText = '';
473
+ let timedOut = false;
474
+ let spawnError = false;
475
+ let exitCode = null;
476
+ let exitSignal = null;
477
+ let spawnEvent = false;
478
+ let exitEvent = false;
479
+ let closeEvent = false;
480
+ let killReturnedTrue = false;
481
+ let exited = false;
482
+ let closed = false;
483
+ let settled = false;
484
+ let timer;
485
+ const result = await new Promise((resolve, reject) => {
486
+ const settle = () => {
487
+ if (settled || !closed)
488
+ return;
489
+ settled = true;
490
+ if (timer !== undefined)
491
+ clearTimeout(timer);
492
+ const outcome = spawnError ? 'spawn_error' : timedOut ? 'timeout' : exitSignal !== null ? 'signal' : 'exit';
493
+ const diagnostic = processDiagnostic(operation, startedAt, phase, environment, deadlineMs, {
494
+ outcome, exitCode, signal: exitSignal, spawnEvent, exitEvent, closeEvent, killReturnedTrue, stdoutBytes, stderrBytes,
495
+ });
496
+ if (outcome === 'exit' && exitCode === 0)
497
+ resolve({ ...diagnostic, stdout });
498
+ else
499
+ reject(new WindowsAclProcessError(diagnostic));
500
+ };
501
+ child.stdout?.on('data', (chunk) => {
502
+ const bytes = Buffer.byteLength(chunk);
503
+ stdoutBytes += bytes;
504
+ if (stdoutBytes <= WINDOWS_ACL_MAX_OUTPUT_BYTES)
505
+ stdout += chunk.toString();
506
+ });
507
+ child.stderr?.on('data', (chunk) => {
508
+ const text = chunk.toString();
509
+ stderrBytes += Buffer.byteLength(text);
510
+ phaseText = `${phaseText}${text}`.slice(-256);
511
+ const matches = phaseText.matchAll(new RegExp(`${escapeRegExp(WINDOWS_ACL_PHASE_PREFIX)}(started|before_get_acl|after_get_acl|before_set_acl|after_set_acl)`, 'gu'));
512
+ for (const match of matches) {
513
+ const next = match[1];
514
+ if (next !== undefined && WINDOWS_ACL_PHASES.has(next))
515
+ phase = next;
516
+ }
517
+ });
518
+ child.once('spawn', () => { spawnEvent = true; });
519
+ child.once('error', () => { spawnError = true; settle(); });
520
+ child.once('exit', (code, signal) => {
521
+ exitEvent = true;
522
+ exited = true;
523
+ exitCode = code;
524
+ exitSignal = signal;
525
+ settle();
526
+ });
527
+ child.once('close', () => { closeEvent = true; closed = true; settle(); });
528
+ timer = setTimeout(() => {
529
+ if (exited || settled)
530
+ return;
531
+ timedOut = true;
532
+ try {
533
+ killReturnedTrue = child.kill('SIGKILL') === true;
534
+ }
535
+ catch {
536
+ killReturnedTrue = false;
537
+ }
538
+ }, deadlineMs);
539
+ });
540
+ return result;
541
+ }
542
+ /** @internal source-test probe; not re-exported from the package root. */
543
+ export function __testSelectWindowsAclExecutable(environment) {
544
+ return selectWindowsAclExecutable(environment);
545
+ }
546
+ function aclEnvironment(path, sid, kind) {
547
+ const allowed = new Set(['systemroot', 'windir', 'path', 'pathext', 'temp', 'tmp', 'comspec']);
548
+ const environment = {};
549
+ for (const [key, value] of Object.entries(process.env)) {
550
+ if (allowed.has(key.toLowerCase()) && value !== undefined)
551
+ environment[key] = value;
552
+ }
553
+ environment.BUNGEE_DAEMON_ACL_PATH = path;
554
+ if (sid !== undefined)
555
+ environment.BUNGEE_DAEMON_ACL_SID = sid;
556
+ if (kind !== undefined)
557
+ environment.BUNGEE_DAEMON_ACL_KIND = kind;
558
+ return environment;
559
+ }
560
+ function defaultWindowsAclAdapter(deadlineMs = WINDOWS_ACL_DEADLINE_MS) {
561
+ const phase = (value) => `[Console]::Error.WriteLine('${WINDOWS_ACL_PHASE_PREFIX}${value}');`;
562
+ const moduleBootstrap = '$env:PSModulePath=[System.IO.Path]::Combine($PSHOME,"Modules");'
563
+ + 'Import-Module Microsoft.PowerShell.Security -ErrorAction Stop;';
564
+ const readScript = phase('started') + moduleBootstrap + phase('before_get_acl')
565
+ + '$item=Get-Item -LiteralPath $env:BUNGEE_DAEMON_ACL_PATH -Force;'
566
+ + '$a=if($item -is [System.IO.DirectoryInfo]){[System.IO.FileSystemAclExtensions]::GetAccessControl([System.IO.DirectoryInfo]$item,[System.Security.AccessControl.AccessControlSections]::Access)}'
567
+ + 'elseif($item -is [System.IO.FileInfo]){[System.IO.FileSystemAclExtensions]::GetAccessControl([System.IO.FileInfo]$item,[System.Security.AccessControl.AccessControlSections]::Access)}'
568
+ + 'else{throw "Windows ACL target is not a file or directory"};'
569
+ + phase('after_get_acl')
570
+ + '$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value;'
571
+ + '$e=@($a.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])|ForEach-Object { @{sid=$_.IdentityReference.Value;'
572
+ + 'access=$(if([int]$_.AccessControlType -eq 0){"allow"}else{"deny"});rights=[int]$_.FileSystemRights;'
573
+ + 'inheritance=[int]$_.InheritanceFlags;propagation=[int]$_.PropagationFlags;inherited=[bool]$_.IsInherited} });'
574
+ + '[Console]::Out.Write((ConvertTo-Json -Compress -Depth 4 @{currentSid=$sid;entries=$e}))';
575
+ const setScript = phase('started') + moduleBootstrap + '$p=$env:BUNGEE_DAEMON_ACL_PATH;$u=$env:BUNGEE_DAEMON_ACL_SID;'
576
+ + '$k=$env:BUNGEE_DAEMON_ACL_KIND;$a=if($k -eq "directory"){[System.Security.AccessControl.DirectorySecurity]::new()}else{[System.Security.AccessControl.FileSecurity]::new()};'
577
+ + '$a.SetAccessRuleProtection($true,$false);$i=if($k -eq "directory"){[System.Security.AccessControl.InheritanceFlags]::ObjectInherit -bor [System.Security.AccessControl.InheritanceFlags]::ContainerInherit}else{[System.Security.AccessControl.InheritanceFlags]::None};'
578
+ + '@($u,"S-1-5-18","S-1-5-32-544")|ForEach-Object {$a.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new($_),[System.Security.AccessControl.FileSystemRights]::FullControl,$i,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow))};'
579
+ + phase('before_set_acl') + 'if($k -eq "directory"){[System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.DirectoryInfo]::new($p),$a)}else{[System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($p),$a)};' + phase('after_set_acl');
580
+ const readBlock = phase('before_get_acl') + '$item=Get-Item -LiteralPath $p -Force;'
581
+ + '$a=if($item -is [System.IO.DirectoryInfo]){[System.IO.FileSystemAclExtensions]::GetAccessControl([System.IO.DirectoryInfo]$item,[System.Security.AccessControl.AccessControlSections]::Access)}'
582
+ + 'elseif($item -is [System.IO.FileInfo]){[System.IO.FileSystemAclExtensions]::GetAccessControl([System.IO.FileInfo]$item,[System.Security.AccessControl.AccessControlSections]::Access)}'
583
+ + 'else{throw "Windows ACL target is not a file or directory"};'
584
+ + '$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value;'
585
+ + '$e=@($a.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])|ForEach-Object { @{sid=$_.IdentityReference.Value;'
586
+ + 'access=$(if([int]$_.AccessControlType -eq 0){"allow"}else{"deny"});rights=[int]$_.FileSystemRights;'
587
+ + 'inheritance=[int]$_.InheritanceFlags;propagation=[int]$_.PropagationFlags;inherited=[bool]$_.IsInherited} });'
588
+ + phase('after_get_acl') + '@{currentSid=$sid;entries=$e}';
589
+ const ensureScript = '$ErrorActionPreference=\'Stop\';' + phase('started') + moduleBootstrap + '$p=$env:BUNGEE_DAEMON_ACL_PATH;$k=$env:BUNGEE_DAEMON_ACL_KIND;'
590
+ + '$readAcl={ ' + readBlock + ' };$before=&$readAcl;$sid=$before.currentSid;'
591
+ + `$parts=$sid -split "-";$validSid=($sid -cmatch '^S-[0-9]+(?:-[0-9]+)+$') -and (@($parts|Select-Object -Skip 1|Where-Object { $_.Length -gt 1 -and $_.StartsWith("0") }).Count -eq 0);`
592
+ + '$allowed=@($sid,"S-1-5-18","S-1-5-32-544");$inheritance=if($k -eq "directory"){3}else{0};$missing=@{};$repair=$false;'
593
+ + 'if($validSid){foreach($name in $allowed){$missing[$name]=$true};foreach($r in @($before.entries)){'
594
+ + 'if($allowed -notcontains $r.sid){$repair=$true;continue};$missing.Remove($r.sid);'
595
+ + 'if($r.access -ne "allow" -or [int]$r.rights -ne 2032127 -or [int]$r.inheritance -ne $inheritance -or [int]$r.propagation -ne 0 -or [bool]$r.inherited){$repair=$true}};'
596
+ + 'if($missing.Count -ne 0){$repair=$true}};'
597
+ + 'if($repair){$a=if($k -eq "directory"){[System.Security.AccessControl.DirectorySecurity]::new()}else{[System.Security.AccessControl.FileSecurity]::new()};'
598
+ + '$a.SetAccessRuleProtection($true,$false);$i=if($k -eq "directory"){[System.Security.AccessControl.InheritanceFlags]::ObjectInherit -bor [System.Security.AccessControl.InheritanceFlags]::ContainerInherit}else{[System.Security.AccessControl.InheritanceFlags]::None};'
599
+ + '@($sid,"S-1-5-18","S-1-5-32-544")|ForEach-Object {$a.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new($_),[System.Security.AccessControl.FileSystemRights]::FullControl,$i,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow))};'
600
+ + phase('before_set_acl') + 'if($k -eq "directory"){[System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.DirectoryInfo]::new($p),$a)}else{[System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.FileInfo]::new($p),$a)};' + phase('after_set_acl') + '};'
601
+ + '$after=&$readAcl;[Console]::Out.Write((ConvertTo-Json -Compress -Depth 6 @{before=$before;after=$after;repaired=$repair}))';
602
+ return {
603
+ async read(path) {
604
+ let diagnostic;
605
+ try {
606
+ const result = await runPowerShell('read', readScript, aclEnvironment(path), deadlineMs);
607
+ const { stdout, ...completedDiagnostic } = result;
608
+ diagnostic = completedDiagnostic;
609
+ const value = JSON.parse(stdout);
610
+ if (!Array.isArray(value.entries))
611
+ fail('acl', 'Windows ACL probe was invalid');
612
+ return value;
613
+ }
614
+ catch (error) {
615
+ if (error instanceof WindowsAclProcessError)
616
+ fail('acl', 'Windows ACL probe failed', error);
617
+ fail('acl', 'Windows ACL probe was invalid', new WindowsAclProcessError(diagnostic));
618
+ }
619
+ },
620
+ async set(path, currentSid, kind) {
621
+ if (!canonicalSid(currentSid) || kind === undefined)
622
+ fail('acl', 'Windows ACL update arguments are invalid');
623
+ try {
624
+ await runPowerShell('set', setScript, aclEnvironment(path, currentSid, kind), deadlineMs);
625
+ }
626
+ catch (error) {
627
+ if (error instanceof WindowsAclProcessError)
628
+ fail('acl', 'Windows ACL update failed', error);
629
+ fail('acl', 'Windows ACL update failed');
630
+ }
631
+ },
632
+ async ensure(path, kind) {
633
+ let diagnostic;
634
+ try {
635
+ const result = await runPowerShell('ensure', ensureScript, aclEnvironment(path, undefined, kind), deadlineMs);
636
+ const { stdout, ...completedDiagnostic } = result;
637
+ diagnostic = completedDiagnostic;
638
+ const value = JSON.parse(stdout);
639
+ if (value.before === undefined || value.after === undefined || typeof value.repaired !== 'boolean'
640
+ || typeof value.before.currentSid !== 'string' || typeof value.after.currentSid !== 'string'
641
+ || value.before.currentSid !== value.after.currentSid
642
+ || !Array.isArray(value.before.entries) || !Array.isArray(value.after.entries)) {
643
+ fail('acl', 'Windows ACL ensure was invalid');
644
+ }
645
+ const beforeValidation = windowsAclSecure(value.before, kind);
646
+ if (beforeValidation?.reason === 'invalid_current_sid')
647
+ throw new WindowsAclValidationError(beforeValidation);
648
+ if ((beforeValidation !== null) !== value.repaired)
649
+ fail('acl', 'Windows ACL ensure envelope was contradictory');
650
+ const afterValidation = windowsAclSecure(value.after, kind);
651
+ if (afterValidation !== null)
652
+ throw new WindowsAclValidationError(afterValidation);
653
+ }
654
+ catch (error) {
655
+ if (error instanceof WindowsAclProcessError)
656
+ fail('acl', 'Windows ACL ensure failed', error);
657
+ if (error instanceof WindowsAclValidationError)
658
+ throw error;
659
+ fail('acl', 'Windows ACL ensure was invalid', new WindowsAclProcessError(diagnostic));
660
+ }
661
+ },
662
+ };
663
+ }
664
+ /** @internal source-test probe; not re-exported from the package root. */
665
+ export async function __testReadWindowsAcl(path, deadlineMs = WINDOWS_ACL_DEADLINE_MS) {
666
+ return defaultWindowsAclAdapter(deadlineMs).read(path);
667
+ }
668
+ /** @internal source-test probe; not re-exported from the package root. */
669
+ export async function __testEnsureWindowsAcl(path, kind, deadlineMs = WINDOWS_ACL_DEADLINE_MS) {
670
+ const adapter = defaultWindowsAclAdapter(deadlineMs);
671
+ await adapter.ensure(path, kind);
672
+ }
673
+ /** @internal source-test probe; not re-exported from the package root. */
674
+ export function __testWindowsAclIsSecure(snapshot, kind) {
675
+ return windowsAclSecure(snapshot, kind) === null;
676
+ }
677
+ /** @internal source-test factory; not re-exported from the package root. */
678
+ export function __testCreateDaemonFileAclError(overrides = {}) {
679
+ const operation = overrides.operation === 'read' || overrides.operation === 'set' || overrides.operation === 'ensure' ? overrides.operation : 'forged_operation';
680
+ const outcome = overrides.outcome === 'exit' || overrides.outcome === 'timeout' || overrides.outcome === 'signal' || overrides.outcome === 'spawn_error'
681
+ ? overrides.outcome : 'forged_outcome';
682
+ const lastPhase = overrides.last_phase === null || overrides.last_phase === 'started' || overrides.last_phase === 'before_get_acl'
683
+ || overrides.last_phase === 'after_get_acl' || overrides.last_phase === 'before_set_acl' || overrides.last_phase === 'after_set_acl'
684
+ ? overrides.last_phase : 'forged_phase';
685
+ const diagnostic = {
686
+ operation, outcome, elapsed_ms: typeof overrides.elapsed_ms === 'number' ? overrides.elapsed_ms : 1,
687
+ exit_code: typeof overrides.exit_code === 'number' ? overrides.exit_code : 17,
688
+ signal: typeof overrides.signal === 'string' && WINDOWS_ACL_SIGNALS.has(overrides.signal) ? overrides.signal : null,
689
+ spawn_event: overrides.spawn_event === true,
690
+ exit_event: overrides.exit_event === true,
691
+ close_event: overrides.close_event === true,
692
+ kill_returned_true: overrides.kill_returned_true === true,
693
+ stdout_bytes: typeof overrides.stdout_bytes === 'number' ? overrides.stdout_bytes : 0,
694
+ stderr_bytes: typeof overrides.stderr_bytes === 'number' ? overrides.stderr_bytes : 0,
695
+ last_phase: lastPhase,
696
+ psmodulepath_present: false,
697
+ systemroot_present: overrides.systemroot_present === true,
698
+ };
699
+ return new DaemonFileError('acl', 'Windows ACL process failed', new WindowsAclProcessError(diagnostic));
700
+ }
701
+ async function fsyncDirectory(root, platform, options) {
702
+ const unsupported = platform === 'win32' ? WINDOWS_UNSUPPORTED_FSYNC : POSIX_UNSUPPORTED_FSYNC;
703
+ let handle;
704
+ testStage(options, 'file_sync');
705
+ try {
706
+ handle = await open(root, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY);
707
+ await handle.sync();
708
+ }
709
+ catch (error) {
710
+ if (!unsupported.has(error.code ?? ''))
711
+ throw error;
712
+ }
713
+ finally {
714
+ if (handle !== undefined) {
715
+ testStage(options, 'file_close');
716
+ try {
717
+ await handle.close();
718
+ }
719
+ catch (error) {
720
+ if (!unsupported.has(error.code ?? ''))
721
+ throw error;
722
+ }
723
+ }
724
+ }
725
+ }
726
+ async function writeAndVerify(handle, target, root, bytes, options, expected, verifyAclBeforeWrite = false) {
727
+ const platform = currentPlatform(options);
728
+ testStage(options, 'descriptor_stat');
729
+ const before = await handle.stat();
730
+ assertDescriptor(before, expected, platform);
731
+ if (platform !== 'win32')
732
+ await handle.chmod(0o600);
733
+ testStage(options, 'descriptor_stat');
734
+ const secured = await handle.stat();
735
+ assertDescriptor(secured, expected, platform);
736
+ if (platform !== 'win32' && mode(secured) !== 0o600)
737
+ fail('permissions', 'temporary metadata file is not 0600');
738
+ await verifyLstat(target, identity(secured), platform, 0o600, options);
739
+ if (verifyAclBeforeWrite && platform === 'win32') {
740
+ await ensureWindowsAcl(target, options.windowsAcl ?? defaultWindowsAclAdapter(), 'file', options);
741
+ }
742
+ testStage(options, 'file_write');
743
+ await handle.writeFile(bytes);
744
+ testStage(options, 'file_sync');
745
+ await handle.sync();
746
+ testStage(options, 'descriptor_stat');
747
+ const after = await handle.stat();
748
+ assertDescriptor(after, identity(secured), platform);
749
+ if (after.size !== bytes.byteLength || (platform !== 'win32' && mode(after) !== 0o600))
750
+ fail('race', 'temporary metadata file changed while writing');
751
+ await verifyLstat(target, identity(after), platform, 0o600, options);
752
+ if (platform === 'win32')
753
+ await ensureWindowsAcl(target, options.windowsAcl ?? defaultWindowsAclAdapter(), 'file', options);
754
+ return identity(after);
755
+ }
756
+ async function renameAtomically(temporary, target, options) {
757
+ const platform = currentPlatform(options);
758
+ const replace = options.testHooks?.rename ?? rename;
759
+ for (let attempt = 0;; attempt += 1) {
760
+ try {
761
+ await replace(temporary, target);
762
+ return;
763
+ }
764
+ catch (error) {
765
+ if (platform !== 'win32' || error.code !== 'EPERM' || attempt >= WINDOWS_RENAME_RETRIES) {
766
+ throw error;
767
+ }
768
+ const sleep = options.testHooks?.sleep
769
+ ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
770
+ await sleep(WINDOWS_RENAME_RETRY_DELAY_MS);
771
+ }
772
+ }
773
+ }
774
+ async function atomicReplace(target, root, bytes, options) {
775
+ const temporary = join(root, `.${METADATA_FILENAME}.${process.pid}.${randomBytes(16).toString('hex')}.tmp`);
776
+ const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
777
+ let temporaryIdentity;
778
+ try {
779
+ testStage(options, 'descriptor_stat');
780
+ const opened = await handle.stat();
781
+ assertDescriptor(opened, undefined, currentPlatform(options));
782
+ temporaryIdentity = identity(opened);
783
+ const writtenIdentity = await writeAndVerify(handle, temporary, root, bytes, options);
784
+ if (!sameIdentity(temporaryIdentity, writtenIdentity))
785
+ fail('race', 'temporary metadata identity changed');
786
+ testStage(options, 'file_close');
787
+ await handle.close();
788
+ await renameAtomically(temporary, target, options);
789
+ await verifyLstat(target, temporaryIdentity, currentPlatform(options), 0o600, options);
790
+ if (currentPlatform(options) === 'win32')
791
+ await ensureWindowsAcl(target, options.windowsAcl ?? defaultWindowsAclAdapter(), 'file', options);
792
+ await fsyncDirectory(root, currentPlatform(options), options);
793
+ }
794
+ catch (error) {
795
+ try {
796
+ await handle.close();
797
+ }
798
+ catch { /* best effort */ }
799
+ if (temporaryIdentity !== undefined)
800
+ await removeOwnedFile(temporary, temporaryIdentity);
801
+ throw error;
802
+ }
803
+ }
804
+ export async function readDaemonMetadataFile(path, options) {
805
+ for (let attempt = 0; attempt < 4; attempt += 1) {
806
+ try {
807
+ return (await readEvidence(path, options)).metadata;
808
+ }
809
+ catch (error) {
810
+ if (!(error instanceof DaemonFileError) || error.code !== 'race' || attempt === 3)
811
+ throw error;
812
+ }
813
+ }
814
+ fail('race', 'metadata read retries were exhausted');
815
+ }
816
+ const LEGAL_TRANSITIONS = {
817
+ launching: ['starting'], starting: ['armed'], armed: ['stopping'], stopping: [],
818
+ };
819
+ function sameImmutable(left, right) {
820
+ return left.schema === right.schema && left.launcher_pid === right.launcher_pid && left.boot_nonce === right.boot_nonce
821
+ && left.shutdown_secret === right.shutdown_secret && left.executable === right.executable && left.entrypoint === right.entrypoint;
822
+ }
823
+ function validTransition(current, next) {
824
+ if (!LEGAL_TRANSITIONS[current.state].includes(next.state) || !sameImmutable(current, next))
825
+ return false;
826
+ if (current.state === 'starting' && (next.state !== 'armed' || next.pid !== current.pid))
827
+ return false;
828
+ if (current.state === 'armed' && (next.state !== 'stopping' || next.pid !== current.pid
829
+ || next.instance_id !== current.instance_id || next.management_host !== current.management_host
830
+ || next.management_port !== current.management_port))
831
+ return false;
832
+ return true;
833
+ }
834
+ export async function transitionDaemonMetadataFile(path, transition, options) {
835
+ const first = await readEvidence(path, options);
836
+ if (first.metadata.boot_nonce !== transition.expectedBootNonce || first.metadata.state !== transition.expectedState)
837
+ fail('state', 'metadata owner or state does not match');
838
+ if (first.metadata.shutdown_secret !== transition.expectedShutdownSecret)
839
+ fail('secret', 'metadata secret does not match');
840
+ if (!validTransition(first.metadata, transition.next))
841
+ fail('transition', 'metadata state transition is illegal or mutates ownership');
842
+ const second = await readEvidence(path, options);
843
+ if (!sameIdentity(first.identity, second.identity) || !bytesEqual(first.bytes, second.bytes))
844
+ fail('race', 'metadata changed before transition');
845
+ await atomicReplace(first.target, first.root, new TextEncoder().encode(encodeDaemonMetadataV1(transition.next)), options);
846
+ return transition.next;
847
+ }
848
+ /**
849
+ * Deletes metadata only after the caller has independently proven the owner is dead.
850
+ * This is a guarded delete, not a cross-process CAS primitive.
851
+ */
852
+ export async function deleteDaemonMetadataAfterOwnerExit(path, expected, options) {
853
+ let first;
854
+ try {
855
+ first = await readEvidence(path, options);
856
+ }
857
+ catch (error) {
858
+ if (error.code === 'ENOENT')
859
+ return false;
860
+ throw error;
861
+ }
862
+ if (first.metadata.boot_nonce !== expected.bootNonce || first.metadata.state !== expected.state
863
+ || first.metadata.shutdown_secret !== expected.shutdownSecret)
864
+ return false;
865
+ let second;
866
+ try {
867
+ second = await readEvidence(path, options);
868
+ }
869
+ catch (error) {
870
+ if (error.code === 'ENOENT')
871
+ return false;
872
+ throw error;
873
+ }
874
+ if (!sameIdentity(first.identity, second.identity) || !bytesEqual(first.bytes, second.bytes))
875
+ fail('race', 'metadata changed before delete');
876
+ await verifyLstat(second.target, second.identity, currentPlatform(options), 0o600, options);
877
+ await unlink(second.target);
878
+ await fsyncDirectory(second.root, currentPlatform(options), options);
879
+ return true;
880
+ }
881
+ /** Deletes a launch record owned by the current CLI process. */
882
+ export async function deleteDaemonMetadataForLauncher(path, expected, options) {
883
+ if (process.pid <= 0)
884
+ fail('owner', 'current process pid is invalid');
885
+ const first = await readEvidence(path, options);
886
+ if (first.metadata.state !== 'launching' || first.metadata.launcher_pid !== process.pid
887
+ || first.metadata.boot_nonce !== expected.bootNonce
888
+ || first.metadata.shutdown_secret !== expected.shutdownSecret)
889
+ return false;
890
+ const second = await readEvidence(path, options);
891
+ if (!sameIdentity(first.identity, second.identity) || !bytesEqual(first.bytes, second.bytes)) {
892
+ fail('race', 'metadata changed before launcher delete');
893
+ }
894
+ await verifyLstat(second.target, second.identity, currentPlatform(options), 0o600, options);
895
+ await unlink(second.target);
896
+ await fsyncDirectory(second.root, currentPlatform(options), options);
897
+ return true;
898
+ }
899
+ /** Deletes the current master's stopping record after runtime cleanup succeeds. */
900
+ export async function deleteDaemonMetadataForMaster(path, expected, options) {
901
+ if (expected.pid !== process.pid)
902
+ fail('owner', 'metadata pid is not the current process');
903
+ const first = await readEvidence(path, options);
904
+ if (first.metadata.state !== 'stopping' || first.metadata.boot_nonce !== expected.bootNonce
905
+ || first.metadata.shutdown_secret !== expected.shutdownSecret || first.metadata.pid !== expected.pid) {
906
+ fail('state', 'metadata is not the expected stopping record for this master');
907
+ }
908
+ const second = await readEvidence(path, options);
909
+ if (!sameIdentity(first.identity, second.identity) || !bytesEqual(first.bytes, second.bytes)) {
910
+ fail('race', 'metadata changed before master delete');
911
+ }
912
+ await verifyLstat(second.target, second.identity, currentPlatform(options), 0o600, options);
913
+ await unlink(second.target);
914
+ await fsyncDirectory(second.root, currentPlatform(options), options);
915
+ return true;
916
+ }
917
+ export async function createLaunchingDaemonMetadataFile(path, metadata, options) {
918
+ if (metadata.state !== 'launching')
919
+ fail('state', 'launch metadata must be launching');
920
+ const safe = await secureTarget(path, options, false);
921
+ const bytes = new TextEncoder().encode(encodeDaemonMetadataV1(metadata));
922
+ testStage(options, 'create_open');
923
+ const handle = await open(safe.target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
924
+ let createdIdentity;
925
+ try {
926
+ testStage(options, 'descriptor_stat');
927
+ const opened = await handle.stat();
928
+ assertDescriptor(opened, undefined, currentPlatform(options));
929
+ createdIdentity = identity(opened);
930
+ await writeAndVerify(handle, safe.target, safe.root, bytes, options, createdIdentity, true);
931
+ testStage(options, 'file_close');
932
+ await handle.close();
933
+ await fsyncDirectory(safe.root, currentPlatform(options), options);
934
+ }
935
+ catch (error) {
936
+ try {
937
+ await handle.close();
938
+ }
939
+ catch { /* best effort */ }
940
+ if (createdIdentity !== undefined)
941
+ await removeOwnedFile(safe.target, createdIdentity);
942
+ throw error;
943
+ }
944
+ }