@openclaw/fs-safe 0.5.1 → 0.5.3
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 +86 -0
- package/README.md +19 -6
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +20 -12
- package/dist/archive-entry.d.ts +1 -0
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +37 -6
- package/dist/archive-errors.d.ts +1 -0
- package/dist/archive-errors.d.ts.map +1 -1
- package/dist/archive-errors.js +4 -0
- package/dist/archive-input.d.ts +7 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +11 -1
- package/dist/archive-limits.js +1 -1
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +12 -6
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +2 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +62 -37
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +3 -2
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +15 -1
- package/dist/archive-tar-runtime.d.ts +1 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-tar-runtime.js +11 -0
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +8 -4
- package/dist/archive-zip-entry.d.ts +7 -1
- package/dist/archive-zip-entry.d.ts.map +1 -1
- package/dist/archive-zip-entry.js +11 -1
- package/dist/archive-zip-integrity.d.ts +5 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -0
- package/dist/archive-zip-integrity.js +54 -0
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +12 -1
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +9 -5
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +13 -2
- package/dist/deny-mutations.d.ts +1 -0
- package/dist/deny-mutations.d.ts.map +1 -1
- package/dist/deny-mutations.js +6 -39
- package/dist/device-path.d.ts +1 -0
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +6 -6
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +5 -4
- package/dist/error-detail.d.ts +2 -0
- package/dist/error-detail.d.ts.map +1 -0
- package/dist/error-detail.js +4 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +4 -1
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +88 -35
- package/dist/file-store-boundary.d.ts +6 -0
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +20 -5
- package/dist/file-store-sync-write.d.ts +9 -0
- package/dist/file-store-sync-write.d.ts.map +1 -0
- package/dist/file-store-sync-write.js +97 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +62 -139
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +27 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +5 -6
- package/dist/install-path.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +19 -13
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +1 -3
- package/dist/local-file-access.d.ts +3 -2
- package/dist/local-file-access.d.ts.map +1 -1
- package/dist/local-file-access.js +12 -6
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +35 -30
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +51 -12
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +1 -0
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +14 -1
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +44 -17
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +6 -5
- package/dist/path-policy.d.ts.map +1 -1
- package/dist/path-policy.js +1 -7
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -0
- package/dist/permission-exec.d.ts +6 -0
- package/dist/permission-exec.d.ts.map +1 -0
- package/dist/permission-exec.js +28 -0
- package/dist/permissions-windows.d.ts +56 -0
- package/dist/permissions-windows.d.ts.map +1 -0
- package/dist/permissions-windows.js +285 -0
- package/dist/permissions.d.ts +3 -46
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +17 -328
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +21 -4
- package/dist/pinned-write.js +2 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +45 -4
- package/dist/read-error.d.ts +2 -0
- package/dist/read-error.d.ts.map +1 -0
- package/dist/read-error.js +11 -0
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +21 -2
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +10 -1
- package/dist/replace-file-copy-fallback.d.ts +3 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +18 -11
- package/dist/replace-file-descriptor.d.ts +33 -0
- package/dist/replace-file-descriptor.d.ts.map +1 -0
- package/dist/replace-file-descriptor.js +96 -0
- package/dist/replace-file.d.ts +9 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +64 -72
- package/dist/root-context.d.ts +8 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +43 -2
- package/dist/root-errors.d.ts +7 -0
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +46 -1
- package/dist/root-file.d.ts +1 -0
- package/dist/root-file.d.ts.map +1 -1
- package/dist/root-file.js +2 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +216 -169
- package/dist/root-path-symlink.d.ts +3 -0
- package/dist/root-path-symlink.d.ts.map +1 -0
- package/dist/root-path-symlink.js +35 -0
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +129 -190
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +15 -0
- package/dist/safe-path-segment.d.ts +2 -0
- package/dist/safe-path-segment.d.ts.map +1 -1
- package/dist/safe-path-segment.js +12 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +29 -6
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +5 -1
- package/dist/secure-temp-dir.d.ts.map +1 -1
- package/dist/secure-temp-dir.js +6 -1
- package/dist/short-path.d.ts +2 -0
- package/dist/short-path.d.ts.map +1 -0
- package/dist/short-path.js +7 -0
- package/dist/sidecar-lock-acquire.d.ts +30 -0
- package/dist/sidecar-lock-acquire.d.ts.map +1 -0
- package/dist/sidecar-lock-acquire.js +260 -0
- package/dist/sidecar-lock-policy.d.ts +3 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +20 -3
- package/dist/sidecar-lock-reclaim.d.ts +5 -1
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +59 -9
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +27 -226
- package/dist/symlink-parents.d.ts.map +1 -1
- package/dist/symlink-parents.js +9 -8
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +15 -4
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/timing.d.ts +2 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +9 -0
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +10 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +15 -0
- package/docs/advanced.md +7 -1
- package/docs/archive.md +27 -20
- package/docs/assets/readme-banner.jpg +0 -0
- package/docs/atomic.md +48 -19
- package/docs/errors.md +34 -8
- package/docs/file-store.md +14 -1
- package/docs/filename.md +17 -16
- package/docs/index.md +1 -0
- package/docs/install-path.md +9 -6
- package/docs/json.md +8 -2
- package/docs/local-roots.md +79 -103
- package/docs/native-helper.md +11 -5
- package/docs/native.md +2 -2
- package/docs/output.md +8 -3
- package/docs/path-scope.md +4 -1
- package/docs/path.md +7 -3
- package/docs/permissions.md +8 -0
- package/docs/public-api.md +109 -0
- package/docs/quickstart.md +6 -2
- package/docs/reading.md +13 -10
- package/docs/root.md +26 -4
- package/docs/secret-file.md +21 -4
- package/docs/security-model.md +21 -4
- package/docs/sidecar-lock.md +15 -5
- package/docs/temp.md +42 -5
- package/docs/test-hooks.md +2 -0
- package/docs/types.md +32 -15
- package/docs/writing.md +25 -12
- package/package.json +15 -4
- package/dist/archive-file-io.d.ts +0 -9
- package/dist/archive-file-io.d.ts.map +0 -1
- package/dist/archive-file-io.js +0 -11
- package/dist/file-store-source.d.ts +0 -5
- package/dist/file-store-source.d.ts.map +0 -1
- package/dist/file-store-source.js +0 -30
- package/dist/file-url.d.ts +0 -2
- package/dist/file-url.d.ts.map +0 -1
- package/dist/file-url.js +0 -1
- package/dist/path-stat.d.ts +0 -4
- package/dist/path-stat.d.ts.map +0 -1
- package/dist/path-stat.js +0 -15
- package/dist/windows-permissions-native.d.ts +0 -9
- package/dist/windows-permissions-native.d.ts.map +0 -1
- package/dist/windows-permissions-native.js +0 -31
package/dist/root-impl.js
CHANGED
|
@@ -11,14 +11,14 @@ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
|
11
11
|
import { assertMutationNotDenied, mergeDenyMutationPolicies, } from "./deny-mutations.js";
|
|
12
12
|
import { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
13
13
|
import { runPinnedWriteHelper, runPinnedWriteWithRenamePolicy, } from "./pinned-write.js";
|
|
14
|
+
import { getNativeBinding } from "./native.js";
|
|
14
15
|
import { validatePinnedOperationPayload } from "./pinned-operation.js";
|
|
15
16
|
import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
|
|
16
17
|
import { assertNoNulPathInput, assertNoUnsafeDeviceReadPath, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
17
18
|
import { readOpenedFileSafely } from "./read-opened-file.js";
|
|
18
|
-
import { pathStatFromStats } from "./path-stat.js";
|
|
19
19
|
import { resolveRootPath } from "./root-path.js";
|
|
20
|
-
import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
|
|
21
|
-
import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
|
|
20
|
+
import { assertRootIdentityCurrent, assertValidRootDestinationPath, assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
|
|
21
|
+
import { fileNotFoundError, hardlinkedPathNotAllowedError, isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, normalizeRemoveGuardError, normalizeRemovePathError, outsideWorkspaceError, } from "./root-errors.js";
|
|
22
22
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
23
23
|
import { stringifyJsonDocument } from "./json-stringify.js";
|
|
24
24
|
import { walkRoot } from "./root-walk.js";
|
|
@@ -31,11 +31,11 @@ function logWarn(message) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
34
|
-
const NONBLOCK_OPEN_FLAG = "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0;
|
|
35
|
-
const OPEN_READ_FLAGS = fsConstants.O_RDONLY |
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
34
|
+
const NONBLOCK_OPEN_FLAG = process.platform !== "win32" && "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0;
|
|
35
|
+
const OPEN_READ_FLAGS = fsConstants.O_RDONLY |
|
|
36
|
+
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0) |
|
|
37
|
+
NONBLOCK_OPEN_FLAG;
|
|
38
|
+
const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY | NONBLOCK_OPEN_FLAG;
|
|
39
39
|
const OPEN_WRITE_EXISTING_FLAGS = fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
40
40
|
const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY |
|
|
41
41
|
fsConstants.O_CREAT |
|
|
@@ -48,6 +48,21 @@ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
|
|
|
48
48
|
fsConstants.O_EXCL |
|
|
49
49
|
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
50
50
|
export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024;
|
|
51
|
+
function pathStatFromStats(stat) {
|
|
52
|
+
return {
|
|
53
|
+
dev: Number(stat.dev),
|
|
54
|
+
gid: Number(stat.gid),
|
|
55
|
+
ino: Number(stat.ino),
|
|
56
|
+
isDirectory: stat.isDirectory(),
|
|
57
|
+
isFile: stat.isFile(),
|
|
58
|
+
isSymbolicLink: stat.isSymbolicLink(),
|
|
59
|
+
mode: stat.mode,
|
|
60
|
+
mtimeMs: stat.mtimeMs,
|
|
61
|
+
nlink: stat.nlink,
|
|
62
|
+
size: stat.size,
|
|
63
|
+
uid: stat.uid,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
51
66
|
function openResult(params) {
|
|
52
67
|
return {
|
|
53
68
|
handle: params.handle,
|
|
@@ -60,14 +75,17 @@ function openResult(params) {
|
|
|
60
75
|
async function openVerifiedLocalFile(filePath, options) {
|
|
61
76
|
assertNoUnsafeDeviceReadPath(filePath);
|
|
62
77
|
const fsSafeTestHooks = getFsSafeTestHooks();
|
|
78
|
+
let preOpenStat;
|
|
63
79
|
// Reject directories before opening so we never surface EISDIR to callers (e.g. tool
|
|
64
80
|
// results that get sent to messaging channels). See openclaw/openclaw#31186.
|
|
65
81
|
try {
|
|
66
|
-
|
|
67
|
-
if (
|
|
82
|
+
preOpenStat = await fs.lstat(filePath);
|
|
83
|
+
if (preOpenStat.isSymbolicLink() && options?.symlinks !== "follow-within-root") {
|
|
84
|
+
throw new FsSafeError("symlink", "symlink not allowed");
|
|
85
|
+
}
|
|
86
|
+
if (!preOpenStat.isFile() && !preOpenStat.isSymbolicLink()) {
|
|
68
87
|
throw new FsSafeError("not-file", "not a file");
|
|
69
88
|
}
|
|
70
|
-
await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
|
|
71
89
|
}
|
|
72
90
|
catch (err) {
|
|
73
91
|
if (err instanceof FsSafeError) {
|
|
@@ -75,15 +93,14 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
75
93
|
}
|
|
76
94
|
// ENOENT and other lstat errors: fall through and let fs.open handle.
|
|
77
95
|
}
|
|
96
|
+
if (preOpenStat) {
|
|
97
|
+
await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
|
|
98
|
+
}
|
|
78
99
|
let handle;
|
|
79
100
|
try {
|
|
80
101
|
const openFlags = options?.symlinks === "follow-within-root"
|
|
81
|
-
?
|
|
82
|
-
|
|
83
|
-
: OPEN_READ_FOLLOW_FLAGS
|
|
84
|
-
: options?.nonBlockingRead
|
|
85
|
-
? OPEN_READ_NONBLOCK_FLAGS
|
|
86
|
-
: OPEN_READ_FLAGS;
|
|
102
|
+
? OPEN_READ_FOLLOW_FLAGS
|
|
103
|
+
: OPEN_READ_FLAGS;
|
|
87
104
|
await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
|
|
88
105
|
handle = await fs.open(filePath, openFlags);
|
|
89
106
|
try {
|
|
@@ -96,7 +113,7 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
96
113
|
}
|
|
97
114
|
catch (err) {
|
|
98
115
|
if (isNotFoundPathError(err)) {
|
|
99
|
-
throw
|
|
116
|
+
throw fileNotFoundError();
|
|
100
117
|
}
|
|
101
118
|
if (isSymlinkOpenError(err)) {
|
|
102
119
|
throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
|
|
@@ -112,8 +129,13 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
112
129
|
if (!stat.isFile()) {
|
|
113
130
|
throw new FsSafeError("not-file", "not a file");
|
|
114
131
|
}
|
|
132
|
+
if (preOpenStat &&
|
|
133
|
+
!preOpenStat.isSymbolicLink() &&
|
|
134
|
+
!sameFileIdentity(stat, preOpenStat)) {
|
|
135
|
+
throw new FsSafeError("path-mismatch", "path changed before open");
|
|
136
|
+
}
|
|
115
137
|
if (options?.hardlinks === "reject" && stat.nlink > 1) {
|
|
116
|
-
throw
|
|
138
|
+
throw hardlinkedPathNotAllowedError();
|
|
117
139
|
}
|
|
118
140
|
if (options?.symlinks === "follow-within-root") {
|
|
119
141
|
const pathStat = await fs.stat(filePath);
|
|
@@ -133,7 +155,7 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
133
155
|
const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
|
|
134
156
|
const realStat = await fs.stat(realPath);
|
|
135
157
|
if (options?.hardlinks === "reject" && realStat.nlink > 1) {
|
|
136
|
-
throw
|
|
158
|
+
throw hardlinkedPathNotAllowedError();
|
|
137
159
|
}
|
|
138
160
|
if (!sameFileIdentity(stat, realStat)) {
|
|
139
161
|
throw new FsSafeError("path-mismatch", "path mismatch");
|
|
@@ -146,17 +168,19 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
146
168
|
throw err;
|
|
147
169
|
}
|
|
148
170
|
if (isNotFoundPathError(err)) {
|
|
149
|
-
throw
|
|
171
|
+
throw fileNotFoundError();
|
|
150
172
|
}
|
|
151
173
|
throw err;
|
|
152
174
|
}
|
|
153
175
|
}
|
|
154
176
|
class RootHandle {
|
|
177
|
+
rootIdentity;
|
|
155
178
|
rootDir;
|
|
156
179
|
rootReal;
|
|
157
180
|
rootWithSep;
|
|
158
181
|
defaults;
|
|
159
182
|
constructor(context, defaults = {}) {
|
|
183
|
+
this.rootIdentity = context.rootIdentity;
|
|
160
184
|
this.rootDir = context.rootDir;
|
|
161
185
|
this.rootReal = context.rootReal;
|
|
162
186
|
this.rootWithSep = context.rootWithSep;
|
|
@@ -165,11 +189,19 @@ class RootHandle {
|
|
|
165
189
|
get context() {
|
|
166
190
|
return {
|
|
167
191
|
rootDir: this.rootDir,
|
|
192
|
+
rootIdentity: this.rootIdentity,
|
|
168
193
|
rootReal: this.rootReal,
|
|
169
194
|
rootWithSep: this.rootWithSep,
|
|
170
195
|
};
|
|
171
196
|
}
|
|
197
|
+
mutationOptions(options) {
|
|
198
|
+
return {
|
|
199
|
+
...options,
|
|
200
|
+
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
172
203
|
async resolve(relativePath) {
|
|
204
|
+
assertValidRootDestinationPath(relativePath);
|
|
173
205
|
return (await resolvePathInRoot(this.context, relativePath, { allowFinalSymlink: true })).resolved;
|
|
174
206
|
}
|
|
175
207
|
async open(relativePath, options = {}) {
|
|
@@ -209,67 +241,67 @@ class RootHandle {
|
|
|
209
241
|
};
|
|
210
242
|
}
|
|
211
243
|
async openWritable(relativePath, options = {}) {
|
|
244
|
+
assertValidRootDestinationPath(relativePath);
|
|
212
245
|
const writeMode = options.writeMode ?? "replace";
|
|
213
246
|
return await openWritableFileInRoot(this.context, {
|
|
214
247
|
relativePath,
|
|
215
248
|
mkdir: this.defaults.mkdir,
|
|
216
249
|
mode: this.defaults.mode,
|
|
217
|
-
...options,
|
|
218
|
-
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
250
|
+
...this.mutationOptions(options),
|
|
219
251
|
append: writeMode === "append",
|
|
220
252
|
truncateExisting: writeMode === "replace",
|
|
221
253
|
});
|
|
222
254
|
}
|
|
223
255
|
async append(relativePath, data, options = {}) {
|
|
256
|
+
assertValidRootDestinationPath(relativePath);
|
|
224
257
|
await appendFileInRoot(this.context, {
|
|
225
258
|
relativePath,
|
|
226
259
|
data,
|
|
227
260
|
mkdir: this.defaults.mkdir,
|
|
228
261
|
mode: this.defaults.mode,
|
|
229
|
-
...options,
|
|
230
|
-
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
262
|
+
...this.mutationOptions(options),
|
|
231
263
|
});
|
|
232
264
|
}
|
|
233
265
|
async remove(relativePath, options = {}) {
|
|
234
266
|
assertValidRootRelativePath(relativePath);
|
|
235
267
|
await removePathInRoot(this.context, {
|
|
236
268
|
relativePath,
|
|
237
|
-
|
|
269
|
+
...this.mutationOptions(options),
|
|
238
270
|
});
|
|
239
271
|
}
|
|
240
272
|
async mkdir(relativePath, options = {}) {
|
|
241
|
-
|
|
273
|
+
assertValidRootDestinationPath(relativePath);
|
|
242
274
|
await mkdirPathInRoot(this.context, {
|
|
243
275
|
relativePath,
|
|
244
|
-
|
|
276
|
+
...this.mutationOptions(options),
|
|
245
277
|
});
|
|
246
278
|
}
|
|
247
279
|
async ensureRoot(options = {}) {
|
|
248
280
|
await mkdirPathInRoot(this.context, {
|
|
249
281
|
relativePath: "",
|
|
250
282
|
allowRoot: true,
|
|
251
|
-
|
|
283
|
+
...this.mutationOptions(options),
|
|
252
284
|
});
|
|
253
285
|
}
|
|
254
286
|
async write(relativePath, data, options = {}) {
|
|
287
|
+
assertValidRootDestinationPath(relativePath);
|
|
255
288
|
await writeFileInRoot(this.context, {
|
|
256
289
|
relativePath,
|
|
257
290
|
data,
|
|
258
291
|
mkdir: this.defaults.mkdir,
|
|
259
292
|
mode: this.defaults.mode,
|
|
260
293
|
renameIdentity: this.defaults.renameIdentity,
|
|
261
|
-
...options,
|
|
262
|
-
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
294
|
+
...this.mutationOptions(options),
|
|
263
295
|
});
|
|
264
296
|
}
|
|
265
297
|
async create(relativePath, data, options = {}) {
|
|
298
|
+
assertValidRootDestinationPath(relativePath);
|
|
266
299
|
await writeFileInRoot(this.context, {
|
|
267
300
|
relativePath,
|
|
268
301
|
data,
|
|
269
302
|
mkdir: this.defaults.mkdir,
|
|
270
303
|
mode: this.defaults.mode,
|
|
271
|
-
...options,
|
|
272
|
-
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
304
|
+
...this.mutationOptions(options),
|
|
273
305
|
overwrite: false,
|
|
274
306
|
});
|
|
275
307
|
}
|
|
@@ -284,15 +316,14 @@ class RootHandle {
|
|
|
284
316
|
await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
|
|
285
317
|
}
|
|
286
318
|
async copyIn(relativePath, sourcePath, options = {}) {
|
|
287
|
-
|
|
319
|
+
assertValidRootDestinationPath(relativePath);
|
|
288
320
|
await copyFileInRoot(this.context, {
|
|
289
321
|
sourcePath,
|
|
290
322
|
relativePath,
|
|
291
323
|
maxBytes: this.defaults.maxBytes,
|
|
292
324
|
mkdir: this.defaults.mkdir,
|
|
293
325
|
mode: this.defaults.mode,
|
|
294
|
-
...options,
|
|
295
|
-
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
326
|
+
...this.mutationOptions(options),
|
|
296
327
|
});
|
|
297
328
|
}
|
|
298
329
|
async exists(relativePath) {
|
|
@@ -319,9 +350,9 @@ class RootHandle {
|
|
|
319
350
|
}
|
|
320
351
|
async move(fromRelative, toRelative, options = {}) {
|
|
321
352
|
assertValidRootRelativePath(fromRelative);
|
|
322
|
-
|
|
353
|
+
assertValidRootDestinationPath(toRelative);
|
|
323
354
|
validatePinnedOperationPayload({ from: fromRelative, to: toRelative });
|
|
324
|
-
const denyMutations =
|
|
355
|
+
const { denyMutations } = this.mutationOptions(options);
|
|
325
356
|
await assertMoveMutationAllowed(this.context, {
|
|
326
357
|
fromRelative,
|
|
327
358
|
toRelative,
|
|
@@ -353,6 +384,8 @@ export async function root(rootDir, defaults = {}) {
|
|
|
353
384
|
async function openFileInRoot(root, params) {
|
|
354
385
|
const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath, {
|
|
355
386
|
allowFinalSymlink: true,
|
|
387
|
+
rejectUnsafeDeviceReads: true,
|
|
388
|
+
rejectSymlinks: params.symlinks !== "follow-within-root",
|
|
356
389
|
});
|
|
357
390
|
let opened;
|
|
358
391
|
try {
|
|
@@ -369,11 +402,11 @@ async function openFileInRoot(root, params) {
|
|
|
369
402
|
}
|
|
370
403
|
if (params.hardlinks !== "allow" && opened.stat.nlink > 1) {
|
|
371
404
|
await opened.handle.close().catch(() => { });
|
|
372
|
-
throw
|
|
405
|
+
throw hardlinkedPathNotAllowedError();
|
|
373
406
|
}
|
|
374
407
|
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
375
408
|
await opened.handle.close().catch(() => { });
|
|
376
|
-
throw
|
|
409
|
+
throw outsideWorkspaceError();
|
|
377
410
|
}
|
|
378
411
|
return opened;
|
|
379
412
|
}
|
|
@@ -455,33 +488,47 @@ async function verifyAtomicWriteResult(params) {
|
|
|
455
488
|
throw new FsSafeError("path-mismatch", "path changed during write");
|
|
456
489
|
}
|
|
457
490
|
if (!isPathInside(params.root.rootWithSep, opened.realPath)) {
|
|
458
|
-
throw
|
|
491
|
+
throw outsideWorkspaceError();
|
|
459
492
|
}
|
|
460
493
|
}
|
|
461
494
|
finally {
|
|
462
495
|
await opened.handle.close().catch(() => { });
|
|
463
496
|
}
|
|
464
497
|
}
|
|
465
|
-
async function
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
498
|
+
async function resolveGuardedWritePathInRoot(root, params) {
|
|
499
|
+
const resolvedPath = await resolvePathInRoot(root, params.relativePath, {
|
|
500
|
+
aliasErrorCode: "path-alias",
|
|
501
|
+
allowFinalSymlink: params.allowFinalSymlink,
|
|
502
|
+
});
|
|
503
|
+
await assertMutationNotDenied(resolvedPath.resolved, params.denyMutations, params.protectDeniedAncestors ? { protectAncestors: true } : undefined);
|
|
504
|
+
if (await (params.shouldAssertNoPathAlias?.(resolvedPath) ?? true)) {
|
|
505
|
+
try {
|
|
506
|
+
await assertNoPathAliasEscape({
|
|
507
|
+
absolutePath: resolvedPath.resolved,
|
|
508
|
+
rootPath: resolvedPath.rootReal,
|
|
509
|
+
boundaryLabel: "root",
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
throw new FsSafeError("path-alias", "path alias escape blocked", {
|
|
514
|
+
cause: error instanceof Error ? error : undefined,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
477
517
|
}
|
|
518
|
+
return resolvedPath;
|
|
519
|
+
}
|
|
520
|
+
async function openWritableFileInRoot(root, params) {
|
|
521
|
+
const { rootReal, rootWithSep, resolved } = await resolveGuardedWritePathInRoot(root, {
|
|
522
|
+
relativePath: params.relativePath,
|
|
523
|
+
denyMutations: params.denyMutations,
|
|
524
|
+
});
|
|
478
525
|
let ioPath = params.mkdir === false
|
|
479
526
|
? resolved
|
|
480
527
|
: await prepareRootWriteTarget(rootReal, resolved);
|
|
481
528
|
try {
|
|
482
529
|
const resolvedRealPath = await fs.realpath(ioPath);
|
|
483
530
|
if (!isPathInside(rootWithSep, resolvedRealPath)) {
|
|
484
|
-
throw
|
|
531
|
+
throw outsideWorkspaceError();
|
|
485
532
|
}
|
|
486
533
|
ioPath = resolvedRealPath;
|
|
487
534
|
}
|
|
@@ -512,7 +559,7 @@ async function openWritableFileInRoot(root, params) {
|
|
|
512
559
|
}
|
|
513
560
|
catch (err) {
|
|
514
561
|
if (isNotFoundPathError(err)) {
|
|
515
|
-
throw
|
|
562
|
+
throw fileNotFoundError();
|
|
516
563
|
}
|
|
517
564
|
if (isSymlinkOpenError(err)) {
|
|
518
565
|
throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
|
|
@@ -523,13 +570,17 @@ async function openWritableFileInRoot(root, params) {
|
|
|
523
570
|
throw err;
|
|
524
571
|
}
|
|
525
572
|
let realPathForCleanup = null;
|
|
573
|
+
let createdIdentity = null;
|
|
526
574
|
try {
|
|
527
575
|
const stat = await handle.stat();
|
|
576
|
+
if (createdForWrite) {
|
|
577
|
+
createdIdentity = stat;
|
|
578
|
+
}
|
|
528
579
|
if (!stat.isFile()) {
|
|
529
|
-
throw new FsSafeError("
|
|
580
|
+
throw new FsSafeError("not-file", "path is not a regular file under root");
|
|
530
581
|
}
|
|
531
582
|
if (stat.nlink > 1) {
|
|
532
|
-
throw
|
|
583
|
+
throw hardlinkedPathNotAllowedError();
|
|
533
584
|
}
|
|
534
585
|
try {
|
|
535
586
|
const lstat = await fs.lstat(ioPath);
|
|
@@ -552,10 +603,10 @@ async function openWritableFileInRoot(root, params) {
|
|
|
552
603
|
throw new FsSafeError("path-mismatch", "path mismatch");
|
|
553
604
|
}
|
|
554
605
|
if (realStat.nlink > 1) {
|
|
555
|
-
throw
|
|
606
|
+
throw hardlinkedPathNotAllowedError();
|
|
556
607
|
}
|
|
557
608
|
if (!isPathInside(rootWithSep, realPath)) {
|
|
558
|
-
throw
|
|
609
|
+
throw outsideWorkspaceError();
|
|
559
610
|
}
|
|
560
611
|
// Truncate only after boundary and identity checks complete. This avoids
|
|
561
612
|
// irreversible side effects if a symlink target changes before validation.
|
|
@@ -575,8 +626,8 @@ async function openWritableFileInRoot(root, params) {
|
|
|
575
626
|
const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError;
|
|
576
627
|
const cleanupPath = realPathForCleanup ?? ioPath;
|
|
577
628
|
await handle.close().catch(() => { });
|
|
578
|
-
if (cleanupCreatedPath) {
|
|
579
|
-
await
|
|
629
|
+
if (cleanupCreatedPath && createdIdentity) {
|
|
630
|
+
await removePathIfIdentityUnchanged(cleanupPath, createdIdentity).catch(() => { });
|
|
580
631
|
}
|
|
581
632
|
throw err;
|
|
582
633
|
}
|
|
@@ -621,7 +672,11 @@ async function appendFileInRoot(root, params) {
|
|
|
621
672
|
}
|
|
622
673
|
async function removePathInRoot(root, params) {
|
|
623
674
|
validatePinnedOperationPayload({ relativePath: params.relativePath });
|
|
624
|
-
const resolved = await
|
|
675
|
+
const resolved = await resolvePinnedPathInRoot(root, {
|
|
676
|
+
relativePath: params.relativePath,
|
|
677
|
+
denyMutations: params.denyMutations,
|
|
678
|
+
remove: true,
|
|
679
|
+
});
|
|
625
680
|
try {
|
|
626
681
|
await removePathFallback(resolved);
|
|
627
682
|
}
|
|
@@ -640,15 +695,16 @@ async function mkdirPathInRoot(root, params) {
|
|
|
640
695
|
}
|
|
641
696
|
}
|
|
642
697
|
async function writeFileInRoot(root, params) {
|
|
643
|
-
|
|
644
|
-
|
|
698
|
+
await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
|
|
699
|
+
if (process.platform === "win32" &&
|
|
700
|
+
(params.renameIdentity === "verify-content-with-lock" || !getNativeBinding())) {
|
|
645
701
|
await writeFileFallback(root, params);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
|
|
705
|
+
await serializePathWrite(pinned.targetPath, async () => {
|
|
706
|
+
await commitPinnedWriteInRoot(root, pinned, params);
|
|
646
707
|
});
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
|
|
650
|
-
await serializePathWrite(pinned.targetPath, async () => {
|
|
651
|
-
await commitPinnedWriteInRoot(root, pinned, params);
|
|
652
708
|
});
|
|
653
709
|
}
|
|
654
710
|
async function commitPinnedWriteInRoot(root, pinned, params) {
|
|
@@ -664,6 +720,7 @@ async function commitPinnedWriteInRoot(root, pinned, params) {
|
|
|
664
720
|
mode: params.mode ?? pinned.mode,
|
|
665
721
|
overwrite: params.overwrite,
|
|
666
722
|
input: { kind: "buffer", data: params.data, encoding: params.encoding },
|
|
723
|
+
rootIdentity: root.rootIdentity,
|
|
667
724
|
});
|
|
668
725
|
}
|
|
669
726
|
catch (error) {
|
|
@@ -701,26 +758,35 @@ async function copyFileInRoot(root, params) {
|
|
|
701
758
|
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`);
|
|
702
759
|
}
|
|
703
760
|
try {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
await
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
761
|
+
await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
|
|
762
|
+
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
|
|
763
|
+
await serializePathWrite(pinned.targetPath, async () => {
|
|
764
|
+
await assertCopySourceCurrent(source);
|
|
765
|
+
let identity;
|
|
766
|
+
try {
|
|
767
|
+
identity = await runPinnedWriteHelper({
|
|
768
|
+
rootPath: pinned.rootReal,
|
|
769
|
+
relativeParentPath: pinned.relativeParentPath,
|
|
770
|
+
basename: pinned.basename,
|
|
771
|
+
mkdir: params.mkdir !== false,
|
|
772
|
+
mode: pinned.mode,
|
|
773
|
+
overwrite: true,
|
|
774
|
+
maxBytes: params.maxBytes,
|
|
775
|
+
input: { kind: "stream", stream: source.handle.createReadStream() },
|
|
776
|
+
rootIdentity: root.rootIdentity,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
throw normalizePinnedWriteError(error);
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
await assertCopySourcePathCurrent(source);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
await removePathIfIdentityUnchanged(pinned.targetPath, identity).catch(() => undefined);
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
716
789
|
});
|
|
717
|
-
try {
|
|
718
|
-
await assertCopySourcePathCurrent(source);
|
|
719
|
-
}
|
|
720
|
-
catch (error) {
|
|
721
|
-
await removeCopyTargetIfUnchanged(pinned.targetPath, identity).catch(() => undefined);
|
|
722
|
-
throw error;
|
|
723
|
-
}
|
|
724
790
|
});
|
|
725
791
|
}
|
|
726
792
|
finally {
|
|
@@ -740,7 +806,7 @@ async function assertCopySourcePathCurrent(source) {
|
|
|
740
806
|
throw new FsSafeError("path-mismatch", "copy source path changed");
|
|
741
807
|
}
|
|
742
808
|
}
|
|
743
|
-
async function
|
|
809
|
+
async function removePathIfIdentityUnchanged(targetPath, identity) {
|
|
744
810
|
const parentGuard = await createAsyncDirectoryGuard(path.dirname(targetPath));
|
|
745
811
|
const current = await fs.lstat(targetPath);
|
|
746
812
|
if (current.isSymbolicLink() || !sameFileIdentity(current, identity)) {
|
|
@@ -751,25 +817,15 @@ async function removeCopyTargetIfUnchanged(targetPath, identity) {
|
|
|
751
817
|
});
|
|
752
818
|
}
|
|
753
819
|
async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode, denyMutations) {
|
|
754
|
-
const { rootReal, rootWithSep, resolved } = await
|
|
755
|
-
|
|
820
|
+
const { rootReal, rootWithSep, resolved } = await resolveGuardedWritePathInRoot(root, {
|
|
821
|
+
relativePath,
|
|
822
|
+
denyMutations,
|
|
756
823
|
});
|
|
757
|
-
await assertMutationNotDenied(resolved, denyMutations);
|
|
758
|
-
try {
|
|
759
|
-
await assertNoPathAliasEscape({
|
|
760
|
-
absolutePath: resolved,
|
|
761
|
-
rootPath: rootReal,
|
|
762
|
-
boundaryLabel: "root",
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
catch (err) {
|
|
766
|
-
throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
|
|
767
|
-
}
|
|
768
824
|
// resolvePathInRoot already enforces isPathInside, so any actual escape
|
|
769
825
|
// is rejected upstream.
|
|
770
826
|
const relativeResolved = path.relative(rootReal, resolved);
|
|
771
827
|
if (path.isAbsolute(relativeResolved)) {
|
|
772
|
-
throw
|
|
828
|
+
throw outsideWorkspaceError();
|
|
773
829
|
}
|
|
774
830
|
const relativePosix = relativeResolved
|
|
775
831
|
? relativeResolved.split(path.sep).join(path.posix.sep)
|
|
@@ -784,11 +840,12 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode,
|
|
|
784
840
|
relativePath,
|
|
785
841
|
hardlinks: "reject",
|
|
786
842
|
nonBlockingRead: true,
|
|
843
|
+
symlinks: "follow-within-root",
|
|
787
844
|
});
|
|
788
845
|
try {
|
|
789
846
|
mode = requestedMode ?? (opened.stat.mode & 0o777);
|
|
790
847
|
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
791
|
-
throw
|
|
848
|
+
throw outsideWorkspaceError();
|
|
792
849
|
}
|
|
793
850
|
}
|
|
794
851
|
finally {
|
|
@@ -812,17 +869,9 @@ async function resolvePinnedPathInRoot(root, params) {
|
|
|
812
869
|
return await resolvePinnedOperationPathInRoot(root, {
|
|
813
870
|
allowRoot: params.allowRoot,
|
|
814
871
|
denyMutations: params.denyMutations,
|
|
815
|
-
protectDenyMutationAncestors:
|
|
872
|
+
protectDenyMutationAncestors: params.remove === true,
|
|
816
873
|
relativePath: params.relativePath,
|
|
817
|
-
policy: PATH_ALIAS_POLICIES.strict,
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
async function resolvePinnedRemovePathInRoot(root, relativePath, denyMutations) {
|
|
821
|
-
return await resolvePinnedOperationPathInRoot(root, {
|
|
822
|
-
denyMutations,
|
|
823
|
-
protectDenyMutationAncestors: true,
|
|
824
|
-
relativePath,
|
|
825
|
-
policy: PATH_ALIAS_POLICIES.unlinkTarget,
|
|
874
|
+
policy: params.remove ? PATH_ALIAS_POLICIES.unlinkTarget : PATH_ALIAS_POLICIES.strict,
|
|
826
875
|
});
|
|
827
876
|
}
|
|
828
877
|
async function resolvePinnedOperationPathInRoot(root, params) {
|
|
@@ -840,11 +889,11 @@ async function resolvePinnedOperationPathInRoot(root, params) {
|
|
|
840
889
|
relativeResolved === "." ||
|
|
841
890
|
firstSegment === ".." ||
|
|
842
891
|
path.isAbsolute(relativeResolved)) {
|
|
843
|
-
throw
|
|
892
|
+
throw outsideWorkspaceError();
|
|
844
893
|
}
|
|
845
894
|
const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
|
|
846
895
|
if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
|
|
847
|
-
throw
|
|
896
|
+
throw outsideWorkspaceError();
|
|
848
897
|
}
|
|
849
898
|
await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations, {
|
|
850
899
|
protectAncestors: params.protectDenyMutationAncestors,
|
|
@@ -852,6 +901,7 @@ async function resolvePinnedOperationPathInRoot(root, params) {
|
|
|
852
901
|
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
|
|
853
902
|
}
|
|
854
903
|
async function resolvePinnedRootPathInRoot(root, params) {
|
|
904
|
+
await assertRootIdentityCurrent(root);
|
|
855
905
|
const rootReal = root.rootReal;
|
|
856
906
|
let resolved;
|
|
857
907
|
try {
|
|
@@ -876,11 +926,25 @@ async function resolvePinnedRootPathInRoot(root, params) {
|
|
|
876
926
|
canonicalPath: resolved.canonicalPath,
|
|
877
927
|
};
|
|
878
928
|
}
|
|
929
|
+
async function prepareRemoveGuard(targetPath) {
|
|
930
|
+
try {
|
|
931
|
+
const guard = await createAsyncDirectoryGuard(path.dirname(targetPath));
|
|
932
|
+
await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", targetPath);
|
|
933
|
+
await assertAsyncDirectoryGuard(guard);
|
|
934
|
+
return guard;
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
throw normalizeRemoveGuardError(error);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
879
940
|
async function removePathFallback(resolved) {
|
|
880
|
-
const guard = await
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
941
|
+
const guard = await prepareRemoveGuard(resolved.resolved);
|
|
942
|
+
try {
|
|
943
|
+
await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
throw normalizeRemovePathError(error);
|
|
947
|
+
}
|
|
884
948
|
await assertAsyncDirectoryGuard(guard).catch(() => undefined);
|
|
885
949
|
}
|
|
886
950
|
async function mkdirPathFallback(resolved) {
|
|
@@ -892,13 +956,13 @@ async function mkdirPathFallback(resolved) {
|
|
|
892
956
|
async function statPathFallback(root, relativePath) {
|
|
893
957
|
const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
|
|
894
958
|
try {
|
|
895
|
-
|
|
959
|
+
const stat = pathStatFromStats(await fs.lstat(resolved.resolved));
|
|
960
|
+
await assertRootIdentityCurrent(root);
|
|
961
|
+
return stat;
|
|
896
962
|
}
|
|
897
963
|
catch (error) {
|
|
898
964
|
if (isNotFoundPathError(error)) {
|
|
899
|
-
throw
|
|
900
|
-
cause: error instanceof Error ? error : undefined,
|
|
901
|
-
});
|
|
965
|
+
throw fileNotFoundError(error instanceof Error ? error : undefined);
|
|
902
966
|
}
|
|
903
967
|
throw error;
|
|
904
968
|
}
|
|
@@ -909,6 +973,7 @@ async function listPathFallback(root, relativePath, withFileTypes) {
|
|
|
909
973
|
const names = await fs.readdir(resolved.resolved);
|
|
910
974
|
const sortedNames = names.toSorted();
|
|
911
975
|
if (!withFileTypes) {
|
|
976
|
+
await assertRootIdentityCurrent(root);
|
|
912
977
|
return sortedNames;
|
|
913
978
|
}
|
|
914
979
|
const entries = [];
|
|
@@ -918,6 +983,7 @@ async function listPathFallback(root, relativePath, withFileTypes) {
|
|
|
918
983
|
...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))),
|
|
919
984
|
});
|
|
920
985
|
}
|
|
986
|
+
await assertRootIdentityCurrent(root);
|
|
921
987
|
return entries;
|
|
922
988
|
}
|
|
923
989
|
catch (error) {
|
|
@@ -930,6 +996,8 @@ async function listPathFallback(root, relativePath, withFileTypes) {
|
|
|
930
996
|
}
|
|
931
997
|
}
|
|
932
998
|
async function assertMoveMutationAllowed(root, params) {
|
|
999
|
+
// Keep this preflight separate from the pinned resolutions in movePathFallback:
|
|
1000
|
+
// mutation denials must take precedence over source alias or identity failures.
|
|
933
1001
|
const source = await resolvePathInRoot(root, params.fromRelative, {
|
|
934
1002
|
aliasErrorCode: "path-alias",
|
|
935
1003
|
allowFinalSymlink: true,
|
|
@@ -951,40 +1019,29 @@ async function movePathFallback(root, params) {
|
|
|
951
1019
|
relativePath: params.fromRelative,
|
|
952
1020
|
policy: PATH_ALIAS_POLICIES.strict,
|
|
953
1021
|
});
|
|
954
|
-
const target = await
|
|
955
|
-
aliasErrorCode: "path-alias",
|
|
956
|
-
allowFinalSymlink: true,
|
|
957
|
-
});
|
|
958
|
-
await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
|
|
959
|
-
await resolvePinnedRootPathInRoot(root, {
|
|
1022
|
+
const target = await resolveGuardedWritePathInRoot(root, {
|
|
960
1023
|
relativePath: params.toRelative,
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
absolutePath: target.resolved,
|
|
969
|
-
rootPath: target.rootReal,
|
|
970
|
-
boundaryLabel: "root",
|
|
971
|
-
});
|
|
972
|
-
}
|
|
973
|
-
catch (error) {
|
|
974
|
-
throw new FsSafeError("path-alias", "path alias escape blocked", {
|
|
975
|
-
cause: error instanceof Error ? error : undefined,
|
|
1024
|
+
denyMutations: params.denyMutations,
|
|
1025
|
+
allowFinalSymlink: true,
|
|
1026
|
+
protectDeniedAncestors: true,
|
|
1027
|
+
shouldAssertNoPathAlias: async (resolvedTarget) => {
|
|
1028
|
+
await resolvePinnedRootPathInRoot(root, {
|
|
1029
|
+
relativePath: params.toRelative,
|
|
1030
|
+
policy: PATH_ALIAS_POLICIES.unlinkTarget,
|
|
976
1031
|
});
|
|
977
|
-
|
|
978
|
-
|
|
1032
|
+
const targetStat = await fs.lstat(resolvedTarget.resolved).catch(() => undefined);
|
|
1033
|
+
return !(process.platform !== "win32" &&
|
|
1034
|
+
params.overwrite &&
|
|
1035
|
+
targetStat?.isSymbolicLink() === true);
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
979
1038
|
let sourceStat;
|
|
980
1039
|
try {
|
|
981
1040
|
sourceStat = await fs.lstat(source.resolved);
|
|
982
1041
|
}
|
|
983
1042
|
catch (error) {
|
|
984
1043
|
if (isNotFoundPathError(error)) {
|
|
985
|
-
throw
|
|
986
|
-
cause: error instanceof Error ? error : undefined,
|
|
987
|
-
});
|
|
1044
|
+
throw fileNotFoundError(error instanceof Error ? error : undefined);
|
|
988
1045
|
}
|
|
989
1046
|
throw error;
|
|
990
1047
|
}
|
|
@@ -992,7 +1049,7 @@ async function movePathFallback(root, params) {
|
|
|
992
1049
|
throw new FsSafeError("symlink", "symlink not allowed");
|
|
993
1050
|
}
|
|
994
1051
|
if (sourceStat.isFile() && sourceStat.nlink > 1) {
|
|
995
|
-
throw
|
|
1052
|
+
throw hardlinkedPathNotAllowedError();
|
|
996
1053
|
}
|
|
997
1054
|
if (!params.overwrite && sourceStat.isDirectory()) {
|
|
998
1055
|
throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
|
|
@@ -1021,9 +1078,7 @@ async function movePathFallback(root, params) {
|
|
|
1021
1078
|
}
|
|
1022
1079
|
catch (error) {
|
|
1023
1080
|
if (isNotFoundPathError(error)) {
|
|
1024
|
-
throw
|
|
1025
|
-
cause: error instanceof Error ? error : undefined,
|
|
1026
|
-
});
|
|
1081
|
+
throw fileNotFoundError(error instanceof Error ? error : undefined);
|
|
1027
1082
|
}
|
|
1028
1083
|
if (hasNodeErrorCode(error, "EEXIST")) {
|
|
1029
1084
|
throw new FsSafeError("already-exists", "destination exists", {
|
|
@@ -1089,30 +1144,22 @@ async function writeFileFallback(root, params) {
|
|
|
1089
1144
|
}
|
|
1090
1145
|
}
|
|
1091
1146
|
async function writeMissingFileFallback(root, params) {
|
|
1092
|
-
const { rootReal, resolved } = await
|
|
1093
|
-
|
|
1147
|
+
const { rootReal, resolved } = await resolveGuardedWritePathInRoot(root, {
|
|
1148
|
+
relativePath: params.relativePath,
|
|
1149
|
+
denyMutations: params.denyMutations,
|
|
1094
1150
|
});
|
|
1095
|
-
await assertMutationNotDenied(resolved, params.denyMutations);
|
|
1096
|
-
try {
|
|
1097
|
-
await assertNoPathAliasEscape({
|
|
1098
|
-
absolutePath: resolved,
|
|
1099
|
-
rootPath: rootReal,
|
|
1100
|
-
boundaryLabel: "root",
|
|
1101
|
-
});
|
|
1102
|
-
}
|
|
1103
|
-
catch (err) {
|
|
1104
|
-
throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
|
|
1105
|
-
}
|
|
1106
1151
|
const targetPath = params.mkdir === false
|
|
1107
1152
|
? resolved
|
|
1108
1153
|
: await prepareRootWriteTarget(rootReal, resolved);
|
|
1109
1154
|
const parentGuard = await createAsyncDirectoryGuard(path.dirname(targetPath));
|
|
1110
1155
|
let created = false;
|
|
1156
|
+
let createdIdentity;
|
|
1111
1157
|
try {
|
|
1112
1158
|
const { handle, writtenStat } = await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
1113
1159
|
const handle = await fs.open(targetPath, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
|
|
1114
1160
|
created = true;
|
|
1115
1161
|
try {
|
|
1162
|
+
createdIdentity = await handle.stat();
|
|
1116
1163
|
if (typeof params.data === "string") {
|
|
1117
1164
|
await handle.writeFile(params.data, params.encoding ?? "utf8");
|
|
1118
1165
|
}
|
|
@@ -1148,8 +1195,8 @@ async function writeMissingFileFallback(root, params) {
|
|
|
1148
1195
|
throw err;
|
|
1149
1196
|
}
|
|
1150
1197
|
finally {
|
|
1151
|
-
if (created) {
|
|
1152
|
-
await
|
|
1198
|
+
if (created && createdIdentity) {
|
|
1199
|
+
await removePathIfIdentityUnchanged(targetPath, createdIdentity).catch(() => undefined);
|
|
1153
1200
|
}
|
|
1154
1201
|
}
|
|
1155
1202
|
}
|