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