@openclaw/fs-safe 0.2.5 → 0.2.7

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.
@@ -2,17 +2,17 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
4
4
  import { FsSafeError } from "./errors.js";
5
+ import { isPathRelativeEscape } from "./path.js";
5
6
  function isSameOrChildPath(candidate, parent) {
6
- return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
7
- }
8
- function isPathEscape(relativePath) {
9
- return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
7
+ const parentPrefix = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
8
+ return candidate === parent || candidate.startsWith(parentPrefix);
10
9
  }
11
10
  export async function mkdirPathComponentsWithGuards(params) {
12
11
  const root = path.resolve(params.rootReal);
12
+ const rootCanonical = path.resolve(await fs.realpath(root));
13
13
  const target = path.resolve(params.targetPath);
14
14
  const relative = path.relative(root, target);
15
- if (isPathEscape(relative)) {
15
+ if (isPathRelativeEscape(relative)) {
16
16
  throw new FsSafeError("outside-workspace", "directory is outside workspace root");
17
17
  }
18
18
  let current = root;
@@ -35,7 +35,7 @@ export async function mkdirPathComponentsWithGuards(params) {
35
35
  }
36
36
  // Node's recursive mkdir follows symlinks in missing components. Build one
37
37
  // segment at a time and realpath-check each segment before descending.
38
- if (!isSameOrChildPath(path.resolve(await fs.realpath(next)), root)) {
38
+ if (!isSameOrChildPath(path.resolve(await fs.realpath(next)), rootCanonical)) {
39
39
  throw new FsSafeError("outside-workspace", "directory escaped workspace root");
40
40
  }
41
41
  await createAsyncDirectoryGuard(next);
@@ -1 +1 @@
1
- {"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAM5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+KD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOhB;AAiDD,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAwBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CAkDd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
1
+ {"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAM5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+KD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOhB;AAsDD,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAwBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CAkDd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
@@ -215,6 +215,9 @@ async function readBoundedUtf8File(params) {
215
215
  if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
216
216
  throw new Error("queue entry is not a regular file");
217
217
  }
218
+ if (initialStat.nlink > 1) {
219
+ throw new Error("queue entry hardlinks are not allowed");
220
+ }
218
221
  if (initialStat.size > params.maxBytes) {
219
222
  throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
220
223
  }
@@ -228,6 +231,8 @@ async function readBoundedUtf8File(params) {
228
231
  if (!openedStat.isFile() ||
229
232
  pathStat.isSymbolicLink() ||
230
233
  !pathStat.isFile() ||
234
+ openedStat.nlink > 1 ||
235
+ pathStat.nlink > 1 ||
231
236
  !sameFileIdentity(initialStat, openedStat) ||
232
237
  !sameFileIdentity(pathStat, openedStat)) {
233
238
  throw new Error("queue entry changed during read");
@@ -1 +1 @@
1
- {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA8BrC"}
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA+BrC"}
package/dist/output.js CHANGED
@@ -42,6 +42,7 @@ export async function writeExternalFileWithinRoot(options) {
42
42
  if (requestedTargetPath.length === 0) {
43
43
  throw new FsSafeError("invalid-path", "target path is required");
44
44
  }
45
+ assertFileTargetPath(requestedTargetPath);
45
46
  const targetPath = toRootPathInput({
46
47
  rootDir: targetRoot.rootDir,
47
48
  rootReal: targetRoot.rootReal,
package/dist/path.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare function assertNoNulPathInput(filePath: string, message?: string)
6
6
  export declare function isNotFoundPathError(value: unknown): boolean;
7
7
  export declare function isSymlinkOpenError(value: unknown): boolean;
8
8
  export declare function isPathInside(root: string, target: string): boolean;
9
+ export declare function isPathRelativeEscape(relativePath: string): boolean;
9
10
  export declare function resolveSafeBaseDir(rootDir: string): string;
10
11
  export declare function isWithinDir(rootDir: string, targetPath: string): boolean;
11
12
  export declare function safeRealpathSync(targetPath: string, cache?: Map<string, string>): string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAUzB,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
1
+ {"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAUzB,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAElE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
package/dist/path.js CHANGED
@@ -55,6 +55,9 @@ export function isPathInside(root, target) {
55
55
  const firstSegment = relative.split(path.posix.sep)[0];
56
56
  return relative === "" || (firstSegment !== ".." && !path.isAbsolute(relative));
57
57
  }
58
+ export function isPathRelativeEscape(relativePath) {
59
+ return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
60
+ }
58
61
  export function resolveSafeBaseDir(rootDir) {
59
62
  const resolved = path.resolve(rootDir);
60
63
  return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AAwZA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AA2ND,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIjE"}
1
+ {"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AA2ZA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AAiOD,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEjE"}
@@ -3,26 +3,20 @@ import fsSync from "node:fs";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { getFsSafePythonConfig } from "./pinned-python-config.js";
5
5
  const PINNED_PYTHON_WORKER_SOURCE = String.raw `
6
- import base64
7
- import errno
8
- import json
9
- import os
10
- import secrets
11
- import stat
12
- import sys
13
-
6
+ import base64, errno, json, os, secrets, stat, sys
14
7
  DIR_FLAGS = os.O_RDONLY
15
8
  if hasattr(os, "O_DIRECTORY"):
16
9
  DIR_FLAGS |= os.O_DIRECTORY
17
10
  if hasattr(os, "O_NOFOLLOW"):
18
11
  DIR_FLAGS |= os.O_NOFOLLOW
19
12
  READ_FLAGS = os.O_RDONLY
13
+ if hasattr(os, "O_NONBLOCK"):
14
+ READ_FLAGS |= os.O_NONBLOCK
20
15
  if hasattr(os, "O_NOFOLLOW"):
21
16
  READ_FLAGS |= os.O_NOFOLLOW
22
17
  WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
23
18
  if hasattr(os, "O_NOFOLLOW"):
24
19
  WRITE_FLAGS |= os.O_NOFOLLOW
25
-
26
20
  def split_relative(value):
27
21
  if value in ("", "."):
28
22
  return []
@@ -35,10 +29,8 @@ def split_relative(value):
35
29
  if part == "..":
36
30
  raise OSError(errno.EPERM, "path traversal is not allowed")
37
31
  return parts
38
-
39
32
  def open_dir(path_value, dir_fd=None):
40
33
  return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)
41
-
42
34
  def walk_dir(root_fd, segments, mkdir_enabled=False):
43
35
  current_fd = os.dup(root_fd)
44
36
  try:
@@ -56,14 +48,12 @@ def walk_dir(root_fd, segments, mkdir_enabled=False):
56
48
  except Exception:
57
49
  os.close(current_fd)
58
50
  raise
59
-
60
51
  def parent_and_basename(root_fd, relative):
61
52
  segments = split_relative(relative)
62
53
  if not segments:
63
54
  raise OSError(errno.EPERM, "operation requires a non-root path")
64
55
  parent_fd = walk_dir(root_fd, segments[:-1])
65
56
  return parent_fd, segments[-1]
66
-
67
57
  def encode_stat(st):
68
58
  mode = st.st_mode
69
59
  return {
@@ -79,14 +69,12 @@ def encode_stat(st):
79
69
  "size": st.st_size,
80
70
  "uid": st.st_uid,
81
71
  }
82
-
83
72
  def reject_unsafe_endpoint(st):
84
73
  mode = st.st_mode
85
74
  if stat.S_ISLNK(mode):
86
75
  raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
87
76
  if stat.S_ISREG(mode) and st.st_nlink > 1:
88
77
  raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
89
-
90
78
  def copy_bytes(source_fd, dest_fd):
91
79
  while True:
92
80
  chunk = os.read(source_fd, 65536)
@@ -98,7 +86,6 @@ def copy_bytes(source_fd, dest_fd):
98
86
  if written <= 0:
99
87
  raise OSError(errno.EIO, "short write")
100
88
  view = view[written:]
101
-
102
89
  def write_all(fd, data):
103
90
  view = memoryview(data)
104
91
  while view:
@@ -106,11 +93,9 @@ def write_all(fd, data):
106
93
  if written <= 0:
107
94
  raise OSError(errno.EIO, "short write")
108
95
  view = view[written:]
109
-
110
96
  def link_unsupported(exc):
111
97
  unsupported = (errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP))
112
98
  return getattr(exc, "errno", None) in unsupported
113
-
114
99
  def link_no_replace(name, new_name, source_fd, target_fd):
115
100
  linked = False
116
101
  try:
@@ -125,11 +110,9 @@ def link_no_replace(name, new_name, source_fd, target_fd):
125
110
  os.fsync(source_fd)
126
111
  if source_fd != target_fd:
127
112
  os.fsync(target_fd)
128
-
129
113
  def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basename, mode, expected=None, unlink_source=False):
130
114
  source_fd = os.open(source_name, READ_FLAGS, dir_fd=source_parent_fd)
131
- dest_fd = None
132
- success = False
115
+ dest_fd = None; success = False; dest_stat = None
133
116
  try:
134
117
  if expected is not None:
135
118
  source_stat = os.fstat(source_fd)
@@ -138,6 +121,7 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
138
121
  dest_fd = os.open(basename, WRITE_FLAGS, mode, dir_fd=target_parent_fd)
139
122
  copy_bytes(source_fd, dest_fd)
140
123
  os.fsync(dest_fd)
124
+ dest_stat = os.fstat(dest_fd)
141
125
  success = True
142
126
  finally:
143
127
  os.close(source_fd)
@@ -153,19 +137,40 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
153
137
  try: os.unlink(basename, dir_fd=target_parent_fd)
154
138
  except FileNotFoundError: pass
155
139
  raise
156
-
157
- def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode):
140
+ return dest_stat
141
+ def same_identity(left, right):
142
+ return left.st_dev == right.st_dev and left.st_ino == right.st_ino
143
+ def verify_temp_name(parent_fd, temp_name, expected_stat):
144
+ current_stat = os.lstat(temp_name, dir_fd=parent_fd)
145
+ if stat.S_ISLNK(current_stat.st_mode) or not same_identity(current_stat, expected_stat):
146
+ raise RuntimeError("fs-safe-temp-mismatch")
147
+ def verify_committed_temp(parent_fd, basename, expected_stat):
148
+ final_stat = os.lstat(basename, dir_fd=parent_fd)
149
+ if not stat.S_ISLNK(final_stat.st_mode) and same_identity(final_stat, expected_stat):
150
+ return final_stat
151
+ try: os.unlink(basename, dir_fd=parent_fd)
152
+ except FileNotFoundError: pass
153
+ raise RuntimeError("fs-safe-temp-mismatch")
154
+ def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, expected_stat):
155
+ verify_temp_name(parent_fd, temp_name, expected_stat)
158
156
  if overwrite:
159
157
  os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
158
+ return verify_committed_temp(parent_fd, basename, expected_stat)
160
159
  else:
161
160
  try:
162
161
  os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)
162
+ final_stat = verify_committed_temp(parent_fd, basename, expected_stat)
163
163
  os.unlink(temp_name, dir_fd=parent_fd)
164
+ return final_stat
164
165
  except OSError as exc:
165
166
  if not link_unsupported(exc):
166
167
  raise
167
- copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, unlink_source=True)
168
-
168
+ return copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, expected_stat, True)
169
+ def assert_expected_root(root_fd, payload):
170
+ if "rootDev" in payload or "rootIno" in payload:
171
+ root_stat = os.fstat(root_fd)
172
+ if root_stat.st_dev != int(payload["rootDev"]) or root_stat.st_ino != int(payload["rootIno"]):
173
+ raise RuntimeError("fs-safe-root-mismatch")
169
174
  def stat_path(root_fd, payload):
170
175
  relative = payload.get("relativePath", "")
171
176
  segments = split_relative(relative)
@@ -179,7 +184,6 @@ def stat_path(root_fd, payload):
179
184
  return encode_stat(st)
180
185
  finally:
181
186
  os.close(parent_fd)
182
-
183
187
  def readdir_path(root_fd, payload):
184
188
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")))
185
189
  try:
@@ -195,12 +199,9 @@ def readdir_path(root_fd, payload):
195
199
  return entries
196
200
  finally:
197
201
  os.close(dir_fd)
198
-
199
202
  def mkdirp_path(root_fd, payload):
200
203
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True)
201
- os.close(dir_fd)
202
- return None
203
-
204
+ os.close(dir_fd); return None
204
205
  def remove_tree(parent_fd, basename):
205
206
  st = os.lstat(basename, dir_fd=parent_fd)
206
207
  if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
@@ -213,7 +214,6 @@ def remove_tree(parent_fd, basename):
213
214
  os.rmdir(basename, dir_fd=parent_fd)
214
215
  else:
215
216
  os.unlink(basename, dir_fd=parent_fd)
216
-
217
217
  def remove_path(root_fd, payload):
218
218
  parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", ""))
219
219
  try:
@@ -296,14 +296,15 @@ def write_path(root_fd, payload):
296
296
  except FileNotFoundError:
297
297
  pass
298
298
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
299
+ os.fchmod(temp_fd, mode)
299
300
  write_all(temp_fd, data)
300
301
  os.fsync(temp_fd)
302
+ temp_stat = os.fstat(temp_fd)
301
303
  os.close(temp_fd)
302
304
  temp_fd = None
303
- commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
305
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
304
306
  temp_name = None
305
307
  os.fsync(parent_fd)
306
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
307
308
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
308
309
  finally:
309
310
  if temp_fd is not None:
@@ -334,6 +335,7 @@ def copy_path(root_fd, payload):
334
335
  raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
335
336
  parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
336
337
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
338
+ os.fchmod(temp_fd, mode)
337
339
  written_bytes = 0
338
340
  while True:
339
341
  chunk = os.read(source_fd, 65536)
@@ -349,12 +351,12 @@ def copy_path(root_fd, payload):
349
351
  raise OSError(errno.EIO, "short write")
350
352
  view = view[written:]
351
353
  os.fsync(temp_fd)
354
+ temp_stat = os.fstat(temp_fd)
352
355
  os.close(temp_fd)
353
356
  temp_fd = None
354
- commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
357
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
355
358
  temp_name = None
356
359
  os.fsync(parent_fd)
357
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
358
360
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
359
361
  finally:
360
362
  os.close(source_fd)
@@ -371,6 +373,7 @@ def copy_path(root_fd, payload):
371
373
  def run_operation(operation, root_path, payload):
372
374
  root_fd = open_dir(root_path)
373
375
  try:
376
+ assert_expected_root(root_fd, payload)
374
377
  if operation == "stat":
375
378
  return stat_path(root_fd, payload)
376
379
  if operation == "readdir":
@@ -475,6 +478,12 @@ function mapWorkerError(response) {
475
478
  if (message.includes("fs-safe-source-mismatch")) {
476
479
  return new FsSafeError("path-mismatch", "source path changed during copy");
477
480
  }
481
+ if (message.includes("fs-safe-temp-mismatch")) {
482
+ return new FsSafeError("path-mismatch", "temp path changed during write");
483
+ }
484
+ if (message.includes("fs-safe-root-mismatch")) {
485
+ return new FsSafeError("path-mismatch", "root path changed during operation");
486
+ }
478
487
  if (message.includes("fs-safe-directory-noreplace-unsupported")) {
479
488
  return new FsSafeError("invalid-path", "directory moves require overwrite: true");
480
489
  }
@@ -655,9 +664,7 @@ export function validatePinnedOperationPayload(payload) {
655
664
  }
656
665
  }
657
666
  export function isPinnedHelperUnavailable(error) {
658
- return error instanceof Error &&
659
- "code" in error &&
660
- error.code === "helper-unavailable";
667
+ return error instanceof Error && "code" in error && error.code === "helper-unavailable";
661
668
  }
662
669
  function validatePinnedRelativePath(relativePath) {
663
670
  if (relativePath.length === 0 || relativePath === ".") {
@@ -1,4 +1,4 @@
1
- import { type Readable } from "node:stream";
1
+ import type { Readable } from "node:stream";
2
2
  import type { FileIdentityStat } from "./file-identity.js";
3
3
  type PinnedWriteInput = {
4
4
  kind: "buffer";
@@ -17,6 +17,7 @@ export declare function runPinnedWriteHelper(params: {
17
17
  overwrite?: boolean;
18
18
  maxBytes?: number;
19
19
  input: PinnedWriteInput;
20
+ rootIdentity?: FileIdentityStat;
20
21
  }): Promise<FileIdentityStat>;
21
22
  export declare function runPinnedCopyHelper(params: {
22
23
  rootPath: string;
@@ -28,6 +29,7 @@ export declare function runPinnedCopyHelper(params: {
28
29
  maxBytes?: number;
29
30
  sourcePath: string;
30
31
  sourceIdentity: FileIdentityStat;
32
+ rootIdentity?: FileIdentityStat;
31
33
  }): Promise<FileIdentityStat>;
32
34
  export {};
33
35
  //# sourceMappingURL=pinned-write.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAIA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAS3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAuFzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoB5B"}
1
+ {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAW3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AA6EzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;IACxB,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAwC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;IACjC,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqB5B"}
@@ -2,11 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import fsSync from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { Transform } from "node:stream";
6
- import { pipeline } from "node:stream/promises";
7
- import { createNearestExistingDirectoryGuard } from "./directory-guard.js";
5
+ import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
8
6
  import { FsSafeError } from "./errors.js";
7
+ import { sameFileIdentity } from "./file-identity.js";
9
8
  import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
9
+ import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
10
10
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
11
11
  import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
12
12
  function byteLength(input, encoding) {
@@ -28,29 +28,21 @@ function assertWithinMaxBytes(bytes, maxBytes) {
28
28
  throw new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
29
29
  }
30
30
  }
31
- function createMaxBytesTransform(maxBytes) {
32
- if (maxBytes === undefined) {
33
- return undefined;
34
- }
31
+ async function writeStreamToHandle(stream, handle, maxBytes) {
35
32
  let bytes = 0;
36
- return new Transform({
37
- transform(chunk, _encoding, callback) {
38
- bytes += chunk.byteLength;
39
- if (bytes > maxBytes) {
40
- callback(new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
41
- return;
33
+ for await (const chunk of stream) {
34
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
35
+ bytes += buffer.byteLength;
36
+ assertWithinMaxBytes(bytes, maxBytes);
37
+ let offset = 0;
38
+ while (offset < buffer.byteLength) {
39
+ const { bytesWritten } = await handle.write(buffer, offset, buffer.byteLength - offset);
40
+ if (bytesWritten <= 0) {
41
+ throw new FsSafeError("helper-failed", "fallback stream write made no progress");
42
42
  }
43
- callback(null, chunk);
44
- },
45
- });
46
- }
47
- async function pipelineWithMaxBytes(stream, destination, maxBytes) {
48
- const limiter = createMaxBytesTransform(maxBytes);
49
- if (limiter) {
50
- await pipeline(stream, limiter, destination);
51
- return;
43
+ offset += bytesWritten;
44
+ }
52
45
  }
53
- await pipeline(stream, destination);
54
46
  }
55
47
  async function inputToBase64(input, maxBytes) {
56
48
  if (input.kind === "buffer") {
@@ -96,6 +88,7 @@ export async function runPinnedWriteHelper(params) {
96
88
  mode: params.mode || 0o600,
97
89
  overwrite: params.overwrite !== false,
98
90
  relativeParentPath: params.relativeParentPath,
91
+ ...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
99
92
  };
100
93
  try {
101
94
  return await runPinnedPythonOperation({
@@ -126,6 +119,7 @@ export async function runPinnedCopyHelper(params) {
126
119
  mode: params.mode || 0o600,
127
120
  overwrite: params.overwrite !== false,
128
121
  relativeParentPath: params.relativeParentPath,
122
+ ...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
129
123
  sourceDev: params.sourceIdentity.dev,
130
124
  sourceIno: params.sourceIdentity.ino,
131
125
  sourcePath: params.sourcePath,
@@ -136,12 +130,12 @@ async function runPinnedWriteFallback(params) {
136
130
  const parentPath = params.relativeParentPath
137
131
  ? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
138
132
  : params.rootPath;
139
- const parentGuard = await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
140
133
  if (params.mkdir) {
141
- await withAsyncDirectoryGuards([parentGuard], async () => {
142
- await fs.mkdir(parentPath, { recursive: true });
143
- });
134
+ await mkdirPathComponentsWithGuards({ rootReal: params.rootPath, targetPath: parentPath });
144
135
  }
136
+ const parentGuard = params.mkdir
137
+ ? await createAsyncDirectoryGuard(parentPath)
138
+ : await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
145
139
  const targetPath = path.join(parentPath, params.basename);
146
140
  if (params.overwrite === false) {
147
141
  let handle = await withAsyncDirectoryGuards([parentGuard], async () => await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode), {
@@ -163,7 +157,7 @@ async function runPinnedWriteFallback(params) {
163
157
  }
164
158
  }
165
159
  else {
166
- await pipelineWithMaxBytes(params.input.stream, handle.createWriteStream(), params.maxBytes);
160
+ await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
167
161
  }
168
162
  const stat = await handle.stat();
169
163
  created = false;
@@ -184,7 +178,9 @@ async function runPinnedWriteFallback(params) {
184
178
  ? fsSync.constants.O_NOFOLLOW
185
179
  : 0);
186
180
  let handle;
187
- let handleClosedByStream = false;
181
+ let tempStat;
182
+ let targetStat;
183
+ let renamed = false;
188
184
  try {
189
185
  handle = await fs.open(tempPath, tempFlags, params.mode);
190
186
  if (params.input.kind === "buffer") {
@@ -197,27 +193,34 @@ async function runPinnedWriteFallback(params) {
197
193
  }
198
194
  }
199
195
  else {
200
- const writable = handle.createWriteStream();
201
- writable.once("close", () => {
202
- handleClosedByStream = true;
203
- });
204
- await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
196
+ await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
205
197
  }
206
- if (!handleClosedByStream) {
207
- await handle.close().catch(() => undefined);
208
- handle = undefined;
198
+ tempStat = await handle.stat();
199
+ const tempPathStat = await fs.lstat(tempPath);
200
+ if (tempPathStat.isSymbolicLink() || !sameFileIdentity(tempPathStat, tempStat)) {
201
+ throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
209
202
  }
203
+ const expectedTempStat = tempStat;
204
+ await handle.close().catch(() => undefined);
205
+ handle = undefined;
210
206
  await withAsyncDirectoryGuards([parentGuard], async () => {
211
207
  await fs.rename(tempPath, targetPath);
208
+ renamed = true;
209
+ targetStat = await fs.lstat(targetPath);
210
+ if (targetStat.isSymbolicLink() || !sameFileIdentity(targetStat, expectedTempStat)) {
211
+ throw new FsSafeError("path-mismatch", "fallback target changed during write");
212
+ }
212
213
  });
213
214
  }
214
215
  catch (error) {
215
- if (handle && !handleClosedByStream) {
216
- await handle.close().catch(() => undefined);
216
+ await handle?.close().catch(() => undefined);
217
+ if (!renamed) {
218
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
217
219
  }
218
- await fs.rm(tempPath, { force: true }).catch(() => undefined);
219
220
  throw error;
220
221
  }
221
- const stat = await fs.stat(targetPath);
222
- return { dev: stat.dev, ino: stat.ino };
222
+ if (!targetStat) {
223
+ throw new FsSafeError("path-mismatch", "fallback target was not verified");
224
+ }
225
+ return { dev: targetStat.dev, ino: targetStat.ino };
223
226
  }
package/dist/root-path.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { isNotFoundPathError, isPathInside } from "./path.js";
5
+ import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
6
6
  export const ROOT_PATH_ALIAS_POLICIES = {
7
7
  strict: Object.freeze({
8
8
  allowFinalSymlinkForUnlink: false,
@@ -531,7 +531,7 @@ function relativeInsideRoot(rootPath, targetPath) {
531
531
  if (!relative || relative === ".") {
532
532
  return "";
533
533
  }
534
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
534
+ if (isPathRelativeEscape(relative)) {
535
535
  return "";
536
536
  }
537
537
  return relative;
@@ -1 +1 @@
1
- {"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"root-paths.d.ts","sourceRoot":"","sources":["../src/root-paths.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
- import { isNotFoundPathError, isPathInside } from "./path.js";
4
+ import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
5
5
  import { root as openRoot } from "./root.js";
6
6
  function invalidPath(scopeLabel) {
7
7
  return {
@@ -9,6 +9,10 @@ function invalidPath(scopeLabel) {
9
9
  error: `Invalid path: must stay within ${scopeLabel}`,
10
10
  };
11
11
  }
12
+ function pathStaysWithinRoot(rootDir, candidatePath) {
13
+ const relative = path.relative(rootDir, candidatePath);
14
+ return Boolean(relative) && !isPathRelativeEscape(relative);
15
+ }
12
16
  async function resolveRealPathIfExists(targetPath) {
13
17
  try {
14
18
  return await fs.realpath(targetPath);
@@ -58,11 +62,14 @@ export function resolvePathWithinRoot(params) {
58
62
  if (!params.defaultFileName) {
59
63
  return { ok: false, error: "path is required" };
60
64
  }
61
- return { ok: true, path: path.join(root, params.defaultFileName) };
65
+ const defaultPath = path.resolve(root, params.defaultFileName);
66
+ if (!pathStaysWithinRoot(root, defaultPath)) {
67
+ return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
68
+ }
69
+ return { ok: true, path: defaultPath };
62
70
  }
63
71
  const resolved = path.resolve(root, raw);
64
- const rel = path.relative(root, resolved);
65
- if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
72
+ if (!pathStaysWithinRoot(root, resolved)) {
66
73
  return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
67
74
  }
68
75
  return { ok: true, path: resolved };
@@ -118,7 +125,7 @@ async function resolveNearestExistingPath(targetPath) {
118
125
  }
119
126
  async function assertNoSymlinkSegments(params) {
120
127
  const relative = path.relative(params.rootDir, params.targetPath);
121
- if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
128
+ if (isPathRelativeEscape(relative)) {
122
129
  throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
123
130
  }
124
131
  let current = params.rootDir;
@@ -195,6 +202,10 @@ export async function ensureDirectoryWithinRoot(params) {
195
202
  }
196
203
  }
197
204
  }
205
+ const currentReal = await fs.realpath(current);
206
+ if (!isPathInside(rootReal, currentReal)) {
207
+ return invalidPath(params.scopeLabel);
208
+ }
198
209
  }
199
210
  const targetReal = await fs.realpath(targetPath);
200
211
  if (!isPathInside(rootReal, targetReal)) {