@opengsd/gsd-core 1.20.0 → 1.21.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.
Files changed (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -49,13 +49,18 @@ exports.envGet = envGet;
49
49
  exports.resolveExecutableBinary = resolveExecutableBinary;
50
50
  exports.projectSpawnInvocation = projectSpawnInvocation;
51
51
  exports.execTool = execTool;
52
+ exports.execArgvQuarantined = execArgvQuarantined;
52
53
  exports.resolveGsdToolsPath = resolveGsdToolsPath;
53
54
  exports.dispatchGsdCommand = dispatchGsdCommand;
54
55
  exports.probeTty = probeTty;
56
+ exports.windowsDeviceNamespacePath = windowsDeviceNamespacePath;
55
57
  exports.normalizeContent = normalizeContent;
56
58
  exports.contentChangedAfterNormalize = contentChangedAfterNormalize;
57
59
  exports.retryRenameSync = retryRenameSync;
60
+ exports.openExclusiveTempSync = openExclusiveTempSync;
58
61
  exports.platformWriteSync = platformWriteSync;
62
+ exports.platformRestoreFileSync = platformRestoreFileSync;
63
+ exports.platformReadBytesSync = platformReadBytesSync;
59
64
  exports.platformReadSync = platformReadSync;
60
65
  exports.platformEnsureDir = platformEnsureDir;
61
66
  const node_path_1 = __importDefault(require("node:path"));
@@ -64,6 +69,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
64
69
  // can intercept calls from this seam — destructured imports capture references
65
70
  // at load time and become un-mockable.
66
71
  const node_child_process_1 = __importDefault(require("node:child_process"));
72
+ const node_crypto_1 = require("node:crypto");
67
73
  const pattern_cjs_1 = require("./pattern.cjs");
68
74
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
69
75
  const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
@@ -211,7 +217,8 @@ function projectPortableHookBaseDir({ configDir, homeDir }) {
211
217
  const normalizedHome = posixNormalize(String(homeDir || ''));
212
218
  if (!normalizedConfigDir || !normalizedHome)
213
219
  return normalizedConfigDir;
214
- return normalizedConfigDir.startsWith(normalizedHome)
220
+ // Boundary-checked: `/home/ux` is not under `/home/u`.
221
+ return (normalizedConfigDir.startsWith(normalizedHome) && (normalizedConfigDir.length === normalizedHome.length || normalizedConfigDir[normalizedHome.length] === '/'))
215
222
  ? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
216
223
  : normalizedConfigDir;
217
224
  }
@@ -355,7 +362,11 @@ function isManagedHookCommand(commandText, opts = {}) {
355
362
  const normalizedCommand = posixNormalize(commandText);
356
363
  if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
357
364
  const normalizedHooksDir = `${posixNormalize(node_path_1.default.join(opts.configDir, 'hooks'))}/`;
358
- if (!normalizedCommand.includes(normalizedHooksDir))
365
+ // #5850: a PowerShell single-quoted literal doubles every quote delimiter in
366
+ // the path, so a configDir containing one only matches once the doubling is undone.
367
+ // Only inside single-quoted literals, where the doubling is the escaping.
368
+ const collapsedCommand = normalizedCommand.replace(/'((?:[^']|'')*)'/g, (_literal, body) => `'${body.replace(/(['\u2018\u2019\u201A\u201B])\1/g, '$1')}'`);
369
+ if (!normalizedCommand.includes(normalizedHooksDir) && !collapsedCommand.includes(normalizedHooksDir))
359
370
  return false;
360
371
  }
361
372
  for (const basename of managedBasenames) {
@@ -959,6 +970,87 @@ function execTool(program, args, opts = {}) {
959
970
  // passed. Resolution must not leak an absolute path into that message.
960
971
  return _spawnResult(result, program);
961
972
  }
973
+ /**
974
+ * Seam primitive for running untrusted argv with no shell (#4310, ADR-4310).
975
+ * Refuses cmd.exe mediation: a `.cmd`/`.bat` target would run through a shell,
976
+ * so it is reported as `refused` and never spawned. Output is returned
977
+ * untrimmed; this function never throws.
978
+ */
979
+ function execArgvQuarantined(program, args, opts) {
980
+ try {
981
+ const platform = opts.platform ?? process.platform;
982
+ const notFound = { outcome: 'not_found', exitCode: null, stdout: '', stderr: '', signal: null };
983
+ const isWin = platform === 'win32';
984
+ const pathApi = isWin ? node_path_1.default.win32 : node_path_1.default.posix;
985
+ const env = opts.env ?? process.env;
986
+ let resolved = null;
987
+ if (pathApi.isAbsolute(program)) {
988
+ // An absolute program is addressed directly: no PATH lookup is involved.
989
+ resolved = _isFile(program, true, platform) ? program : null;
990
+ }
991
+ else if (program.includes('/') || program.includes('\\')) {
992
+ // The quarantine passes bare names only; a relative path would resolve against the project cwd.
993
+ return notFound;
994
+ }
995
+ else {
996
+ // Only ABSOLUTE PATH entries are searched: an empty or relative entry would
997
+ // resolve against the project (the spawn cwd), so a committed ./git could run.
998
+ const segments = String(envGet(env, 'PATH') ?? '')
999
+ .split(isWin ? ';' : ':')
1000
+ .filter((seg) => seg.length > 0 && pathApi.isAbsolute(seg));
1001
+ if (isWin) {
1002
+ const joined = segments.join(';');
1003
+ resolved = resolveExecutableBinary(program, { platform, env: { ...env, PATH: joined, Path: joined } });
1004
+ }
1005
+ else {
1006
+ for (const seg of segments) {
1007
+ const candidate = node_path_1.default.posix.join(seg, program);
1008
+ if (_isFile(candidate, true, platform)) {
1009
+ resolved = candidate;
1010
+ break;
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+ if (resolved === null || !pathApi.isAbsolute(resolved))
1016
+ return notFound;
1017
+ if (isWin && CMD_MEDIATED_EXT.test(node_path_1.default.win32.basename(resolved))) {
1018
+ return { outcome: 'refused', exitCode: null, stdout: '', stderr: '', signal: null };
1019
+ }
1020
+ const result = node_child_process_1.default.spawnSync(resolved, args, {
1021
+ cwd: opts.cwd,
1022
+ env: opts.env,
1023
+ encoding: 'utf-8',
1024
+ stdio: ['ignore', 'pipe', 'pipe'],
1025
+ timeout: opts.timeout,
1026
+ maxBuffer: opts.maxBuffer,
1027
+ windowsHide: true,
1028
+ shell: false,
1029
+ });
1030
+ const stdout = (result.stdout ?? '').toString();
1031
+ const stderr = (result.stderr ?? '').toString();
1032
+ const signal = result.signal ?? null;
1033
+ const spawnError = result.error;
1034
+ const code = spawnError?.code;
1035
+ let outcome;
1036
+ if (code === 'ENOENT')
1037
+ outcome = 'not_found';
1038
+ else if (isSpawnTimeout({ error: result.error }))
1039
+ outcome = 'timed_out';
1040
+ else if (code === 'ENOBUFS')
1041
+ outcome = 'overflow';
1042
+ else if (result.error)
1043
+ outcome = 'error';
1044
+ else if (result.status === null && signal)
1045
+ outcome = 'killed';
1046
+ else
1047
+ outcome = 'exited';
1048
+ return { outcome, exitCode: outcome === 'exited' ? result.status : null, stdout, stderr, signal };
1049
+ }
1050
+ catch {
1051
+ return { outcome: 'error', exitCode: null, stdout: '', stderr: '', signal: null };
1052
+ }
1053
+ }
962
1054
  /**
963
1055
  * Resolve the absolute path to gsd-tools.cjs relative to THIS module.
964
1056
  *
@@ -1210,6 +1302,76 @@ function _normalizeMd(content, prior, preserveFencedStructure = false) {
1210
1302
  text = text.replace(/\n*$/, '\n');
1211
1303
  return text;
1212
1304
  }
1305
+ /**
1306
+ * #5759: the device-namespace path (`\\.\NUL`) to open for `target`, or null when `target` is not
1307
+ * a spelling that names a device on every Windows. Two spellings qualify: an EXACT bare reserved
1308
+ * stem (`NUL`, `CON`, `PRN`, `AUX`, `COM1`-`COM9`, `LPT1`-`LPT9`, `CONIN$`, `CONOUT$`, any case,
1309
+ * optionally prefixed by `.\` or `./`), and an explicit `\\.\` / `\\?\` device form whose remainder
1310
+ * is exactly such a stem. Everything else is null and takes the checked path: a name with an
1311
+ * extension, trailing dot or space, `:stream` or superscript digit (`nul.txt`, `NUL.`, `NUL:s`,
1312
+ * `COM¹`) is an ordinary file on Windows 11, and a reserved name inside a directory path
1313
+ * (`sub\NUL`, `D:\x\NUL`, `C:NUL`, `\\?\C:\x\NUL`) is an ordinary file on 24H2 / Server 2025.
1314
+ * The device is always opened through `\\.\<STEM>` (uppercase), never through a resolved path.
1315
+ *
1316
+ * Deliberate: on pre-Windows-11-24H2 systems a reserved name with a directory component
1317
+ * (`<dir>\NUL`) is still the device, but it now takes the checked path (the device is never
1318
+ * opened through a directory path), so a write there may fail rather than discard data.
1319
+ */
1320
+ function windowsDeviceNamespacePath(target) {
1321
+ const namespaced = /^\\\\[.?]\\/.test(target);
1322
+ let rest = namespaced ? target.slice(4) : target;
1323
+ if (!namespaced)
1324
+ while (/^\.[\\/]/.test(rest))
1325
+ rest = rest.slice(2);
1326
+ // Only an exact stem qualifies: no extension, trailing dot/space, `:stream` or superscript
1327
+ // COM¹/LPT² digit. On Windows 11 `nul.txt` / `con.txt` are ordinary creatable files, so routing
1328
+ // them to the device would silently discard the data.
1329
+ const m = /^(nul|con|prn|aux|com[1-9]|lpt[1-9]|conin\$|conout\$)$/i.exec(rest);
1330
+ if (m === null)
1331
+ return null;
1332
+ return `\\\\.\\${m[1].toUpperCase()}`;
1333
+ }
1334
+ const DEVICE_OPEN_FALLTHROUGH = new Set(['ENOENT', 'EISDIR', 'EACCES', 'EPERM', 'EBUSY', 'EINVAL']);
1335
+ /**
1336
+ * #5759: writes `normalized` in place when `filePath` is a bare reserved name or a `\\.\` / `\\?\`
1337
+ * device form (see `windowsDeviceNamespacePath`) and the handle opened on `\\.\<NAME>` is a
1338
+ * character device. Returns false (nothing written, handle closed untouched) for any other
1339
+ * outcome so the caller takes the checked path: a path with a directory component (never opened,
1340
+ * so a link or an ordinary file named NUL is never touched), a name that is an ordinary file on
1341
+ * Windows 11 (open fails with ENOENT/EISDIR/EACCES/EPERM/EBUSY/EINVAL, or the handle is not a
1342
+ * character device). The open is O_WRONLY only: no O_CREAT, no O_TRUNC.
1343
+ */
1344
+ function writeWindowsDeviceInPlace(filePath, normalized, encoding, seams) {
1345
+ if ((seams?.platform ?? process.platform) !== 'win32')
1346
+ return false;
1347
+ const device = windowsDeviceNamespacePath(filePath);
1348
+ if (device === null)
1349
+ return false;
1350
+ const fsx = seams?.fs ?? node_fs_1.default;
1351
+ // No lstat: the `\\.\<STEM>` device namespace path never resolves a cwd entry, so an ordinary
1352
+ // file or link named like the stem in the cwd is not reachable through it.
1353
+ let deviceFd;
1354
+ try {
1355
+ deviceFd = fsx.openSync(device, node_fs_1.default.constants.O_WRONLY);
1356
+ }
1357
+ catch (err) {
1358
+ if (DEVICE_OPEN_FALLTHROUGH.has(err.code ?? ''))
1359
+ return false;
1360
+ throw err;
1361
+ }
1362
+ try {
1363
+ if (!fsx.fstatSync(deviceFd).isCharacterDevice())
1364
+ return false;
1365
+ fsx.writeFileSync(deviceFd, normalized, encoding);
1366
+ return true;
1367
+ }
1368
+ finally {
1369
+ try {
1370
+ fsx.closeSync(deviceFd);
1371
+ }
1372
+ catch { /* already closed */ }
1373
+ }
1374
+ }
1213
1375
  function normalizeContent(filePath, content, opts = {}) {
1214
1376
  const encoding = opts.encoding ?? 'utf-8';
1215
1377
  if (opts.verbatim === true)
@@ -1247,6 +1409,16 @@ function contentChangedAfterNormalize(filePath, before, after, opts = {}) {
1247
1409
  const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
1248
1410
  const RENAME_MAX_ATTEMPTS = 3;
1249
1411
  const RENAME_RETRY_BACKOFF_MS = 50;
1412
+ /** The exact identity of an open descriptor. */
1413
+ function identityOfFd(fd) {
1414
+ const s = node_fs_1.default.fstatSync(fd, { bigint: true });
1415
+ return { dev: s.dev, ino: s.ino };
1416
+ }
1417
+ /** The exact identity of the entry at `p` (a final symlink is not followed). */
1418
+ function identityOfPath(p) {
1419
+ const s = node_fs_1.default.lstatSync(p, { bigint: true });
1420
+ return { dev: s.dev, ino: s.ino };
1421
+ }
1250
1422
  /** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
1251
1423
  let _renameSleepBuf = null;
1252
1424
  function renameBackoff() {
@@ -1308,7 +1480,7 @@ function readPriorMarkdown(filePath, encoding) {
1308
1480
  let fd;
1309
1481
  try {
1310
1482
  // O_NONBLOCK keeps opening a FIFO from waiting for a writer; it is absent on Windows.
1311
- fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NONBLOCK ?? 0));
1483
+ fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NONBLOCK ?? 0) | (node_fs_1.default.constants.O_NOCTTY ?? 0));
1312
1484
  const stat = node_fs_1.default.fstatSync(fd);
1313
1485
  if (!stat.isFile() || stat.size > MAX_PRIOR_BYTES)
1314
1486
  return undefined;
@@ -1337,29 +1509,676 @@ function readPriorMarkdown(filePath, encoding) {
1337
1509
  }
1338
1510
  }
1339
1511
  }
1512
+ /** Collisions tolerated while creating a temp file before giving up (#5759). */
1513
+ const EXCLUSIVE_TEMP_ATTEMPTS = 5;
1514
+ /** Default temp-name suffix: pid plus 48 random bits, so a name cannot be predicted and planted. */
1515
+ function defaultTempSuffix() {
1516
+ return `${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
1517
+ }
1518
+ /**
1519
+ * Create `<basePath>.tmp.<suffix>` exclusively and return its open write descriptor (#5759).
1520
+ * `O_CREAT|O_EXCL` fails with EEXIST on ANY existing entry — a regular file, a FIFO, or a
1521
+ * symlink (dangling or not) — so a planted entry is never opened, written through or blocked
1522
+ * on; `O_NOFOLLOW` is added where the platform has it (absent on Windows). On EEXIST a new
1523
+ * suffix is drawn, up to EXCLUSIVE_TEMP_ATTEMPTS times, then the EEXIST is thrown. On
1524
+ * ENAMETOOLONG (a target whose name leaves no room for the default suffix) the suffix shrinks
1525
+ * to 4 random bytes of hex, then to 2, before the error is thrown. Any
1526
+ * other error is thrown at once. `nextSuffix` is a seam for tests; it applies until a shrink.
1527
+ * `mode` is the creation mode (default 0o666, narrowed by the umask): a caller replacing a
1528
+ * private file passes that file's mode so the temp is never briefly more readable than it.
1529
+ * `label` names the temp (`<basePath>.<label>.<suffix>`; default `tmp`).
1530
+ *
1531
+ * Crash-orphaned temp files are named `<target>.tmp.<pid>.<12 hex>` and are NOT reaped: a
1532
+ * process killed between the create and the rename leaves one beside its target, and a later
1533
+ * write picks a fresh name rather than reusing it.
1534
+ */
1535
+ function openExclusiveTempSync(basePath, nextSuffix = defaultTempSuffix, mode = 0o666, label = 'tmp') {
1536
+ const flags = node_fs_1.default.constants.O_WRONLY | node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | (node_fs_1.default.constants.O_NOFOLLOW ?? 0);
1537
+ let collision;
1538
+ let shrink = 0; // 0 = nextSuffix, 1 = 4 random bytes, 2 = 2 random bytes
1539
+ for (let attempt = 0; attempt < EXCLUSIVE_TEMP_ATTEMPTS; attempt++) {
1540
+ const suffix = shrink === 0 ? nextSuffix() : (0, node_crypto_1.randomBytes)(shrink === 1 ? 4 : 2).toString('hex');
1541
+ const tmpPath = `${basePath}.${label}.${suffix}`;
1542
+ try {
1543
+ return { fd: node_fs_1.default.openSync(tmpPath, flags, mode), tmpPath };
1544
+ }
1545
+ catch (err) {
1546
+ const code = err.code;
1547
+ if (code === 'ENAMETOOLONG' && shrink < 2) {
1548
+ shrink += 1;
1549
+ attempt -= 1; // a shorter name is a retry of the same attempt, not a collision
1550
+ continue;
1551
+ }
1552
+ if (code !== 'EEXIST')
1553
+ throw err;
1554
+ collision = err;
1555
+ }
1556
+ }
1557
+ throw collision;
1558
+ }
1559
+ /**
1560
+ * Restore limit: an existing target is captured for restore only up to MAX_PRIOR_BYTES (2 MiB);
1561
+ * a larger one is refused with EFBIG BEFORE it is truncated. A failed write puts the old bytes
1562
+ * back; if that restore itself fails the thrown error carries `restoreFailed = true`.
1563
+ *
1564
+ * Write `normalized` to the target path itself (the non-atomic fallback). Opens non-blocking
1565
+ * and fstats the descriptor, so a FIFO or device at the target can neither block the open
1566
+ * (a FIFO with no reader fails ENXIO) nor be written to: anything but a regular file is
1567
+ * refused with code ENXIO.
1568
+ */
1569
+ function writeTargetDirect(filePath, normalized, encoding, walked, afterLstat, checkParent = () => { }) {
1570
+ // The walked parent is re-checked at the top and again right before each open that can create
1571
+ // or modify the target; a swap refuses with ENXIO before anything is created or written.
1572
+ checkParent();
1573
+ const nonBlock = (node_fs_1.default.constants.O_NONBLOCK ?? 0) | (node_fs_1.default.constants.O_NOCTTY ?? 0);
1574
+ const noFollow = node_fs_1.default.constants.O_NOFOLLOW ?? 0;
1575
+ const refuse = (message, code) => {
1576
+ throw Object.assign(new Error(`${code}: cannot write ${filePath}: ${message}`), { code });
1577
+ };
1578
+ const LINK_MESSAGE = 'a symlinked target cannot be replaced atomically, and is never written through — refusing';
1579
+ // What an O_NOFOLLOW open of a final-component symlink fails with: ELOOP (Linux, macOS), EMLINK
1580
+ // (FreeBSD) or EFTYPE (NetBSD). All mean "that is a link": refused like the lstat.
1581
+ const refuseIfLinkOpen = (err) => {
1582
+ const code = err.code;
1583
+ if (code === 'ELOOP' || code === 'EMLINK' || code === 'EFTYPE')
1584
+ refuse(LINK_MESSAGE, 'ENXIO');
1585
+ };
1586
+ const refuseMultiplyLinked = (st) => {
1587
+ if (st.nlink > 1)
1588
+ refuse('a multiply-linked target cannot be replaced atomically, and writing it in place would change every other name for it — refusing', 'ENXIO');
1589
+ };
1590
+ // Probe an existing target READ-ONLY and non-blocking, so a device or FIFO is never opened
1591
+ // for write: it is classified by fstat, and the old bytes are captured for a restore.
1592
+ // Returns null when the target is absent.
1593
+ const probe = () => {
1594
+ let fd;
1595
+ try {
1596
+ fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_RDONLY | noFollow | nonBlock);
1597
+ }
1598
+ catch (err) {
1599
+ if (err.code === 'ENOENT')
1600
+ return null;
1601
+ refuseIfLinkOpen(err);
1602
+ throw err;
1603
+ }
1604
+ try {
1605
+ const st = node_fs_1.default.fstatSync(fd);
1606
+ if (st.isDirectory())
1607
+ refuse('it is a directory', 'EISDIR');
1608
+ if (!st.isFile())
1609
+ refuse('not a regular file (FIFO/device) — refusing', 'ENXIO');
1610
+ refuseMultiplyLinked(st);
1611
+ // The truncate-then-write below needs the old bytes to put back on a failed write. A file
1612
+ // too large to hold (or read short) is refused BEFORE anything is truncated. Remaining
1613
+ // caveat: between the truncate and the first successful write the file is empty.
1614
+ if (st.size > MAX_PRIOR_BYTES)
1615
+ refuse(`existing file is larger than ${MAX_PRIOR_BYTES} bytes`, 'EFBIG');
1616
+ const prior = Buffer.alloc(st.size);
1617
+ let got = 0;
1618
+ while (got < prior.length) {
1619
+ const n = node_fs_1.default.readSync(fd, prior, got, prior.length - got, got);
1620
+ if (n <= 0)
1621
+ break;
1622
+ got += n;
1623
+ }
1624
+ if (got !== prior.length)
1625
+ refuse('existing content could not be read back', 'EIO');
1626
+ return { prior, ...identityOfFd(fd) };
1627
+ }
1628
+ finally {
1629
+ try {
1630
+ node_fs_1.default.closeSync(fd);
1631
+ }
1632
+ catch { /* already closed */ }
1633
+ }
1634
+ };
1635
+ // A symlinked target is never written through: the atomic publish replaces the link entry, and
1636
+ // where that is impossible the direct path must not open the link and modify whatever it points at.
1637
+ try {
1638
+ if (node_fs_1.default.lstatSync(filePath).isSymbolicLink())
1639
+ refuse(LINK_MESSAGE, 'ENXIO');
1640
+ }
1641
+ catch (err) {
1642
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')
1643
+ throw err;
1644
+ }
1645
+ afterLstat?.();
1646
+ let created = false;
1647
+ let fd;
1648
+ let prior = null;
1649
+ checkParent();
1650
+ const existing = probe();
1651
+ if (existing !== null) {
1652
+ // The path walk recorded what sat at the final physical target when it approved the path. A
1653
+ // target the walk saw as absent, or as a different file than the one probed now, is refused
1654
+ // before anything is opened for write: the path no longer leads where the walk looked.
1655
+ if (walked === 'absent')
1656
+ return refuse('it appeared after the path was checked', 'ENXIO');
1657
+ if (existing.dev !== walked.dev || existing.ino !== walked.ino) {
1658
+ refuse('the target is not the file the path check approved', 'ENXIO');
1659
+ }
1660
+ checkParent();
1661
+ try {
1662
+ fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_WRONLY | noFollow | nonBlock);
1663
+ }
1664
+ catch (err) {
1665
+ refuseIfLinkOpen(err);
1666
+ throw err;
1667
+ }
1668
+ const st = node_fs_1.default.fstatSync(fd);
1669
+ const opened = identityOfFd(fd);
1670
+ if (!st.isFile() || opened.dev !== existing.dev || opened.ino !== existing.ino) {
1671
+ try {
1672
+ node_fs_1.default.closeSync(fd);
1673
+ }
1674
+ catch { /* already closed */ }
1675
+ refuse('the target changed while it was being opened', 'ENXIO');
1676
+ }
1677
+ if (st.nlink > 1) {
1678
+ try {
1679
+ node_fs_1.default.closeSync(fd);
1680
+ }
1681
+ catch { /* already closed */ }
1682
+ refuseMultiplyLinked(st);
1683
+ }
1684
+ prior = existing.prior;
1685
+ }
1686
+ else {
1687
+ if (walked !== 'absent')
1688
+ return refuse('the target vanished after the path was checked', 'ENXIO');
1689
+ // Create exclusively and without following a link: anything at the path now (a symlink
1690
+ // planted since the probe, a dangling one, a descriptor alias reached through a swapped
1691
+ // directory) makes this EEXIST, never a file created through it or written to.
1692
+ checkParent();
1693
+ try {
1694
+ fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_WRONLY | node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | (node_fs_1.default.constants.O_NOFOLLOW ?? 0) | nonBlock, 0o666);
1695
+ created = true;
1696
+ }
1697
+ catch (err) {
1698
+ if (err.code !== 'EEXIST')
1699
+ throw err;
1700
+ return refuse('something appeared at the target (a dangling symlink, or a file created since the check) — refusing to create through it', 'ENXIO');
1701
+ }
1702
+ }
1703
+ // What guards this write, and what does not. The path walk in platformWriteSync resolves the
1704
+ // physical parent directory of every link hop, refuses a descriptor alias, and records what sat
1705
+ // at the final physical target ({dev, ino}, or absent). Here: a target the walk saw absent is
1706
+ // only ever CREATED with O_CREAT|O_EXCL|O_NOFOLLOW (an alias or link appearing there fails
1707
+ // EEXIST); a target the walk saw present must probe as the same dev/ino, and the descriptor
1708
+ // opened for write must match the probe, or nothing is truncated or written. There is
1709
+ // deliberately NO post-open /proc check: opening /dev/fd/N re-opens the underlying object, so
1710
+ // the new descriptor never reads back as an alias. The probe and write opens add O_NOFOLLOW, so
1711
+ // a link swapped in after the lstat above fails ELOOP and is refused. Remaining race: the
1712
+ // walk-to-probe and probe-to-open steps are separate syscalls, so a swap that restores the very
1713
+ // same file (same dev/ino) between them is indistinguishable from no swap, and a file replaced
1714
+ // after the final fstat is written under its old identity. Nothing here defends a directory an attacker can
1715
+ // rewrite at will beyond refusing every identity change it can observe.
1716
+ let failure;
1717
+ try {
1718
+ // Truncate BEFORE writing so a partial write can never leave the new prefix followed by the
1719
+ // old tail; the old bytes held above are put back if the write fails.
1720
+ node_fs_1.default.ftruncateSync(fd, 0);
1721
+ try {
1722
+ node_fs_1.default.writeFileSync(fd, normalized, encoding);
1723
+ }
1724
+ catch (err) {
1725
+ if (prior !== null) {
1726
+ try {
1727
+ node_fs_1.default.ftruncateSync(fd, 0);
1728
+ let put = 0;
1729
+ while (put < prior.length)
1730
+ put += node_fs_1.default.writeSync(fd, prior, put, prior.length - put, put);
1731
+ }
1732
+ catch {
1733
+ // Best-effort restore; the original error is the one reported, flagged so the caller
1734
+ // knows the target may now hold neither the old nor the new content.
1735
+ const flagged = err instanceof Error ? err : new Error(String(err));
1736
+ flagged.restoreFailed = true;
1737
+ throw flagged;
1738
+ }
1739
+ }
1740
+ throw err;
1741
+ }
1742
+ }
1743
+ catch (err) {
1744
+ failure = err instanceof Error ? err : new Error(String(err));
1745
+ }
1746
+ // The identity of the descriptor this write created, taken before it is closed: the cleanup
1747
+ // unlinks the PATH only if it still names that same file, so a parent swapped after the create
1748
+ // can never make it delete a same-named file elsewhere.
1749
+ let createdIdentity = null;
1750
+ if (failure !== undefined && created) {
1751
+ try {
1752
+ const st = node_fs_1.default.fstatSync(fd);
1753
+ if (st.isFile())
1754
+ createdIdentity = identityOfFd(fd);
1755
+ }
1756
+ catch { /* identity unknown: the file is left in place */ }
1757
+ }
1758
+ try {
1759
+ node_fs_1.default.closeSync(fd);
1760
+ }
1761
+ catch { /* already closed */ }
1762
+ if (failure !== undefined) {
1763
+ if (created && createdIdentity !== null) {
1764
+ try {
1765
+ const now = node_fs_1.default.lstatSync(filePath);
1766
+ const nowId = identityOfPath(filePath);
1767
+ if (now.isFile() && nowId.dev === createdIdentity.dev && nowId.ino === createdIdentity.ino) {
1768
+ node_fs_1.default.unlinkSync(filePath);
1769
+ }
1770
+ }
1771
+ catch { /* already gone */ }
1772
+ }
1773
+ throw failure;
1774
+ }
1775
+ }
1776
+ /** Block the thread for `ms` milliseconds without a busy spin. */
1777
+ function defaultSleep(ms) {
1778
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1779
+ }
1780
+ /**
1781
+ * Write `data` to a special target — a FIFO with a reader, or a process-own descriptor alias —
1782
+ * through ONE descriptor, never a reopen, so a reader that leaves cannot turn into a blocked
1783
+ * open. For a FIFO the non-blocking open is the reader probe (ENXIO = no reader); an alias
1784
+ * writes to the descriptor number it names. A full pipe (EAGAIN) backs off 1, 2, 4 … 50 ms through
1785
+ * `sleep` (Atomics.wait, no busy spin) with no overall bound by default (a slow reader is the
1786
+ * user's own pipeline); a test may set `maxWaitMs`, and once the summed back-off passes it the
1787
+ * write throws ETIMEDOUT; a reader that went away (EPIPE — Node ignores SIGPIPE, so it surfaces as an error)
1788
+ * is rethrown with a clear message. Both seams are for tests.
1789
+ */
1790
+ function writeSpecialTargetWithBackoff(filePath, data, wait = {}, existingFd) {
1791
+ const sleep = wait.sleep ?? defaultSleep;
1792
+ // No bound by default: a slow existing reader is the user's own pipeline (`| less`).
1793
+ const maxWaitMs = wait.maxWaitMs ?? Infinity;
1794
+ // A descriptor alias (/dev/stdout …) is written through the fd the process already holds —
1795
+ // never reopened by path, which fails on a Linux socketpair stdio and loses O_APPEND.
1796
+ const fd = existingFd ?? node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_WRONLY | (node_fs_1.default.constants.O_NONBLOCK ?? 0) | (node_fs_1.default.constants.O_NOCTTY ?? 0));
1797
+ try {
1798
+ let off = 0;
1799
+ let waited = 0;
1800
+ let delay = 1;
1801
+ while (off < data.length) {
1802
+ try {
1803
+ off += node_fs_1.default.writeSync(fd, data, off, data.length - off);
1804
+ delay = 1;
1805
+ }
1806
+ catch (err) {
1807
+ const code = err.code;
1808
+ if (code === 'EAGAIN') {
1809
+ if (waited >= maxWaitMs) {
1810
+ throw Object.assign(new Error(`ETIMEDOUT: ${filePath}: the reader did not drain the pipe within ${maxWaitMs} ms`), { code: 'ETIMEDOUT' });
1811
+ }
1812
+ sleep(delay);
1813
+ waited += delay;
1814
+ delay = Math.min(delay * 2, 50);
1815
+ continue;
1816
+ }
1817
+ if (code === 'EPIPE') {
1818
+ throw Object.assign(new Error(`EPIPE: ${filePath}: the reader went away mid-write`), { code: 'EPIPE' });
1819
+ }
1820
+ throw err;
1821
+ }
1822
+ }
1823
+ }
1824
+ finally {
1825
+ if (existingFd === undefined) {
1826
+ try {
1827
+ node_fs_1.default.closeSync(fd);
1828
+ }
1829
+ catch { /* already closed */ }
1830
+ }
1831
+ }
1832
+ }
1833
+ /** The descriptor number a process-own alias names (/dev/stdout -> 1 …), or null. */
1834
+ function descriptorAliasFd(resolvedTarget) {
1835
+ if (resolvedTarget === '/dev/stdout')
1836
+ return 1;
1837
+ if (resolvedTarget === '/dev/stderr')
1838
+ return 2;
1839
+ // Only stdin/stdout/stderr: a larger N is some other descriptor the process happens to hold.
1840
+ const m = /^\/(?:dev|proc\/self)\/fd\/(0|[1-9]\d*)$/.exec(resolvedTarget);
1841
+ return m !== null && Number(m[1]) <= 2 ? Number(m[1]) : null;
1842
+ }
1843
+ /** A path component that is absent (ENOENT) or is not a directory (ENOTDIR). */
1844
+ function isMissing(err) {
1845
+ const code = err.code;
1846
+ return code === 'ENOENT' || code === 'ENOTDIR';
1847
+ }
1848
+ /**
1849
+ * A guard on the directory that holds `filePath`: its identity is taken when the
1850
+ * guard is made (null when the directory is absent; `record()` takes it again
1851
+ * once the directory exists) and `changed()` re-stats and reports whether the
1852
+ * directory is no longer that one (or is absent). A stat error other than a
1853
+ * missing directory goes to `statFailed`, which must throw.
1854
+ */
1855
+ function makeParentGuard(filePath, statFailed) {
1856
+ const stat = () => {
1857
+ try {
1858
+ const s = node_fs_1.default.statSync(node_path_1.default.dirname(filePath), { bigint: true });
1859
+ return { dev: s.dev, ino: s.ino };
1860
+ }
1861
+ catch (err) {
1862
+ if (isMissing(err))
1863
+ return null;
1864
+ return statFailed(err);
1865
+ }
1866
+ };
1867
+ let walked = stat();
1868
+ return {
1869
+ record() { walked ??= stat(); },
1870
+ changed() {
1871
+ const now = stat();
1872
+ return now === null || walked === null || now.dev !== walked.dev || now.ino !== walked.ino;
1873
+ },
1874
+ };
1875
+ }
1876
+ /**
1877
+ * Write `content` to `filePath` atomically: a sibling temp file (`<file>.tmp.<pid>.<12 hex>`,
1878
+ * created exclusively, never following a link, never blocking on a planted FIFO) is renamed
1879
+ * over the target. When that is impossible the write falls back to the target itself.
1880
+ *
1881
+ * The direct fallback refuses, with an error whose `code` is `ENXIO`: a target that is not a
1882
+ * regular file (a FIFO, a character or block device such as `/dev/null`, a socket); every
1883
+ * symlinked target (live or dangling; the final component is opened O_NOFOLLOW); and a
1884
+ * multiply-linked target (nlink > 1; an in-place write would change every other name for it).
1885
+ * The atomic path replaces whatever sits at the target by rename, a symlink or extra hard link
1886
+ * included, never writing through it, and is otherwise unaffected. A crash between the temp
1887
+ * create and the rename leaves `<file>.tmp.<pid>.<hex>` behind; such files are not reaped.
1888
+ */
1340
1889
  function platformWriteSync(filePath, content, opts = {}) {
1890
+ // #5759: a Windows device target (a bare NUL/CON/... name, or a \\.\ / \\?\ device form) has no
1891
+ // real parent directory, so it is routed BEFORE the physical path walk below (which would refuse
1892
+ // `\\.\nul` with ENXIO). Only under allowSpecialTarget; without it the walk refuses as before.
1893
+ // The handle is opened on `\\.\<NAME>` and fstat must prove a character device before any byte
1894
+ // is written; any other outcome (Windows 11 24H2+ treats a reserved name in a directory path as
1895
+ // an ordinary file) falls through to the checked path.
1896
+ if (opts.allowSpecialTarget === true && windowsDeviceNamespacePath(filePath) !== null) {
1897
+ const device = normalizeContent(filePath, content, { ...opts, prior: opts.prior });
1898
+ if (writeWindowsDeviceInPlace(filePath, device.content, device.encoding, opts.deviceSeams))
1899
+ return;
1900
+ }
1901
+ // A descriptor beyond stdio names some other open file of this process (a log, a pipe, a
1902
+ // regular file): never written through its /dev/fd or /proc alias, flag or no flag.
1903
+ // A symlink to such an alias is refused too (every link hop is walked), a path reaching one
1904
+ // through a symlinked directory is refused (the physical parent directory is resolved), and a
1905
+ // non-canonical descriptor name (/dev/fd/01) is refused outright. Every classification is made
1906
+ // on the PHYSICAL path: the kernel resolves `..` after following symlinks, so a lexical
1907
+ // path.resolve/normalize would disagree with it and is never used for a decision here.
1908
+ const refuseFdAlias = (candidate) => {
1909
+ const m = /^\/(?:dev|proc\/(self|thread-self|\d+)(?:\/task\/\d+)?)\/fd\/(\d+)$/.exec(candidate);
1910
+ if (m === null)
1911
+ return;
1912
+ // Only this process's own descriptors are ever written by alias: /proc/<another pid>/fd/N
1913
+ // names a different process's file and is refused whatever N is.
1914
+ if (m[1] !== undefined && m[1] !== 'self' && m[1] !== 'thread-self' && Number(m[1]) !== process.pid) {
1915
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: another process's descriptors are never written`), { code: 'ENXIO' });
1916
+ }
1917
+ if (!/^(?:0|[1-9]\d*)$/.test(m[2]) || Number(m[2]) > 2) {
1918
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: only descriptors 0-2 may be written by alias`), { code: 'ENXIO' });
1919
+ }
1920
+ };
1921
+ // Only ENOENT (a component is absent) and ENOTDIR (a component is a non-directory) mean "nothing
1922
+ // can exist there". Any other failure (EACCES, ELOOP, ENAMETOOLONG ...) leaves the path
1923
+ // unclassified and is refused: guessing it harmless is the window an attacker needs.
1924
+ const refuseUnresolved = (code, what = 'a directory on its path cannot be resolved') => {
1925
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: ${what} (${code ?? 'unknown error'}) — refusing`), { code: 'ENXIO' });
1926
+ };
1927
+ // The parent directory of a raw (un-normalised) path, resolved physically by the OS, or null
1928
+ // when a component is absent (ENOENT/ENOTDIR); any other failure refuses with ENXIO.
1929
+ // `fs.realpathSync` (JS) would collapse `..` lexically first;
1930
+ // the native form hands the whole string to realpath(3). A /proc/<own pid> prefix is folded to
1931
+ // /proc/self so the alias patterns see one spelling.
1932
+ const physicalDir = (rawPath) => {
1933
+ let real;
1934
+ try {
1935
+ real = node_fs_1.default.realpathSync.native(node_path_1.default.dirname(rawPath));
1936
+ }
1937
+ catch (err) {
1938
+ if (isMissing(err))
1939
+ return null;
1940
+ return refuseUnresolved(err.code);
1941
+ }
1942
+ return real.replace(/^\/proc\/(\d+)\/(?=fd$|task\/)/, (m, pid) => (Number(pid) === process.pid ? '/proc/self/' : m));
1943
+ };
1944
+ // A path may reach an alias through a DIRECTORY symlink (/tmp/d -> /dev/fd, then /tmp/d/7), or
1945
+ // through `..` after one (/tmp/b -> /dev/fd, then /tmp/b/../fd/7): classify a path by its
1946
+ // physical parent directory.
1947
+ const refuseViaRealDir = (rawPath) => {
1948
+ const realDir = physicalDir(rawPath);
1949
+ if (realDir !== null && /^(?:\/dev\/fd|\/proc\/(?:self|thread-self|\d+)(?:\/task\/\d+)?\/fd)$/.test(realDir)) {
1950
+ refuseFdAlias(`${realDir}/${node_path_1.default.basename(rawPath)}`);
1951
+ }
1952
+ };
1953
+ // The physical parent directory plus the final name, with the final component not followed
1954
+ // (null when a parent component is absent, ENOENT/ENOTDIR; any other failure refuses).
1955
+ const physicalPath = (rawPath) => {
1956
+ const realDir = physicalDir(rawPath);
1957
+ return realDir === null ? null : `${realDir === '/' ? '' : realDir}/${node_path_1.default.basename(rawPath)}`;
1958
+ };
1959
+ refuseFdAlias(node_path_1.default.resolve(filePath)); // a lexical match is an alias spelled out: refuse; never used to ALLOW
1960
+ // realpath of the target itself is not used: on Linux it resolves /dev/fd/N through /proc to
1961
+ // pipe:[..] or the file, losing the alias. Each link hop is read and resolved against the
1962
+ // physical directory that holds it, bounded at 40 hops.
1963
+ let hop = filePath;
1964
+ for (let hops = 0; hops < 40; hops++) {
1965
+ // Runs on EVERY write: one native realpath of the parent directory (about one lstat per path
1966
+ // component), plus one lstat of the hop to see whether it is a link to follow. The realpath
1967
+ // stays unconditional because the target's own lstat type cannot distinguish a descriptor alias
1968
+ // reached through a symlinked directory (on macOS lstat of /dev/fd/N reports the underlying
1969
+ // file's type), so skipping regular-looking targets would reopen that gap.
1970
+ refuseViaRealDir(hop);
1971
+ let isLink = false;
1972
+ try {
1973
+ isLink = node_fs_1.default.lstatSync(hop).isSymbolicLink();
1974
+ }
1975
+ catch (err) {
1976
+ if (!isMissing(err))
1977
+ refuseUnresolved(err.code);
1978
+ /* absent: nothing to follow */
1979
+ }
1980
+ if (!isLink)
1981
+ break;
1982
+ let link;
1983
+ try {
1984
+ link = node_fs_1.default.readlinkSync(hop);
1985
+ }
1986
+ catch (err) {
1987
+ return refuseUnresolved(err.code, 'a symbolic link on its path cannot be read');
1988
+ }
1989
+ let next = null;
1990
+ // Kept raw (joined by string, never normalised): the next iteration's native realpath
1991
+ // resolves any `..` in it against the symlinks it crosses.
1992
+ if (node_path_1.default.isAbsolute(link)) {
1993
+ next = link;
1994
+ }
1995
+ else {
1996
+ const holder = physicalDir(hop);
1997
+ if (holder !== null)
1998
+ next = `${holder === '/' ? '' : holder}/${link}`;
1999
+ }
2000
+ if (next === null) {
2001
+ // A relative link whose holding directory cannot be resolved cannot be classified: refuse.
2002
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: a relative link in an unresolvable directory — refusing`), { code: 'ENXIO' });
2003
+ }
2004
+ // physical, never lexical: `..` follows symlinks. A null result here means a component of the
2005
+ // link target is absent; the final-target identity recorded below then reads it as absent.
2006
+ const physicalNext = physicalPath(next);
2007
+ if (physicalNext !== null)
2008
+ refuseFdAlias(physicalNext);
2009
+ refuseViaRealDir(next);
2010
+ hop = next;
2011
+ }
2012
+ // The identity of the final physical target as the walk saw it: {dev, ino}, or 'absent'. The
2013
+ // direct fallback write is tied to it (see writeTargetDirect).
2014
+ let walked = 'absent';
2015
+ const finalPath = physicalPath(hop);
2016
+ if (finalPath !== null) {
2017
+ let st = null;
2018
+ try {
2019
+ st = node_fs_1.default.lstatSync(finalPath);
2020
+ }
2021
+ catch (err) {
2022
+ if (!isMissing(err))
2023
+ refuseUnresolved(err.code, 'the target cannot be inspected');
2024
+ }
2025
+ if (st !== null) {
2026
+ // Still a link after 40 hops: an unclassifiable chain.
2027
+ if (st.isSymbolicLink())
2028
+ refuseUnresolved('ELOOP', 'the symbolic link chain is circular or longer than 40 links');
2029
+ walked = identityOfPath(finalPath);
2030
+ }
2031
+ }
2032
+ // The identity of the directory that holds the target entry, taken by a separate stat of
2033
+ // dirname(filePath) right after the walk (null when absent: it is then recorded right after
2034
+ // mkdir below). The atomic publish creates its temp file in, and renames within, this
2035
+ // directory, so it is re-checked against this identity before each step.
2036
+ const parentGuard = makeParentGuard(filePath, (err) => refuseUnresolved(err.code, 'its directory cannot be inspected'));
2037
+ const parentChanged = () => parentGuard.changed();
2038
+ const refuseParentSwap = () => {
2039
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: its directory is not the one the path check approved — refusing`), { code: 'ENXIO' });
2040
+ };
2041
+ // Handed to the direct fallbacks, which re-run it before each open that can create or modify.
2042
+ const checkParent = () => { if (parentChanged())
2043
+ refuseParentSwap(); };
2044
+ opts.writeSeams?.afterPathWalk?.();
1341
2045
  // #5165: a Markdown write is scoped to what it changed relative to the file it replaces.
1342
2046
  let prior = opts.prior;
1343
2047
  if (prior === undefined && opts.verbatim !== true && node_path_1.default.extname(filePath).toLowerCase() === '.md') {
1344
2048
  prior = readPriorMarkdown(filePath, opts.encoding ?? 'utf-8');
1345
2049
  }
1346
2050
  const { content: normalized, encoding } = normalizeContent(filePath, content, { ...opts, prior });
2051
+ if (opts.allowSpecialTarget === true) {
2052
+ // A user-chosen destination such as /dev/stdout: written in place, no temp file, no restore.
2053
+ // Windows device targets (a bare NUL/CON/PRN/AUX/COM1-9/LPT1-9 name or a \\.\ form) were
2054
+ // already tried at the top of this function, before the path walk. A reserved name inside a
2055
+ // directory path is an ordinary file on Windows 11 24H2+ and takes the checked path here.
2056
+ let special = null;
2057
+ try {
2058
+ special = node_fs_1.default.statSync(filePath);
2059
+ }
2060
+ catch { /* absent: the ordinary path below */ }
2061
+ // The process's own descriptor aliases: /dev/stdout may resolve to a regular file
2062
+ // (`> out.txt`) or a socket (its own stdio), which is then written in place. Any other
2063
+ // regular file takes the atomic path.
2064
+ // Classified on the physical path (parent resolved by the OS, `..` after a symlink included).
2065
+ const physicalTarget = physicalPath(filePath);
2066
+ if (special !== null && physicalTarget === null) {
2067
+ // An existing target whose parent cannot be resolved cannot be classified: never guessed lexically.
2068
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: its directory cannot be resolved — refusing`), { code: 'ENXIO' });
2069
+ }
2070
+ const resolvedTarget = physicalTarget ?? node_path_1.default.resolve(filePath);
2071
+ const isDescriptorAlias = descriptorAliasFd(resolvedTarget) !== null;
2072
+ if (special !== null && (special.isBlockDevice() || (special.isSocket() && !isDescriptorAlias))) {
2073
+ throw Object.assign(new Error(`ENXIO: cannot write ${filePath}: block devices and sockets are never written`), { code: 'ENXIO' });
2074
+ }
2075
+ if (special !== null && (special.isCharacterDevice() || special.isFIFO() || isDescriptorAlias)) {
2076
+ const aliasFd = descriptorAliasFd(resolvedTarget);
2077
+ if (aliasFd !== null) {
2078
+ writeSpecialTargetWithBackoff(filePath, Buffer.from(normalized, encoding), opts.specialTargetWait, aliasFd);
2079
+ return;
2080
+ }
2081
+ if (special.isFIFO()) {
2082
+ writeSpecialTargetWithBackoff(filePath, Buffer.from(normalized, encoding), opts.specialTargetWait);
2083
+ return;
2084
+ }
2085
+ const fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_WRONLY | (node_fs_1.default.constants.O_NOCTTY ?? 0) | (special.isFile() ? node_fs_1.default.constants.O_TRUNC : 0));
2086
+ try {
2087
+ node_fs_1.default.writeFileSync(fd, normalized, encoding);
2088
+ }
2089
+ finally {
2090
+ try {
2091
+ node_fs_1.default.closeSync(fd);
2092
+ }
2093
+ catch { /* already closed */ }
2094
+ }
2095
+ return;
2096
+ }
2097
+ }
1347
2098
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
1348
- const tmpPath = filePath + '.tmp.' + process.pid;
1349
- // Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
1350
- // direct fallback write cannot truncate a concurrent reader of an existing file.
2099
+ parentGuard.record();
2100
+ if (opts.atomic === true) {
2101
+ publishViaTempFile(filePath, normalized, encoding, 'tmp', {
2102
+ checkParent: () => { if (parentChanged())
2103
+ refuseParentSwap(); },
2104
+ createTemp: opts.writeSeams?.createTemp,
2105
+ onCreated: opts.onCreated,
2106
+ });
2107
+ return;
2108
+ }
2109
+ // Step 1: write the sibling tmp file, created exclusively under an unpredictable name so a
2110
+ // planted symlink or FIFO is never followed or blocked on (#5759). If THIS fails, nothing
2111
+ // was published, so a direct fallback write cannot truncate a concurrent reader of an
2112
+ // existing file.
2113
+ // An existing regular target keeps its permission bits (a 0600 config must not come back 0644
2114
+ // after the rename): the temp file is CREATED with that mode, so it is never more permissive
2115
+ // than the target it replaces, not even for an instant. Without one the temp is created 0o666
2116
+ // and the kernel applies the umask (the process umask is never read or changed here).
2117
+ let targetMode = null;
2118
+ try {
2119
+ const existing = node_fs_1.default.lstatSync(filePath);
2120
+ if (existing.isFile())
2121
+ targetMode = existing.mode & 0o777;
2122
+ }
2123
+ catch { /* no existing target, or no permission bits on this platform */ }
2124
+ let tmpPath;
2125
+ let tmpFd;
2126
+ if (parentChanged())
2127
+ refuseParentSwap();
2128
+ try {
2129
+ ({ fd: tmpFd, tmpPath } = (opts.writeSeams?.createTemp ?? openExclusiveTempSync)(filePath, undefined, targetMode ?? 0o666));
2130
+ }
2131
+ catch {
2132
+ writeTargetDirect(filePath, normalized, encoding, walked, opts.writeSeams?.afterDirectLstat, checkParent);
2133
+ return;
2134
+ }
2135
+ if (targetMode !== null) {
2136
+ try {
2137
+ node_fs_1.default.fchmodSync(tmpFd, targetMode);
2138
+ }
2139
+ catch { /* no permission bits on this platform */ }
2140
+ }
2141
+ let wrote = false;
1351
2142
  try {
1352
- node_fs_1.default.writeFileSync(tmpPath, normalized, encoding);
2143
+ node_fs_1.default.writeFileSync(tmpFd, normalized, encoding);
2144
+ wrote = true;
2145
+ }
2146
+ catch { /* fall back below */ }
2147
+ try {
2148
+ node_fs_1.default.closeSync(tmpFd);
1353
2149
  }
1354
2150
  catch {
2151
+ wrote = false;
2152
+ }
2153
+ if (!wrote) {
1355
2154
  try {
1356
2155
  node_fs_1.default.unlinkSync(tmpPath);
1357
2156
  }
1358
2157
  catch { /* already gone */ }
1359
- node_fs_1.default.writeFileSync(filePath, normalized, encoding);
2158
+ writeTargetDirect(filePath, normalized, encoding, walked, opts.writeSeams?.afterDirectLstat, checkParent);
1360
2159
  return;
1361
2160
  }
1362
- // Step 2: atomic publish, retrying transient Windows locks.
2161
+ // Step 2: atomic publish, retrying transient Windows locks. The directory is re-checked against
2162
+ // the walked identity immediately before the rename: a parent swapped for a symlink to another
2163
+ // directory would otherwise receive the rename. Remaining race: the stat and the rename are
2164
+ // separate syscalls, so a swap landing between them (or during a retry) is not observed, and a
2165
+ // temp file created before such a swap stays in the directory it was created in. That temp may
2166
+ // therefore be orphaned in the old directory after a parent swap: a minor leak. What gets
2167
+ // unlinked differs by path: the direct-write fallback unlinks only a file whose dev/ino match
2168
+ // the descriptor this write created, while this temp cleanup is by NAME. After a parent swap
2169
+ // that name resolves through the new parent, and deleting another file is avoided only because
2170
+ // the temp name (pid plus 48 random bits, see defaultTempSuffix) is unpredictable and cannot
2171
+ // pre-exist in a different directory. The same holds for the refusal just below: once the parent has been swapped, unlinkSync of
2172
+ // the temp path resolves through the NEW parent, so the real temp stays orphaned in the original
2173
+ // directory. Documented residual: a same-class race that needs write access to the parent
2174
+ // directory; no further race machinery guards it.
2175
+ if (parentChanged()) {
2176
+ try {
2177
+ node_fs_1.default.unlinkSync(tmpPath);
2178
+ }
2179
+ catch { /* already gone */ }
2180
+ refuseParentSwap();
2181
+ }
1363
2182
  const renameErr = atomicRenameWithRetry(tmpPath, filePath);
1364
2183
  if (renameErr === null)
1365
2184
  return;
@@ -1375,7 +2194,188 @@ function platformWriteSync(filePath, content, opts = {}) {
1375
2194
  }
1376
2195
  // Atomic publish is genuinely impossible here (e.g. EXDEV cross-device move):
1377
2196
  // fall back to a direct write to preserve write availability.
1378
- node_fs_1.default.writeFileSync(filePath, normalized, encoding);
2197
+ writeTargetDirect(filePath, normalized, encoding, walked, opts.writeSeams?.afterDirectLstat, checkParent);
2198
+ }
2199
+ /**
2200
+ * #5831: write `data` to `tmpPath` and rename it over `filePath`, with no
2201
+ * direct-write fallback. The temp file is removed on any failure — also a
2202
+ * write that created it and failed partway — and the error is thrown; the
2203
+ * destination is then exactly as it was.
2204
+ *
2205
+ * The temp file is `<filePath>.<label>.<pid>.<random>`, created exclusively
2206
+ * (O_EXCL, O_NOFOLLOW) and written through that descriptor, so an entry planted
2207
+ * at any name is never opened or followed (#5759). It takes an existing regular
2208
+ * target's permission bits, so a private file is never briefly more readable.
2209
+ */
2210
+ function publishViaTempFile(filePath, data, encoding, label, hooks = {}) {
2211
+ let mode = 0o666;
2212
+ let hasTargetMode = false;
2213
+ if (hooks.targetMode !== undefined) {
2214
+ mode = hooks.targetMode & 0o777;
2215
+ hasTargetMode = true;
2216
+ }
2217
+ else {
2218
+ try {
2219
+ const st = node_fs_1.default.lstatSync(filePath);
2220
+ if (st.isFile()) {
2221
+ mode = st.mode & 0o777;
2222
+ hasTargetMode = true;
2223
+ }
2224
+ }
2225
+ catch { /* absent: default mode */ }
2226
+ }
2227
+ hooks.checkParent?.();
2228
+ const { fd, tmpPath } = hooks.createTemp !== undefined
2229
+ ? hooks.createTemp(filePath, undefined, mode)
2230
+ : openExclusiveTempSync(filePath, undefined, mode, label);
2231
+ if (hasTargetMode) {
2232
+ try {
2233
+ node_fs_1.default.fchmodSync(fd, mode);
2234
+ }
2235
+ catch { /* no permission bits on this platform */ }
2236
+ }
2237
+ try {
2238
+ let identity;
2239
+ try {
2240
+ if (typeof data === 'string')
2241
+ node_fs_1.default.writeFileSync(fd, data, encoding);
2242
+ else
2243
+ node_fs_1.default.writeFileSync(fd, data);
2244
+ if (hooks.onCreated !== undefined) {
2245
+ identity = identityOfFd(fd);
2246
+ }
2247
+ }
2248
+ finally {
2249
+ try {
2250
+ node_fs_1.default.closeSync(fd);
2251
+ }
2252
+ catch { /* already closed */ }
2253
+ }
2254
+ hooks.checkParent?.();
2255
+ const renameErr = atomicRenameWithRetry(tmpPath, filePath);
2256
+ if (renameErr !== null)
2257
+ throw renameErr;
2258
+ // Identity is captured from the open fd before the rename, but reported only once the rename
2259
+ // succeeded: a failed rename unlinks the temp, freeing that inode, so nothing was published.
2260
+ if (identity !== undefined)
2261
+ hooks.onCreated?.(identity);
2262
+ }
2263
+ catch (err) {
2264
+ // After a parent swap this path resolves through the swapped parent, so the real temp may be
2265
+ // orphaned in the original directory (accepted residual); nothing is ever published or left in the attacker's.
2266
+ try {
2267
+ node_fs_1.default.unlinkSync(tmpPath);
2268
+ }
2269
+ catch { /* never created, or already gone */ }
2270
+ throw err;
2271
+ }
2272
+ }
2273
+ /**
2274
+ * #5831: remove a file (absent is fine), retrying the transient Windows lock
2275
+ * errnos (EPERM/EBUSY/EACCES — DEFECT.WINDOWS-FS-OPS) like the rename does.
2276
+ */
2277
+ function removeFileWithRetry(filePath) {
2278
+ for (let attempt = 1;; attempt++) {
2279
+ try {
2280
+ node_fs_1.default.rmSync(filePath, { force: true });
2281
+ return;
2282
+ }
2283
+ catch (err) {
2284
+ const code = err.code ?? '';
2285
+ if (attempt >= RENAME_MAX_ATTEMPTS || !RENAME_RETRY_ERRNOS.has(code))
2286
+ throw err;
2287
+ renameBackoff();
2288
+ }
2289
+ }
2290
+ }
2291
+ /**
2292
+ * #5831: put `filePath` back to an exact earlier state — `bytes` written
2293
+ * verbatim (no normalization) through a temp file and rename, or the file
2294
+ * removed when `bytes` is null (it did not exist). Throws on failure, leaving
2295
+ * no temp file behind; the destination is then as it was before the call.
2296
+ *
2297
+ * `unlinkOnlyIf` (with `bytes` null): the file is removed only while `lstat`
2298
+ * still reports that device and inode — the file this run created. Any other
2299
+ * entry at the path (a file another process put there since) is left in place
2300
+ * and the call throws ESTALE. Absent is fine either way. Remaining race: the
2301
+ * `lstat` identity check and the `rmSync` are separate syscalls, so another
2302
+ * process replacing the entry between them is not observed and its file is
2303
+ * removed. Documented residual: it needs write access to the parent directory
2304
+ * and lands inside a window of two syscalls; no further race machinery guards it.
2305
+ *
2306
+ * `mode` (with `bytes`): the permission bits the restored file gets, in place of
2307
+ * those of whatever sits at the path now (the default when the path is absent).
2308
+ *
2309
+ * With `bytes`, the directory holding the file is re-checked against the identity
2310
+ * it had at entry before the temp file is created and again before the rename,
2311
+ * and a changed directory is refused with ENXIO. Remaining race: the stat and the
2312
+ * following syscall are separate, so a swap landing between them is not observed;
2313
+ * a temp file created before such a swap may be orphaned in the original directory
2314
+ * (accepted residual, same class as the parent swap in `platformWriteSync`).
2315
+ */
2316
+ function platformRestoreFileSync(filePath, bytes, opts = {}) {
2317
+ if (bytes === null) {
2318
+ const expected = opts.unlinkOnlyIf;
2319
+ if (expected !== undefined) {
2320
+ let nowId;
2321
+ try {
2322
+ nowId = identityOfPath(filePath);
2323
+ }
2324
+ catch (err) {
2325
+ if (err.code === 'ENOENT')
2326
+ return;
2327
+ throw err;
2328
+ }
2329
+ if (nowId.dev !== expected.dev || nowId.ino !== expected.ino) {
2330
+ throw Object.assign(new Error(`ESTALE: ${filePath} is no longer the file this run created — left in place`), { code: 'ESTALE' });
2331
+ }
2332
+ }
2333
+ removeFileWithRetry(filePath);
2334
+ return;
2335
+ }
2336
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
2337
+ const parentGuard = makeParentGuard(filePath, (err) => { throw err; });
2338
+ const checkParent = () => {
2339
+ if (parentGuard.changed()) {
2340
+ throw Object.assign(new Error(`ENXIO: cannot restore ${filePath}: its directory is not the one it had at entry — refusing`), { code: 'ENXIO' });
2341
+ }
2342
+ };
2343
+ publishViaTempFile(filePath, bytes, undefined, 'restore', { checkParent, targetMode: opts.mode });
2344
+ }
2345
+ /**
2346
+ * #5831: a file's exact bytes, or null when it does not exist. Opened
2347
+ * non-blocking and checked with fstat, so a FIFO or device at the path fails
2348
+ * with ENXIO instead of blocking the caller (who may hold a lock); a directory
2349
+ * fails with EISDIR. There is no size cap: the caller needs every byte. Other
2350
+ * read errors throw. With `noFollow` the final component is opened O_NOFOLLOW, so
2351
+ * a symlink there fails with ELOOP instead of being read through (no effect where
2352
+ * the platform has no O_NOFOLLOW).
2353
+ */
2354
+ function platformReadBytesSync(filePath, opts = {}) {
2355
+ let fd;
2356
+ try {
2357
+ fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NONBLOCK ?? 0) | (node_fs_1.default.constants.O_NOCTTY ?? 0) |
2358
+ (opts.noFollow === true ? (node_fs_1.default.constants.O_NOFOLLOW ?? 0) : 0));
2359
+ }
2360
+ catch (err) {
2361
+ if (err.code === 'ENOENT')
2362
+ return null;
2363
+ throw err;
2364
+ }
2365
+ try {
2366
+ const st = node_fs_1.default.fstatSync(fd);
2367
+ if (st.isDirectory())
2368
+ throw Object.assign(new Error(`EISDIR: ${filePath} is a directory`), { code: 'EISDIR' });
2369
+ if (!st.isFile())
2370
+ throw Object.assign(new Error(`ENXIO: ${filePath} is not a regular file — refusing to read it`), { code: 'ENXIO' });
2371
+ return node_fs_1.default.readFileSync(fd);
2372
+ }
2373
+ finally {
2374
+ try {
2375
+ node_fs_1.default.closeSync(fd);
2376
+ }
2377
+ catch { /* already closed */ }
2378
+ }
1379
2379
  }
1380
2380
  function platformReadSync(filePath, opts = {}) {
1381
2381
  const encoding = opts.encoding ?? 'utf-8';