@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +265 -161
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/gsd-core/bin/gsd-tools.cjs +554 -823
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +99 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +20 -18
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +20 -28
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-id.cjs +26 -9
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +170 -45
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +103 -46
- package/gsd-core/bin/lib/state.cjs +448 -63
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +168 -13
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +4 -3
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +9 -6
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +168 -13
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +11 -2
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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';
|