@openclaw/fs-safe 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +39 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +2 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +11 -7
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +60 -53
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +38 -33
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +62 -53
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +28 -7
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +10 -0
- package/docs/atomic.md +33 -5
- package/docs/config.md +32 -3
- package/docs/index.md +3 -2
- package/docs/json-store.md +4 -0
- package/docs/json.md +34 -0
- package/docs/output.md +92 -0
- package/docs/sidecar-lock.md +13 -12
- package/docs/store.md +33 -0
- package/docs/temp.md +10 -2
- package/docs/testing.md +10 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.1 - 2026-05-08
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- Align POSIX and Windows handling for literal `..`-prefixed write targets, preserve whitespace in direct home-relative path inputs, and run the check suite on Windows CI. (#14; thanks @sjf)
|
|
10
|
+
- Keep source prepack builds isolated from parent monorepo ambient type packages such as Bun typings. (#13; thanks @Kaspre)
|
|
11
|
+
- Let secret-file reads follow symlink paths through the pinned real target unless callers opt into `rejectSymlink: true`.
|
|
12
|
+
|
|
13
|
+
## 0.2.0 - 2026-05-07
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- Add `writeExternalFileWithinRoot()` for libraries that require an output path while preserving caller-provided destination names. (#7; thanks @jesse-merhi)
|
|
18
|
+
- Add root JSON helpers and durable JSON queue helpers for file-backed work queues with pending, delivered, failed, and acknowledgement flows.
|
|
19
|
+
- Add `ensureAbsoluteDirectory()` for creating trusted absolute directory paths one segment at a time while rejecting symlink and non-directory components. (#12; thanks @jesse-merhi)
|
|
20
|
+
- Add a `durable: false` option to async atomic text and JSON writes so callers can preserve replace semantics while skipping temp-file and parent-directory fsync. (#9; thanks @sallyom)
|
|
21
|
+
- Add process-wide sidecar lock defaults while keeping JSON store locking opt-in per resource.
|
|
22
|
+
|
|
23
|
+
### Security and Correctness
|
|
24
|
+
|
|
25
|
+
- Harden Root fallback mutators, archive merges, private store reads/writes, durable queue ids, JSON fallback writes, sibling temp writes, temp filename sanitization, and trash moves against symlink-swap and path traversal edge cases.
|
|
26
|
+
- Centralize safe path segment validation, directory identity guards, guarded mkdir, and guarded mutation wrappers so filesystem helpers reuse the same race-resistant checks.
|
|
27
|
+
- Route archive ZIP staging, temp workspace sync reads, secret-file commits, and atomic move/replace fallbacks through shared pinned-read or guarded-write primitives without applying private-directory modes to public paths.
|
|
28
|
+
- Close guarded fallback write handles without following path names if post-write directory verification fails, avoiding descriptor leaks and unsafe cleanup in symlink-swap races.
|
|
29
|
+
- Harden temp filename prefixes, local-root reads, private store imports, durable queue reads, and regular-file byte caps against Deepsec-reported path traversal, symlink, and oversized-read races.
|
|
30
|
+
- Harden sidecar lock cleanup and stale-lock handling so stale third-party locks fail closed instead of being deleted by path.
|
|
31
|
+
|
|
32
|
+
### Compatibility
|
|
33
|
+
|
|
34
|
+
- Make cross-device move fallbacks reject source changes during staged copies and clean up only the source entries copied into the staged destination, preserving concurrent source additions or replacements instead of recursively deleting them.
|
|
35
|
+
- Preserve directory modes during cross-device directory moves.
|
|
36
|
+
- Preserve empty-directory pruning and broken-symlink trash moves across guarded fallback paths.
|
|
37
|
+
- Preserve sync file-store read policy errors for directory and hardlink validation failures.
|
|
38
|
+
- Preserve existing temp workspace leaf filename behavior for names such as `.env` and filenames containing spaces.
|
|
39
|
+
- Preserve public parent-directory modes when writing JSON, moving files across devices, and extracting archives.
|
|
40
|
+
- Make `prepack` portable on Windows and add the missing pnpm workspace `packages` field so package preparation succeeds consistently.
|
|
41
|
+
|
|
42
|
+
### Tests
|
|
43
|
+
|
|
44
|
+
- Added regression coverage for the filesystem race and traversal findings fixed in this release.
|
|
45
|
+
- Added Deepsec regression coverage for unsafe temp tokens, dangling symlinks, default read caps, private `copyIn()` races, symlinked queue entries, oversized queue entries, and fresh sidecar lock preservation.
|
|
46
|
+
- Added regression coverage for external-output traversal rejection, guarded cleanup, sidecar lock stale handling, move fallback cleanup, durable queue validation, sync read policy failures, and absolute-directory validation.
|
|
47
|
+
- Added a static filesystem-boundary primitive check that blocks reintroducing known raw copy/read/guard patterns.
|
|
48
|
+
|
|
49
|
+
### Docs and Tooling
|
|
50
|
+
|
|
51
|
+
- Added docs for external output writers, durable JSON queue helpers, sidecar lock defaults, boundary guardrails, and absolute-directory creation.
|
|
52
|
+
- Enable ClawSweeper dispatch for pull-request review automation.
|
|
53
|
+
|
|
3
54
|
## 0.1.2 - 2026-05-06
|
|
4
55
|
|
|
5
56
|
### Fixes
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Full docs and reference at **[fs-safe.io](https://fs-safe.io)**.
|
|
|
24
24
|
|
|
25
25
|
## Contents
|
|
26
26
|
|
|
27
|
-
[Why this exists](#why-this-exists) · [Not a sandbox](#not-a-sandbox) · [Install](#install) · [Quick start](#quick-start) · [Reading](#reading) · [Subpaths](#subpaths) · [Failure semantics](#failure-semantics-in-the-name) · [Atomic writes](#atomic-writes) · [Stores](#stores) · [Secure absolute reads](#secure-absolute-file-reads) · [Walking](#directory-walking) · [Archive extraction](#archive-extraction) · [Path scopes](#advanced-path-scopes) · [Errors](#errors) · [Safety model](#safety-model) · [Limitations](#limitations)
|
|
27
|
+
[Why this exists](#why-this-exists) · [Not a sandbox](#not-a-sandbox) · [Install](#install) · [Quick start](#quick-start) · [Reading](#reading) · [Subpaths](#subpaths) · [Failure semantics](#failure-semantics-in-the-name) · [Atomic writes](#atomic-writes) · [External outputs](#external-outputs) · [Stores](#stores) · [Secure absolute reads](#secure-absolute-file-reads) · [Walking](#directory-walking) · [Archive extraction](#archive-extraction) · [Path scopes](#advanced-path-scopes) · [Errors](#errors) · [Safety model](#safety-model) · [Limitations](#limitations)
|
|
28
28
|
|
|
29
29
|
## Why this exists
|
|
30
30
|
|
|
@@ -172,6 +172,7 @@ that OpenClaw needs to compose higher-level APIs are grouped under
|
|
|
172
172
|
| `@openclaw/fs-safe/config` | process-global Python helper configuration |
|
|
173
173
|
| `@openclaw/fs-safe/path` | canonical path checks: `isPathInside`, `safeRealpathSync`, `isNotFoundPathError`, `isSymlinkOpenError` |
|
|
174
174
|
| `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants |
|
|
175
|
+
| `@openclaw/fs-safe/output` | `writeExternalFileWithinRoot` for external libraries that need a temp output path |
|
|
175
176
|
| `@openclaw/fs-safe/store` | `fileStore`, `fileStoreSync`, and `jsonStore` |
|
|
176
177
|
| `@openclaw/fs-safe/secret` | strict and try-style secret file read/write helpers |
|
|
177
178
|
| `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `replaceFileAtomicSync`, `replaceDirectoryAtomic`, `movePathWithCopyFallback` |
|
|
@@ -197,9 +198,14 @@ await tryReadJson("./config.json"); // returns null on missing or invalid
|
|
|
197
198
|
await readJson("./manifest.json"); // throws on missing or invalid
|
|
198
199
|
```
|
|
199
200
|
|
|
201
|
+
For one-off structured reads under a trusted root, `readRootJsonObjectSync()`
|
|
202
|
+
performs the root-bounded open and JSON object validation in one step. Use
|
|
203
|
+
`readRootStructuredFileSync()` when the parser lives outside fs-safe, such as
|
|
204
|
+
JSON5-backed plugin manifests.
|
|
205
|
+
|
|
200
206
|
## Atomic writes
|
|
201
207
|
|
|
202
|
-
`replaceFileAtomic()` writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, rename retry / copy fallback on `EPERM`, parent-directory fsync, and a `beforeRename` hook for backup or observer flows are all opt-in.
|
|
208
|
+
`replaceFileAtomic()` writes a sibling temp file, optionally fsyncs it, and renames it over the destination. Mode preservation, rename retry / copy fallback on `EPERM`, parent-directory fsync, and a `beforeRename` hook for backup or observer flows are all opt-in. `movePathWithCopyFallback()` stages cross-device moves before commit and removes only the copied source entries, so concurrent source additions or replacements are preserved.
|
|
203
209
|
|
|
204
210
|
```ts
|
|
205
211
|
import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
|
|
@@ -215,6 +221,32 @@ await replaceFileAtomic({
|
|
|
215
221
|
|
|
216
222
|
`replaceFileAtomicSync()` covers the synchronous case with the same options shape. Both accept an injectable `fileSystem` for tests.
|
|
217
223
|
|
|
224
|
+
## External outputs
|
|
225
|
+
|
|
226
|
+
Use `writeExternalFileWithinRoot()` when a browser download, renderer, media
|
|
227
|
+
tool, or native library needs an absolute path to write to:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
|
|
231
|
+
|
|
232
|
+
await writeExternalFileWithinRoot({
|
|
233
|
+
rootDir: "/safe/workspace/downloads",
|
|
234
|
+
path: "reports/today.pdf",
|
|
235
|
+
write: async (filePath) => {
|
|
236
|
+
await download.saveAs(filePath);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The callback receives a private temp file path, not the final destination. After
|
|
242
|
+
the callback returns, fs-safe finalizes the staged file with `Root.copyIn()`,
|
|
243
|
+
creating missing parents by default and rejecting traversal, symlink parent
|
|
244
|
+
escapes, hardlinked final targets, and size-limit violations.
|
|
245
|
+
|
|
246
|
+
Use it when the final filename is known before the external writer runs. If the
|
|
247
|
+
filename depends on sniffing the produced bytes, write to a private temp
|
|
248
|
+
workspace first, then finalize through the normal root APIs after validation.
|
|
249
|
+
|
|
218
250
|
## Stores
|
|
219
251
|
|
|
220
252
|
Use `fileStore().json()` for small state files that need explicit fallback
|
|
@@ -260,6 +292,11 @@ const opened = await media.open("inbound/photo.jpg");
|
|
|
260
292
|
await media.pruneExpired({ ttlMs: 10 * 60 * 1000, recursive: true });
|
|
261
293
|
```
|
|
262
294
|
|
|
295
|
+
The `store` subpath also includes durable JSON queue helpers for the common
|
|
296
|
+
"one JSON file per work item" pattern: atomic entry writes, pending-entry loads,
|
|
297
|
+
acknowledgement via `.delivered` markers, failed-entry moves, and stale temp
|
|
298
|
+
cleanup. Retry, dedupe, and transport semantics stay with the caller.
|
|
299
|
+
|
|
263
300
|
`tempWorkspace()` exposes `write()`, `writeText()`, `writeJson()`, `copyIn()`, and `read()` for
|
|
264
301
|
single-file scratch workflows without hand-rolled path joins, plus a `store: FileStore` view of
|
|
265
302
|
the workspace dir for the richer cases (`writeStream`, `readJsonIfExists`, `store.json<T>(rel)`).
|
package/dist/absolute-path.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FsSafeError, type FsSafeErrorCode } from "./errors.js";
|
|
1
2
|
export type AbsolutePathSymlinkPolicy = "reject" | "follow";
|
|
2
3
|
export type ResolvedAbsolutePath = {
|
|
3
4
|
path: string;
|
|
@@ -7,8 +8,21 @@ export type ResolvedWritableAbsolutePath = ResolvedAbsolutePath & {
|
|
|
7
8
|
parentDir: string;
|
|
8
9
|
parentExists: boolean;
|
|
9
10
|
};
|
|
11
|
+
export type EnsureAbsoluteDirectoryOptions = {
|
|
12
|
+
scopeLabel?: string;
|
|
13
|
+
mode?: number;
|
|
14
|
+
};
|
|
15
|
+
export type EnsureAbsoluteDirectoryResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
path: string;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
code: FsSafeErrorCode;
|
|
21
|
+
error: FsSafeError;
|
|
22
|
+
};
|
|
10
23
|
export declare function assertAbsolutePathInput(filePath: string): string;
|
|
11
24
|
export declare function findExistingAncestor(filePath: string): Promise<string | null>;
|
|
25
|
+
export declare function ensureAbsoluteDirectory(dirPath: string, options?: EnsureAbsoluteDirectoryOptions): Promise<EnsureAbsoluteDirectoryResult>;
|
|
12
26
|
export declare function canonicalPathFromExistingAncestor(filePath: string): Promise<string>;
|
|
13
27
|
export declare function resolveAbsolutePathForRead(filePath: string, options?: {
|
|
14
28
|
symlinks?: AbsolutePathSymlinkPolicy;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"absolute-path.d.ts","sourceRoot":"","sources":["../src/absolute-path.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"absolute-path.d.ts","sourceRoot":"","sources":["../src/absolute-path.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,MAAM,yBAAyB,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,oBAAoB,GAAG;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,8BAA8B,GAAG;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,6BAA6B,GACrC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC1B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,CAAC;AAmL7D,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWhE;AAWD,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAEnF;AAuBD,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,6BAA6B,CAAC,CA+ExC;AAED,wBAAsB,iCAAiC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAazF;AAED,wBAAsB,0BAA0B,CAC9C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,yBAAyB,CAAA;CAAO,GACrD,OAAO,CAAC,oBAAoB,CAAC,CAe/B;AAED,wBAAsB,2BAA2B,CAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,yBAAyB,CAAA;CAAO,GACrD,OAAO,CAAC,4BAA4B,CAAC,CAsBvC"}
|
package/dist/absolute-path.js
CHANGED
|
@@ -1,6 +1,131 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, } from "./directory-guard.js";
|
|
3
4
|
import { FsSafeError } from "./errors.js";
|
|
5
|
+
function ensureDirectoryFailure(code, message, cause) {
|
|
6
|
+
return {
|
|
7
|
+
ok: false,
|
|
8
|
+
code,
|
|
9
|
+
error: new FsSafeError(code, message, { cause }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
async function assertGuardResult(guard, scopeLabel) {
|
|
13
|
+
try {
|
|
14
|
+
await assertAsyncDirectoryGuard(guard);
|
|
15
|
+
return { ok: true };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err instanceof FsSafeError) {
|
|
19
|
+
return await directoryGuardFailure(err, guard.dir, scopeLabel);
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function createDirectoryGuardResult(dir, scopeLabel) {
|
|
25
|
+
try {
|
|
26
|
+
return { ok: true, guard: await createAsyncDirectoryGuard(dir) };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err instanceof FsSafeError) {
|
|
30
|
+
return await directoryGuardFailure(err, dir, scopeLabel);
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function classifyDirectoryLookupError(err, scopeLabel) {
|
|
36
|
+
const code = err.code;
|
|
37
|
+
if (code === "ENOENT") {
|
|
38
|
+
return ensureDirectoryFailure("not-found", `directory path must have a real existing ancestor within ${scopeLabel}`, err);
|
|
39
|
+
}
|
|
40
|
+
if (code === "ENOTDIR") {
|
|
41
|
+
return ensureDirectoryFailure("not-file", `path must be a real directory within ${scopeLabel}`, err);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function classifyExistingDirectorySegment(stat, scopeLabel) {
|
|
46
|
+
if (stat.isSymbolicLink()) {
|
|
47
|
+
return ensureDirectoryFailure("symlink", `directory path traverses a symlink within ${scopeLabel}`);
|
|
48
|
+
}
|
|
49
|
+
if (!stat.isDirectory()) {
|
|
50
|
+
return ensureDirectoryFailure("not-file", `path must be a real directory within ${scopeLabel}`);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
async function directoryGuardFailure(err, dir, scopeLabel) {
|
|
55
|
+
if (err.code !== "not-file") {
|
|
56
|
+
return { ok: false, code: err.code, error: err };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const stat = await fs.lstat(dir);
|
|
60
|
+
const failure = classifyExistingDirectorySegment(stat, scopeLabel);
|
|
61
|
+
if (failure) {
|
|
62
|
+
return failure;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (lookupErr) {
|
|
66
|
+
const failure = classifyDirectoryLookupError(lookupErr, scopeLabel);
|
|
67
|
+
if (failure) {
|
|
68
|
+
return failure;
|
|
69
|
+
}
|
|
70
|
+
throw lookupErr;
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, code: err.code, error: err };
|
|
73
|
+
}
|
|
74
|
+
async function resolveTrustedDirectoryPrefix(targetPath, scopeLabel) {
|
|
75
|
+
const root = path.parse(targetPath).root;
|
|
76
|
+
let current = root;
|
|
77
|
+
let currentStat;
|
|
78
|
+
try {
|
|
79
|
+
currentStat = await fs.lstat(current);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const failure = classifyDirectoryLookupError(err, scopeLabel);
|
|
83
|
+
if (failure) {
|
|
84
|
+
return failure;
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
const rootFailure = classifyExistingDirectorySegment(currentStat, scopeLabel);
|
|
89
|
+
if (rootFailure) {
|
|
90
|
+
return rootFailure;
|
|
91
|
+
}
|
|
92
|
+
// Walk forward with lstat. Looking backward for the "nearest existing
|
|
93
|
+
// ancestor" can cross an existing suffix through a symlinked parent before
|
|
94
|
+
// this helper gets a chance to reject that parent.
|
|
95
|
+
const segments = path.relative(root, targetPath).split(path.sep).filter(Boolean);
|
|
96
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
97
|
+
const segment = segments[index];
|
|
98
|
+
if (!segment) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const next = path.join(current, segment);
|
|
102
|
+
try {
|
|
103
|
+
const nextStat = await fs.lstat(next);
|
|
104
|
+
const segmentFailure = classifyExistingDirectorySegment(nextStat, scopeLabel);
|
|
105
|
+
if (segmentFailure) {
|
|
106
|
+
return segmentFailure;
|
|
107
|
+
}
|
|
108
|
+
current = next;
|
|
109
|
+
currentStat = nextStat;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const code = err.code;
|
|
113
|
+
if (code === "ENOENT") {
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
ancestorPath: current,
|
|
117
|
+
missingSegments: segments.slice(index),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const failure = classifyDirectoryLookupError(err, scopeLabel);
|
|
121
|
+
if (failure) {
|
|
122
|
+
return failure;
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, ancestorPath: current, missingSegments: [] };
|
|
128
|
+
}
|
|
4
129
|
export function assertAbsolutePathInput(filePath) {
|
|
5
130
|
if (!filePath) {
|
|
6
131
|
throw new FsSafeError("invalid-path", "path is required");
|
|
@@ -23,11 +148,13 @@ async function pathExists(filePath) {
|
|
|
23
148
|
}
|
|
24
149
|
}
|
|
25
150
|
export async function findExistingAncestor(filePath) {
|
|
151
|
+
return (await findExistingAncestorWithStat(filePath))?.path ?? null;
|
|
152
|
+
}
|
|
153
|
+
async function findExistingAncestorWithStat(filePath) {
|
|
26
154
|
let current = path.resolve(filePath);
|
|
27
155
|
while (true) {
|
|
28
156
|
try {
|
|
29
|
-
await fs.lstat(current);
|
|
30
|
-
return current;
|
|
157
|
+
return { path: current, stat: await fs.lstat(current) };
|
|
31
158
|
}
|
|
32
159
|
catch (err) {
|
|
33
160
|
if (err.code !== "ENOENT") {
|
|
@@ -41,6 +168,80 @@ export async function findExistingAncestor(filePath) {
|
|
|
41
168
|
current = parent;
|
|
42
169
|
}
|
|
43
170
|
}
|
|
171
|
+
export async function ensureAbsoluteDirectory(dirPath, options = {}) {
|
|
172
|
+
const scopeLabel = options.scopeLabel ?? "directory";
|
|
173
|
+
let targetPath;
|
|
174
|
+
try {
|
|
175
|
+
targetPath = assertAbsolutePathInput(dirPath);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (err instanceof FsSafeError) {
|
|
179
|
+
return { ok: false, code: err.code, error: err };
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
const prefix = await resolveTrustedDirectoryPrefix(targetPath, scopeLabel);
|
|
184
|
+
if (!prefix.ok) {
|
|
185
|
+
return prefix;
|
|
186
|
+
}
|
|
187
|
+
let current = prefix.ancestorPath;
|
|
188
|
+
const initialGuard = await createDirectoryGuardResult(prefix.ancestorPath, scopeLabel);
|
|
189
|
+
if (!initialGuard.ok) {
|
|
190
|
+
return initialGuard;
|
|
191
|
+
}
|
|
192
|
+
let currentGuard = initialGuard.guard;
|
|
193
|
+
for (const segment of prefix.missingSegments) {
|
|
194
|
+
current = path.join(current, segment);
|
|
195
|
+
while (true) {
|
|
196
|
+
const guardResult = await assertGuardResult(currentGuard, scopeLabel);
|
|
197
|
+
if (!guardResult.ok) {
|
|
198
|
+
return guardResult;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const stat = await fs.lstat(current);
|
|
202
|
+
if (stat.isSymbolicLink()) {
|
|
203
|
+
return ensureDirectoryFailure("symlink", `directory path traverses a symlink within ${scopeLabel}`);
|
|
204
|
+
}
|
|
205
|
+
if (!stat.isDirectory()) {
|
|
206
|
+
return ensureDirectoryFailure("not-file", `path must be a real directory within ${scopeLabel}`);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
if (err.code !== "ENOENT") {
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
const parentStillValid = await assertGuardResult(currentGuard, scopeLabel);
|
|
215
|
+
if (!parentStillValid.ok) {
|
|
216
|
+
return parentStillValid;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await fs.mkdir(current, { mode: options.mode });
|
|
220
|
+
}
|
|
221
|
+
catch (mkdirErr) {
|
|
222
|
+
if (mkdirErr.code === "EEXIST") {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
throw mkdirErr;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const nextGuard = await createDirectoryGuardResult(current, scopeLabel);
|
|
230
|
+
if (!nextGuard.ok) {
|
|
231
|
+
return nextGuard;
|
|
232
|
+
}
|
|
233
|
+
const previousGuardStillValid = await assertGuardResult(currentGuard, scopeLabel);
|
|
234
|
+
if (!previousGuardStillValid.ok) {
|
|
235
|
+
return previousGuardStillValid;
|
|
236
|
+
}
|
|
237
|
+
currentGuard = nextGuard.guard;
|
|
238
|
+
}
|
|
239
|
+
const finalGuardResult = await assertGuardResult(currentGuard, scopeLabel);
|
|
240
|
+
if (!finalGuardResult.ok) {
|
|
241
|
+
return finalGuardResult;
|
|
242
|
+
}
|
|
243
|
+
return { ok: true, path: targetPath };
|
|
244
|
+
}
|
|
44
245
|
export async function canonicalPathFromExistingAncestor(filePath) {
|
|
45
246
|
const ancestor = await findExistingAncestor(filePath);
|
|
46
247
|
if (!ancestor) {
|
package/dist/advanced.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export { createAsyncLock } from "./async-lock.js";
|
|
2
|
-
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, type AbsolutePathSymlinkPolicy, type ResolvedAbsolutePath, type ResolvedWritableAbsolutePath, } from "./absolute-path.js";
|
|
2
|
+
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, type AbsolutePathSymlinkPolicy, type EnsureAbsoluteDirectoryOptions, type EnsureAbsoluteDirectoryResult, type ResolvedAbsolutePath, type ResolvedWritableAbsolutePath, } from "./absolute-path.js";
|
|
3
3
|
export { sameFileIdentity, type FileIdentityStat } from "./file-identity.js";
|
|
4
4
|
export { sanitizeUntrustedFileName } from "./filename.js";
|
|
5
5
|
export { pathExists, pathExistsSync } from "./fs.js";
|
|
6
6
|
export { resolveLocalPathFromRootsSync, readLocalFileFromRoots, type LocalRootsInputOptions, type LocalRootsPathResult, type LocalRootsReadResult, type ReadLocalFileFromRootsOptions, type ResolveLocalPathFromRootsSyncOptions, } from "./local-roots.js";
|
|
7
7
|
export { assertNoWindowsNetworkPath, basenameFromMediaSource, hasEncodedFileUrlSeparator, isWindowsDriveLetterPath, isWindowsNetworkPath, safeFileURLToPath, trySafeFileURLToPath, } from "./local-file-access.js";
|
|
8
8
|
export { formatPosixMode } from "./mode.js";
|
|
9
|
+
export { configureFsSafeLocks, getFsSafeLockConfig, type FsSafeLockConfig, } from "./lock-config.js";
|
|
9
10
|
export { assertNoHardlinkedFinalPath, assertNoPathAliasEscape, PATH_ALIAS_POLICIES, type PathAliasPolicy, } from "./path-policy.js";
|
|
10
11
|
export { openRootFile, openRootFileSync, canUseRootFileOpen, matchRootFileOpenFailure, type OpenRootFileParams, type OpenRootFileSyncParams, type RootFileOpenFailure, type RootFileOpenFailureReason, type RootFileOpenResult, } from "./root-file.js";
|
|
11
12
|
export { ROOT_PATH_ALIAS_POLICIES, resolvePathViaExistingAncestorSync, resolveRootPath, resolveRootPathSync, type ResolvedRootPath, type RootPathAliasPolicy, } from "./root-path.js";
|
package/dist/advanced.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"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"}
|
package/dist/advanced.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// public subpaths; prefer root/json/store/temp/archive unless you are building a
|
|
3
3
|
// higher-level primitive.
|
|
4
4
|
export { createAsyncLock } from "./async-lock.js";
|
|
5
|
-
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, } from "./absolute-path.js";
|
|
5
|
+
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, } from "./absolute-path.js";
|
|
6
6
|
export { sameFileIdentity } from "./file-identity.js";
|
|
7
7
|
export { sanitizeUntrustedFileName } from "./filename.js";
|
|
8
8
|
export { pathExists, pathExistsSync } from "./fs.js";
|
|
9
9
|
export { resolveLocalPathFromRootsSync, readLocalFileFromRoots, } from "./local-roots.js";
|
|
10
10
|
export { assertNoWindowsNetworkPath, basenameFromMediaSource, hasEncodedFileUrlSeparator, isWindowsDriveLetterPath, isWindowsNetworkPath, safeFileURLToPath, trySafeFileURLToPath, } from "./local-file-access.js";
|
|
11
11
|
export { formatPosixMode } from "./mode.js";
|
|
12
|
+
export { configureFsSafeLocks, getFsSafeLockConfig, } from "./lock-config.js";
|
|
12
13
|
export { assertNoHardlinkedFinalPath, assertNoPathAliasEscape, PATH_ALIAS_POLICIES, } from "./path-policy.js";
|
|
13
14
|
export { openRootFile, openRootFileSync, canUseRootFileOpen, matchRootFileOpenFailure, } from "./root-file.js";
|
|
14
15
|
export { ROOT_PATH_ALIAS_POLICIES, resolvePathViaExistingAncestorSync, resolveRootPath, resolveRootPathSync, } from "./root-path.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"archive-staging.d.ts","sourceRoot":"","sources":["../src/archive-staging.ts"],"names":[],"mappings":"AAiBA,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;AAkCD,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,CAmChB;AAiGD,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"}
|
package/dist/archive-staging.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, } from "./directory-guard.js";
|
|
3
5
|
import { FsSafeError } from "./errors.js";
|
|
4
|
-
import { root } from "./root.js";
|
|
6
|
+
import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
|
|
5
7
|
import { isNotFoundPathError, isPathInside } from "./path.js";
|
|
6
8
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
9
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
7
10
|
const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination";
|
|
8
11
|
const ARCHIVE_STAGING_MODE = 0o700;
|
|
9
12
|
export class ArchiveSecurityError extends Error {
|
|
@@ -17,6 +20,28 @@ export class ArchiveSecurityError extends Error {
|
|
|
17
20
|
function symlinkTraversalError(originalPath) {
|
|
18
21
|
return new ArchiveSecurityError("destination-symlink-traversal", `${ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK}: ${originalPath}`);
|
|
19
22
|
}
|
|
23
|
+
async function createDirectoryIdentityGuard(dir) {
|
|
24
|
+
try {
|
|
25
|
+
return await createAsyncDirectoryGuard(dir);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err instanceof FsSafeError && err.code === "not-file") {
|
|
29
|
+
throw new ArchiveSecurityError("destination-symlink", "archive destination is a symlink");
|
|
30
|
+
}
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function assertDirectoryIdentityGuard(guard) {
|
|
35
|
+
try {
|
|
36
|
+
await assertAsyncDirectoryGuard(guard);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof FsSafeError) {
|
|
40
|
+
throw new ArchiveSecurityError("destination-symlink-traversal", "archive destination changed during extraction");
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
20
45
|
export async function prepareArchiveDestinationDir(destDir) {
|
|
21
46
|
const stat = await fs.lstat(destDir);
|
|
22
47
|
if (stat.isSymbolicLink()) {
|
|
@@ -63,13 +88,19 @@ async function assertResolvedInsideDestination(params) {
|
|
|
63
88
|
}
|
|
64
89
|
}
|
|
65
90
|
export async function prepareArchiveOutputPath(params) {
|
|
91
|
+
const targetRoot = await root(params.destinationRealDir);
|
|
92
|
+
const destinationGuard = await createDirectoryIdentityGuard(params.destinationRealDir);
|
|
93
|
+
const relPath = params.relPath.split(path.sep).join(path.posix.sep);
|
|
66
94
|
await assertNoSymlinkTraversal({
|
|
67
95
|
rootDir: params.destinationDir,
|
|
68
|
-
relPath
|
|
96
|
+
relPath,
|
|
69
97
|
originalPath: params.originalPath,
|
|
70
98
|
});
|
|
71
99
|
if (params.isDirectory) {
|
|
72
|
-
await
|
|
100
|
+
await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", params.outPath);
|
|
101
|
+
await assertDirectoryIdentityGuard(destinationGuard);
|
|
102
|
+
await targetRoot.mkdir(relPath);
|
|
103
|
+
await assertDirectoryIdentityGuard(destinationGuard);
|
|
73
104
|
await assertResolvedInsideDestination({
|
|
74
105
|
destinationRealDir: params.destinationRealDir,
|
|
75
106
|
targetPath: params.outPath,
|
|
@@ -77,14 +108,52 @@ export async function prepareArchiveOutputPath(params) {
|
|
|
77
108
|
});
|
|
78
109
|
return;
|
|
79
110
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
111
|
+
const parentRel = path.posix.dirname(relPath);
|
|
112
|
+
if (parentRel !== ".") {
|
|
113
|
+
await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", path.dirname(params.outPath));
|
|
114
|
+
await assertDirectoryIdentityGuard(destinationGuard);
|
|
115
|
+
await targetRoot.mkdir(parentRel);
|
|
116
|
+
await assertDirectoryIdentityGuard(destinationGuard);
|
|
117
|
+
}
|
|
82
118
|
await assertResolvedInsideDestination({
|
|
83
119
|
destinationRealDir: params.destinationRealDir,
|
|
84
|
-
targetPath:
|
|
120
|
+
targetPath: path.dirname(params.outPath),
|
|
85
121
|
originalPath: params.originalPath,
|
|
86
122
|
});
|
|
87
123
|
}
|
|
124
|
+
async function chmodInsideDestinationBestEffort(params) {
|
|
125
|
+
await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("chmod", params.destinationPath);
|
|
126
|
+
const destinationGuard = await createDirectoryIdentityGuard(params.destinationRealDir);
|
|
127
|
+
await assertDirectoryIdentityGuard(destinationGuard);
|
|
128
|
+
const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
|
|
129
|
+
? fsSync.constants.O_NOFOLLOW
|
|
130
|
+
: 0;
|
|
131
|
+
const handle = await fs
|
|
132
|
+
.open(params.destinationPath, fsSync.constants.O_RDONLY | noFollowFlag)
|
|
133
|
+
.catch(() => null);
|
|
134
|
+
if (!handle) {
|
|
135
|
+
const stat = await fs.lstat(params.destinationPath).catch(() => null);
|
|
136
|
+
if (stat?.isSymbolicLink()) {
|
|
137
|
+
throw symlinkTraversalError(params.originalPath);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const stat = await handle.stat();
|
|
143
|
+
if (!stat.isDirectory() && !stat.isFile()) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const realPath = await resolveOpenedFileRealPathForHandle(handle, params.destinationPath);
|
|
147
|
+
if (!isPathInside(params.destinationRealDir, realPath)) {
|
|
148
|
+
throw symlinkTraversalError(params.originalPath);
|
|
149
|
+
}
|
|
150
|
+
await handle.chmod(params.mode).catch(() => undefined);
|
|
151
|
+
await assertDirectoryIdentityGuard(destinationGuard);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await handle.close().catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
88
157
|
async function applyStagedEntryMode(params) {
|
|
89
158
|
const destinationPath = path.join(params.destinationRealDir, params.relPath);
|
|
90
159
|
await assertResolvedInsideDestination({
|
|
@@ -93,7 +162,12 @@ async function applyStagedEntryMode(params) {
|
|
|
93
162
|
originalPath: params.originalPath,
|
|
94
163
|
});
|
|
95
164
|
if (params.mode !== 0) {
|
|
96
|
-
await
|
|
165
|
+
await chmodInsideDestinationBestEffort({
|
|
166
|
+
destinationRealDir: params.destinationRealDir,
|
|
167
|
+
destinationPath,
|
|
168
|
+
mode: params.mode,
|
|
169
|
+
originalPath: params.originalPath,
|
|
170
|
+
});
|
|
97
171
|
}
|
|
98
172
|
}
|
|
99
173
|
async function assertExtractedFileHasNoHardlinkAlias(params) {
|
package/dist/archive.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAgBzE,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,6BAA6B,EAC7B,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,EACvB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,oBAAoB,EAAE,KAAK,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAC3F,OAAO,EACL,kCAAkC,EAClC,iCAAiC,EACjC,4BAA4B,EAC5B,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,8BAA8B,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrF,OAAO,EACL,2BAA2B,EAC3B,iCAAiC,EACjC,KAAK,mBAAmB,GACzB,MAAM,4BAA4B,CAAC;AA8MpC,wBAAsB,cAAc,CAAC,MAAM,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyEhB"}
|