@openclaw/fs-safe 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 - 2026-05-06
4
+
5
+ ### Fixes
6
+
7
+ - Preserve the caller's destination path spelling during staged archive merges so symlink-rebind checks catch alias races on macOS.
8
+ - Reject archive writes that gain a hardlink alias during post-write verification and clean up the destination file.
9
+
3
10
  ## 0.1.0 - 2026-05-06
4
11
 
5
12
  ### Features
package/README.md CHANGED
@@ -241,8 +241,9 @@ const opened = await media.open("inbound/photo.jpg");
241
241
  await media.pruneExpired({ ttlMs: 10 * 60 * 1000, recursive: true });
242
242
  ```
243
243
 
244
- `tempWorkspace()` also exposes `writeText()`, `writeJson()`, and `copyIn()` for
245
- single-file scratch workflows without hand-rolled path joins.
244
+ `tempWorkspace()` exposes `write()`, `writeText()`, `writeJson()`, `copyIn()`, and `read()` for
245
+ single-file scratch workflows without hand-rolled path joins, plus a `store: FileStore` view of
246
+ the workspace dir for the richer cases (`writeStream`, `readJsonIfExists`, `store.json<T>(rel)`).
246
247
 
247
248
  `tempFile()` is the smaller one-file temp helper. It is intentionally an
248
249
  advanced primitive: use `tempWorkspace()` for the stable temp surface and reach
@@ -1 +1 @@
1
- {"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,CAAC;AAEpC,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,IAAI,EAAE,wBAAwB,CAAC;gBAEnB,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAKpF;AASD,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYnF;AA6CD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBhB;AAmBD,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CAkBb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
1
+ {"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,wBAAwB,GAChC,2BAA2B,GAC3B,qBAAqB,GACrB,+BAA+B,CAAC;AAEpC,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,IAAI,EAAE,wBAAwB,CAAC;gBAEnB,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAKpF;AASD,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYnF;AA6CD,wBAAsB,wBAAwB,CAAC,MAAM,EAAE;IACrD,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBhB;AAqDD,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,MAAM,EAAE;IAC5D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;CACzC,GAAG,OAAO,CAAC,CAAC,CAAC,CAkBb;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAyEhB;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,oBAAoB,CAE7F"}
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
3
4
  import { root } from "./root.js";
4
5
  import { isNotFoundPathError, isPathInside } from "./path.js";
5
6
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
@@ -95,6 +96,32 @@ async function applyStagedEntryMode(params) {
95
96
  await fs.chmod(destinationPath, params.mode).catch(() => undefined);
96
97
  }
97
98
  }
99
+ async function assertExtractedFileHasNoHardlinkAlias(params) {
100
+ const destinationPath = path.join(params.destinationRealDir, params.relPath);
101
+ await assertResolvedInsideDestination({
102
+ destinationRealDir: params.destinationRealDir,
103
+ targetPath: destinationPath,
104
+ originalPath: params.originalPath,
105
+ });
106
+ const stat = await fs.lstat(destinationPath);
107
+ if (stat.isFile() && stat.nlink > 1) {
108
+ throw symlinkTraversalError(params.originalPath);
109
+ }
110
+ }
111
+ async function removeExtractedDestinationFile(params) {
112
+ const destinationPath = path.join(params.destinationRealDir, params.relPath);
113
+ let resolved;
114
+ try {
115
+ resolved = await fs.realpath(destinationPath);
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ if (!isPathInside(params.destinationRealDir, resolved)) {
121
+ return;
122
+ }
123
+ await fs.rm(destinationPath, { force: true }).catch(() => undefined);
124
+ }
98
125
  export async function withStagedArchiveDestination(params) {
99
126
  const stagingRoot = resolveSecureTempRoot({
100
127
  fallbackPrefix: "fs-safe-archive",
@@ -155,13 +182,30 @@ export async function mergeExtractedTreeIntoDestination(params) {
155
182
  originalPath,
156
183
  isDirectory: false,
157
184
  });
158
- await targetRoot.copyIn(relPath, sourcePath, { mkdir: true });
159
- await applyStagedEntryMode({
160
- destinationRealDir: params.destinationRealDir,
161
- relPath,
162
- mode: sourceStat.mode & 0o777,
163
- originalPath,
164
- });
185
+ try {
186
+ await targetRoot.copyIn(relPath, sourcePath, { mkdir: true });
187
+ await assertExtractedFileHasNoHardlinkAlias({
188
+ destinationRealDir: params.destinationRealDir,
189
+ relPath,
190
+ originalPath,
191
+ });
192
+ await applyStagedEntryMode({
193
+ destinationRealDir: params.destinationRealDir,
194
+ relPath,
195
+ mode: sourceStat.mode & 0o777,
196
+ originalPath,
197
+ });
198
+ }
199
+ catch (err) {
200
+ await removeExtractedDestinationFile({
201
+ destinationRealDir: params.destinationRealDir,
202
+ relPath,
203
+ });
204
+ if (err instanceof FsSafeError && (err.code === "hardlink" || err.code === "path-alias")) {
205
+ throw symlinkTraversalError(originalPath);
206
+ }
207
+ throw err;
208
+ }
165
209
  }
166
210
  };
167
211
  await walk(params.sourceDir);
package/dist/archive.js CHANGED
@@ -168,7 +168,7 @@ async function extractZip(params) {
168
168
  }
169
169
  await mergeExtractedTreeIntoDestination({
170
170
  sourceDir: stagingRealDir,
171
- destinationDir: destinationRealDir,
171
+ destinationDir: params.destDir,
172
172
  destinationRealDir,
173
173
  });
174
174
  },
@@ -223,7 +223,7 @@ export async function extractArchive(params) {
223
223
  });
224
224
  await mergeExtractedTreeIntoDestination({
225
225
  sourceDir: stagingDir,
226
- destinationDir: destinationRealDir,
226
+ destinationDir: params.destDir,
227
227
  destinationRealDir,
228
228
  });
229
229
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {