@openclaw/fs-safe 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -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 +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  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 +122 -125
  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/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
package/dist/archive.js CHANGED
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { constants as fsConstants } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
@@ -10,7 +9,7 @@ import { resolveArchiveKind } from "./archive-kind.js";
10
9
  import { mergeExtractedTreeIntoDestination, prepareArchiveDestinationDir, prepareArchiveOutputPath, withStagedArchiveDestination, } from "./archive-staging.js";
11
10
  import { createTarEntryPreflightChecker, readTarEntryInfo, } from "./archive-tar.js";
12
11
  import { loadZipArchiveWithPreflight } from "./archive-zip-preflight.js";
13
- import { isNotFoundPathError } from "./path.js";
12
+ import { writeSiblingTempFile } from "./sibling-temp.js";
14
13
  import { withTimeout } from "./timing.js";
15
14
  export { isWindowsDrivePath, normalizeArchiveEntryPath, resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-entry.js";
16
15
  export { resolveArchiveKind, resolvePackedRootDir } from "./archive-kind.js";
@@ -24,24 +23,6 @@ const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY |
24
23
  fsConstants.O_CREAT |
25
24
  fsConstants.O_EXCL |
26
25
  (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
27
- async function cleanupPartialRegularFile(filePath) {
28
- let stat;
29
- try {
30
- stat = await fs.lstat(filePath);
31
- }
32
- catch (err) {
33
- if (isNotFoundPathError(err)) {
34
- return;
35
- }
36
- throw err;
37
- }
38
- if (stat.isFile()) {
39
- await fs.unlink(filePath).catch(() => undefined);
40
- }
41
- }
42
- function buildArchiveAtomicTempPath(targetPath) {
43
- return path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`);
44
- }
45
26
  const ZIP_UNIX_FILE_TYPE_MASK = 0o170000;
46
27
  const ZIP_UNIX_SYMLINK_TYPE = 0o120000;
47
28
  function isZipSymlinkEntry(entry) {
@@ -80,23 +61,28 @@ async function writeZipFileEntry(params) {
80
61
  const readable = await readZipEntryStream(params.entry);
81
62
  const destinationPath = params.outPath;
82
63
  let tempHandle = null;
83
- let tempPath = null;
84
64
  let handleClosedByStream = false;
85
65
  try {
86
- tempPath = buildArchiveAtomicTempPath(destinationPath);
87
- tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, 0o666);
88
- const writable = tempHandle.createWriteStream();
89
- writable.once("close", () => {
90
- handleClosedByStream = true;
66
+ await writeSiblingTempFile({
67
+ dir: path.dirname(destinationPath),
68
+ tempPrefix: `.${path.basename(destinationPath)}.fs-safe-archive`,
69
+ chmodDir: false,
70
+ writeTemp: async (tempPath) => {
71
+ tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, 0o666);
72
+ const writable = tempHandle.createWriteStream();
73
+ writable.once("close", () => {
74
+ handleClosedByStream = true;
75
+ });
76
+ await pipeline(readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable);
77
+ if (!handleClosedByStream) {
78
+ await tempHandle.close().catch(() => undefined);
79
+ handleClosedByStream = true;
80
+ }
81
+ tempHandle = null;
82
+ return destinationPath;
83
+ },
84
+ resolveFinalPath: (filePath) => filePath,
91
85
  });
92
- await pipeline(readable, createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }), writable);
93
- if (!handleClosedByStream) {
94
- await tempHandle.close().catch(() => undefined);
95
- handleClosedByStream = true;
96
- }
97
- tempHandle = null;
98
- await fs.rename(tempPath, destinationPath);
99
- tempPath = null;
100
86
  // Best-effort permission restore for zip entries created on unix.
101
87
  if (typeof params.entry.unixPermissions === "number") {
102
88
  const mode = params.entry.unixPermissions & 0o777;
@@ -106,17 +92,14 @@ async function writeZipFileEntry(params) {
106
92
  }
107
93
  }
108
94
  catch (err) {
109
- if (tempPath) {
110
- await fs.rm(tempPath, { force: true }).catch(() => undefined);
111
- }
112
- else {
113
- await cleanupPartialRegularFile(destinationPath).catch(() => undefined);
114
- }
95
+ // Failures here happen before the temp has been committed. The destination
96
+ // parent may already be untrusted, so cleanup must stay limited to temp state.
115
97
  throw err;
116
98
  }
117
99
  finally {
118
- if (tempHandle && !handleClosedByStream) {
119
- await tempHandle.close().catch(() => undefined);
100
+ const openTempHandle = tempHandle;
101
+ if (openTempHandle && !handleClosedByStream) {
102
+ await openTempHandle.close().catch(() => undefined);
120
103
  }
121
104
  }
122
105
  }
@@ -0,0 +1,8 @@
1
+ import { Transform } from "node:stream";
2
+ export declare function createMaxBytesTransform(maxBytes: number): Transform;
3
+ export declare function createBoundedReadStream(opened: {
4
+ handle: {
5
+ createReadStream(): NodeJS.ReadableStream;
6
+ };
7
+ }, maxBytes: number | undefined): NodeJS.ReadableStream;
8
+ //# sourceMappingURL=bounded-read-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded-read-stream.d.ts","sourceRoot":"","sources":["../src/bounded-read-stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAkBnE;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE;IAAE,MAAM,EAAE;QAAE,gBAAgB,IAAI,MAAM,CAAC,cAAc,CAAA;KAAE,CAAA;CAAE,EACjE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAAC,cAAc,CAGvB"}
@@ -0,0 +1,20 @@
1
+ import { Transform } from "node:stream";
2
+ import { FsSafeError } from "./errors.js";
3
+ export function createMaxBytesTransform(maxBytes) {
4
+ let bytes = 0;
5
+ return new Transform({
6
+ transform(chunk, _encoding, callback) {
7
+ const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
8
+ bytes += buffer.byteLength;
9
+ if (bytes > maxBytes) {
10
+ callback(new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
11
+ return;
12
+ }
13
+ callback(null, buffer);
14
+ },
15
+ });
16
+ }
17
+ export function createBoundedReadStream(opened, maxBytes) {
18
+ const stream = opened.handle.createReadStream();
19
+ return maxBytes === undefined ? stream : stream.pipe(createMaxBytesTransform(maxBytes));
20
+ }
package/dist/config.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { configureFsSafePython, getFsSafePythonConfig, type FsSafePythonConfig, type FsSafePythonMode, } from "./pinned-python-config.js";
2
+ export { configureFsSafeLocks, getFsSafeLockConfig, type FsSafeLockConfig, } from "./lock-config.js";
2
3
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
package/dist/config.js CHANGED
@@ -1 +1,2 @@
1
1
  export { configureFsSafePython, getFsSafePythonConfig, } from "./pinned-python-config.js";
2
+ export { configureFsSafeLocks, getFsSafeLockConfig, } from "./lock-config.js";
@@ -0,0 +1,18 @@
1
+ import type { Stats } from "node:fs";
2
+ export type AsyncDirectoryGuard = {
3
+ dir: string;
4
+ realPath: string;
5
+ stat: Stats;
6
+ };
7
+ export type SyncDirectoryGuard = {
8
+ dir: string;
9
+ realPath: string;
10
+ stat: Stats;
11
+ };
12
+ export declare function createAsyncDirectoryGuard(dir: string): Promise<AsyncDirectoryGuard>;
13
+ export declare function assertAsyncDirectoryGuard(guard: AsyncDirectoryGuard): Promise<void>;
14
+ export declare function createSyncDirectoryGuard(dir: string): SyncDirectoryGuard;
15
+ export declare function assertSyncDirectoryGuard(guard: SyncDirectoryGuard): void;
16
+ export declare function createNearestExistingDirectoryGuard(rootReal: string, targetPath: string): Promise<AsyncDirectoryGuard>;
17
+ export declare function createNearestExistingSyncDirectoryGuard(rootReal: string, targetPath: string): SyncDirectoryGuard;
18
+ //# sourceMappingURL=directory-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directory-guard.d.ts","sourceRoot":"","sources":["../src/directory-guard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAQrC,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;CACb,CAAC;AAEF,wBAAsB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAMzF;AAED,wBAAsB,yBAAyB,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAQzF;AAED,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,CAMxE;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAQxE;AAED,wBAAsB,mCAAmC,CACvD,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB,kBAAkB,CAcpB"}
@@ -0,0 +1,70 @@
1
+ import fsSync from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { sameFileIdentity } from "./file-identity.js";
6
+ import { isNotFoundPathError } from "./path.js";
7
+ export async function createAsyncDirectoryGuard(dir) {
8
+ const stat = await fs.lstat(dir);
9
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
10
+ throw new FsSafeError("not-file", "directory component must be a directory");
11
+ }
12
+ return { dir, realPath: await fs.realpath(dir), stat };
13
+ }
14
+ export async function assertAsyncDirectoryGuard(guard) {
15
+ const stat = await fs.lstat(guard.dir);
16
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
17
+ throw new FsSafeError("not-file", "directory component must be a directory");
18
+ }
19
+ if (!sameFileIdentity(stat, guard.stat) || (await fs.realpath(guard.dir)) !== guard.realPath) {
20
+ throw new FsSafeError("path-mismatch", "directory changed during operation");
21
+ }
22
+ }
23
+ export function createSyncDirectoryGuard(dir) {
24
+ const stat = fsSync.lstatSync(dir);
25
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
26
+ throw new FsSafeError("not-file", "directory component must be a directory");
27
+ }
28
+ return { dir, realPath: fsSync.realpathSync(dir), stat };
29
+ }
30
+ export function assertSyncDirectoryGuard(guard) {
31
+ const stat = fsSync.lstatSync(guard.dir);
32
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
33
+ throw new FsSafeError("not-file", "directory component must be a directory");
34
+ }
35
+ if (!sameFileIdentity(stat, guard.stat) || fsSync.realpathSync(guard.dir) !== guard.realPath) {
36
+ throw new FsSafeError("path-mismatch", "directory changed during operation");
37
+ }
38
+ }
39
+ export async function createNearestExistingDirectoryGuard(rootReal, targetPath) {
40
+ let current = path.resolve(targetPath);
41
+ const root = path.resolve(rootReal);
42
+ while (current !== root) {
43
+ try {
44
+ return await createAsyncDirectoryGuard(current);
45
+ }
46
+ catch (error) {
47
+ if (!isNotFoundPathError(error)) {
48
+ throw error;
49
+ }
50
+ current = path.dirname(current);
51
+ }
52
+ }
53
+ return await createAsyncDirectoryGuard(root);
54
+ }
55
+ export function createNearestExistingSyncDirectoryGuard(rootReal, targetPath) {
56
+ let current = path.resolve(targetPath);
57
+ const root = path.resolve(rootReal);
58
+ while (current !== root) {
59
+ try {
60
+ return createSyncDirectoryGuard(current);
61
+ }
62
+ catch (error) {
63
+ if (!isNotFoundPathError(error)) {
64
+ throw error;
65
+ }
66
+ current = path.dirname(current);
67
+ }
68
+ }
69
+ return createSyncDirectoryGuard(root);
70
+ }
@@ -1,7 +1,9 @@
1
- import { type SidecarLockAcquireOptions, type SidecarLockHandle, type SidecarLockHeldEntry, type SidecarLockRetryOptions } from "./sidecar-lock.js";
1
+ import { type SidecarLockAcquireOptions, type SidecarLockHandle, type SidecarLockHeldEntry, type SidecarLockRetryOptions, type SidecarLockStaleRecovery } from "./sidecar-lock.js";
2
2
  export type FileLockRetryOptions = SidecarLockRetryOptions;
3
- export type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = Omit<SidecarLockAcquireOptions<TPayload>, "targetPath"> & {
3
+ export type FileLockStaleRecovery = SidecarLockStaleRecovery;
4
+ export type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = Omit<SidecarLockAcquireOptions<TPayload>, "targetPath" | "staleMs"> & {
4
5
  managerKey?: string;
6
+ staleMs?: number;
5
7
  };
6
8
  export type FileLockHandle = SidecarLockHandle;
7
9
  export type FileLockHeldEntry = SidecarLockHeldEntry;
@@ -1 +1 @@
1
- {"version":3,"file":"file-lock.d.ts","sourceRoot":"","sources":["../src/file-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,yBAAyB,EAC9B,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,EAC7B,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,oBAAoB,GAAG,uBAAuB,CAAC;AAE3D,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,iBAAiB,CAAC;AAC/C,MAAM,MAAM,iBAAiB,GAAG,oBAAoB,CAAC;AAErD,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9C,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,GACxC,OAAO,CAAC,cAAc,CAAC,CAAC;IAC3B,QAAQ,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClD,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAAC;IACd,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,KAAK,IAAI,IAAI,CAAC;IACd,WAAW,IAAI,iBAAiB,EAAE,CAAC;CACpC,CAAC;AAMF,wBAAsB,eAAe,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,GACxC,OAAO,CAAC,cAAc,CAAC,CAGzB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAGZ;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAelE;AAED,wBAAsB,2BAA2B,CAC/C,UAAU,EAAE,MAAM,EAClB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAEzF"}
1
+ {"version":3,"file":"file-lock.d.ts","sourceRoot":"","sources":["../src/file-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,yBAAyB,EAC9B,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAC9B,MAAM,mBAAmB,CAAC;AAG3B,MAAM,MAAM,oBAAoB,GAAG,uBAAuB,CAAC;AAC3D,MAAM,MAAM,qBAAqB,GAAG,wBAAwB,CAAC;AAE7D,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,GAAG,SAAS,CACzB,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,iBAAiB,CAAC;AAC/C,MAAM,MAAM,iBAAiB,GAAG,oBAAoB,CAAC;AAErD,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9C,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,GACxC,OAAO,CAAC,cAAc,CAAC,CAAC;IAC3B,QAAQ,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClD,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAAC;IACd,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,KAAK,IAAI,IAAI,CAAC;IACd,WAAW,IAAI,iBAAiB,EAAE,CAAC;CACpC,CAAC;AAmBF,wBAAsB,eAAe,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,GACxC,OAAO,CAAC,cAAc,CAAC,CAGzB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAGZ;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAelE;AAED,wBAAsB,2BAA2B,CAC/C,UAAU,EAAE,MAAM,EAClB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAEzF"}
package/dist/file-lock.js CHANGED
@@ -1,7 +1,18 @@
1
1
  import { createSidecarLockManager, } from "./sidecar-lock.js";
2
+ import { getFsSafeLockConfig } from "./lock-config.js";
2
3
  function resolveFileLockManagerKey(targetPath, managerKey) {
3
4
  return managerKey ?? `fs-safe.file-lock:${targetPath}`;
4
5
  }
6
+ function withLockDefaults(options) {
7
+ const defaults = getFsSafeLockConfig();
8
+ return {
9
+ ...options,
10
+ retry: options.retry ?? defaults.retry,
11
+ staleMs: options.staleMs ?? defaults.staleMs ?? 30_000,
12
+ staleRecovery: options.staleRecovery ?? defaults.staleRecovery,
13
+ timeoutMs: options.timeoutMs ?? defaults.timeoutMs,
14
+ };
15
+ }
5
16
  export async function acquireFileLock(targetPath, options) {
6
17
  return await createFileLockManager(resolveFileLockManagerKey(targetPath, options.managerKey))
7
18
  .acquire(targetPath, options);
@@ -15,11 +26,11 @@ export function createFileLockManager(key) {
15
26
  return {
16
27
  acquire: async (targetPath, options) => {
17
28
  const { managerKey: _managerKey, ...acquireOptions } = options;
18
- return await manager.acquire({ ...acquireOptions, targetPath });
29
+ return await manager.acquire({ ...withLockDefaults(acquireOptions), targetPath });
19
30
  },
20
31
  withLock: async (targetPath, options, fn) => {
21
32
  const { managerKey: _managerKey, ...acquireOptions } = options;
22
- return await manager.withLock({ ...acquireOptions, targetPath }, fn);
33
+ return await manager.withLock({ ...withLockDefaults(acquireOptions), targetPath }, fn);
23
34
  },
24
35
  drain: manager.drain,
25
36
  reset: manager.reset,
@@ -0,0 +1,25 @@
1
+ import { type Readable } from "node:stream";
2
+ import { type SyncDirectoryGuard } from "./directory-guard.js";
3
+ import { type Root } from "./root.js";
4
+ export type SyncParentGuard = SyncDirectoryGuard;
5
+ export declare function ensureParentInRoot(scopedRoot: Root, relativePath: string, mode: number): Promise<void>;
6
+ export declare function openWritableStoreRoot(params: {
7
+ rootDir: string;
8
+ dirMode: number;
9
+ maxBytes?: number;
10
+ }): Promise<Root>;
11
+ export declare function writeStreamToTempSource(params: {
12
+ stream: Readable;
13
+ maxBytes?: number;
14
+ mode: number;
15
+ }): Promise<{
16
+ path: string;
17
+ cleanup: () => Promise<void>;
18
+ }>;
19
+ export declare function assertSyncDirectoryGuard(guard: SyncParentGuard): void;
20
+ export declare function ensureParentSync(params: {
21
+ rootDir: string;
22
+ filePath: string;
23
+ mode: number;
24
+ }): SyncParentGuard;
25
+ //# sourceMappingURL=file-store-boundary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-store-boundary.d.ts","sourceRoot":"","sources":["../src/file-store-boundary.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAA4C,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAGhF,MAAM,MAAM,eAAe,GAAG,kBAAkB,CAAC;AAOjD,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,IAAI,EAChB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+CD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,MAAM,EAAE,QAAQ,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,CAuC1D;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAWrE;AAUD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,eAAe,CAwClB"}
@@ -0,0 +1,168 @@
1
+ import syncFs from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Transform } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { assertSyncDirectoryGuard as assertDirectoryGuardSync, createSyncDirectoryGuard, } from "./directory-guard.js";
7
+ import { FsSafeError } from "./errors.js";
8
+ import { isPathInside } from "./path.js";
9
+ import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
10
+ import { resolveSecureTempRoot } from "./secure-temp-dir.js";
11
+ function parentRelativePath(relativePath) {
12
+ const parent = path.posix.dirname(relativePath);
13
+ return parent === "." ? "" : parent;
14
+ }
15
+ export async function ensureParentInRoot(scopedRoot, relativePath, mode) {
16
+ const parent = parentRelativePath(relativePath);
17
+ if (!parent) {
18
+ return;
19
+ }
20
+ await scopedRoot.mkdir(parent);
21
+ await chmodDirectoryInRootBestEffort(scopedRoot, parent, mode).catch(() => undefined);
22
+ }
23
+ export async function openWritableStoreRoot(params) {
24
+ await fs.mkdir(params.rootDir, { recursive: true, mode: params.dirMode });
25
+ await fs.chmod(params.rootDir, params.dirMode).catch(() => undefined);
26
+ return await root(params.rootDir, { hardlinks: "reject", maxBytes: params.maxBytes });
27
+ }
28
+ async function chmodDirectoryInRootBestEffort(scopedRoot, relativePath, mode) {
29
+ const dirPath = await scopedRoot.resolve(relativePath);
30
+ const directoryFlag = "O_DIRECTORY" in syncFs.constants ? syncFs.constants.O_DIRECTORY : 0;
31
+ const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants
32
+ ? syncFs.constants.O_NOFOLLOW
33
+ : 0;
34
+ const handle = await fs.open(dirPath, syncFs.constants.O_RDONLY | directoryFlag | noFollowFlag);
35
+ try {
36
+ const stat = await handle.stat();
37
+ if (!stat.isDirectory()) {
38
+ return;
39
+ }
40
+ const realPath = await resolveOpenedFileRealPathForHandle(handle, dirPath);
41
+ if (!isPathInside(scopedRoot.rootWithSep, realPath)) {
42
+ throw new FsSafeError("outside-workspace", "directory is outside store root");
43
+ }
44
+ await handle.chmod(mode).catch(() => undefined);
45
+ }
46
+ finally {
47
+ await handle.close().catch(() => undefined);
48
+ }
49
+ }
50
+ function createMaxBytesTransform(maxBytes) {
51
+ if (maxBytes === undefined) {
52
+ return undefined;
53
+ }
54
+ let total = 0;
55
+ return new Transform({
56
+ transform(chunk, _encoding, callback) {
57
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
58
+ total += buffer.byteLength;
59
+ if (total > maxBytes) {
60
+ callback(new FsSafeError("too-large", `file exceeds maximum size of ${maxBytes} bytes`));
61
+ return;
62
+ }
63
+ callback(null, buffer);
64
+ },
65
+ });
66
+ }
67
+ export async function writeStreamToTempSource(params) {
68
+ const tempRoot = resolveSecureTempRoot({
69
+ fallbackPrefix: "fs-safe-file-store",
70
+ unsafeFallbackLabel: "file store temp dir",
71
+ warn: () => undefined,
72
+ });
73
+ const dir = await fs.mkdtemp(path.join(tempRoot, "fs-safe-file-store-"));
74
+ const filePath = path.join(dir, "payload");
75
+ let handle = null;
76
+ let handleClosedByStream = false;
77
+ try {
78
+ handle = await fs.open(filePath, "wx", params.mode);
79
+ const writable = handle.createWriteStream();
80
+ writable.once("close", () => {
81
+ handleClosedByStream = true;
82
+ });
83
+ const limiter = createMaxBytesTransform(params.maxBytes);
84
+ if (limiter) {
85
+ await pipeline(params.stream, limiter, writable);
86
+ }
87
+ else {
88
+ await pipeline(params.stream, writable);
89
+ }
90
+ if (!handleClosedByStream) {
91
+ await handle.close().catch(() => undefined);
92
+ }
93
+ await fs.chmod(filePath, params.mode).catch(() => undefined);
94
+ return {
95
+ path: filePath,
96
+ cleanup: async () => {
97
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
98
+ },
99
+ };
100
+ }
101
+ catch (err) {
102
+ if (handle && !handleClosedByStream) {
103
+ await handle.close().catch(() => undefined);
104
+ }
105
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
106
+ throw err;
107
+ }
108
+ }
109
+ export function assertSyncDirectoryGuard(guard) {
110
+ try {
111
+ assertDirectoryGuardSync(guard);
112
+ }
113
+ catch (error) {
114
+ if (error instanceof FsSafeError && error.code === "path-mismatch") {
115
+ throw new FsSafeError("path-mismatch", "store directory changed during write", {
116
+ cause: error,
117
+ });
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+ function chmodDirectorySyncBestEffort(dir, mode) {
123
+ try {
124
+ syncFs.chmodSync(dir, mode);
125
+ }
126
+ catch {
127
+ // Best-effort on platforms that do not enforce POSIX modes.
128
+ }
129
+ }
130
+ export function ensureParentSync(params) {
131
+ const rootDir = path.resolve(params.rootDir);
132
+ const dir = path.dirname(path.resolve(params.filePath));
133
+ const relative = path.relative(rootDir, dir);
134
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
135
+ throw new FsSafeError("outside-workspace", "file path escapes store root");
136
+ }
137
+ syncFs.mkdirSync(rootDir, { recursive: true, mode: params.mode });
138
+ const rootStat = syncFs.lstatSync(rootDir);
139
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
140
+ throw new FsSafeError("not-file", `store root must be a directory: ${rootDir}`);
141
+ }
142
+ const rootReal = syncFs.realpathSync(rootDir);
143
+ chmodDirectorySyncBestEffort(rootDir, params.mode);
144
+ let current = rootDir;
145
+ for (const segment of path.relative(rootDir, dir).split(path.sep).filter(Boolean)) {
146
+ current = path.join(current, segment);
147
+ try {
148
+ const stat = syncFs.lstatSync(current);
149
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
150
+ throw new FsSafeError("not-file", `store directory component must be a directory: ${current}`);
151
+ }
152
+ }
153
+ catch (error) {
154
+ if (error.code !== "ENOENT") {
155
+ throw error;
156
+ }
157
+ syncFs.mkdirSync(current, { mode: params.mode });
158
+ }
159
+ const currentReal = syncFs.realpathSync(current);
160
+ if (!isPathInside(rootReal, currentReal)) {
161
+ throw new FsSafeError("outside-workspace", "store directory escapes root");
162
+ }
163
+ chmodDirectorySyncBestEffort(current, params.mode);
164
+ }
165
+ const guard = createSyncDirectoryGuard(dir);
166
+ assertSyncDirectoryGuard(guard);
167
+ return guard;
168
+ }
@@ -0,0 +1,12 @@
1
+ export type FileStorePruneOptions = {
2
+ ttlMs: number;
3
+ recursive?: boolean;
4
+ maxDepth?: number;
5
+ pruneEmptyDirs?: boolean;
6
+ };
7
+ export declare function pruneExpiredStoreEntries(params: {
8
+ rootDir: string;
9
+ dirMode: number;
10
+ options: FileStorePruneOptions;
11
+ }): Promise<void>;
12
+ //# sourceMappingURL=file-store-prune.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-store-prune.d.ts","sourceRoot":"","sources":["../src/file-store-prune.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,qBAAqB,GAAG;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,qBAAqB,CAAC;CAChC,GAAG,OAAO,CAAC,IAAI,CAAC,CAsFhB"}
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { isPathInside } from "./path.js";
5
+ import { root } from "./root.js";
6
+ import { getFsSafeTestHooks } from "./test-hooks.js";
7
+ export async function pruneExpiredStoreEntries(params) {
8
+ const now = Date.now();
9
+ const recursive = params.options.recursive ?? false;
10
+ const maxDepth = params.options.maxDepth;
11
+ const pruneEmptyDirs = (recursive || maxDepth !== undefined) && (params.options.pruneEmptyDirs ?? false);
12
+ await fs.mkdir(params.rootDir, { recursive: true, mode: params.dirMode });
13
+ const rootReal = await fs.realpath(params.rootDir);
14
+ const scopedRoot = await root(rootReal);
15
+ const rootGuard = {
16
+ dir: rootReal,
17
+ realPath: rootReal,
18
+ stat: await fs.lstat(rootReal),
19
+ };
20
+ async function assertRootGuard() {
21
+ const stat = await fs.lstat(rootGuard.dir);
22
+ if (stat.isSymbolicLink() ||
23
+ !stat.isDirectory() ||
24
+ stat.dev !== rootGuard.stat.dev ||
25
+ stat.ino !== rootGuard.stat.ino ||
26
+ (await fs.realpath(rootGuard.dir)) !== rootGuard.realPath) {
27
+ throw new FsSafeError("path-mismatch", "store root changed during prune");
28
+ }
29
+ }
30
+ async function readStableDirectory(dir) {
31
+ const before = await fs.lstat(dir).catch(() => null);
32
+ if (!before || before.isSymbolicLink() || !before.isDirectory()) {
33
+ return null;
34
+ }
35
+ const real = await fs.realpath(dir).catch(() => null);
36
+ if (!real || !isPathInside(rootReal, real)) {
37
+ return null;
38
+ }
39
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
40
+ if (!entries) {
41
+ return null;
42
+ }
43
+ const after = await fs.lstat(dir).catch(() => null);
44
+ if (!after || before.dev !== after.dev || before.ino !== after.ino) {
45
+ return null;
46
+ }
47
+ return entries;
48
+ }
49
+ async function pruneDir(dir, relativeDir, depth) {
50
+ const entries = await readStableDirectory(dir);
51
+ if (!entries) {
52
+ return false;
53
+ }
54
+ for (const entry of entries) {
55
+ const fullPath = path.join(dir, entry.name);
56
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
57
+ const stat = await fs.lstat(fullPath).catch(() => null);
58
+ if (!stat || stat.isSymbolicLink()) {
59
+ continue;
60
+ }
61
+ if (stat.isDirectory()) {
62
+ const shouldDescend = maxDepth !== undefined ? depth < maxDepth : recursive;
63
+ if (shouldDescend) {
64
+ await getFsSafeTestHooks()?.beforeFileStorePruneDescend?.(fullPath);
65
+ }
66
+ if (shouldDescend && (await pruneDir(fullPath, relativePath, depth + 1))) {
67
+ await assertRootGuard();
68
+ // Keep empty-dir pruning on the same root-bounded remove path as files;
69
+ // the Root fallback handles empty directories without recursive delete.
70
+ await scopedRoot.remove(relativePath).catch(() => undefined);
71
+ }
72
+ continue;
73
+ }
74
+ if (stat.isFile() && now - stat.mtimeMs > params.options.ttlMs) {
75
+ await assertRootGuard();
76
+ await scopedRoot.remove(relativePath).catch(() => undefined);
77
+ }
78
+ }
79
+ if (!pruneEmptyDirs) {
80
+ return false;
81
+ }
82
+ const remaining = await readStableDirectory(dir);
83
+ return remaining !== null && remaining.length === 0;
84
+ }
85
+ await pruneDir(rootReal, "", 0);
86
+ }
@@ -0,0 +1,5 @@
1
+ export declare function readFileStoreCopySource(params: {
2
+ sourcePath: string;
3
+ maxBytes?: number;
4
+ }): Promise<Buffer>;
5
+ //# sourceMappingURL=file-store-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-store-source.d.ts","sourceRoot":"","sources":["../src/file-store-source.ts"],"names":[],"mappings":"AAIA,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyBlB"}
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import { FsSafeError } from "./errors.js";
3
+ import { readRegularFile } from "./regular-file.js";
4
+ export async function readFileStoreCopySource(params) {
5
+ const sourceStat = await fs.lstat(params.sourcePath);
6
+ if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
7
+ throw new FsSafeError("not-file", "source path is not a file");
8
+ }
9
+ if (params.maxBytes !== undefined && sourceStat.size > params.maxBytes) {
10
+ throw new FsSafeError("too-large", `file exceeds maximum size of ${params.maxBytes} bytes`);
11
+ }
12
+ try {
13
+ return (await readRegularFile({ filePath: params.sourcePath, maxBytes: params.maxBytes }))
14
+ .buffer;
15
+ }
16
+ catch (error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ if (message.includes("regular file") || message.includes("not a regular file")) {
19
+ throw new FsSafeError("not-file", "source path is not a file", {
20
+ cause: error instanceof Error ? error : undefined,
21
+ });
22
+ }
23
+ if (params.maxBytes !== undefined && message.includes(`exceeds ${params.maxBytes} bytes`)) {
24
+ throw new FsSafeError("too-large", `file exceeds maximum size of ${params.maxBytes} bytes`, {
25
+ cause: error instanceof Error ? error : undefined,
26
+ });
27
+ }
28
+ throw error;
29
+ }
30
+ }