@openclaw/fs-safe 0.1.2 → 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.
- package/CHANGELOG.md +41 -0
- package/README.md +39 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +2 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +11 -7
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +60 -53
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +57 -51
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -1
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +10 -0
- package/docs/atomic.md +33 -5
- package/docs/config.md +32 -3
- package/docs/index.md +3 -2
- package/docs/json-store.md +4 -0
- package/docs/json.md +34 -0
- package/docs/output.md +92 -0
- package/docs/sidecar-lock.md +13 -12
- package/docs/store.md +33 -0
- package/docs/temp.md +10 -2
- package/docs/testing.md +10 -0
- package/package.json +7 -2
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ExternalFileWriteOptions<T = void> = {
|
|
2
|
+
rootDir: string;
|
|
3
|
+
path: string;
|
|
4
|
+
write: (filePath: string) => Promise<T>;
|
|
5
|
+
maxBytes?: number;
|
|
6
|
+
mode?: number;
|
|
7
|
+
};
|
|
8
|
+
export type ExternalFileWriteResult<T = void> = {
|
|
9
|
+
path: string;
|
|
10
|
+
result: T;
|
|
11
|
+
};
|
|
12
|
+
export declare function writeExternalFileWithinRoot<T = void>(options: ExternalFileWriteOptions<T>): Promise<ExternalFileWriteResult<T>>;
|
|
13
|
+
//# sourceMappingURL=output.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA8BrC"}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { sanitizeUntrustedFileName } from "./filename.js";
|
|
4
|
+
import { isPathInside } from "./path.js";
|
|
5
|
+
import { root } from "./root.js";
|
|
6
|
+
import { tempFile } from "./temp-target.js";
|
|
7
|
+
function tempFileNameForTarget(targetPath) {
|
|
8
|
+
return sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
|
|
9
|
+
}
|
|
10
|
+
function ensureTrailingSep(value) {
|
|
11
|
+
return value.endsWith(path.sep) ? value : `${value}${path.sep}`;
|
|
12
|
+
}
|
|
13
|
+
function toRootPathInput(params) {
|
|
14
|
+
if (!path.isAbsolute(params.targetPath)) {
|
|
15
|
+
return params.targetPath;
|
|
16
|
+
}
|
|
17
|
+
const absoluteTarget = path.resolve(params.targetPath);
|
|
18
|
+
const rootDir = path.resolve(params.rootDir);
|
|
19
|
+
if (isPathInside(ensureTrailingSep(rootDir), absoluteTarget)) {
|
|
20
|
+
return path.relative(rootDir, absoluteTarget);
|
|
21
|
+
}
|
|
22
|
+
if (isPathInside(ensureTrailingSep(params.rootReal), absoluteTarget)) {
|
|
23
|
+
return path.relative(params.rootReal, absoluteTarget);
|
|
24
|
+
}
|
|
25
|
+
return params.targetPath;
|
|
26
|
+
}
|
|
27
|
+
function assertFileTargetPath(targetPath) {
|
|
28
|
+
const basename = path.basename(targetPath);
|
|
29
|
+
if (!targetPath ||
|
|
30
|
+
targetPath === "." ||
|
|
31
|
+
targetPath.endsWith("/") ||
|
|
32
|
+
targetPath.endsWith("\\") ||
|
|
33
|
+
!basename ||
|
|
34
|
+
basename === "." ||
|
|
35
|
+
basename === "..") {
|
|
36
|
+
throw new FsSafeError("invalid-path", "target path must name a file");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function writeExternalFileWithinRoot(options) {
|
|
40
|
+
const targetRoot = await root(options.rootDir);
|
|
41
|
+
const requestedTargetPath = options.path;
|
|
42
|
+
if (requestedTargetPath.length === 0) {
|
|
43
|
+
throw new FsSafeError("invalid-path", "target path is required");
|
|
44
|
+
}
|
|
45
|
+
const targetPath = toRootPathInput({
|
|
46
|
+
rootDir: targetRoot.rootDir,
|
|
47
|
+
rootReal: targetRoot.rootReal,
|
|
48
|
+
targetPath: requestedTargetPath,
|
|
49
|
+
});
|
|
50
|
+
assertFileTargetPath(targetPath);
|
|
51
|
+
const finalPath = await targetRoot.resolve(targetPath);
|
|
52
|
+
const staged = await tempFile({
|
|
53
|
+
prefix: "fs-safe-output",
|
|
54
|
+
fileName: tempFileNameForTarget(targetPath),
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
const result = await options.write(staged.path);
|
|
58
|
+
await targetRoot.copyIn(targetPath, staged.path, {
|
|
59
|
+
maxBytes: options.maxBytes,
|
|
60
|
+
mode: options.mode,
|
|
61
|
+
mkdir: true,
|
|
62
|
+
sourceHardlinks: "reject",
|
|
63
|
+
});
|
|
64
|
+
return { path: finalPath, result };
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await staged.cleanup();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-stat.d.ts","sourceRoot":"","sources":["../src/path-stat.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,KAAK,GAAG,QAAQ,CAcvD"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function pathStatFromStats(stat) {
|
|
2
|
+
return {
|
|
3
|
+
dev: Number(stat.dev),
|
|
4
|
+
gid: Number(stat.gid),
|
|
5
|
+
ino: Number(stat.ino),
|
|
6
|
+
isDirectory: stat.isDirectory(),
|
|
7
|
+
isFile: stat.isFile(),
|
|
8
|
+
isSymbolicLink: stat.isSymbolicLink(),
|
|
9
|
+
mode: stat.mode,
|
|
10
|
+
mtimeMs: stat.mtimeMs,
|
|
11
|
+
nlink: stat.nlink,
|
|
12
|
+
size: stat.size,
|
|
13
|
+
uid: stat.uid,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAIA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAS3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAuFzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoB5B"}
|
package/dist/pinned-write.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import fsSync from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { Transform } from "node:stream";
|
|
5
6
|
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
6
8
|
import { FsSafeError } from "./errors.js";
|
|
9
|
+
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
7
10
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
8
11
|
import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
|
|
9
12
|
function byteLength(input, encoding) {
|
|
@@ -67,13 +70,13 @@ async function inputToBase64(input, maxBytes) {
|
|
|
67
70
|
return Buffer.concat(chunks, bytes).toString("base64");
|
|
68
71
|
}
|
|
69
72
|
export async function runPinnedWriteHelper(params) {
|
|
70
|
-
if (getFsSafePythonConfig().mode === "off") {
|
|
71
|
-
return await runPinnedWriteFallback(params);
|
|
72
|
-
}
|
|
73
73
|
assertSafeBasename(params.basename);
|
|
74
74
|
validatePinnedOperationPayload({
|
|
75
75
|
relativeParentPath: params.relativeParentPath,
|
|
76
76
|
});
|
|
77
|
+
if (getFsSafePythonConfig().mode === "off") {
|
|
78
|
+
return await runPinnedWriteFallback(params);
|
|
79
|
+
}
|
|
77
80
|
if (params.input.kind === "stream") {
|
|
78
81
|
try {
|
|
79
82
|
assertPinnedPythonOperationAvailable();
|
|
@@ -133,12 +136,21 @@ async function runPinnedWriteFallback(params) {
|
|
|
133
136
|
const parentPath = params.relativeParentPath
|
|
134
137
|
? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
|
|
135
138
|
: params.rootPath;
|
|
139
|
+
const parentGuard = await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
|
|
136
140
|
if (params.mkdir) {
|
|
137
|
-
await
|
|
141
|
+
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
142
|
+
await fs.mkdir(parentPath, { recursive: true });
|
|
143
|
+
});
|
|
138
144
|
}
|
|
139
145
|
const targetPath = path.join(parentPath, params.basename);
|
|
140
146
|
if (params.overwrite === false) {
|
|
141
|
-
|
|
147
|
+
let handle = await withAsyncDirectoryGuards([parentGuard], async () => await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode), {
|
|
148
|
+
onPostGuardFailure: async (openedHandle) => {
|
|
149
|
+
// The parent failed verification, so targetPath may now resolve
|
|
150
|
+
// somewhere else. Close the fd, but do not clean up by path.
|
|
151
|
+
await openedHandle.close().catch(() => undefined);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
142
154
|
let created = true;
|
|
143
155
|
try {
|
|
144
156
|
if (params.input.kind === "buffer") {
|
|
@@ -164,32 +176,45 @@ async function runPinnedWriteFallback(params) {
|
|
|
164
176
|
}
|
|
165
177
|
}
|
|
166
178
|
}
|
|
167
|
-
const tempPath = path.join(parentPath, `.${params.basename}.fallback.tmp`);
|
|
179
|
+
const tempPath = path.join(parentPath, `.${params.basename}.${randomUUID()}.fallback.tmp`);
|
|
180
|
+
const tempFlags = fsSync.constants.O_WRONLY |
|
|
181
|
+
fsSync.constants.O_CREAT |
|
|
182
|
+
fsSync.constants.O_EXCL |
|
|
183
|
+
(process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
|
|
184
|
+
? fsSync.constants.O_NOFOLLOW
|
|
185
|
+
: 0);
|
|
186
|
+
let handle;
|
|
187
|
+
let handleClosedByStream = false;
|
|
168
188
|
try {
|
|
189
|
+
handle = await fs.open(tempPath, tempFlags, params.mode);
|
|
169
190
|
if (params.input.kind === "buffer") {
|
|
170
191
|
assertWithinMaxBytes(byteLength(params.input.data, params.input.encoding), params.maxBytes);
|
|
171
192
|
if (typeof params.input.data === "string") {
|
|
172
|
-
await
|
|
173
|
-
encoding: params.input.encoding ?? "utf8",
|
|
174
|
-
mode: params.mode,
|
|
175
|
-
});
|
|
193
|
+
await handle.writeFile(params.input.data, params.input.encoding ?? "utf8");
|
|
176
194
|
}
|
|
177
195
|
else {
|
|
178
|
-
await
|
|
196
|
+
await handle.writeFile(params.input.data);
|
|
179
197
|
}
|
|
180
198
|
}
|
|
181
199
|
else {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
200
|
+
const writable = handle.createWriteStream();
|
|
201
|
+
writable.once("close", () => {
|
|
202
|
+
handleClosedByStream = true;
|
|
203
|
+
});
|
|
204
|
+
await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
|
|
205
|
+
}
|
|
206
|
+
if (!handleClosedByStream) {
|
|
207
|
+
await handle.close().catch(() => undefined);
|
|
208
|
+
handle = undefined;
|
|
189
209
|
}
|
|
190
|
-
await
|
|
210
|
+
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
211
|
+
await fs.rename(tempPath, targetPath);
|
|
212
|
+
});
|
|
191
213
|
}
|
|
192
214
|
catch (error) {
|
|
215
|
+
if (handle && !handleClosedByStream) {
|
|
216
|
+
await handle.close().catch(() => undefined);
|
|
217
|
+
}
|
|
193
218
|
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
194
219
|
throw error;
|
|
195
220
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"private-temp-workspace.d.ts","sourceRoot":"","sources":["../src/private-temp-workspace.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"private-temp-workspace.d.ts","sourceRoot":"","sources":["../src/private-temp-workspace.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAIzB,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3D,SAAS,CACP,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,OAAO,EACb,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GACtC,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9D,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IAC3D,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAClD,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM,CAAC;IAC5F,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,OAAO,IAAI,IAAI,CAAC;IAChB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC;CAC1B,CAAC;AAsGF,wBAAsB,aAAa,CACjC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,aAAa,CAAC,CAExB;AAED,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,CAAC,SAAS,EAAE,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5C,OAAO,CAAC,CAAC,CAAC,CAUZ;AAED,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,oBAAoB,GAC5B,iBAAiB,CAwEnB;AAED,wBAAgB,qBAAqB,CAAC,CAAC,EACrC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,CAAC,SAAS,EAAE,iBAAiB,KAAK,CAAC,GACvC,CAAC,CAUH"}
|
|
@@ -3,6 +3,7 @@ import fsSync from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileStore, fileStoreSync, } from "./file-store.js";
|
|
6
|
+
import { openRootFileSync } from "./root-file.js";
|
|
6
7
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
7
8
|
function sanitizeTempPrefix(prefix) {
|
|
8
9
|
const sanitized = prefix.trim().replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
@@ -12,21 +13,20 @@ function sanitizeTempPrefix(prefix) {
|
|
|
12
13
|
return sanitized.endsWith("-") ? sanitized : `${sanitized}-`;
|
|
13
14
|
}
|
|
14
15
|
function resolveWorkspaceLeaf(dir, fileName) {
|
|
15
|
-
|
|
16
|
-
if (!raw ||
|
|
17
|
-
raw === "." ||
|
|
18
|
-
raw === ".." ||
|
|
19
|
-
raw.includes("\0") ||
|
|
20
|
-
raw.includes("/") ||
|
|
21
|
-
raw.includes("\\") ||
|
|
22
|
-
path.basename(raw) !== raw) {
|
|
23
|
-
throw new Error(`Invalid temp workspace file name: ${JSON.stringify(fileName)}`);
|
|
24
|
-
}
|
|
25
|
-
return path.join(dir, raw);
|
|
16
|
+
return path.join(dir, assertWorkspaceFileName(fileName));
|
|
26
17
|
}
|
|
27
18
|
function assertWorkspaceFileName(fileName) {
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
const value = fileName.trim();
|
|
20
|
+
if (!value ||
|
|
21
|
+
value === "." ||
|
|
22
|
+
value === ".." ||
|
|
23
|
+
value.includes("\0") ||
|
|
24
|
+
value.includes("/") ||
|
|
25
|
+
value.includes("\\") ||
|
|
26
|
+
path.basename(value) !== value) {
|
|
27
|
+
throw new Error(`Invalid temp workspace file name: ${JSON.stringify(fileName)}`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
30
|
}
|
|
31
31
|
async function ensurePrivateDirectory(dir, mode) {
|
|
32
32
|
await fs.mkdir(dir, { recursive: true, mode });
|
|
@@ -144,8 +144,21 @@ export function tempWorkspaceSync(options) {
|
|
|
144
144
|
trailingNewline: writeOptions?.trailingNewline,
|
|
145
145
|
}),
|
|
146
146
|
read: (fileName) => {
|
|
147
|
-
const
|
|
148
|
-
|
|
147
|
+
const opened = openRootFileSync({
|
|
148
|
+
absolutePath: store.path(assertWorkspaceFileName(fileName)),
|
|
149
|
+
rootPath: dir,
|
|
150
|
+
boundaryLabel: "temp workspace",
|
|
151
|
+
rejectHardlinks: true,
|
|
152
|
+
});
|
|
153
|
+
if (!opened.ok) {
|
|
154
|
+
throw Object.assign(new Error(`File not found: ${fileName}`), { code: "ENOENT" });
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return fsSync.readFileSync(opened.fd);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
fsSync.closeSync(opened.fd);
|
|
161
|
+
}
|
|
149
162
|
},
|
|
150
163
|
cleanup: () => {
|
|
151
164
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAQ7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;AA2DD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CActF;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,CAc3E;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC,CAgC3C;AA6CD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IACpF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;CACb,CAoBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
|
package/dist/regular-file.js
CHANGED
|
@@ -17,6 +17,44 @@ function resolveRegularFileReadFlags() {
|
|
|
17
17
|
? fsSync.constants.O_NOFOLLOW
|
|
18
18
|
: 0));
|
|
19
19
|
}
|
|
20
|
+
async function readFileHandleBounded(params) {
|
|
21
|
+
if (params.maxBytes === undefined) {
|
|
22
|
+
return await params.handle.readFile();
|
|
23
|
+
}
|
|
24
|
+
const chunks = [];
|
|
25
|
+
const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
|
|
26
|
+
let total = 0;
|
|
27
|
+
while (true) {
|
|
28
|
+
const { bytesRead } = await params.handle.read(scratch, 0, scratch.length, null);
|
|
29
|
+
if (bytesRead === 0) {
|
|
30
|
+
return Buffer.concat(chunks, total);
|
|
31
|
+
}
|
|
32
|
+
total += bytesRead;
|
|
33
|
+
if (total > params.maxBytes) {
|
|
34
|
+
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
|
35
|
+
}
|
|
36
|
+
chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readFileDescriptorBounded(params) {
|
|
40
|
+
if (params.maxBytes === undefined) {
|
|
41
|
+
return fsSync.readFileSync(params.fd);
|
|
42
|
+
}
|
|
43
|
+
const chunks = [];
|
|
44
|
+
const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
|
|
45
|
+
let total = 0;
|
|
46
|
+
while (true) {
|
|
47
|
+
const bytesRead = fsSync.readSync(params.fd, scratch, 0, scratch.length, null);
|
|
48
|
+
if (bytesRead === 0) {
|
|
49
|
+
return Buffer.concat(chunks, total);
|
|
50
|
+
}
|
|
51
|
+
total += bytesRead;
|
|
52
|
+
if (total > params.maxBytes) {
|
|
53
|
+
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
|
54
|
+
}
|
|
55
|
+
chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
20
58
|
export async function statRegularFile(filePath) {
|
|
21
59
|
let stat;
|
|
22
60
|
try {
|
|
@@ -69,10 +107,13 @@ export async function readRegularFile(params) {
|
|
|
69
107
|
if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
|
|
70
108
|
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
|
71
109
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
110
|
+
// With a byte cap, avoid readFile(): a raced file growth would allocate
|
|
111
|
+
// the oversized content before the post-read check could reject it.
|
|
112
|
+
const buffer = await readFileHandleBounded({
|
|
113
|
+
handle,
|
|
114
|
+
filePath: params.filePath,
|
|
115
|
+
maxBytes: params.maxBytes,
|
|
116
|
+
});
|
|
76
117
|
return { buffer, stat };
|
|
77
118
|
}
|
|
78
119
|
finally {
|
|
@@ -99,10 +140,13 @@ function readOpenedRegularFileSync(params) {
|
|
|
99
140
|
if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
|
|
100
141
|
throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
|
|
101
142
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
143
|
+
// Keep capped sync reads incremental for the same reason as async reads:
|
|
144
|
+
// readFileSync(fd) would buffer a raced oversized file before throwing.
|
|
145
|
+
const buffer = readFileDescriptorBounded({
|
|
146
|
+
fd: params.fd,
|
|
147
|
+
filePath: params.filePath,
|
|
148
|
+
maxBytes: params.maxBytes,
|
|
149
|
+
});
|
|
106
150
|
return { buffer, stat };
|
|
107
151
|
}
|
|
108
152
|
export function readRegularFileSync(params) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replace-directory.d.ts","sourceRoot":"","sources":["../src/replace-directory.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"replace-directory.d.ts","sourceRoot":"","sources":["../src/replace-directory.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,6BAA6B,GAAG;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,IAAI,CAAC,CAiCf"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { guardedRename, guardedRm } from "./guarded-mutation.js";
|
|
3
4
|
export async function replaceDirectoryAtomic(options) {
|
|
4
5
|
const targetDir = path.resolve(options.targetDir);
|
|
5
6
|
const stagedDir = path.resolve(options.stagedDir);
|
|
@@ -8,7 +9,7 @@ export async function replaceDirectoryAtomic(options) {
|
|
|
8
9
|
let backupCreated = false;
|
|
9
10
|
await fs.mkdir(parentDir, { recursive: true });
|
|
10
11
|
try {
|
|
11
|
-
await
|
|
12
|
+
await guardedRename({ from: targetDir, to: backupDir });
|
|
12
13
|
backupCreated = true;
|
|
13
14
|
}
|
|
14
15
|
catch (err) {
|
|
@@ -17,16 +18,16 @@ export async function replaceDirectoryAtomic(options) {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
try {
|
|
20
|
-
await
|
|
21
|
+
await guardedRename({ from: stagedDir, to: targetDir });
|
|
21
22
|
}
|
|
22
23
|
catch (err) {
|
|
23
24
|
if (backupCreated) {
|
|
24
|
-
await
|
|
25
|
+
await guardedRename({ from: backupDir, to: targetDir }).catch(() => undefined);
|
|
25
26
|
backupCreated = false;
|
|
26
27
|
}
|
|
27
28
|
throw err;
|
|
28
29
|
}
|
|
29
30
|
if (backupCreated) {
|
|
30
|
-
await
|
|
31
|
+
await guardedRm({ target: backupDir, recursive: true, force: true, verifyAfter: false });
|
|
31
32
|
}
|
|
32
33
|
}
|
package/dist/replace-file.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
export type ReplaceFileAtomicFileSystem = {
|
|
4
4
|
promises: Pick<typeof fs, "mkdir" | "chmod" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat">;
|
|
5
5
|
};
|
|
6
|
-
export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "statSync" | "lstatSync">;
|
|
6
|
+
export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "statSync" | "lstatSync">;
|
|
7
7
|
type ReplaceFileAtomicBaseOptions = {
|
|
8
8
|
filePath: string;
|
|
9
9
|
content: string | Uint8Array;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAMlC,MAAM,MAAM,2BAA2B,GAAG;IACxC,QAAQ,EAAE,IAAI,CACZ,OAAO,EAAE,EACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,MAAM,GACN,OAAO,CACV,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,OAAO,MAAM,EACX,WAAW,GACX,WAAW,GACX,cAAc,GACd,eAAe,GACf,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,UAAU,GACV,WAAW,GACX,WAAW,GACX,UAAU,GACV,WAAW,CACd,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACxC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,4BAA4B,GAAG;IACpE,UAAU,CAAC,EAAE,2BAA2B,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,4BAA4B,GAAG;IACxE,UAAU,CAAC,EAAE,+BAA+B,CAAC;IAC7C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACzE,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAAC;CACpC,CAAC;AAySF,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAMlC;AAyDD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,uBAAuB,CAiEzB"}
|
package/dist/replace-file.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|