@openclaw/fs-safe 0.1.0 → 0.1.2
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 +24 -0
- package/README.md +23 -3
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +51 -7
- package/dist/archive.js +2 -2
- package/dist/file-store-boundary.d.ts +27 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +164 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +62 -72
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +464 -251
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/docs/advanced.md +100 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +163 -0
- package/docs/config.md +65 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +76 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +200 -0
- package/docs/json.md +158 -0
- package/docs/local-roots.md +164 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +229 -0
- package/docs/store.md +44 -0
- package/docs/temp.md +276 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +168 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +5 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- package/dist/sibling-temp-write.js +0 -40
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { Transform } from "node:stream";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { FsSafeError } from "./errors.js";
|
|
8
8
|
import { sameFileIdentity } from "./file-identity.js";
|
|
9
9
|
import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
|
|
10
|
-
import { runPinnedWriteHelper } from "./pinned-write.js";
|
|
11
|
-
import {
|
|
10
|
+
import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
|
|
11
|
+
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
12
12
|
import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
|
|
13
|
-
import { hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
14
|
-
import { helperReaddir, helperStat, runPinnedHelper } from "./pinned-helper.js";
|
|
13
|
+
import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
14
|
+
import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
|
|
15
15
|
import { resolveRootPath } from "./root-path.js";
|
|
16
|
+
import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
|
|
17
|
+
import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
|
|
16
18
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
19
|
+
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
20
|
+
import { serializePathWrite } from "./write-queue.js";
|
|
17
21
|
function logWarn(message) {
|
|
18
22
|
if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
|
|
19
23
|
console.warn(message);
|
|
@@ -36,21 +40,19 @@ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
|
|
|
36
40
|
fsConstants.O_CREAT |
|
|
37
41
|
fsConstants.O_EXCL |
|
|
38
42
|
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
return expandHomePrefix(relativePath, { home: cachedHomePath.real });
|
|
43
|
+
export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024;
|
|
44
|
+
function closeHandleForDispose(handle) {
|
|
45
|
+
return handle.close().catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
function openResult(params) {
|
|
48
|
+
return {
|
|
49
|
+
handle: params.handle,
|
|
50
|
+
realPath: params.realPath,
|
|
51
|
+
stat: params.stat,
|
|
52
|
+
[Symbol.asyncDispose]: async () => {
|
|
53
|
+
await closeHandleForDispose(params.handle);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
54
56
|
}
|
|
55
57
|
async function openVerifiedLocalFile(filePath, options) {
|
|
56
58
|
const fsSafeTestHooks = getFsSafeTestHooks();
|
|
@@ -71,7 +73,7 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
71
73
|
}
|
|
72
74
|
let handle;
|
|
73
75
|
try {
|
|
74
|
-
const openFlags = options?.
|
|
76
|
+
const openFlags = options?.symlinks === "follow-within-root"
|
|
75
77
|
? options?.nonBlockingRead
|
|
76
78
|
? OPEN_READ_FOLLOW_NONBLOCK_FLAGS
|
|
77
79
|
: OPEN_READ_FOLLOW_FLAGS
|
|
@@ -106,10 +108,10 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
106
108
|
if (!stat.isFile()) {
|
|
107
109
|
throw new FsSafeError("not-file", "not a file");
|
|
108
110
|
}
|
|
109
|
-
if (options?.
|
|
111
|
+
if (options?.hardlinks === "reject" && stat.nlink > 1) {
|
|
110
112
|
throw new FsSafeError("hardlink", "hardlinked path not allowed");
|
|
111
113
|
}
|
|
112
|
-
if (options?.
|
|
114
|
+
if (options?.symlinks === "follow-within-root") {
|
|
113
115
|
const pathStat = await fs.stat(filePath);
|
|
114
116
|
if (!sameFileIdentity(stat, pathStat)) {
|
|
115
117
|
throw new FsSafeError("path-mismatch", "path changed during read");
|
|
@@ -126,13 +128,13 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
126
128
|
}
|
|
127
129
|
const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
|
|
128
130
|
const realStat = await fs.stat(realPath);
|
|
129
|
-
if (options?.
|
|
131
|
+
if (options?.hardlinks === "reject" && realStat.nlink > 1) {
|
|
130
132
|
throw new FsSafeError("hardlink", "hardlinked path not allowed");
|
|
131
133
|
}
|
|
132
134
|
if (!sameFileIdentity(stat, realStat)) {
|
|
133
135
|
throw new FsSafeError("path-mismatch", "path mismatch");
|
|
134
136
|
}
|
|
135
|
-
return { handle, realPath, stat };
|
|
137
|
+
return openResult({ handle, realPath, stat });
|
|
136
138
|
}
|
|
137
139
|
catch (err) {
|
|
138
140
|
await handle.close().catch(() => { });
|
|
@@ -145,35 +147,7 @@ async function openVerifiedLocalFile(filePath, options) {
|
|
|
145
147
|
throw err;
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
|
-
|
|
149
|
-
let rootReal;
|
|
150
|
-
try {
|
|
151
|
-
rootReal = await fs.realpath(rootDir);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
if (isNotFoundPathError(err)) {
|
|
155
|
-
throw new FsSafeError("not-found", "root dir not found");
|
|
156
|
-
}
|
|
157
|
-
throw err;
|
|
158
|
-
}
|
|
159
|
-
return {
|
|
160
|
-
rootDir: path.resolve(rootDir),
|
|
161
|
-
rootReal,
|
|
162
|
-
rootWithSep: ensureTrailingSep(rootReal),
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
async function resolvePathInSafeRoot(root, relativePath) {
|
|
166
|
-
const expanded = await expandRelativePathWithHome(relativePath);
|
|
167
|
-
const resolved = path.resolve(root.rootWithSep, expanded);
|
|
168
|
-
if (!isPathInside(root.rootWithSep, resolved)) {
|
|
169
|
-
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
170
|
-
}
|
|
171
|
-
return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved };
|
|
172
|
-
}
|
|
173
|
-
async function resolvePathWithinRoot(params) {
|
|
174
|
-
return await resolvePathInSafeRoot(await resolveSafeRootContext(params.rootDir), params.relativePath);
|
|
175
|
-
}
|
|
176
|
-
export class SafeRoot {
|
|
150
|
+
class RootHandle {
|
|
177
151
|
rootDir;
|
|
178
152
|
rootReal;
|
|
179
153
|
rootWithSep;
|
|
@@ -192,75 +166,90 @@ export class SafeRoot {
|
|
|
192
166
|
};
|
|
193
167
|
}
|
|
194
168
|
async resolve(relativePath) {
|
|
195
|
-
return (await
|
|
169
|
+
return (await resolvePathInRoot(this.context, relativePath)).resolved;
|
|
196
170
|
}
|
|
197
|
-
async
|
|
198
|
-
return await
|
|
171
|
+
async open(relativePath, options = {}) {
|
|
172
|
+
return await openFileInRoot(this.context, {
|
|
199
173
|
relativePath,
|
|
200
|
-
...this.defaults
|
|
201
|
-
...this.defaults.open,
|
|
174
|
+
...readDefaults(this.defaults),
|
|
202
175
|
...options,
|
|
203
176
|
});
|
|
204
177
|
}
|
|
205
|
-
async
|
|
206
|
-
return await
|
|
178
|
+
async read(relativePath, options = {}) {
|
|
179
|
+
return await readFileInRoot(this.context, {
|
|
207
180
|
relativePath,
|
|
208
|
-
...this.defaults
|
|
181
|
+
...readDefaults(this.defaults),
|
|
209
182
|
...options,
|
|
210
183
|
});
|
|
211
184
|
}
|
|
212
|
-
async
|
|
213
|
-
return (await this.
|
|
185
|
+
async readBytes(relativePath, options = {}) {
|
|
186
|
+
return (await this.read(relativePath, options)).buffer;
|
|
214
187
|
}
|
|
215
188
|
async readText(relativePath, options = {}) {
|
|
216
189
|
const { encoding = "utf8", ...readOptions } = options;
|
|
217
|
-
return (await this.
|
|
190
|
+
return (await this.read(relativePath, readOptions)).buffer.toString(encoding);
|
|
218
191
|
}
|
|
219
192
|
async readJson(relativePath, options = {}) {
|
|
220
193
|
return JSON.parse(await this.readText(relativePath, options));
|
|
221
194
|
}
|
|
222
|
-
async
|
|
223
|
-
return await
|
|
195
|
+
async readAbsolute(filePath, options = {}) {
|
|
196
|
+
return await readPathInRoot(this.context, {
|
|
197
|
+
filePath,
|
|
198
|
+
...readDefaults(this.defaults),
|
|
199
|
+
...options,
|
|
200
|
+
});
|
|
224
201
|
}
|
|
225
202
|
reader(options = {}) {
|
|
226
203
|
return async (filePath) => {
|
|
227
|
-
return (await this.
|
|
204
|
+
return (await this.readAbsolute(filePath, options)).buffer;
|
|
228
205
|
};
|
|
229
206
|
}
|
|
230
207
|
async openWritable(relativePath, options = {}) {
|
|
231
|
-
|
|
208
|
+
const writeMode = options.writeMode ?? "replace";
|
|
209
|
+
return await openWritableFileInRoot(this.context, {
|
|
232
210
|
relativePath,
|
|
233
|
-
|
|
211
|
+
mkdir: this.defaults.mkdir,
|
|
212
|
+
mode: this.defaults.mode,
|
|
234
213
|
...options,
|
|
214
|
+
append: writeMode === "append",
|
|
215
|
+
truncateExisting: writeMode === "replace",
|
|
235
216
|
});
|
|
236
217
|
}
|
|
237
218
|
async append(relativePath, data, options = {}) {
|
|
238
|
-
await
|
|
219
|
+
await appendFileInRoot(this.context, {
|
|
239
220
|
relativePath,
|
|
240
221
|
data,
|
|
241
|
-
|
|
222
|
+
mkdir: this.defaults.mkdir,
|
|
223
|
+
mode: this.defaults.mode,
|
|
242
224
|
...options,
|
|
243
225
|
});
|
|
244
226
|
}
|
|
245
|
-
async
|
|
246
|
-
|
|
227
|
+
async remove(relativePath) {
|
|
228
|
+
assertValidRootRelativePath(relativePath);
|
|
229
|
+
await removePathInRoot(this.context, relativePath);
|
|
230
|
+
}
|
|
231
|
+
async mkdir(relativePath) {
|
|
232
|
+
assertValidRootRelativePath(relativePath);
|
|
233
|
+
await mkdirPathInRoot(this.context, { relativePath });
|
|
247
234
|
}
|
|
248
|
-
async
|
|
249
|
-
await
|
|
235
|
+
async ensureRoot() {
|
|
236
|
+
await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true });
|
|
250
237
|
}
|
|
251
|
-
async
|
|
252
|
-
await
|
|
238
|
+
async write(relativePath, data, options = {}) {
|
|
239
|
+
await writeFileInRoot(this.context, {
|
|
253
240
|
relativePath,
|
|
254
241
|
data,
|
|
255
|
-
|
|
242
|
+
mkdir: this.defaults.mkdir,
|
|
243
|
+
mode: this.defaults.mode,
|
|
256
244
|
...options,
|
|
257
245
|
});
|
|
258
246
|
}
|
|
259
|
-
async
|
|
260
|
-
|
|
247
|
+
async create(relativePath, data, options = {}) {
|
|
248
|
+
await writeFileInRoot(this.context, {
|
|
261
249
|
relativePath,
|
|
262
250
|
data,
|
|
263
|
-
|
|
251
|
+
mkdir: this.defaults.mkdir,
|
|
252
|
+
mode: this.defaults.mode,
|
|
264
253
|
...options,
|
|
265
254
|
overwrite: false,
|
|
266
255
|
});
|
|
@@ -268,47 +257,103 @@ export class SafeRoot {
|
|
|
268
257
|
async writeJson(relativePath, data, options = {}) {
|
|
269
258
|
const { replacer, space, trailingNewline = true, ...writeOptions } = options;
|
|
270
259
|
const json = JSON.stringify(data, replacer, space);
|
|
271
|
-
await this.
|
|
260
|
+
await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
|
|
272
261
|
}
|
|
273
262
|
async createJson(relativePath, data, options = {}) {
|
|
274
263
|
const { replacer, space, trailingNewline = true, ...writeOptions } = options;
|
|
275
264
|
const json = JSON.stringify(data, replacer, space);
|
|
276
|
-
|
|
265
|
+
await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
|
|
277
266
|
}
|
|
278
|
-
async
|
|
279
|
-
|
|
267
|
+
async copyIn(relativePath, sourcePath, options = {}) {
|
|
268
|
+
assertValidRootRelativePath(relativePath);
|
|
269
|
+
await copyFileInRoot(this.context, {
|
|
280
270
|
sourcePath,
|
|
281
271
|
relativePath,
|
|
282
|
-
|
|
272
|
+
maxBytes: this.defaults.maxBytes,
|
|
273
|
+
mkdir: this.defaults.mkdir,
|
|
274
|
+
mode: this.defaults.mode,
|
|
283
275
|
...options,
|
|
284
276
|
});
|
|
285
277
|
}
|
|
286
|
-
async
|
|
287
|
-
|
|
278
|
+
async exists(relativePath) {
|
|
279
|
+
try {
|
|
280
|
+
await this.stat(relativePath);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
if (err instanceof FsSafeError && err.code === "not-found") {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
288
289
|
}
|
|
289
|
-
async
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
290
|
+
async stat(relativePath) {
|
|
291
|
+
assertValidRootRelativePath(relativePath);
|
|
292
|
+
try {
|
|
293
|
+
return await helperStat(this.rootReal, relativePath);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (canFallbackFromPythonError(error)) {
|
|
297
|
+
return await statPathFallback(this.context, relativePath);
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
293
301
|
}
|
|
294
|
-
async
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
302
|
+
async list(relativePath, options = {}) {
|
|
303
|
+
assertValidRootRelativePath(relativePath);
|
|
304
|
+
try {
|
|
305
|
+
return options.withFileTypes === true
|
|
306
|
+
? await helperReaddir(this.rootReal, relativePath, true)
|
|
307
|
+
: await helperReaddir(this.rootReal, relativePath, false);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (canFallbackFromPythonError(error)) {
|
|
311
|
+
return await listPathFallback(this.context, relativePath, options.withFileTypes === true);
|
|
312
|
+
}
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async move(fromRelative, toRelative, options = {}) {
|
|
317
|
+
assertValidRootRelativePath(fromRelative);
|
|
318
|
+
assertValidRootRelativePath(toRelative);
|
|
319
|
+
try {
|
|
320
|
+
await runPinnedHelper("rename", this.rootReal, {
|
|
321
|
+
from: fromRelative,
|
|
322
|
+
overwrite: options.overwrite ?? false,
|
|
323
|
+
to: toRelative,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (canFallbackFromPythonError(error)) {
|
|
328
|
+
await movePathFallback(this.context, {
|
|
329
|
+
fromRelative,
|
|
330
|
+
overwrite: options.overwrite ?? false,
|
|
331
|
+
toRelative,
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
300
337
|
}
|
|
301
338
|
}
|
|
302
|
-
|
|
303
|
-
return
|
|
339
|
+
function readDefaults(defaults) {
|
|
340
|
+
return {
|
|
341
|
+
hardlinks: defaults.hardlinks,
|
|
342
|
+
maxBytes: defaults.maxBytes ?? DEFAULT_ROOT_MAX_BYTES,
|
|
343
|
+
nonBlockingRead: defaults.nonBlockingRead,
|
|
344
|
+
symlinks: defaults.symlinks,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
export async function root(rootDir, defaults = {}) {
|
|
348
|
+
return new RootHandle(await resolveRootContext(rootDir), defaults);
|
|
304
349
|
}
|
|
305
|
-
async function
|
|
306
|
-
const { rootWithSep, resolved } = await
|
|
350
|
+
async function openFileInRoot(root, params) {
|
|
351
|
+
const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
|
|
307
352
|
let opened;
|
|
308
353
|
try {
|
|
309
354
|
opened = await openVerifiedLocalFile(resolved, {
|
|
310
355
|
nonBlockingRead: params.nonBlockingRead,
|
|
311
|
-
|
|
356
|
+
symlinks: params.symlinks,
|
|
312
357
|
});
|
|
313
358
|
}
|
|
314
359
|
catch (err) {
|
|
@@ -317,7 +362,7 @@ async function openFileInSafeRoot(root, params) {
|
|
|
317
362
|
}
|
|
318
363
|
throw err;
|
|
319
364
|
}
|
|
320
|
-
if (params.
|
|
365
|
+
if (params.hardlinks !== "allow" && opened.stat.nlink > 1) {
|
|
321
366
|
await opened.handle.close().catch(() => { });
|
|
322
367
|
throw new FsSafeError("hardlink", "hardlinked path not allowed");
|
|
323
368
|
}
|
|
@@ -327,8 +372,8 @@ async function openFileInSafeRoot(root, params) {
|
|
|
327
372
|
}
|
|
328
373
|
return opened;
|
|
329
374
|
}
|
|
330
|
-
async function
|
|
331
|
-
const opened = await
|
|
375
|
+
async function readFileInRoot(root, params) {
|
|
376
|
+
const opened = await openFileInRoot(root, params);
|
|
332
377
|
try {
|
|
333
378
|
return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
|
|
334
379
|
}
|
|
@@ -336,16 +381,18 @@ async function readFileInSafeRoot(root, params) {
|
|
|
336
381
|
await opened.handle.close().catch(() => { });
|
|
337
382
|
}
|
|
338
383
|
}
|
|
339
|
-
async function
|
|
384
|
+
async function readPathInRoot(root, params) {
|
|
340
385
|
const rootDir = root.rootDir;
|
|
341
386
|
const candidatePath = path.isAbsolute(params.filePath)
|
|
342
387
|
? path.resolve(params.filePath)
|
|
343
388
|
: path.resolve(rootDir, params.filePath);
|
|
344
389
|
const relativePath = path.relative(rootDir, candidatePath);
|
|
345
|
-
return await
|
|
390
|
+
return await readFileInRoot(root, {
|
|
346
391
|
relativePath,
|
|
347
|
-
|
|
392
|
+
hardlinks: params.hardlinks,
|
|
348
393
|
maxBytes: params.maxBytes,
|
|
394
|
+
nonBlockingRead: params.nonBlockingRead,
|
|
395
|
+
symlinks: params.symlinks,
|
|
349
396
|
});
|
|
350
397
|
}
|
|
351
398
|
export async function readLocalFileSafely(params) {
|
|
@@ -358,6 +405,7 @@ export async function readLocalFileSafely(params) {
|
|
|
358
405
|
}
|
|
359
406
|
}
|
|
360
407
|
export async function openLocalFileSafely(params) {
|
|
408
|
+
assertNoNulPathInput(params.filePath, "file path contains a NUL byte");
|
|
361
409
|
return await openVerifiedLocalFile(params.filePath);
|
|
362
410
|
}
|
|
363
411
|
async function readOpenedFileSafely(params) {
|
|
@@ -365,6 +413,9 @@ async function readOpenedFileSafely(params) {
|
|
|
365
413
|
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
|
|
366
414
|
}
|
|
367
415
|
const buffer = await params.opened.handle.readFile();
|
|
416
|
+
if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
|
|
417
|
+
throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`);
|
|
418
|
+
}
|
|
368
419
|
return {
|
|
369
420
|
buffer,
|
|
370
421
|
realPath: params.opened.realPath,
|
|
@@ -379,6 +430,27 @@ function buildAtomicWriteTempPath(targetPath) {
|
|
|
379
430
|
const base = path.basename(targetPath);
|
|
380
431
|
return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`);
|
|
381
432
|
}
|
|
433
|
+
function rootWriteQueueKey(root, relativePath) {
|
|
434
|
+
return `${root.rootReal}\0${relativePath}`;
|
|
435
|
+
}
|
|
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
|
+
}
|
|
382
454
|
async function writeTempFileForAtomicReplace(params) {
|
|
383
455
|
const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
|
|
384
456
|
try {
|
|
@@ -395,7 +467,7 @@ async function writeTempFileForAtomicReplace(params) {
|
|
|
395
467
|
}
|
|
396
468
|
}
|
|
397
469
|
async function verifyAtomicWriteResult(params) {
|
|
398
|
-
const opened = await openVerifiedLocalFile(params.targetPath, {
|
|
470
|
+
const opened = await openVerifiedLocalFile(params.targetPath, { hardlinks: "reject" });
|
|
399
471
|
try {
|
|
400
472
|
if (!sameFileIdentity(opened.stat, params.expectedIdentity)) {
|
|
401
473
|
throw new FsSafeError("path-mismatch", "path changed during write");
|
|
@@ -482,8 +554,8 @@ async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
|
|
|
482
554
|
}
|
|
483
555
|
return null;
|
|
484
556
|
}
|
|
485
|
-
async function
|
|
486
|
-
const { rootReal, rootWithSep, resolved } = await
|
|
557
|
+
async function openWritableFileInRoot(root, params) {
|
|
558
|
+
const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
|
|
487
559
|
try {
|
|
488
560
|
await assertNoPathAliasEscape({
|
|
489
561
|
absolutePath: resolved,
|
|
@@ -513,20 +585,20 @@ async function openWritableFileInSafeRoot(root, params) {
|
|
|
513
585
|
throw err;
|
|
514
586
|
}
|
|
515
587
|
}
|
|
516
|
-
const
|
|
588
|
+
const mode = params.mode ?? 0o600;
|
|
517
589
|
let handle;
|
|
518
590
|
let createdForWrite = false;
|
|
519
591
|
const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS;
|
|
520
592
|
const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS;
|
|
521
593
|
try {
|
|
522
594
|
try {
|
|
523
|
-
handle = await fs.open(ioPath, existingFlags,
|
|
595
|
+
handle = await fs.open(ioPath, existingFlags, mode);
|
|
524
596
|
}
|
|
525
597
|
catch (err) {
|
|
526
598
|
if (!isNotFoundPathError(err)) {
|
|
527
599
|
throw err;
|
|
528
600
|
}
|
|
529
|
-
handle = await fs.open(ioPath, createFlags,
|
|
601
|
+
handle = await fs.open(ioPath, createFlags, mode);
|
|
530
602
|
createdForWrite = true;
|
|
531
603
|
}
|
|
532
604
|
}
|
|
@@ -537,6 +609,9 @@ async function openWritableFileInSafeRoot(root, params) {
|
|
|
537
609
|
if (isSymlinkOpenError(err)) {
|
|
538
610
|
throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
|
|
539
611
|
}
|
|
612
|
+
if (hasNodeErrorCode(err, "EISDIR")) {
|
|
613
|
+
throw new FsSafeError("not-file", "not a file", { cause: err });
|
|
614
|
+
}
|
|
540
615
|
throw err;
|
|
541
616
|
}
|
|
542
617
|
let realPathForCleanup = null;
|
|
@@ -584,6 +659,9 @@ async function openWritableFileInSafeRoot(root, params) {
|
|
|
584
659
|
createdForWrite,
|
|
585
660
|
realPath,
|
|
586
661
|
stat,
|
|
662
|
+
[Symbol.asyncDispose]: async () => {
|
|
663
|
+
await closeHandleForDispose(handle);
|
|
664
|
+
},
|
|
587
665
|
};
|
|
588
666
|
}
|
|
589
667
|
catch (err) {
|
|
@@ -596,10 +674,11 @@ async function openWritableFileInSafeRoot(root, params) {
|
|
|
596
674
|
throw err;
|
|
597
675
|
}
|
|
598
676
|
}
|
|
599
|
-
async function
|
|
600
|
-
const target = await
|
|
677
|
+
async function appendFileInRoot(root, params) {
|
|
678
|
+
const target = await openWritableFileInRoot(root, {
|
|
601
679
|
relativePath: params.relativePath,
|
|
602
680
|
mkdir: params.mkdir,
|
|
681
|
+
mode: params.mode,
|
|
603
682
|
truncateExisting: false,
|
|
604
683
|
append: true,
|
|
605
684
|
});
|
|
@@ -627,8 +706,8 @@ async function appendFileInSafeRoot(root, params) {
|
|
|
627
706
|
await target.handle.close().catch(() => { });
|
|
628
707
|
}
|
|
629
708
|
}
|
|
630
|
-
async function
|
|
631
|
-
const resolved = await
|
|
709
|
+
async function removePathInRoot(root, relativePath) {
|
|
710
|
+
const resolved = await resolvePinnedRemovePathInRoot(root, relativePath);
|
|
632
711
|
if (process.platform === "win32") {
|
|
633
712
|
await removePathFallback(resolved);
|
|
634
713
|
return;
|
|
@@ -648,8 +727,8 @@ async function removePathInSafeRoot(root, relativePath) {
|
|
|
648
727
|
throw normalizePinnedPathError(error);
|
|
649
728
|
}
|
|
650
729
|
}
|
|
651
|
-
async function
|
|
652
|
-
const resolved = await
|
|
730
|
+
async function mkdirPathInRoot(root, params) {
|
|
731
|
+
const resolved = await resolvePinnedPathInRoot(root, params);
|
|
653
732
|
if (process.platform === "win32") {
|
|
654
733
|
await mkdirPathFallback(resolved);
|
|
655
734
|
return;
|
|
@@ -669,49 +748,57 @@ async function mkdirPathInSafeRoot(root, params) {
|
|
|
669
748
|
throw normalizePinnedPathError(error);
|
|
670
749
|
}
|
|
671
750
|
}
|
|
672
|
-
async function
|
|
751
|
+
async function writeFileInRoot(root, params) {
|
|
673
752
|
if (process.platform === "win32") {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const pinned = await resolvePinnedWriteTargetInSafeRoot(root, params.relativePath);
|
|
677
|
-
let identity;
|
|
678
|
-
try {
|
|
679
|
-
identity = await runPinnedWriteHelper({
|
|
680
|
-
rootPath: pinned.rootReal,
|
|
681
|
-
relativeParentPath: pinned.relativeParentPath,
|
|
682
|
-
basename: pinned.basename,
|
|
683
|
-
mkdir: params.mkdir !== false,
|
|
684
|
-
mode: pinned.mode,
|
|
685
|
-
overwrite: params.overwrite,
|
|
686
|
-
input: {
|
|
687
|
-
kind: "buffer",
|
|
688
|
-
data: params.data,
|
|
689
|
-
encoding: params.encoding,
|
|
690
|
-
},
|
|
753
|
+
await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
|
|
754
|
+
await writeFileFallback(root, params);
|
|
691
755
|
});
|
|
756
|
+
return;
|
|
692
757
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
758
|
+
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
|
|
759
|
+
await serializePathWrite(pinned.targetPath, async () => {
|
|
760
|
+
let identity;
|
|
761
|
+
try {
|
|
762
|
+
identity = await runPinnedWriteHelper({
|
|
763
|
+
rootPath: pinned.rootReal,
|
|
764
|
+
relativeParentPath: pinned.relativeParentPath,
|
|
765
|
+
basename: pinned.basename,
|
|
766
|
+
mkdir: params.mkdir !== false,
|
|
767
|
+
mode: params.mode ?? pinned.mode,
|
|
768
|
+
overwrite: params.overwrite,
|
|
769
|
+
input: {
|
|
770
|
+
kind: "buffer",
|
|
771
|
+
data: params.data,
|
|
772
|
+
encoding: params.encoding,
|
|
773
|
+
},
|
|
774
|
+
});
|
|
696
775
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (params.overwrite === false && isAlreadyExistsError(error)) {
|
|
778
|
+
throw new FsSafeError("already-exists", "file already exists", {
|
|
779
|
+
cause: error instanceof Error ? error : undefined,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
throw normalizePinnedWriteError(error);
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
await verifyAtomicWriteResult({
|
|
786
|
+
root,
|
|
787
|
+
targetPath: pinned.targetPath,
|
|
788
|
+
expectedIdentity: identity,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
|
|
793
|
+
throw err;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
711
796
|
}
|
|
712
|
-
async function
|
|
797
|
+
async function copyFileInRoot(root, params) {
|
|
798
|
+
assertValidRootRelativePath(params.relativePath);
|
|
799
|
+
assertNoNulPathInput(params.sourcePath, "source path contains a NUL byte");
|
|
713
800
|
const source = await openVerifiedLocalFile(params.sourcePath, {
|
|
714
|
-
|
|
801
|
+
hardlinks: params.sourceHardlinks,
|
|
715
802
|
});
|
|
716
803
|
if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
|
|
717
804
|
await source.handle.close().catch(() => { });
|
|
@@ -719,43 +806,57 @@ async function copyFileInSafeRoot(root, params) {
|
|
|
719
806
|
}
|
|
720
807
|
try {
|
|
721
808
|
if (process.platform === "win32") {
|
|
722
|
-
await
|
|
809
|
+
await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
|
|
810
|
+
await copyFileFallback(root, params, source);
|
|
811
|
+
});
|
|
723
812
|
return;
|
|
724
813
|
}
|
|
725
|
-
const pinned = await
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
814
|
+
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
|
|
815
|
+
await serializePathWrite(pinned.targetPath, async () => {
|
|
816
|
+
let identity;
|
|
817
|
+
try {
|
|
818
|
+
if (getFsSafePythonConfig().mode === "off") {
|
|
819
|
+
await copyFileFallback(root, params, source);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
identity = await runPinnedCopyHelper({
|
|
823
|
+
rootPath: pinned.rootReal,
|
|
824
|
+
relativeParentPath: pinned.relativeParentPath,
|
|
825
|
+
basename: pinned.basename,
|
|
826
|
+
mkdir: params.mkdir !== false,
|
|
827
|
+
mode: pinned.mode,
|
|
828
|
+
overwrite: true,
|
|
829
|
+
maxBytes: params.maxBytes,
|
|
830
|
+
sourcePath: source.realPath,
|
|
831
|
+
sourceIdentity: { dev: source.stat.dev, ino: source.stat.ino },
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
if (canFallbackFromPythonError(error)) {
|
|
836
|
+
await copyFileFallback(root, params, source);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
throw normalizePinnedWriteError(error);
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
await verifyAtomicWriteResult({
|
|
843
|
+
root,
|
|
844
|
+
targetPath: pinned.targetPath,
|
|
845
|
+
expectedIdentity: identity,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
740
852
|
});
|
|
741
|
-
try {
|
|
742
|
-
await verifyAtomicWriteResult({
|
|
743
|
-
root,
|
|
744
|
-
targetPath: pinned.targetPath,
|
|
745
|
-
expectedIdentity: identity,
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
catch (err) {
|
|
749
|
-
emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
|
|
750
|
-
throw err;
|
|
751
|
-
}
|
|
752
853
|
}
|
|
753
854
|
finally {
|
|
754
855
|
await source.handle.close().catch(() => { });
|
|
755
856
|
}
|
|
756
857
|
}
|
|
757
|
-
async function
|
|
758
|
-
const { rootReal, rootWithSep, resolved } = await
|
|
858
|
+
async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode) {
|
|
859
|
+
const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath);
|
|
759
860
|
try {
|
|
760
861
|
await assertNoPathAliasEscape({
|
|
761
862
|
absolutePath: resolved,
|
|
@@ -777,15 +878,15 @@ async function resolvePinnedWriteTargetInSafeRoot(root, relativePath) {
|
|
|
777
878
|
if (!basename || basename === "." || basename === "/") {
|
|
778
879
|
throw new FsSafeError("invalid-path", "invalid target path");
|
|
779
880
|
}
|
|
780
|
-
let mode = 0o600;
|
|
881
|
+
let mode = requestedMode ?? 0o600;
|
|
781
882
|
try {
|
|
782
|
-
const opened = await
|
|
883
|
+
const opened = await openFileInRoot(root, {
|
|
783
884
|
relativePath,
|
|
784
|
-
|
|
885
|
+
hardlinks: "reject",
|
|
785
886
|
nonBlockingRead: true,
|
|
786
887
|
});
|
|
787
888
|
try {
|
|
788
|
-
mode = opened.stat.mode & 0o777;
|
|
889
|
+
mode = requestedMode ?? (opened.stat.mode & 0o777);
|
|
789
890
|
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
790
891
|
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
791
892
|
}
|
|
@@ -807,33 +908,28 @@ async function resolvePinnedWriteTargetInSafeRoot(root, relativePath) {
|
|
|
807
908
|
mode: mode || 0o600,
|
|
808
909
|
};
|
|
809
910
|
}
|
|
810
|
-
async function
|
|
811
|
-
|
|
911
|
+
async function resolvePinnedPathInRoot(root, params) {
|
|
912
|
+
return await resolvePinnedOperationPathInRoot(root, {
|
|
913
|
+
allowRoot: params.allowRoot,
|
|
812
914
|
relativePath: params.relativePath,
|
|
813
915
|
policy: PATH_ALIAS_POLICIES.strict,
|
|
814
916
|
});
|
|
815
|
-
const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
|
|
816
|
-
if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
|
|
817
|
-
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
|
|
818
|
-
}
|
|
819
|
-
if (relativeResolved === "" ||
|
|
820
|
-
relativeResolved === "." ||
|
|
821
|
-
relativeResolved.startsWith("..") ||
|
|
822
|
-
path.isAbsolute(relativeResolved)) {
|
|
823
|
-
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
824
|
-
}
|
|
825
|
-
const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
|
|
826
|
-
if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
|
|
827
|
-
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
828
|
-
}
|
|
829
|
-
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
|
|
830
917
|
}
|
|
831
|
-
async function
|
|
832
|
-
|
|
918
|
+
async function resolvePinnedRemovePathInRoot(root, relativePath) {
|
|
919
|
+
return await resolvePinnedOperationPathInRoot(root, {
|
|
833
920
|
relativePath,
|
|
834
921
|
policy: PATH_ALIAS_POLICIES.unlinkTarget,
|
|
835
922
|
});
|
|
923
|
+
}
|
|
924
|
+
async function resolvePinnedOperationPathInRoot(root, params) {
|
|
925
|
+
const resolved = await resolvePinnedRootPathInRoot(root, {
|
|
926
|
+
relativePath: params.relativePath,
|
|
927
|
+
policy: params.policy,
|
|
928
|
+
});
|
|
836
929
|
const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
|
|
930
|
+
if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
|
|
931
|
+
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
|
|
932
|
+
}
|
|
837
933
|
if (relativeResolved === "" ||
|
|
838
934
|
relativeResolved === "." ||
|
|
839
935
|
relativeResolved.startsWith("..") ||
|
|
@@ -844,13 +940,9 @@ async function resolvePinnedRemovePathInSafeRoot(root, relativePath) {
|
|
|
844
940
|
if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
|
|
845
941
|
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
846
942
|
}
|
|
847
|
-
const parentRelative = path.posix.dirname(relativePosix);
|
|
848
|
-
if (parentRelative === "." || parentRelative === "") {
|
|
849
|
-
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
|
|
850
|
-
}
|
|
851
943
|
return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
|
|
852
944
|
}
|
|
853
|
-
async function
|
|
945
|
+
async function resolvePinnedRootPathInRoot(root, params) {
|
|
854
946
|
const rootReal = root.rootReal;
|
|
855
947
|
let resolved;
|
|
856
948
|
try {
|
|
@@ -872,54 +964,168 @@ async function resolvePinnedRootPathInSafeRoot(root, params) {
|
|
|
872
964
|
canonicalPath: resolved.canonicalPath,
|
|
873
965
|
};
|
|
874
966
|
}
|
|
875
|
-
function normalizePinnedWriteError(error) {
|
|
876
|
-
if (error instanceof FsSafeError) {
|
|
877
|
-
return error;
|
|
878
|
-
}
|
|
879
|
-
return new FsSafeError("invalid-path", "path is not a regular file under root", {
|
|
880
|
-
cause: error instanceof Error ? error : undefined,
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
function isAlreadyExistsError(error) {
|
|
884
|
-
return hasNodeErrorCode(error, "EEXIST") || /File exists|EEXIST/i.test(String(error));
|
|
885
|
-
}
|
|
886
|
-
function normalizePinnedPathError(error) {
|
|
887
|
-
if (error instanceof FsSafeError) {
|
|
888
|
-
return error;
|
|
889
|
-
}
|
|
890
|
-
return new FsSafeError("path-alias", "path is not under root", {
|
|
891
|
-
cause: error instanceof Error ? error : undefined,
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
967
|
async function removePathFallback(resolved) {
|
|
895
968
|
await fs.rm(resolved.resolved);
|
|
896
969
|
}
|
|
897
970
|
async function mkdirPathFallback(resolved) {
|
|
898
971
|
await fs.mkdir(resolved.resolved, { recursive: true });
|
|
899
972
|
}
|
|
973
|
+
function pathStatFromStats(stat) {
|
|
974
|
+
return {
|
|
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
|
+
};
|
|
987
|
+
}
|
|
988
|
+
async function statPathFallback(root, relativePath) {
|
|
989
|
+
const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
|
|
990
|
+
try {
|
|
991
|
+
return pathStatFromStats(await fs.lstat(resolved.resolved));
|
|
992
|
+
}
|
|
993
|
+
catch (error) {
|
|
994
|
+
if (isNotFoundPathError(error)) {
|
|
995
|
+
throw new FsSafeError("not-found", "file not found", {
|
|
996
|
+
cause: error instanceof Error ? error : undefined,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
async function listPathFallback(root, relativePath, withFileTypes) {
|
|
1003
|
+
const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
|
|
1004
|
+
try {
|
|
1005
|
+
const names = await fs.readdir(resolved.resolved);
|
|
1006
|
+
const sortedNames = names.toSorted();
|
|
1007
|
+
if (!withFileTypes) {
|
|
1008
|
+
return sortedNames;
|
|
1009
|
+
}
|
|
1010
|
+
const entries = [];
|
|
1011
|
+
for (const name of sortedNames) {
|
|
1012
|
+
entries.push({
|
|
1013
|
+
name,
|
|
1014
|
+
...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))),
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
return entries;
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
if (isNotFoundPathError(error)) {
|
|
1021
|
+
throw new FsSafeError("not-found", "directory not found", {
|
|
1022
|
+
cause: error instanceof Error ? error : undefined,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
throw error;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
async function movePathFallback(root, params) {
|
|
1029
|
+
const source = await resolvePathInRoot(root, params.fromRelative);
|
|
1030
|
+
await resolvePinnedRootPathInRoot(root, {
|
|
1031
|
+
relativePath: params.fromRelative,
|
|
1032
|
+
policy: PATH_ALIAS_POLICIES.strict,
|
|
1033
|
+
});
|
|
1034
|
+
const target = await resolvePathInRoot(root, params.toRelative);
|
|
1035
|
+
await resolvePinnedRootPathInRoot(root, {
|
|
1036
|
+
relativePath: params.toRelative,
|
|
1037
|
+
policy: PATH_ALIAS_POLICIES.unlinkTarget,
|
|
1038
|
+
});
|
|
1039
|
+
try {
|
|
1040
|
+
await assertNoPathAliasEscape({
|
|
1041
|
+
absolutePath: target.resolved,
|
|
1042
|
+
rootPath: target.rootReal,
|
|
1043
|
+
boundaryLabel: "root",
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
throw new FsSafeError("path-alias", "path alias escape blocked", {
|
|
1048
|
+
cause: error instanceof Error ? error : undefined,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
let sourceStat;
|
|
1052
|
+
try {
|
|
1053
|
+
sourceStat = await fs.lstat(source.resolved);
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
if (isNotFoundPathError(error)) {
|
|
1057
|
+
throw new FsSafeError("not-found", "file not found", {
|
|
1058
|
+
cause: error instanceof Error ? error : undefined,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
1063
|
+
if (sourceStat.isSymbolicLink()) {
|
|
1064
|
+
throw new FsSafeError("symlink", "symlink not allowed");
|
|
1065
|
+
}
|
|
1066
|
+
if (sourceStat.isFile() && sourceStat.nlink > 1) {
|
|
1067
|
+
throw new FsSafeError("hardlink", "hardlinked path not allowed");
|
|
1068
|
+
}
|
|
1069
|
+
if (!params.overwrite) {
|
|
1070
|
+
try {
|
|
1071
|
+
await fs.lstat(target.resolved);
|
|
1072
|
+
throw new FsSafeError("already-exists", "destination exists");
|
|
1073
|
+
}
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
if (error instanceof FsSafeError) {
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
if (!isNotFoundPathError(error)) {
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
await fs.rename(source.resolved, target.resolved);
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
if (isNotFoundPathError(error)) {
|
|
1088
|
+
throw new FsSafeError("not-found", "file not found", {
|
|
1089
|
+
cause: error instanceof Error ? error : undefined,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
if (hasNodeErrorCode(error, "EEXIST")) {
|
|
1093
|
+
throw new FsSafeError("already-exists", "destination exists", {
|
|
1094
|
+
cause: error instanceof Error ? error : undefined,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
throw error;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
900
1100
|
async function writeFileFallback(root, params) {
|
|
901
1101
|
if (params.overwrite === false) {
|
|
902
|
-
|
|
1102
|
+
await writeMissingFileFallback(root, params);
|
|
1103
|
+
return;
|
|
903
1104
|
}
|
|
904
|
-
const target = await
|
|
1105
|
+
const target = await openWritableFileInRoot(root, {
|
|
905
1106
|
relativePath: params.relativePath,
|
|
906
1107
|
mkdir: params.mkdir,
|
|
1108
|
+
mode: params.mode,
|
|
907
1109
|
truncateExisting: false,
|
|
908
1110
|
});
|
|
909
1111
|
const destinationPath = target.realPath;
|
|
910
|
-
const
|
|
1112
|
+
const mode = params.mode ?? (target.stat.mode & 0o777);
|
|
911
1113
|
await target.handle.close().catch(() => { });
|
|
912
1114
|
let tempPath = null;
|
|
1115
|
+
let unregisterTempPath = null;
|
|
913
1116
|
try {
|
|
914
1117
|
tempPath = buildAtomicWriteTempPath(destinationPath);
|
|
1118
|
+
unregisterTempPath = registerTempPathForExit(tempPath);
|
|
915
1119
|
const writtenStat = await writeTempFileForAtomicReplace({
|
|
916
1120
|
tempPath,
|
|
917
1121
|
data: params.data,
|
|
918
1122
|
encoding: params.encoding,
|
|
919
|
-
mode:
|
|
1123
|
+
mode: mode || 0o600,
|
|
920
1124
|
});
|
|
921
1125
|
await fs.rename(tempPath, destinationPath);
|
|
922
1126
|
tempPath = null;
|
|
1127
|
+
unregisterTempPath();
|
|
1128
|
+
unregisterTempPath = null;
|
|
923
1129
|
try {
|
|
924
1130
|
await verifyAtomicWriteResult({
|
|
925
1131
|
root,
|
|
@@ -936,11 +1142,11 @@ async function writeFileFallback(root, params) {
|
|
|
936
1142
|
if (tempPath) {
|
|
937
1143
|
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
938
1144
|
}
|
|
1145
|
+
unregisterTempPath?.();
|
|
939
1146
|
}
|
|
940
|
-
return true;
|
|
941
1147
|
}
|
|
942
1148
|
async function writeMissingFileFallback(root, params) {
|
|
943
|
-
const { rootReal, resolved } = await
|
|
1149
|
+
const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath);
|
|
944
1150
|
try {
|
|
945
1151
|
await assertNoPathAliasEscape({
|
|
946
1152
|
absolutePath: resolved,
|
|
@@ -957,7 +1163,7 @@ async function writeMissingFileFallback(root, params) {
|
|
|
957
1163
|
let handle = null;
|
|
958
1164
|
let created = false;
|
|
959
1165
|
try {
|
|
960
|
-
handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, 0o600);
|
|
1166
|
+
handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
|
|
961
1167
|
created = true;
|
|
962
1168
|
if (typeof params.data === "string") {
|
|
963
1169
|
await handle.writeFile(params.data, params.encoding ?? "utf8");
|
|
@@ -974,11 +1180,12 @@ async function writeMissingFileFallback(root, params) {
|
|
|
974
1180
|
expectedIdentity: writtenStat,
|
|
975
1181
|
});
|
|
976
1182
|
created = false;
|
|
977
|
-
return true;
|
|
978
1183
|
}
|
|
979
1184
|
catch (err) {
|
|
980
1185
|
if (hasNodeErrorCode(err, "EEXIST")) {
|
|
981
|
-
|
|
1186
|
+
throw new FsSafeError("already-exists", "file already exists", {
|
|
1187
|
+
cause: err instanceof Error ? err : undefined,
|
|
1188
|
+
});
|
|
982
1189
|
}
|
|
983
1190
|
throw err;
|
|
984
1191
|
}
|
|
@@ -995,20 +1202,23 @@ async function copyFileFallback(root, params, source) {
|
|
|
995
1202
|
let targetClosedByUs = false;
|
|
996
1203
|
let tempHandle = null;
|
|
997
1204
|
let tempPath = null;
|
|
1205
|
+
let unregisterTempPath = null;
|
|
998
1206
|
let tempClosedByStream = false;
|
|
999
1207
|
try {
|
|
1000
|
-
target = await
|
|
1208
|
+
target = await openWritableFileInRoot(root, {
|
|
1001
1209
|
relativePath: params.relativePath,
|
|
1002
1210
|
mkdir: params.mkdir,
|
|
1211
|
+
mode: params.mode,
|
|
1003
1212
|
truncateExisting: false,
|
|
1004
1213
|
});
|
|
1005
1214
|
const destinationPath = target.realPath;
|
|
1006
|
-
const
|
|
1215
|
+
const mode = params.mode ?? (target.stat.mode & 0o777);
|
|
1007
1216
|
await target.handle.close().catch(() => { });
|
|
1008
1217
|
targetClosedByUs = true;
|
|
1009
1218
|
tempPath = buildAtomicWriteTempPath(destinationPath);
|
|
1010
|
-
|
|
1011
|
-
|
|
1219
|
+
unregisterTempPath = registerTempPathForExit(tempPath);
|
|
1220
|
+
tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600);
|
|
1221
|
+
const sourceStream = createBoundedReadStream(source, params.maxBytes);
|
|
1012
1222
|
const targetStream = tempHandle.createWriteStream();
|
|
1013
1223
|
sourceStream.once("close", () => {
|
|
1014
1224
|
sourceClosedByStream = true;
|
|
@@ -1025,6 +1235,8 @@ async function copyFileFallback(root, params, source) {
|
|
|
1025
1235
|
tempHandle = null;
|
|
1026
1236
|
await fs.rename(tempPath, destinationPath);
|
|
1027
1237
|
tempPath = null;
|
|
1238
|
+
unregisterTempPath();
|
|
1239
|
+
unregisterTempPath = null;
|
|
1028
1240
|
try {
|
|
1029
1241
|
await verifyAtomicWriteResult({
|
|
1030
1242
|
root,
|
|
@@ -1044,15 +1256,16 @@ async function copyFileFallback(root, params, source) {
|
|
|
1044
1256
|
throw err;
|
|
1045
1257
|
}
|
|
1046
1258
|
finally {
|
|
1047
|
-
if (tempPath) {
|
|
1048
|
-
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
1049
|
-
}
|
|
1050
1259
|
if (!sourceClosedByStream) {
|
|
1051
1260
|
await source.handle.close().catch(() => { });
|
|
1052
1261
|
}
|
|
1053
1262
|
if (tempHandle && !tempClosedByStream) {
|
|
1054
1263
|
await tempHandle.close().catch(() => { });
|
|
1055
1264
|
}
|
|
1265
|
+
if (tempPath) {
|
|
1266
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
1267
|
+
}
|
|
1268
|
+
unregisterTempPath?.();
|
|
1056
1269
|
if (target && !targetClosedByUs) {
|
|
1057
1270
|
await target.handle.close().catch(() => { });
|
|
1058
1271
|
}
|