@openclaw/fs-safe 0.1.2 → 0.2.1

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 (120) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +39 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +2 -4
  26. package/dist/file-store-boundary.d.ts.map +1 -1
  27. package/dist/file-store-boundary.js +11 -7
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +60 -53
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/home-dir.d.ts.map +1 -1
  44. package/dist/home-dir.js +38 -33
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/json-document-store.d.ts +2 -0
  49. package/dist/json-document-store.d.ts.map +1 -1
  50. package/dist/json-document-store.js +7 -3
  51. package/dist/json-durable-queue.d.ts +45 -0
  52. package/dist/json-durable-queue.d.ts.map +1 -0
  53. package/dist/json-durable-queue.js +200 -0
  54. package/dist/json.d.ts +35 -5
  55. package/dist/json.d.ts.map +1 -1
  56. package/dist/json.js +69 -2
  57. package/dist/local-roots.d.ts.map +1 -1
  58. package/dist/local-roots.js +17 -3
  59. package/dist/lock-config.d.ts +10 -0
  60. package/dist/lock-config.d.ts.map +1 -0
  61. package/dist/lock-config.js +12 -0
  62. package/dist/move-path.d.ts +1 -0
  63. package/dist/move-path.d.ts.map +1 -1
  64. package/dist/move-path.js +195 -7
  65. package/dist/output.d.ts +13 -0
  66. package/dist/output.d.ts.map +1 -0
  67. package/dist/output.js +69 -0
  68. package/dist/path-stat.d.ts +4 -0
  69. package/dist/path-stat.d.ts.map +1 -0
  70. package/dist/path-stat.js +15 -0
  71. package/dist/path.d.ts.map +1 -1
  72. package/dist/path.js +4 -3
  73. package/dist/pinned-write.d.ts.map +1 -1
  74. package/dist/pinned-write.js +44 -19
  75. package/dist/private-temp-workspace.d.ts.map +1 -1
  76. package/dist/private-temp-workspace.js +28 -15
  77. package/dist/regular-file.d.ts.map +1 -1
  78. package/dist/regular-file.js +52 -8
  79. package/dist/replace-directory.d.ts.map +1 -1
  80. package/dist/replace-directory.js +5 -4
  81. package/dist/replace-file.d.ts +1 -1
  82. package/dist/replace-file.d.ts.map +1 -1
  83. package/dist/replace-file.js +93 -31
  84. package/dist/root-impl.d.ts.map +1 -1
  85. package/dist/root-impl.js +62 -53
  86. package/dist/safe-path-segment.d.ts +9 -0
  87. package/dist/safe-path-segment.d.ts.map +1 -0
  88. package/dist/safe-path-segment.js +51 -0
  89. package/dist/secret-file.d.ts.map +1 -1
  90. package/dist/secret-file.js +28 -7
  91. package/dist/sibling-temp.d.ts +1 -0
  92. package/dist/sibling-temp.d.ts.map +1 -1
  93. package/dist/sibling-temp.js +32 -7
  94. package/dist/sidecar-lock.d.ts +2 -0
  95. package/dist/sidecar-lock.d.ts.map +1 -1
  96. package/dist/sidecar-lock.js +93 -13
  97. package/dist/store.d.ts +1 -0
  98. package/dist/store.d.ts.map +1 -1
  99. package/dist/store.js +1 -0
  100. package/dist/temp-target.d.ts.map +1 -1
  101. package/dist/temp-target.js +7 -4
  102. package/dist/test-hooks.d.ts +6 -0
  103. package/dist/test-hooks.d.ts.map +1 -1
  104. package/dist/text-atomic.d.ts +7 -0
  105. package/dist/text-atomic.d.ts.map +1 -1
  106. package/dist/text-atomic.js +3 -2
  107. package/dist/trash.d.ts.map +1 -1
  108. package/dist/trash.js +48 -13
  109. package/docs/advanced.md +10 -0
  110. package/docs/atomic.md +33 -5
  111. package/docs/config.md +32 -3
  112. package/docs/index.md +3 -2
  113. package/docs/json-store.md +4 -0
  114. package/docs/json.md +34 -0
  115. package/docs/output.md +92 -0
  116. package/docs/sidecar-lock.md +13 -12
  117. package/docs/store.md +33 -0
  118. package/docs/temp.md +10 -2
  119. package/docs/testing.md +10 -0
  120. package/package.json +7 -2
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import syncFs from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { assertSafePathPrefix } from "./safe-path-segment.js";
5
6
  import { registerTempPathForExit } from "./temp-cleanup.js";
6
7
  import { serializePathWrite } from "./write-queue.js";
7
8
  function isRetryableRenameError(error) {
@@ -11,6 +12,12 @@ function isPermissionRenameError(error) {
11
12
  const code = error.code;
12
13
  return code === "EPERM" || code === "EEXIST";
13
14
  }
15
+ const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
16
+ const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
17
+ const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY |
18
+ syncFs.constants.O_CREAT |
19
+ syncFs.constants.O_EXCL |
20
+ (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
14
21
  async function sleep(ms) {
15
22
  await new Promise((resolve) => setTimeout(resolve, ms));
16
23
  }
@@ -26,17 +33,7 @@ async function renameWithRetry(params) {
26
33
  continue;
27
34
  }
28
35
  if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
29
- const stat = await params.fsModule.lstat(params.dest).catch((lstatError) => {
30
- if (lstatError.code === "ENOENT") {
31
- return null;
32
- }
33
- throw lstatError;
34
- });
35
- if (stat?.isSymbolicLink()) {
36
- throw new Error(`Refusing copy fallback through symlink destination: ${params.dest}`);
37
- }
38
- await params.fsModule.copyFile(params.src, params.dest);
39
- await params.fsModule.unlink(params.src).catch(() => undefined);
36
+ await copyFallbackReplace(params.fsModule, params.src, params.dest);
40
37
  return { method: "copy-fallback" };
41
38
  }
42
39
  throw error;
@@ -62,25 +59,7 @@ function renameWithRetrySync(params) {
62
59
  continue;
63
60
  }
64
61
  if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
65
- let stat = null;
66
- try {
67
- stat = params.fsModule.lstatSync(params.dest);
68
- }
69
- catch (lstatError) {
70
- if (lstatError.code !== "ENOENT") {
71
- throw lstatError;
72
- }
73
- }
74
- if (stat?.isSymbolicLink()) {
75
- throw new Error(`Refusing copy fallback through symlink destination: ${params.dest}`);
76
- }
77
- params.fsModule.copyFileSync(params.src, params.dest);
78
- try {
79
- params.fsModule.unlinkSync(params.src);
80
- }
81
- catch {
82
- // Best-effort cleanup after fallback replacement.
83
- }
62
+ copyFallbackReplaceSync(params.fsModule, params.src, params.dest);
84
63
  return { method: "copy-fallback" };
85
64
  }
86
65
  throw error;
@@ -88,6 +67,88 @@ function renameWithRetrySync(params) {
88
67
  }
89
68
  throw new Error("Atomic rename retry loop exhausted.");
90
69
  }
70
+ async function copyFallbackReplace(fsModule, src, dest) {
71
+ const sourceStat = await fsModule.lstat(src);
72
+ if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
73
+ throw new Error(`Refusing copy fallback from non-file source: ${src}`);
74
+ }
75
+ const destStat = await fsModule.lstat(dest).catch((lstatError) => {
76
+ if (lstatError.code === "ENOENT") {
77
+ return null;
78
+ }
79
+ throw lstatError;
80
+ });
81
+ if (destStat?.isSymbolicLink()) {
82
+ throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
83
+ }
84
+ if (destStat) {
85
+ await fsModule.rm(dest, { force: true });
86
+ }
87
+ let sourceHandle = null;
88
+ let destHandle = null;
89
+ try {
90
+ sourceHandle = await fsModule.open(src, OPEN_READ_FLAGS);
91
+ destHandle = await fsModule.open(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
92
+ await destHandle.writeFile(await sourceHandle.readFile());
93
+ }
94
+ finally {
95
+ await destHandle?.close().catch(() => undefined);
96
+ await sourceHandle?.close().catch(() => undefined);
97
+ }
98
+ await fsModule.unlink(src).catch(() => undefined);
99
+ }
100
+ function copyFallbackReplaceSync(fsModule, src, dest) {
101
+ const sourceStat = fsModule.lstatSync(src);
102
+ if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
103
+ throw new Error(`Refusing copy fallback from non-file source: ${src}`);
104
+ }
105
+ let destStat = null;
106
+ try {
107
+ destStat = fsModule.lstatSync(dest);
108
+ }
109
+ catch (lstatError) {
110
+ if (lstatError.code !== "ENOENT") {
111
+ throw lstatError;
112
+ }
113
+ }
114
+ if (destStat?.isSymbolicLink()) {
115
+ throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
116
+ }
117
+ if (destStat) {
118
+ fsModule.rmSync(dest, { force: true });
119
+ }
120
+ let sourceFd;
121
+ let destFd;
122
+ try {
123
+ sourceFd = fsModule.openSync(src, OPEN_READ_FLAGS);
124
+ destFd = fsModule.openSync(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
125
+ fsModule.writeFileSync(destFd, fsModule.readFileSync(sourceFd));
126
+ }
127
+ finally {
128
+ if (destFd !== undefined) {
129
+ try {
130
+ fsModule.closeSync(destFd);
131
+ }
132
+ catch {
133
+ // Best-effort close after fallback replacement.
134
+ }
135
+ }
136
+ if (sourceFd !== undefined) {
137
+ try {
138
+ fsModule.closeSync(sourceFd);
139
+ }
140
+ catch {
141
+ // Best-effort close after fallback replacement.
142
+ }
143
+ }
144
+ }
145
+ try {
146
+ fsModule.unlinkSync(src);
147
+ }
148
+ catch {
149
+ // Best-effort cleanup after fallback replacement.
150
+ }
151
+ }
91
152
  function validateReplaceFilePath(filePath) {
92
153
  if (!filePath || filePath.includes("\0")) {
93
154
  throw new Error("Atomic replace file path must be non-empty.");
@@ -95,7 +156,8 @@ function validateReplaceFilePath(filePath) {
95
156
  }
96
157
  function buildReplaceTempPath(filePath, tempPrefix) {
97
158
  const dir = path.dirname(filePath);
98
- return path.join(dir, `${tempPrefix ?? ".fs-safe-replace"}.${process.pid}.${randomUUID()}.tmp`);
159
+ const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-replace", { label: "atomic replace temp prefix" });
160
+ return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
99
161
  }
100
162
  async function resolveMode(options) {
101
163
  const defaultMode = options.mode ?? 0o600;
@@ -1 +1 @@
1
- {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
package/dist/root-impl.js CHANGED
@@ -2,16 +2,20 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { Transform } from "node:stream";
6
5
  import { pipeline } from "node:stream/promises";
6
+ import { createBoundedReadStream } from "./bounded-read-stream.js";
7
+ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
7
8
  import { FsSafeError } from "./errors.js";
8
9
  import { sameFileIdentity } from "./file-identity.js";
10
+ import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
11
+ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
9
12
  import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
10
13
  import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
11
14
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
12
15
  import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
13
16
  import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
14
17
  import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
18
+ import { pathStatFromStats } from "./path-stat.js";
15
19
  import { resolveRootPath } from "./root-path.js";
16
20
  import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
17
21
  import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
@@ -433,24 +437,6 @@ function buildAtomicWriteTempPath(targetPath) {
433
437
  function rootWriteQueueKey(root, relativePath) {
434
438
  return `${root.rootReal}\0${relativePath}`;
435
439
  }
436
- function createMaxBytesTransform(maxBytes) {
437
- let bytes = 0;
438
- return new Transform({
439
- transform(chunk, _encoding, callback) {
440
- const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
441
- bytes += buffer.byteLength;
442
- if (bytes > maxBytes) {
443
- callback(new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
444
- return;
445
- }
446
- callback(null, buffer);
447
- },
448
- });
449
- }
450
- function createBoundedReadStream(opened, maxBytes) {
451
- const stream = opened.handle.createReadStream();
452
- return maxBytes === undefined ? stream : stream.pipe(createMaxBytesTransform(maxBytes));
453
- }
454
440
  async function writeTempFileForAtomicReplace(params) {
455
441
  const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
456
442
  try {
@@ -567,7 +553,10 @@ async function openWritableFileInRoot(root, params) {
567
553
  throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
568
554
  }
569
555
  if (params.mkdir !== false) {
570
- await fs.mkdir(path.dirname(resolved), { recursive: true });
556
+ const parentGuard = await createNearestExistingDirectoryGuard(rootReal, path.dirname(resolved));
557
+ await withAsyncDirectoryGuards([parentGuard], async () => {
558
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
559
+ });
571
560
  }
572
561
  let ioPath = resolved;
573
562
  try {
@@ -867,8 +856,10 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode)
867
856
  catch (err) {
868
857
  throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
869
858
  }
859
+ // resolvePathInRoot already enforces isPathInside, so any actual escape
860
+ // is rejected upstream.
870
861
  const relativeResolved = path.relative(rootReal, resolved);
871
- if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) {
862
+ if (path.isAbsolute(relativeResolved)) {
872
863
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
873
864
  }
874
865
  const relativePosix = relativeResolved
@@ -930,9 +921,10 @@ async function resolvePinnedOperationPathInRoot(root, params) {
930
921
  if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
931
922
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
932
923
  }
924
+ const firstSegment = relativeResolved.split(path.sep)[0];
933
925
  if (relativeResolved === "" ||
934
926
  relativeResolved === "." ||
935
- relativeResolved.startsWith("..") ||
927
+ firstSegment === ".." ||
936
928
  path.isAbsolute(relativeResolved)) {
937
929
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
938
930
  }
@@ -965,25 +957,17 @@ async function resolvePinnedRootPathInRoot(root, params) {
965
957
  };
966
958
  }
967
959
  async function removePathFallback(resolved) {
968
- await fs.rm(resolved.resolved);
960
+ const guard = await createAsyncDirectoryGuard(path.dirname(resolved.resolved));
961
+ await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", resolved.resolved);
962
+ await assertAsyncDirectoryGuard(guard);
963
+ await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
964
+ await assertAsyncDirectoryGuard(guard).catch(() => undefined);
969
965
  }
970
966
  async function mkdirPathFallback(resolved) {
971
- await fs.mkdir(resolved.resolved, { recursive: true });
972
- }
973
- function pathStatFromStats(stat) {
974
- return {
975
- dev: Number(stat.dev),
976
- gid: Number(stat.gid),
977
- ino: Number(stat.ino),
978
- isDirectory: stat.isDirectory(),
979
- isFile: stat.isFile(),
980
- isSymbolicLink: stat.isSymbolicLink(),
981
- mode: stat.mode,
982
- mtimeMs: stat.mtimeMs,
983
- nlink: stat.nlink,
984
- size: stat.size,
985
- uid: stat.uid,
986
- };
967
+ await mkdirPathComponentsWithGuards({
968
+ rootReal: resolved.rootReal, targetPath: resolved.resolved,
969
+ beforeComponent: async (componentPath) => await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("mkdir", componentPath),
970
+ });
987
971
  }
988
972
  async function statPathFallback(root, relativePath) {
989
973
  const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
@@ -1080,6 +1064,11 @@ async function movePathFallback(root, params) {
1080
1064
  }
1081
1065
  }
1082
1066
  }
1067
+ const sourceParentGuard = await createAsyncDirectoryGuard(path.dirname(source.resolved));
1068
+ const targetParentGuard = await createNearestExistingDirectoryGuard(target.rootReal, path.dirname(target.resolved));
1069
+ await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("move", target.resolved);
1070
+ await assertAsyncDirectoryGuard(sourceParentGuard);
1071
+ await assertAsyncDirectoryGuard(targetParentGuard);
1083
1072
  try {
1084
1073
  await fs.rename(source.resolved, target.resolved);
1085
1074
  }
@@ -1096,6 +1085,7 @@ async function movePathFallback(root, params) {
1096
1085
  }
1097
1086
  throw error;
1098
1087
  }
1088
+ await assertAsyncDirectoryGuard(targetParentGuard).catch(() => undefined);
1099
1089
  }
1100
1090
  async function writeFileFallback(root, params) {
1101
1091
  if (params.overwrite === false) {
@@ -1111,6 +1101,7 @@ async function writeFileFallback(root, params) {
1111
1101
  const destinationPath = target.realPath;
1112
1102
  const mode = params.mode ?? (target.stat.mode & 0o777);
1113
1103
  await target.handle.close().catch(() => { });
1104
+ const destinationGuard = await createAsyncDirectoryGuard(path.dirname(destinationPath));
1114
1105
  let tempPath = null;
1115
1106
  let unregisterTempPath = null;
1116
1107
  try {
@@ -1122,7 +1113,10 @@ async function writeFileFallback(root, params) {
1122
1113
  encoding: params.encoding,
1123
1114
  mode: mode || 0o600,
1124
1115
  });
1125
- await fs.rename(tempPath, destinationPath);
1116
+ const commitTempPath = tempPath;
1117
+ await withAsyncDirectoryGuards([destinationGuard], async () => {
1118
+ await fs.rename(commitTempPath, destinationPath);
1119
+ });
1126
1120
  tempPath = null;
1127
1121
  unregisterTempPath();
1128
1122
  unregisterTempPath = null;
@@ -1160,20 +1154,32 @@ async function writeMissingFileFallback(root, params) {
1160
1154
  if (params.mkdir !== false) {
1161
1155
  await fs.mkdir(path.dirname(resolved), { recursive: true });
1162
1156
  }
1163
- let handle = null;
1157
+ const parentGuard = await createAsyncDirectoryGuard(path.dirname(resolved));
1164
1158
  let created = false;
1165
1159
  try {
1166
- handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
1167
- created = true;
1168
- if (typeof params.data === "string") {
1169
- await handle.writeFile(params.data, params.encoding ?? "utf8");
1170
- }
1171
- else {
1172
- await handle.writeFile(params.data);
1173
- }
1174
- const writtenStat = await handle.stat();
1160
+ const { handle, writtenStat } = await withAsyncDirectoryGuards([parentGuard], async () => {
1161
+ const handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
1162
+ created = true;
1163
+ try {
1164
+ if (typeof params.data === "string") {
1165
+ await handle.writeFile(params.data, params.encoding ?? "utf8");
1166
+ }
1167
+ else {
1168
+ await handle.writeFile(params.data);
1169
+ }
1170
+ return { handle, writtenStat: await handle.stat() };
1171
+ }
1172
+ catch (error) {
1173
+ await handle.close().catch(() => undefined);
1174
+ throw error;
1175
+ }
1176
+ }, {
1177
+ onPostGuardFailure: async ({ handle }) => {
1178
+ created = false; // Parent is untrusted now; skip outer path cleanup by name.
1179
+ await handle.close().catch(() => undefined);
1180
+ },
1181
+ });
1175
1182
  await handle.close();
1176
- handle = null;
1177
1183
  await verifyAtomicWriteResult({
1178
1184
  root,
1179
1185
  targetPath: resolved,
@@ -1190,7 +1196,6 @@ async function writeMissingFileFallback(root, params) {
1190
1196
  throw err;
1191
1197
  }
1192
1198
  finally {
1193
- await handle?.close().catch(() => undefined);
1194
1199
  if (created) {
1195
1200
  await fs.rm(resolved, { force: true }).catch(() => undefined);
1196
1201
  }
@@ -1215,6 +1220,7 @@ async function copyFileFallback(root, params, source) {
1215
1220
  const mode = params.mode ?? (target.stat.mode & 0o777);
1216
1221
  await target.handle.close().catch(() => { });
1217
1222
  targetClosedByUs = true;
1223
+ const destinationGuard = await createAsyncDirectoryGuard(path.dirname(destinationPath));
1218
1224
  tempPath = buildAtomicWriteTempPath(destinationPath);
1219
1225
  unregisterTempPath = registerTempPathForExit(tempPath);
1220
1226
  tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600);
@@ -1233,7 +1239,10 @@ async function copyFileFallback(root, params, source) {
1233
1239
  tempClosedByStream = true;
1234
1240
  }
1235
1241
  tempHandle = null;
1236
- await fs.rename(tempPath, destinationPath);
1242
+ const commitTempPath = tempPath;
1243
+ await withAsyncDirectoryGuards([destinationGuard], async () => {
1244
+ await fs.rename(commitTempPath, destinationPath);
1245
+ });
1237
1246
  tempPath = null;
1238
1247
  unregisterTempPath();
1239
1248
  unregisterTempPath = null;
@@ -0,0 +1,9 @@
1
+ export type SafePathSegmentOptions = {
2
+ allowDotPrefix?: boolean;
3
+ label?: string;
4
+ };
5
+ export declare function isSafePathSegment(segment: string, options?: SafePathSegmentOptions): boolean;
6
+ export declare function assertSafePathSegment(segment: string, options?: SafePathSegmentOptions): string;
7
+ export declare function sanitizeSafePathSegment(value: string, fallback: string, options?: SafePathSegmentOptions): string;
8
+ export declare function assertSafePathPrefix(prefix: string, options?: SafePathSegmentOptions): string;
9
+ //# sourceMappingURL=safe-path-segment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safe-path-segment.d.ts","sourceRoot":"","sources":["../src/safe-path-segment.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,sBAAsB,GAAG;IACnC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAaT;AAED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAUR;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAWR;AAED,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAeR"}
@@ -0,0 +1,51 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ const SAFE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
3
+ const SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
4
+ export function isSafePathSegment(segment, options = {}) {
5
+ return (segment !== "" &&
6
+ segment !== "." &&
7
+ segment !== ".." &&
8
+ !segment.includes("/") &&
9
+ !segment.includes("\\") &&
10
+ !segment.includes("\0") &&
11
+ (options.allowDotPrefix === true || !segment.startsWith(".")) &&
12
+ (options.allowDotPrefix === true
13
+ ? SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN.test(segment)
14
+ : SAFE_PATH_SEGMENT_PATTERN.test(segment)));
15
+ }
16
+ export function assertSafePathSegment(segment, options = {}) {
17
+ // Validate the exact value callers will later join into paths; trimming here
18
+ // would let whitespace-padded ids pass and then be used verbatim.
19
+ if (!isSafePathSegment(segment, options)) {
20
+ throw new FsSafeError("invalid-path", `${options.label ?? "path segment"} must be a safe path segment`);
21
+ }
22
+ return segment;
23
+ }
24
+ export function sanitizeSafePathSegment(value, fallback, options = {}) {
25
+ const sanitized = value
26
+ .trim()
27
+ .replace(/[\\/]+/g, "-")
28
+ .replace(/\0/g, "")
29
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
30
+ .replace(/^-+|-+$/g, "");
31
+ if (isSafePathSegment(sanitized, options)) {
32
+ return sanitized;
33
+ }
34
+ return assertSafePathSegment(fallback, { ...options, label: "fallback path segment" });
35
+ }
36
+ export function assertSafePathPrefix(prefix, options = {}) {
37
+ // Prefixes are often derived from safe filenames. Normalize harmless
38
+ // filename characters first, but still reject real path-control bytes.
39
+ if (prefix.includes("/") || prefix.includes("\\") || prefix.includes("\0")) {
40
+ return assertSafePathSegment(prefix, {
41
+ allowDotPrefix: true,
42
+ ...options,
43
+ label: options.label ?? "path prefix",
44
+ });
45
+ }
46
+ return assertSafePathSegment(prefix.replace(/[^A-Za-z0-9._-]+/g, "-"), {
47
+ allowDotPrefix: true,
48
+ ...options,
49
+ label: options.label ?? "path prefix",
50
+ });
51
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAuGF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;AAoFD,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDhB"}
1
+ {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAqHF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;AAoFD,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDhB"}
@@ -2,7 +2,9 @@ import { randomBytes } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import fsp from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { createAsyncDirectoryGuard } from "./directory-guard.js";
5
6
  import { FsSafeError } from "./errors.js";
7
+ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
6
8
  import { resolveHomeRelativePath } from "./home-dir.js";
7
9
  import { openPinnedFileSync } from "./pinned-open.js";
8
10
  export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
@@ -34,12 +36,28 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
34
36
  message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
35
37
  };
36
38
  }
37
- if (options.rejectSymlink && previewStat.isSymbolicLink()) {
38
- return {
39
- ok: false,
40
- code: "symlink",
41
- message: `${label} file at ${resolvedPath} must not be a symlink.`,
42
- };
39
+ if (previewStat.isSymbolicLink()) {
40
+ if (!options.rejectSymlink) {
41
+ try {
42
+ previewStat = fs.statSync(resolvedPath);
43
+ }
44
+ catch (error) {
45
+ const normalized = normalizeSecretReadError(error);
46
+ return {
47
+ ok: false,
48
+ code: error.code === "ENOENT" ? "not-found" : "invalid-path",
49
+ error: normalized,
50
+ message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
51
+ };
52
+ }
53
+ }
54
+ else {
55
+ return {
56
+ ok: false,
57
+ code: "symlink",
58
+ message: `${label} file at ${resolvedPath} must not be a symlink.`,
59
+ };
60
+ }
43
61
  }
44
62
  if (!previewStat.isFile()) {
45
63
  return {
@@ -188,6 +206,7 @@ export async function writeSecretFileAtomic(params) {
188
206
  const resolvedRootReal = await fsp.realpath(resolvedRoot);
189
207
  const parentDir = await fsp.realpath(intendedParentDir);
190
208
  assertRealPathWithinRoot(resolvedRootReal, parentDir);
209
+ const parentGuard = await createAsyncDirectoryGuard(parentDir);
191
210
  const fileName = path.basename(resolvedFile);
192
211
  const finalFilePath = path.join(parentDir, fileName);
193
212
  try {
@@ -220,7 +239,9 @@ export async function writeSecretFileAtomic(params) {
220
239
  if (refreshedParentReal !== parentDir) {
221
240
  throw new Error(`Private secret parent directory changed during write for ${finalFilePath}.`);
222
241
  }
223
- await fsp.rename(tempPath, finalFilePath);
242
+ await withAsyncDirectoryGuards([parentGuard], async () => {
243
+ await fsp.rename(tempPath, finalFilePath);
244
+ });
224
245
  createdTemp = false;
225
246
  await enforcePrivatePathMode(finalFilePath, mode, "file");
226
247
  }
@@ -4,6 +4,7 @@ export type WriteSiblingTempFileOptions<T> = {
4
4
  resolveFinalPath: (result: T) => string;
5
5
  tempPrefix?: string;
6
6
  dirMode?: number;
7
+ chmodDir?: boolean;
7
8
  mode?: number;
8
9
  syncTempFile?: boolean;
9
10
  syncParentDir?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5C,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AAuCF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAoCxC;AAeD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgChB"}
1
+ {"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5C,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA0CF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAyCxC;AAkBD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8ChB"}
@@ -1,12 +1,20 @@
1
1
  import crypto, { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
5
+ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
4
6
  import { sanitizeUntrustedFileName } from "./filename.js";
5
7
  import { root } from "./root.js";
8
+ import { assertSafePathPrefix } from "./safe-path-segment.js";
9
+ import { resolveSecureTempRoot } from "./secure-temp-dir.js";
6
10
  import { registerTempPathForExit } from "./temp-cleanup.js";
11
+ import { getFsSafeTestHooks } from "./test-hooks.js";
7
12
  import { serializePathWrite } from "./write-queue.js";
8
13
  function buildTempPath(dir, tempPrefix) {
9
- return path.join(dir, `${tempPrefix ?? ".fs-safe-stream"}.${process.pid}.${randomUUID()}.tmp`);
14
+ const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-stream", {
15
+ label: "sibling temp prefix",
16
+ });
17
+ return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
10
18
  }
11
19
  async function syncFileBestEffort(filePath) {
12
20
  const handle = await fs.open(filePath, "r+");
@@ -45,7 +53,10 @@ function assertFinalPathIsSibling(dir, filePath) {
45
53
  export async function writeSiblingTempFile(options) {
46
54
  const dir = path.resolve(options.dir);
47
55
  await fs.mkdir(dir, { recursive: true, mode: options.dirMode ?? 0o700 });
48
- await fs.chmod(dir, options.dirMode ?? 0o700).catch(() => undefined);
56
+ if (options.chmodDir !== false) {
57
+ await fs.chmod(dir, options.dirMode ?? 0o700).catch(() => undefined);
58
+ }
59
+ const dirGuard = await createAsyncDirectoryGuard(dir);
49
60
  const tempPath = buildTempPath(dir, options.tempPrefix);
50
61
  const unregisterTempPath = registerTempPathForExit(tempPath);
51
62
  let tempExists = false;
@@ -61,7 +72,9 @@ export async function writeSiblingTempFile(options) {
61
72
  const filePath = path.resolve(options.resolveFinalPath(result));
62
73
  assertFinalPathIsSibling(dir, filePath);
63
74
  await serializePathWrite(filePath, async () => {
64
- await fs.rename(tempPath, filePath);
75
+ await withAsyncDirectoryGuards([dirGuard], async () => {
76
+ await fs.rename(tempPath, filePath);
77
+ });
65
78
  tempExists = false;
66
79
  unregisterTempPath();
67
80
  if (options.mode !== undefined) {
@@ -82,8 +95,11 @@ export async function writeSiblingTempFile(options) {
82
95
  }
83
96
  function buildSiblingTempPath(params) {
84
97
  const id = crypto.randomUUID();
98
+ const safePrefix = assertSafePathPrefix(params.tempPrefix, {
99
+ label: "sibling temp prefix",
100
+ });
85
101
  const safeTail = sanitizeUntrustedFileName(path.basename(params.targetPath), params.fallbackFileName);
86
- return path.join(path.dirname(params.targetPath), `${params.tempPrefix}${id}-${safeTail}.part`);
102
+ return path.join(path.dirname(params.targetPath), `${safePrefix}${id}-${safeTail}.part`);
87
103
  }
88
104
  export async function writeViaSiblingTempPath(params) {
89
105
  const rootDir = await fs
@@ -101,19 +117,28 @@ export async function writeViaSiblingTempPath(params) {
101
117
  path.isAbsolute(relativeTargetPath)) {
102
118
  throw new Error("Target path is outside the allowed root");
103
119
  }
120
+ const rootGuard = await createAsyncDirectoryGuard(rootDir);
121
+ const tempDir = await fs.mkdtemp(path.join(resolveSecureTempRoot({
122
+ fallbackPrefix: "fs-safe-output",
123
+ unsafeFallbackLabel: "sibling temp output dir",
124
+ warn: () => undefined,
125
+ }), "fs-safe-output-"));
104
126
  const tempPath = buildSiblingTempPath({
105
- targetPath,
127
+ targetPath: path.join(tempDir, path.basename(targetPath)),
106
128
  fallbackFileName: params.fallbackFileName ?? "output.bin",
107
129
  tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
108
130
  });
109
- const unregisterTempPath = registerTempPathForExit(tempPath);
131
+ const unregisterTempPath = registerTempPathForExit(tempDir, { recursive: true });
110
132
  try {
133
+ await getFsSafeTestHooks()?.beforeSiblingTempWrite?.(tempPath);
111
134
  await params.writeTemp(tempPath);
135
+ await assertAsyncDirectoryGuard(rootGuard);
112
136
  const targetRoot = await root(rootDir);
113
137
  await targetRoot.copyIn(relativeTargetPath, tempPath, { mkdir: false });
138
+ await assertAsyncDirectoryGuard(rootGuard);
114
139
  }
115
140
  finally {
116
- await fs.rm(tempPath, { force: true }).catch(() => { });
141
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { });
117
142
  unregisterTempPath();
118
143
  }
119
144
  }