@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
@@ -0,0 +1,200 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { sameFileIdentity } from "./file-identity.js";
4
+ import { replaceFileAtomic } from "./replace-file.js";
5
+ import { assertSafePathSegment } from "./safe-path-segment.js";
6
+ export const DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES = 16 * 1024 * 1024;
7
+ function getErrnoCode(error) {
8
+ return error && typeof error === "object" && "code" in error
9
+ ? String(error.code)
10
+ : null;
11
+ }
12
+ function assertSafeQueueEntryId(id) {
13
+ assertSafePathSegment(id, { label: "queue entry id" });
14
+ }
15
+ export async function unlinkBestEffort(filePath) {
16
+ await fs.promises.unlink(filePath).catch(() => undefined);
17
+ }
18
+ export async function jsonDurableQueueEntryExists(filePath) {
19
+ try {
20
+ const stat = await fs.promises.lstat(filePath);
21
+ return stat.isFile();
22
+ }
23
+ catch (error) {
24
+ if (getErrnoCode(error) === "ENOENT") {
25
+ return false;
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+ async function unlinkStaleTmpBestEffort(filePath, now, maxAgeMs) {
31
+ try {
32
+ const stat = await fs.promises.stat(filePath);
33
+ if (stat.isFile() && now - stat.mtimeMs >= maxAgeMs) {
34
+ await unlinkBestEffort(filePath);
35
+ }
36
+ }
37
+ catch (error) {
38
+ if (getErrnoCode(error) !== "ENOENT") {
39
+ throw error;
40
+ }
41
+ }
42
+ }
43
+ export function resolveJsonDurableQueueEntryPaths(queueDir, id) {
44
+ assertSafeQueueEntryId(id);
45
+ return {
46
+ jsonPath: path.join(queueDir, `${id}.json`),
47
+ deliveredPath: path.join(queueDir, `${id}.delivered`),
48
+ };
49
+ }
50
+ export async function ensureJsonDurableQueueDirs(params) {
51
+ await fs.promises.mkdir(params.queueDir, { recursive: true, mode: 0o700 });
52
+ await fs.promises.mkdir(params.failedDir, { recursive: true, mode: 0o700 });
53
+ }
54
+ export async function writeJsonDurableQueueEntry(params) {
55
+ await replaceFileAtomic({
56
+ filePath: params.filePath,
57
+ content: JSON.stringify(params.entry, null, 2),
58
+ mode: 0o600,
59
+ tempPrefix: params.tempPrefix,
60
+ });
61
+ }
62
+ async function readBoundedUtf8File(params) {
63
+ const initialStat = await fs.promises.lstat(params.filePath);
64
+ if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
65
+ throw new Error("queue entry is not a regular file");
66
+ }
67
+ if (initialStat.size > params.maxBytes) {
68
+ throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
69
+ }
70
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" && process.platform !== "win32"
71
+ ? fs.constants.O_NOFOLLOW
72
+ : 0;
73
+ const handle = await fs.promises.open(params.filePath, fs.constants.O_RDONLY | noFollow);
74
+ try {
75
+ const openedStat = await handle.stat();
76
+ const pathStat = await fs.promises.lstat(params.filePath);
77
+ if (!openedStat.isFile() ||
78
+ pathStat.isSymbolicLink() ||
79
+ !pathStat.isFile() ||
80
+ !sameFileIdentity(initialStat, openedStat) ||
81
+ !sameFileIdentity(pathStat, openedStat)) {
82
+ throw new Error("queue entry changed during read");
83
+ }
84
+ const chunks = [];
85
+ const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, params.maxBytes + 1));
86
+ let total = 0;
87
+ while (true) {
88
+ const { bytesRead } = await handle.read(scratch, 0, scratch.length, null);
89
+ if (bytesRead === 0) {
90
+ return Buffer.concat(chunks, total).toString("utf8");
91
+ }
92
+ total += bytesRead;
93
+ if (total > params.maxBytes) {
94
+ throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
95
+ }
96
+ chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
97
+ }
98
+ }
99
+ finally {
100
+ await handle.close();
101
+ }
102
+ }
103
+ export async function readJsonDurableQueueEntry(filePath, options = {}) {
104
+ return JSON.parse(await readBoundedUtf8File({
105
+ filePath,
106
+ maxBytes: options.maxBytes ?? DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES,
107
+ }));
108
+ }
109
+ export async function ackJsonDurableQueueEntry(paths) {
110
+ try {
111
+ await fs.promises.rename(paths.jsonPath, paths.deliveredPath);
112
+ }
113
+ catch (error) {
114
+ if (getErrnoCode(error) === "ENOENT") {
115
+ await unlinkBestEffort(paths.deliveredPath);
116
+ return;
117
+ }
118
+ throw error;
119
+ }
120
+ await unlinkBestEffort(paths.deliveredPath);
121
+ }
122
+ export async function loadJsonDurableQueueEntry(params) {
123
+ try {
124
+ const stat = await fs.promises.lstat(params.paths.jsonPath);
125
+ if (!stat.isFile()) {
126
+ return null;
127
+ }
128
+ const raw = await readJsonDurableQueueEntry(params.paths.jsonPath, {
129
+ maxBytes: params.maxBytes,
130
+ });
131
+ const result = params.read ? await params.read(raw, params.paths.jsonPath) : { entry: raw };
132
+ if (result.migrated) {
133
+ await writeJsonDurableQueueEntry({
134
+ filePath: params.paths.jsonPath,
135
+ entry: result.entry,
136
+ tempPrefix: params.tempPrefix,
137
+ });
138
+ }
139
+ return result.entry;
140
+ }
141
+ catch (error) {
142
+ if (getErrnoCode(error) === "ENOENT") {
143
+ return null;
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+ export async function loadPendingJsonDurableQueueEntries(options) {
149
+ let files;
150
+ try {
151
+ files = await fs.promises.readdir(options.queueDir);
152
+ }
153
+ catch (error) {
154
+ if (getErrnoCode(error) === "ENOENT") {
155
+ return [];
156
+ }
157
+ throw error;
158
+ }
159
+ const now = Date.now();
160
+ for (const file of files) {
161
+ if (file.endsWith(".delivered")) {
162
+ await unlinkBestEffort(path.join(options.queueDir, file));
163
+ }
164
+ else if (options.cleanupTmpMaxAgeMs !== undefined && file.endsWith(".tmp")) {
165
+ await unlinkStaleTmpBestEffort(path.join(options.queueDir, file), now, options.cleanupTmpMaxAgeMs);
166
+ }
167
+ }
168
+ const entries = [];
169
+ for (const file of files) {
170
+ if (!file.endsWith(".json")) {
171
+ continue;
172
+ }
173
+ const filePath = path.join(options.queueDir, file);
174
+ try {
175
+ const stat = await fs.promises.lstat(filePath);
176
+ if (!stat.isFile()) {
177
+ continue;
178
+ }
179
+ const raw = await readJsonDurableQueueEntry(filePath, { maxBytes: options.maxBytes });
180
+ const result = options.read ? await options.read(raw, filePath) : { entry: raw };
181
+ if (result.migrated) {
182
+ await writeJsonDurableQueueEntry({
183
+ filePath,
184
+ entry: result.entry,
185
+ tempPrefix: options.tempPrefix,
186
+ });
187
+ }
188
+ entries.push(result.entry);
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ }
194
+ return entries;
195
+ }
196
+ export async function moveJsonDurableQueueEntryToFailed(params) {
197
+ assertSafeQueueEntryId(params.id);
198
+ await fs.promises.mkdir(params.failedDir, { recursive: true, mode: 0o700 });
199
+ await fs.promises.rename(path.join(params.queueDir, `${params.id}.json`), path.join(params.failedDir, `${params.id}.json`));
200
+ }
package/dist/json.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import fsSync from "node:fs";
2
+ import { type RootFileOpenFailure } from "./root-file.js";
3
+ import { type WriteTextAtomicOptions } from "./text-atomic.js";
1
4
  export declare function tryReadJsonSync<T = unknown>(pathname: string): T | null;
2
5
  export declare function writeJsonSync(pathname: string, data: unknown): void;
3
6
  export declare class JsonFileReadError extends Error {
@@ -5,13 +8,40 @@ export declare class JsonFileReadError extends Error {
5
8
  readonly reason: "read" | "parse";
6
9
  constructor(filePath: string, reason: "read" | "parse", cause: unknown);
7
10
  }
11
+ export type RootStructuredFileReadResult<T> = {
12
+ ok: true;
13
+ value: T;
14
+ stat: fsSync.Stats;
15
+ path: string;
16
+ rootRealPath: string;
17
+ } | {
18
+ ok: false;
19
+ reason: "open";
20
+ failure: RootFileOpenFailure;
21
+ } | {
22
+ ok: false;
23
+ reason: "invalid" | "parse";
24
+ error: string;
25
+ };
26
+ export type ReadRootStructuredFileSyncOptions<T> = {
27
+ rootDir: string;
28
+ rootRealPath?: string;
29
+ relativePath: string;
30
+ boundaryLabel: string;
31
+ rejectHardlinks?: boolean;
32
+ maxBytes?: number;
33
+ parse: (raw: string) => unknown;
34
+ validate?: (value: unknown) => value is T;
35
+ invalidMessage?: string | ((relativePath: string) => string);
36
+ };
37
+ export type ReadRootJsonSyncOptions = Omit<ReadRootStructuredFileSyncOptions<unknown>, "parse" | "validate" | "invalidMessage">;
38
+ export declare function readRootStructuredFileSync<T>(options: ReadRootStructuredFileSyncOptions<T>): RootStructuredFileReadResult<T>;
39
+ export declare function readRootJsonSync<T = unknown>(options: ReadRootJsonSyncOptions): RootStructuredFileReadResult<T>;
40
+ export declare function readRootJsonObjectSync(options: ReadRootJsonSyncOptions): RootStructuredFileReadResult<Record<string, unknown>>;
8
41
  export declare function tryReadJson<T>(filePath: string): Promise<T | null>;
9
42
  export declare function readJson<T>(filePath: string): Promise<T>;
10
43
  export declare function readJsonIfExists<T>(filePath: string): Promise<T | null>;
11
44
  export declare function readJsonSync<T = unknown>(filePath: string): T;
12
- export declare function writeJson(filePath: string, value: unknown, options?: {
13
- mode?: number;
14
- trailingNewline?: boolean;
15
- dirMode?: number;
16
- }): Promise<void>;
45
+ export type WriteJsonOptions = Pick<WriteTextAtomicOptions, "dirMode" | "durable" | "mode" | "trailingNewline">;
46
+ export declare function writeJson(filePath: string, value: unknown, options?: WriteJsonOptions): Promise<void>;
17
47
  //# sourceMappingURL=json.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"AA8FA,wBAAgB,eAAe,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAOvE;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAmB5D;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;gBAEtB,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,EAAE,KAAK,EAAE,OAAO;CAMvE;AAED,wBAAsB,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAOxE;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAY9D;AAED,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAe7E;AAED,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,CAY7D;AAED,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,iBAQzE"}
1
+ {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"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"}
package/dist/json.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readRegularFile, readRegularFileSync } from "./regular-file.js";
5
+ import { openRootFileSync } from "./root-file.js";
5
6
  import { writeTextAtomic } from "./text-atomic.js";
6
7
  const JSON_FILE_MODE = 0o600;
7
8
  const JSON_DIR_MODE = 0o700;
@@ -74,8 +75,8 @@ function renameJsonFileWithFallback(tmpPath, pathname) {
74
75
  fsSync.renameSync(tmpPath, pathname);
75
76
  return;
76
77
  }
77
- fsSync.copyFileSync(tmpPath, pathname);
78
- fsSync.rmSync(tmpPath, { force: true });
78
+ fsSync.rmSync(pathname, { force: true });
79
+ fsSync.renameSync(tmpPath, pathname);
79
80
  return;
80
81
  }
81
82
  throw error;
@@ -131,6 +132,71 @@ export class JsonFileReadError extends Error {
131
132
  this.reason = reason;
132
133
  }
133
134
  }
135
+ function isRecord(value) {
136
+ return typeof value === "object" && value !== null && !Array.isArray(value);
137
+ }
138
+ function resolveInvalidMessage(invalidMessage, relativePath) {
139
+ if (typeof invalidMessage === "function") {
140
+ return invalidMessage(relativePath);
141
+ }
142
+ return invalidMessage ?? `${relativePath} has an unexpected shape`;
143
+ }
144
+ export function readRootStructuredFileSync(options) {
145
+ const absolutePath = path.resolve(options.rootDir, options.relativePath);
146
+ const opened = openRootFileSync({
147
+ absolutePath,
148
+ rootPath: options.rootDir,
149
+ ...(options.rootRealPath !== undefined ? { rootRealPath: options.rootRealPath } : {}),
150
+ boundaryLabel: options.boundaryLabel,
151
+ rejectHardlinks: options.rejectHardlinks,
152
+ maxBytes: options.maxBytes,
153
+ allowedType: "file",
154
+ });
155
+ if (!opened.ok) {
156
+ return { ok: false, reason: "open", failure: opened };
157
+ }
158
+ try {
159
+ const parsed = options.parse(fsSync.readFileSync(opened.fd, "utf8"));
160
+ if (options.validate && !options.validate(parsed)) {
161
+ return {
162
+ ok: false,
163
+ reason: "invalid",
164
+ error: resolveInvalidMessage(options.invalidMessage, options.relativePath),
165
+ };
166
+ }
167
+ return {
168
+ ok: true,
169
+ value: parsed,
170
+ stat: opened.stat,
171
+ path: opened.path,
172
+ rootRealPath: opened.rootRealPath,
173
+ };
174
+ }
175
+ catch (error) {
176
+ return {
177
+ ok: false,
178
+ reason: "parse",
179
+ error: `failed to parse ${options.relativePath}: ${String(error)}`,
180
+ };
181
+ }
182
+ finally {
183
+ fsSync.closeSync(opened.fd);
184
+ }
185
+ }
186
+ export function readRootJsonSync(options) {
187
+ return readRootStructuredFileSync({
188
+ ...options,
189
+ parse: (raw) => JSON.parse(raw),
190
+ });
191
+ }
192
+ export function readRootJsonObjectSync(options) {
193
+ return readRootStructuredFileSync({
194
+ ...options,
195
+ parse: (raw) => JSON.parse(raw),
196
+ validate: isRecord,
197
+ invalidMessage: (relativePath) => `${relativePath} must contain a JSON object`,
198
+ });
199
+ }
134
200
  export async function tryReadJson(filePath) {
135
201
  try {
136
202
  const raw = (await readRegularFile({ filePath })).buffer.toString("utf8");
@@ -194,5 +260,6 @@ export async function writeJson(filePath, value, options) {
194
260
  mode: options?.mode,
195
261
  dirMode: options?.dirMode,
196
262
  trailingNewline: options?.trailingNewline,
263
+ durable: options?.durable,
197
264
  });
198
265
  }
@@ -1 +1 @@
1
- {"version":3,"file":"local-roots.d.ts","sourceRoot":"","sources":["../src/local-roots.ts"],"names":[],"mappings":"AAMA,OAAO,EAAQ,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,oCAAoC,GAAG,sBAAsB,GAAG;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,sBAAsB,GAAG;IACnE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B,CAAC;AA0FF,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,oCAAoC,GAC5C,oBAAoB,GAAG,IAAI,CA6B7B;AAED,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAgCtC"}
1
+ {"version":3,"file":"local-roots.d.ts","sourceRoot":"","sources":["../src/local-roots.ts"],"names":[],"mappings":"AAMA,OAAO,EAAQ,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,oCAAoC,GAAG,sBAAsB,GAAG;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,sBAAsB,GAAG;IACnE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B,CAAC;AAmGF,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,oCAAoC,GAC5C,oBAAoB,GAAG,IAAI,CA6B7B;AAED,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAqCtC"}
@@ -52,8 +52,10 @@ function resolveRootRealSync(rootDir) {
52
52
  }
53
53
  }
54
54
  function resolveCandidateCanonicalSync(filePath) {
55
+ let sawExistingLeaf = false;
55
56
  try {
56
57
  const stat = fsSync.lstatSync(filePath);
58
+ sawExistingLeaf = true;
57
59
  return {
58
60
  exists: true,
59
61
  canonicalPath: fsSync.realpathSync(filePath),
@@ -65,6 +67,11 @@ function resolveCandidateCanonicalSync(filePath) {
65
67
  throw err;
66
68
  }
67
69
  }
70
+ if (sawExistingLeaf) {
71
+ // lstat succeeded but realpath failed: this is an existing dangling
72
+ // symlink, not a missing path callers may safely create through.
73
+ throw new FsSafeError("symlink", "local roots candidate is a dangling symlink");
74
+ }
68
75
  let cursor = filePath;
69
76
  const missingSegments = [];
70
77
  while (true) {
@@ -84,6 +91,8 @@ function resolveCandidateCanonicalSync(filePath) {
84
91
  }
85
92
  catch (err) {
86
93
  if (err.code !== "ENOENT") {
94
+ // Existing ancestors that cannot be canonicalized are symlink/error
95
+ // terrain; do not reconstruct a trusted missing path through them.
87
96
  throw err;
88
97
  }
89
98
  }
@@ -134,12 +143,17 @@ export async function readLocalFileFromRoots(options) {
134
143
  continue;
135
144
  }
136
145
  try {
137
- const result = await scopedRoot.read(relativePath, {
146
+ const readOptions = {
138
147
  hardlinks: options.hardlinks,
139
- maxBytes: options.maxBytes,
140
148
  nonBlockingRead: options.nonBlockingRead,
141
149
  symlinks: options.symlinks,
142
- });
150
+ };
151
+ // Leave maxBytes absent when the caller omits it so Root's own default
152
+ // cap remains in force instead of being overwritten by undefined.
153
+ if (options.maxBytes !== undefined) {
154
+ readOptions.maxBytes = options.maxBytes;
155
+ }
156
+ const result = await scopedRoot.read(relativePath, readOptions);
143
157
  return { ...result, root: scopedRoot.rootReal };
144
158
  }
145
159
  catch {
@@ -0,0 +1,10 @@
1
+ import type { SidecarLockRetryOptions, SidecarLockStaleRecovery } from "./sidecar-lock.js";
2
+ export type FsSafeLockConfig = {
3
+ staleRecovery: SidecarLockStaleRecovery;
4
+ staleMs?: number;
5
+ timeoutMs?: number;
6
+ retry?: SidecarLockRetryOptions;
7
+ };
8
+ export declare function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
9
+ export declare function getFsSafeLockConfig(): FsSafeLockConfig;
10
+ //# sourceMappingURL=lock-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock-config.d.ts","sourceRoot":"","sources":["../src/lock-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAE3F,MAAM,MAAM,gBAAgB,GAAG;IAC7B,aAAa,EAAE,wBAAwB,CAAC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;CACjC,CAAC;AAQF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,IAAI,CAI5E;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,CAEtD"}
@@ -0,0 +1,12 @@
1
+ const DEFAULT_LOCK_CONFIG = {
2
+ staleRecovery: "fail-closed",
3
+ };
4
+ let lockConfig = { ...DEFAULT_LOCK_CONFIG };
5
+ export function configureFsSafeLocks(config) {
6
+ // Process defaults only fill lock options after a caller explicitly enables
7
+ // locking for a resource; this must never turn sidecar locks on globally.
8
+ lockConfig = { ...lockConfig, ...config };
9
+ }
10
+ export function getFsSafeLockConfig() {
11
+ return { ...lockConfig, retry: lockConfig.retry ? { ...lockConfig.retry } : undefined };
12
+ }
@@ -1,5 +1,6 @@
1
1
  export type MovePathWithCopyFallbackOptions = {
2
2
  from: string;
3
+ sourceHardlinks?: "allow" | "reject";
3
4
  to: string;
4
5
  };
5
6
  export declare function movePathWithCopyFallback(options: MovePathWithCopyFallbackOptions): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAef"}
1
+ {"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAqPF,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAuBf"}
package/dist/move-path.js CHANGED
@@ -1,7 +1,186 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
1
3
  import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { guardedRename } from "./guarded-mutation.js";
6
+ function entryIdentity(stat) {
7
+ return {
8
+ ctimeMs: stat.ctimeMs,
9
+ dev: stat.dev,
10
+ ino: stat.ino,
11
+ mode: stat.mode,
12
+ mtimeMs: stat.mtimeMs,
13
+ size: stat.size,
14
+ };
15
+ }
16
+ function sameIdentity(a, b) {
17
+ return (a.dev === b.dev &&
18
+ a.ino === b.ino &&
19
+ a.mode === b.mode &&
20
+ a.size === b.size &&
21
+ a.mtimeMs === b.mtimeMs &&
22
+ a.ctimeMs === b.ctimeMs);
23
+ }
24
+ function sameDirectoryNode(a, b) {
25
+ return a.dev === b.dev && a.ino === b.ino;
26
+ }
27
+ function modeBits(mode) {
28
+ return mode & 0o777;
29
+ }
30
+ function sourceChangedError(sourcePath) {
31
+ return Object.assign(new Error(`Source changed during move fallback: ${sourcePath}`), {
32
+ code: "ESTALE",
33
+ });
34
+ }
35
+ async function assertSourceStillMatches(sourcePath, identity) {
36
+ if (!sameIdentity(identity, entryIdentity(await fs.lstat(sourcePath)))) {
37
+ throw sourceChangedError(sourcePath);
38
+ }
39
+ }
40
+ function regularReadFlags() {
41
+ return (fsConstants.O_RDONLY |
42
+ (typeof fsConstants.O_NOFOLLOW === "number" && process.platform !== "win32"
43
+ ? fsConstants.O_NOFOLLOW
44
+ : 0));
45
+ }
46
+ async function writeAll(handle, buffer, bytesRead) {
47
+ let offset = 0;
48
+ while (offset < bytesRead) {
49
+ const { bytesWritten } = await handle.write(buffer, offset, bytesRead - offset);
50
+ offset += bytesWritten;
51
+ }
52
+ }
53
+ async function copyRegularFilePinned(params) {
54
+ let destinationCreated = false;
55
+ let sourceHandle;
56
+ try {
57
+ sourceHandle = await fs.open(params.from, regularReadFlags());
58
+ }
59
+ catch (error) {
60
+ const code = error?.code;
61
+ if (code === "ELOOP" || code === "ENOENT" || code === "ENOTDIR") {
62
+ throw sourceChangedError(params.from);
63
+ }
64
+ throw error;
65
+ }
66
+ try {
67
+ const openedStat = await sourceHandle.stat();
68
+ if (!openedStat.isFile() || !sameIdentity(params.identity, entryIdentity(openedStat))) {
69
+ throw sourceChangedError(params.from);
70
+ }
71
+ const destinationHandle = await fs.open(params.to, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, modeBits(params.mode) || 0o666);
72
+ destinationCreated = true;
73
+ try {
74
+ const scratch = Buffer.allocUnsafe(64 * 1024);
75
+ while (true) {
76
+ const { bytesRead } = await sourceHandle.read(scratch, 0, scratch.length, null);
77
+ if (bytesRead === 0) {
78
+ break;
79
+ }
80
+ await writeAll(destinationHandle, scratch, bytesRead);
81
+ }
82
+ }
83
+ finally {
84
+ await destinationHandle.close();
85
+ }
86
+ // Re-check the opened handle before the staged tree can be committed. If
87
+ // the source changed while we copied, the caller should retry the move.
88
+ if (!sameIdentity(params.identity, entryIdentity(await sourceHandle.stat()))) {
89
+ throw sourceChangedError(params.from);
90
+ }
91
+ await fs.chmod(params.to, modeBits(params.mode)).catch(() => undefined);
92
+ }
93
+ catch (error) {
94
+ if (destinationCreated) {
95
+ await fs.rm(params.to, { force: true }).catch(() => undefined);
96
+ }
97
+ throw error;
98
+ }
99
+ finally {
100
+ await sourceHandle.close();
101
+ }
102
+ }
103
+ async function copyEntryWithManifest(from, to, options) {
104
+ const sourceStat = await fs.lstat(from);
105
+ const identity = entryIdentity(sourceStat);
106
+ if (sourceStat.isSymbolicLink()) {
107
+ await fs.symlink(await fs.readlink(from), to);
108
+ // readlink() is path-based; verify the symlink we copied is still the one
109
+ // we inspected before letting the staged destination become visible.
110
+ await assertSourceStillMatches(from, identity);
111
+ return { ...identity, kind: "leaf" };
112
+ }
113
+ if (sourceStat.isDirectory()) {
114
+ await fs.mkdir(to, { mode: modeBits(sourceStat.mode) || 0o755 });
115
+ const children = [];
116
+ for (const child of await fs.readdir(from)) {
117
+ children.push({
118
+ name: child,
119
+ manifest: await copyEntryWithManifest(path.join(from, child), path.join(to, child), options),
120
+ });
121
+ }
122
+ // Directory traversal is path-based in Node. Treat a changed parent as a
123
+ // stale move before committing so swapped-in outside trees are not imported.
124
+ await assertSourceStillMatches(from, identity);
125
+ // mkdir() honors process umask. Restore the source mode before commit so
126
+ // EXDEV fallback preserves directory permissions like fs.cp did.
127
+ await fs.chmod(to, modeBits(sourceStat.mode));
128
+ return { ...identity, children, kind: "directory" };
129
+ }
130
+ if (!sourceStat.isFile()) {
131
+ throw new Error(`Refusing to move non-file path with copy fallback: ${from}`);
132
+ }
133
+ if (options.sourceHardlinks === "reject" && sourceStat.nlink > 1) {
134
+ throw new Error(`Refusing to move hardlinked file with copy fallback: ${from}`);
135
+ }
136
+ await copyRegularFilePinned({ from, identity, mode: sourceStat.mode, to });
137
+ return { ...identity, kind: "leaf" };
138
+ }
139
+ function mergeCleanupResults(a, b) {
140
+ return a === "stale" || b === "stale" ? "stale" : "removed";
141
+ }
142
+ async function cleanupCopiedEntry(sourcePath, manifest) {
143
+ let currentStat;
144
+ try {
145
+ currentStat = await fs.lstat(sourcePath);
146
+ }
147
+ catch (error) {
148
+ if (error?.code === "ENOENT") {
149
+ return "removed";
150
+ }
151
+ throw error;
152
+ }
153
+ if (manifest.kind === "directory") {
154
+ if (!currentStat.isDirectory() || !sameDirectoryNode(manifest, entryIdentity(currentStat))) {
155
+ return "stale";
156
+ }
157
+ // A same-inode directory can gain unrelated children after commit. Still
158
+ // clean manifest children so the fallback does not duplicate copied files.
159
+ let result = "removed";
160
+ for (const child of manifest.children) {
161
+ result = mergeCleanupResults(result, await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest));
162
+ }
163
+ try {
164
+ await fs.rmdir(sourcePath);
165
+ }
166
+ catch (error) {
167
+ const code = error?.code;
168
+ if (code === "ENOTEMPTY" || code === "EEXIST") {
169
+ return "stale";
170
+ }
171
+ throw error;
172
+ }
173
+ return result;
174
+ }
175
+ if (!sameIdentity(manifest, entryIdentity(currentStat))) {
176
+ return "stale";
177
+ }
178
+ await fs.unlink(sourcePath);
179
+ return "removed";
180
+ }
2
181
  export async function movePathWithCopyFallback(options) {
3
182
  try {
4
- await fs.rename(options.from, options.to);
183
+ await guardedRename({ from: options.from, to: options.to });
5
184
  return;
6
185
  }
7
186
  catch (error) {
@@ -9,10 +188,19 @@ export async function movePathWithCopyFallback(options) {
9
188
  throw error;
10
189
  }
11
190
  }
12
- await fs.cp(options.from, options.to, {
13
- recursive: true,
14
- force: true,
15
- dereference: false,
16
- });
17
- await fs.rm(options.from, { recursive: true, force: true });
191
+ const targetDir = path.dirname(path.resolve(options.to));
192
+ const staged = path.join(targetDir, `.fs-safe-move-${process.pid}-${randomUUID()}.tmp`);
193
+ try {
194
+ const manifest = await copyEntryWithManifest(options.from, staged, {
195
+ sourceHardlinks: options.sourceHardlinks ?? "allow",
196
+ });
197
+ await guardedRename({ from: staged, to: options.to });
198
+ const cleanupResult = await cleanupCopiedEntry(options.from, manifest);
199
+ if (cleanupResult === "stale") {
200
+ throw sourceChangedError(options.from);
201
+ }
202
+ }
203
+ finally {
204
+ await fs.rm(staged, { recursive: true, force: true }).catch(() => undefined);
205
+ }
18
206
  }