@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 - 2026-05-06
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- Reject `fileStore()` and `fileStoreSync()` writes through symlinked parent directories so store commits cannot escape the configured root.
|
|
8
|
+
|
|
9
|
+
### Tests
|
|
10
|
+
|
|
11
|
+
- Increased filesystem edge coverage around secure temp fallback handling, sibling-temp cleanup, local-root resolution, file locks, and file identity checks.
|
|
12
|
+
- Prevented POSIX test runs from leaving Windows-style secure-temp fallback paths in the repository root.
|
|
13
|
+
|
|
14
|
+
### Docs
|
|
15
|
+
|
|
16
|
+
- Added missing docs pages for `@openclaw/fs-safe/config`, `@openclaw/fs-safe/store`, `@openclaw/fs-safe/advanced`, and `@openclaw/fs-safe/test-hooks`.
|
|
17
|
+
- Corrected path-helper docs for the synchronous `isPathInsideWithRealpath` and `safeRealpathSync` behavior.
|
|
18
|
+
- Included the Markdown docs in the npm package so README links resolve after install.
|
|
19
|
+
|
|
20
|
+
## 0.1.1 - 2026-05-06
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
- Preserve the caller's destination path spelling during staged archive merges so symlink-rebind checks catch alias races on macOS.
|
|
25
|
+
- Reject archive writes that gain a hardlink alias during post-write verification and clean up the destination file.
|
|
26
|
+
|
|
3
27
|
## 0.1.0 - 2026-05-06
|
|
4
28
|
|
|
5
29
|
### Features
|
package/README.md
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
# @openclaw/fs-safe
|
|
1
|
+
# 🛡️ @openclaw/fs-safe
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@openclaw/fs-safe)
|
|
4
|
+
[](https://github.com/openclaw/fs-safe/actions/workflows/ci.yml)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://fs-safe.io)
|
|
2
8
|
|
|
3
9
|
Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.
|
|
4
10
|
|
|
@@ -14,6 +20,12 @@ await fs.write("../escape.txt", "x"); // throws FsSafeError("outside-
|
|
|
14
20
|
|
|
15
21
|
That's the whole pitch. `root()` is the product; the rest of the package — JSON stores, atomic writes, secret files, archive extraction, temp workspaces — is supporting cast for the same boundary.
|
|
16
22
|
|
|
23
|
+
Full docs and reference at **[fs-safe.io](https://fs-safe.io)**.
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
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)
|
|
28
|
+
|
|
17
29
|
## Why this exists
|
|
18
30
|
|
|
19
31
|
Most Node code that has to touch caller-controlled paths reaches for:
|
|
@@ -26,6 +38,13 @@ That validates a *string*. It does not pin the file you opened, defend against a
|
|
|
26
38
|
|
|
27
39
|
The same idea has landed in other languages. Go [added `os.Root` and `OpenInRoot`](https://go.dev/blog/osroot); Rust has had [`cap-std`](https://github.com/bytecodealliance/cap-std) for years. Node's `fs` is path-string-oriented and exposes flags like `O_NOFOLLOW` but not an ergonomic "operate inside this root" API. `fs-safe` fills that gap.
|
|
28
40
|
|
|
41
|
+
| | Root boundary | Atomic writes | Symlink/hardlink defense | TOCTOU resistance | Archive extraction |
|
|
42
|
+
|---|---|---|---|---|---|
|
|
43
|
+
| `path.resolve().startsWith()` | string check only | – | – | – | – |
|
|
44
|
+
| [`write-file-atomic`](https://www.npmjs.com/package/write-file-atomic) | – | ✓ | – | – | – |
|
|
45
|
+
| Go [`os.Root`](https://go.dev/blog/osroot) / Rust [`cap-std`](https://github.com/bytecodealliance/cap-std) | ✓ | platform | ✓ | ✓ | – |
|
|
46
|
+
| **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **✓ (POSIX fd-relative)** | **✓ (ZIP/TAR)** |
|
|
47
|
+
|
|
29
48
|
## Not a sandbox
|
|
30
49
|
|
|
31
50
|
This is a **library-level guardrail**, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. If your threat model is a hostile process, you need OS isolation; if your threat model is "an agent, plugin, upload handler, or CLI will eventually be tricked into writing somewhere it shouldn't," `fs-safe` catches that.
|
|
@@ -241,8 +260,9 @@ const opened = await media.open("inbound/photo.jpg");
|
|
|
241
260
|
await media.pruneExpired({ ttlMs: 10 * 60 * 1000, recursive: true });
|
|
242
261
|
```
|
|
243
262
|
|
|
244
|
-
`tempWorkspace()`
|
|
245
|
-
single-file scratch workflows without hand-rolled path joins
|
|
263
|
+
`tempWorkspace()` exposes `write()`, `writeText()`, `writeJson()`, `copyIn()`, and `read()` for
|
|
264
|
+
single-file scratch workflows without hand-rolled path joins, plus a `store: FileStore` view of
|
|
265
|
+
the workspace dir for the richer cases (`writeStream`, `readJsonIfExists`, `store.json<T>(rel)`).
|
|
246
266
|
|
|
247
267
|
`tempFile()` is the smaller one-file temp helper. It is intentionally an
|
|
248
268
|
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":"
|
|
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"}
|
package/dist/archive-staging.js
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
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:
|
|
226
|
+
destinationDir: params.destDir,
|
|
227
227
|
destinationRealDir,
|
|
228
228
|
});
|
|
229
229
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Readable } from "node:stream";
|
|
2
|
+
import { type Root } from "./root.js";
|
|
3
|
+
export type SyncParentGuard = {
|
|
4
|
+
dir: string;
|
|
5
|
+
realPath: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function ensureParentInRoot(scopedRoot: Root, relativePath: string, mode: number): Promise<void>;
|
|
8
|
+
export declare function openWritableStoreRoot(params: {
|
|
9
|
+
rootDir: string;
|
|
10
|
+
dirMode: number;
|
|
11
|
+
maxBytes?: number;
|
|
12
|
+
}): Promise<Root>;
|
|
13
|
+
export declare function writeStreamToTempSource(params: {
|
|
14
|
+
stream: Readable;
|
|
15
|
+
maxBytes?: number;
|
|
16
|
+
mode: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
path: string;
|
|
19
|
+
cleanup: () => Promise<void>;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function assertSyncDirectoryGuard(guard: SyncParentGuard): void;
|
|
22
|
+
export declare function ensureParentSync(params: {
|
|
23
|
+
rootDir: string;
|
|
24
|
+
filePath: string;
|
|
25
|
+
mode: number;
|
|
26
|
+
}): SyncParentGuard;
|
|
27
|
+
//# sourceMappingURL=file-store-boundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-store-boundary.d.ts","sourceRoot":"","sources":["../src/file-store-boundary.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvD,OAAO,EAA4C,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAGhF,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAOF,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,IAAI,EAChB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AA+CD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,MAAM,EAAE,QAAQ,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,CAuC1D;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CASrE;AAUD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,eAAe,CAwClB"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import syncFs from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Transform } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { isPathInside } from "./path.js";
|
|
8
|
+
import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
|
|
9
|
+
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
10
|
+
function parentRelativePath(relativePath) {
|
|
11
|
+
const parent = path.posix.dirname(relativePath);
|
|
12
|
+
return parent === "." ? "" : parent;
|
|
13
|
+
}
|
|
14
|
+
export async function ensureParentInRoot(scopedRoot, relativePath, mode) {
|
|
15
|
+
const parent = parentRelativePath(relativePath);
|
|
16
|
+
if (!parent) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
await scopedRoot.mkdir(parent);
|
|
20
|
+
await chmodDirectoryInRootBestEffort(scopedRoot, parent, mode).catch(() => undefined);
|
|
21
|
+
}
|
|
22
|
+
export async function openWritableStoreRoot(params) {
|
|
23
|
+
await fs.mkdir(params.rootDir, { recursive: true, mode: params.dirMode });
|
|
24
|
+
await fs.chmod(params.rootDir, params.dirMode).catch(() => undefined);
|
|
25
|
+
return await root(params.rootDir, { hardlinks: "reject", maxBytes: params.maxBytes });
|
|
26
|
+
}
|
|
27
|
+
async function chmodDirectoryInRootBestEffort(scopedRoot, relativePath, mode) {
|
|
28
|
+
const dirPath = await scopedRoot.resolve(relativePath);
|
|
29
|
+
const directoryFlag = "O_DIRECTORY" in syncFs.constants ? syncFs.constants.O_DIRECTORY : 0;
|
|
30
|
+
const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants
|
|
31
|
+
? syncFs.constants.O_NOFOLLOW
|
|
32
|
+
: 0;
|
|
33
|
+
const handle = await fs.open(dirPath, syncFs.constants.O_RDONLY | directoryFlag | noFollowFlag);
|
|
34
|
+
try {
|
|
35
|
+
const stat = await handle.stat();
|
|
36
|
+
if (!stat.isDirectory()) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const realPath = await resolveOpenedFileRealPathForHandle(handle, dirPath);
|
|
40
|
+
if (!isPathInside(scopedRoot.rootWithSep, realPath)) {
|
|
41
|
+
throw new FsSafeError("outside-workspace", "directory is outside store root");
|
|
42
|
+
}
|
|
43
|
+
await handle.chmod(mode).catch(() => undefined);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await handle.close().catch(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function createMaxBytesTransform(maxBytes) {
|
|
50
|
+
if (maxBytes === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
let total = 0;
|
|
54
|
+
return new Transform({
|
|
55
|
+
transform(chunk, _encoding, callback) {
|
|
56
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
57
|
+
total += buffer.byteLength;
|
|
58
|
+
if (total > maxBytes) {
|
|
59
|
+
callback(new FsSafeError("too-large", `file exceeds maximum size of ${maxBytes} bytes`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
callback(null, buffer);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function writeStreamToTempSource(params) {
|
|
67
|
+
const tempRoot = resolveSecureTempRoot({
|
|
68
|
+
fallbackPrefix: "fs-safe-file-store",
|
|
69
|
+
unsafeFallbackLabel: "file store temp dir",
|
|
70
|
+
warn: () => undefined,
|
|
71
|
+
});
|
|
72
|
+
const dir = await fs.mkdtemp(path.join(tempRoot, "fs-safe-file-store-"));
|
|
73
|
+
const filePath = path.join(dir, "payload");
|
|
74
|
+
let handle = null;
|
|
75
|
+
let handleClosedByStream = false;
|
|
76
|
+
try {
|
|
77
|
+
handle = await fs.open(filePath, "wx", params.mode);
|
|
78
|
+
const writable = handle.createWriteStream();
|
|
79
|
+
writable.once("close", () => {
|
|
80
|
+
handleClosedByStream = true;
|
|
81
|
+
});
|
|
82
|
+
const limiter = createMaxBytesTransform(params.maxBytes);
|
|
83
|
+
if (limiter) {
|
|
84
|
+
await pipeline(params.stream, limiter, writable);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
await pipeline(params.stream, writable);
|
|
88
|
+
}
|
|
89
|
+
if (!handleClosedByStream) {
|
|
90
|
+
await handle.close().catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
await fs.chmod(filePath, params.mode).catch(() => undefined);
|
|
93
|
+
return {
|
|
94
|
+
path: filePath,
|
|
95
|
+
cleanup: async () => {
|
|
96
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (handle && !handleClosedByStream) {
|
|
102
|
+
await handle.close().catch(() => undefined);
|
|
103
|
+
}
|
|
104
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function assertSyncDirectoryGuard(guard) {
|
|
109
|
+
const stat = syncFs.lstatSync(guard.dir);
|
|
110
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
111
|
+
throw new FsSafeError("not-file", `store directory component must be a directory: ${guard.dir}`);
|
|
112
|
+
}
|
|
113
|
+
const realPath = syncFs.realpathSync(guard.dir);
|
|
114
|
+
if (realPath !== guard.realPath) {
|
|
115
|
+
throw new FsSafeError("path-mismatch", "store directory changed during write");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function chmodDirectorySyncBestEffort(dir, mode) {
|
|
119
|
+
try {
|
|
120
|
+
syncFs.chmodSync(dir, mode);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Best-effort on platforms that do not enforce POSIX modes.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function ensureParentSync(params) {
|
|
127
|
+
const rootDir = path.resolve(params.rootDir);
|
|
128
|
+
const dir = path.dirname(path.resolve(params.filePath));
|
|
129
|
+
const relative = path.relative(rootDir, dir);
|
|
130
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
131
|
+
throw new FsSafeError("outside-workspace", "file path escapes store root");
|
|
132
|
+
}
|
|
133
|
+
syncFs.mkdirSync(rootDir, { recursive: true, mode: params.mode });
|
|
134
|
+
const rootStat = syncFs.lstatSync(rootDir);
|
|
135
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
136
|
+
throw new FsSafeError("not-file", `store root must be a directory: ${rootDir}`);
|
|
137
|
+
}
|
|
138
|
+
const rootReal = syncFs.realpathSync(rootDir);
|
|
139
|
+
chmodDirectorySyncBestEffort(rootDir, params.mode);
|
|
140
|
+
let current = rootDir;
|
|
141
|
+
for (const segment of path.relative(rootDir, dir).split(path.sep).filter(Boolean)) {
|
|
142
|
+
current = path.join(current, segment);
|
|
143
|
+
try {
|
|
144
|
+
const stat = syncFs.lstatSync(current);
|
|
145
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
146
|
+
throw new FsSafeError("not-file", `store directory component must be a directory: ${current}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error.code !== "ENOENT") {
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
syncFs.mkdirSync(current, { mode: params.mode });
|
|
154
|
+
}
|
|
155
|
+
const currentReal = syncFs.realpathSync(current);
|
|
156
|
+
if (!isPathInside(rootReal, currentReal)) {
|
|
157
|
+
throw new FsSafeError("outside-workspace", "store directory escapes root");
|
|
158
|
+
}
|
|
159
|
+
chmodDirectorySyncBestEffort(current, params.mode);
|
|
160
|
+
}
|
|
161
|
+
const guard = { dir, realPath: syncFs.realpathSync(dir) };
|
|
162
|
+
assertSyncDirectoryGuard(guard);
|
|
163
|
+
return guard;
|
|
164
|
+
}
|
package/dist/file-store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-store.d.ts","sourceRoot":"","sources":["../src/file-store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"file-store.d.ts","sourceRoot":"","sources":["../src/file-store.ts"],"names":[],"mappings":"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"}
|
package/dist/file-store.js
CHANGED
|
@@ -2,13 +2,12 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import syncFs from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { pipeline } from "node:stream/promises";
|
|
6
5
|
import { FsSafeError } from "./errors.js";
|
|
6
|
+
import { assertSyncDirectoryGuard, ensureParentInRoot, ensureParentSync, openWritableStoreRoot, writeStreamToTempSource, } from "./file-store-boundary.js";
|
|
7
7
|
import { createJsonStore } from "./json-document-store.js";
|
|
8
8
|
import { isPathInside, resolveSafeRelativePath } from "./path.js";
|
|
9
9
|
import { root } from "./root.js";
|
|
10
10
|
import { writeSecretFileAtomic } from "./secret-file.js";
|
|
11
|
-
import { writeSiblingTempFile } from "./sibling-temp.js";
|
|
12
11
|
function assertRelativePath(relativePath) {
|
|
13
12
|
const raw = relativePath.trim();
|
|
14
13
|
if (!raw) {
|
|
@@ -24,11 +23,6 @@ function assertStoreFilePath(rootDir, filePath) {
|
|
|
24
23
|
throw new FsSafeError("outside-workspace", "file path escapes store root");
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
|
-
async function ensureParent(filePath, mode) {
|
|
28
|
-
const dir = path.dirname(filePath);
|
|
29
|
-
await fs.mkdir(dir, { recursive: true, mode });
|
|
30
|
-
await fs.chmod(dir, mode).catch(() => undefined);
|
|
31
|
-
}
|
|
32
26
|
function assertMaxBytes(size, maxBytes) {
|
|
33
27
|
if (maxBytes !== undefined && size > maxBytes) {
|
|
34
28
|
throw new FsSafeError("too-large", `file exceeds maximum size of ${maxBytes} bytes`);
|
|
@@ -41,26 +35,26 @@ function isNotFound(error) {
|
|
|
41
35
|
error.code === "ENOTDIR";
|
|
42
36
|
}
|
|
43
37
|
async function copyIntoRoot(params) {
|
|
44
|
-
const
|
|
38
|
+
const relativePath = assertRelativePath(params.relativePath);
|
|
39
|
+
const destination = resolveStorePath(params.rootDir, relativePath);
|
|
45
40
|
const sourceStat = await fs.lstat(params.sourcePath);
|
|
46
41
|
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
|
47
42
|
throw new FsSafeError("not-file", "source path is not a file");
|
|
48
43
|
}
|
|
49
44
|
assertMaxBytes(sourceStat.size, params.maxBytes);
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
dirMode
|
|
45
|
+
const dirMode = params.dirMode ?? 0o700;
|
|
46
|
+
const scopedRoot = await openWritableStoreRoot({
|
|
47
|
+
rootDir: params.rootDir,
|
|
48
|
+
dirMode,
|
|
49
|
+
maxBytes: params.maxBytes,
|
|
50
|
+
});
|
|
51
|
+
await ensureParentInRoot(scopedRoot, relativePath, dirMode);
|
|
52
|
+
await scopedRoot.copyIn(relativePath, params.sourcePath, {
|
|
53
|
+
maxBytes: params.maxBytes,
|
|
54
|
+
mkdir: false,
|
|
54
55
|
mode: params.mode ?? 0o600,
|
|
55
|
-
tempPrefix: params.tempPrefix ?? `.${path.basename(destination)}`,
|
|
56
|
-
writeTemp: async (tempPath) => {
|
|
57
|
-
await fs.copyFile(params.sourcePath, tempPath);
|
|
58
|
-
},
|
|
59
|
-
resolveFinalPath: () => destination,
|
|
60
|
-
syncTempFile: true,
|
|
61
|
-
syncParentDir: true,
|
|
62
56
|
});
|
|
63
|
-
return
|
|
57
|
+
return destination;
|
|
64
58
|
}
|
|
65
59
|
export function fileStore(options) {
|
|
66
60
|
const rootDir = path.resolve(options.rootDir);
|
|
@@ -72,7 +66,8 @@ export function fileStore(options) {
|
|
|
72
66
|
return await root(rootDir, { hardlinks: "reject", maxBytes });
|
|
73
67
|
}
|
|
74
68
|
async function write(relativePath, data, writeOptions) {
|
|
75
|
-
const
|
|
69
|
+
const safeRelativePath = assertRelativePath(relativePath);
|
|
70
|
+
const destination = resolveStorePath(rootDir, safeRelativePath);
|
|
76
71
|
const content = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
77
72
|
assertMaxBytes(content.byteLength, writeOptions?.maxBytes ?? maxBytes);
|
|
78
73
|
if (privateMode) {
|
|
@@ -85,20 +80,18 @@ export function fileStore(options) {
|
|
|
85
80
|
});
|
|
86
81
|
return destination;
|
|
87
82
|
}
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
dirMode:
|
|
83
|
+
const writeDirMode = writeOptions?.dirMode ?? dirMode;
|
|
84
|
+
const scopedRoot = await openWritableStoreRoot({
|
|
85
|
+
rootDir,
|
|
86
|
+
dirMode: writeDirMode,
|
|
87
|
+
maxBytes: writeOptions?.maxBytes ?? maxBytes,
|
|
88
|
+
});
|
|
89
|
+
await ensureParentInRoot(scopedRoot, safeRelativePath, writeDirMode);
|
|
90
|
+
await scopedRoot.write(safeRelativePath, content, {
|
|
91
|
+
mkdir: false,
|
|
92
92
|
mode: writeOptions?.mode ?? mode,
|
|
93
|
-
tempPrefix: writeOptions?.tempPrefix ?? `.${path.basename(destination)}`,
|
|
94
|
-
writeTemp: async (tempPath) => {
|
|
95
|
-
await fs.writeFile(tempPath, content);
|
|
96
|
-
},
|
|
97
|
-
resolveFinalPath: () => destination,
|
|
98
|
-
syncTempFile: true,
|
|
99
|
-
syncParentDir: true,
|
|
100
93
|
});
|
|
101
|
-
return
|
|
94
|
+
return destination;
|
|
102
95
|
}
|
|
103
96
|
return {
|
|
104
97
|
rootDir,
|
|
@@ -106,7 +99,8 @@ export function fileStore(options) {
|
|
|
106
99
|
root: openRoot,
|
|
107
100
|
write,
|
|
108
101
|
writeStream: async (relativePath, stream, writeOptions) => {
|
|
109
|
-
const
|
|
102
|
+
const safeRelativePath = assertRelativePath(relativePath);
|
|
103
|
+
const destination = resolveStorePath(rootDir, safeRelativePath);
|
|
110
104
|
const limit = writeOptions?.maxBytes ?? maxBytes;
|
|
111
105
|
if (privateMode) {
|
|
112
106
|
const chunks = [];
|
|
@@ -126,34 +120,26 @@ export function fileStore(options) {
|
|
|
126
120
|
});
|
|
127
121
|
return destination;
|
|
128
122
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
dir: path.dirname(destination),
|
|
133
|
-
dirMode: writeOptions?.dirMode ?? dirMode,
|
|
123
|
+
const staged = await writeStreamToTempSource({
|
|
124
|
+
stream,
|
|
125
|
+
maxBytes: limit,
|
|
134
126
|
mode: writeOptions?.mode ?? mode,
|
|
135
|
-
tempPrefix: writeOptions?.tempPrefix ?? `.${path.basename(destination)}`,
|
|
136
|
-
writeTemp: async (tempPath) => {
|
|
137
|
-
const writable = await fs.open(tempPath, "w", writeOptions?.mode ?? mode);
|
|
138
|
-
try {
|
|
139
|
-
const out = writable.createWriteStream();
|
|
140
|
-
stream.on("data", (chunk) => {
|
|
141
|
-
total += Buffer.byteLength(chunk);
|
|
142
|
-
if (limit !== undefined && total > limit) {
|
|
143
|
-
stream.destroy(new FsSafeError("too-large", `file exceeds maximum size of ${limit} bytes`));
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
await pipeline(stream, out);
|
|
147
|
-
}
|
|
148
|
-
finally {
|
|
149
|
-
await writable.close().catch(() => undefined);
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
resolveFinalPath: () => destination,
|
|
153
|
-
syncTempFile: true,
|
|
154
|
-
syncParentDir: true,
|
|
155
127
|
});
|
|
156
|
-
|
|
128
|
+
try {
|
|
129
|
+
await copyIntoRoot({
|
|
130
|
+
rootDir,
|
|
131
|
+
relativePath: safeRelativePath,
|
|
132
|
+
sourcePath: staged.path,
|
|
133
|
+
maxBytes: limit,
|
|
134
|
+
mode: writeOptions?.mode ?? mode,
|
|
135
|
+
tempPrefix: writeOptions?.tempPrefix,
|
|
136
|
+
dirMode: writeOptions?.dirMode ?? dirMode,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await staged.cleanup();
|
|
141
|
+
}
|
|
142
|
+
return destination;
|
|
157
143
|
},
|
|
158
144
|
copyIn: async (relativePath, sourcePath, writeOptions) => privateMode
|
|
159
145
|
? await (async () => {
|
|
@@ -274,16 +260,6 @@ export function fileStore(options) {
|
|
|
274
260
|
},
|
|
275
261
|
};
|
|
276
262
|
}
|
|
277
|
-
function ensureParentSync(filePath, mode) {
|
|
278
|
-
const dir = path.dirname(filePath);
|
|
279
|
-
syncFs.mkdirSync(dir, { recursive: true, mode });
|
|
280
|
-
try {
|
|
281
|
-
syncFs.chmodSync(dir, mode);
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
// Best-effort on platforms that do not enforce POSIX modes.
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
263
|
function ensurePrivateDirectorySync(rootDir, targetDir, mode) {
|
|
288
264
|
const root = path.resolve(rootDir);
|
|
289
265
|
const target = path.resolve(targetDir);
|
|
@@ -330,6 +306,7 @@ function ensurePrivateDirectorySync(rootDir, targetDir, mode) {
|
|
|
330
306
|
function writeFileSyncAtomic(params) {
|
|
331
307
|
const filePath = path.resolve(params.filePath);
|
|
332
308
|
assertStoreFilePath(params.rootDir, filePath);
|
|
309
|
+
let parentGuard;
|
|
333
310
|
if (params.privateMode) {
|
|
334
311
|
ensurePrivateDirectorySync(params.rootDir, path.dirname(filePath), params.dirMode);
|
|
335
312
|
try {
|
|
@@ -345,11 +322,18 @@ function writeFileSyncAtomic(params) {
|
|
|
345
322
|
}
|
|
346
323
|
}
|
|
347
324
|
else {
|
|
348
|
-
ensureParentSync(
|
|
325
|
+
parentGuard = ensureParentSync({
|
|
326
|
+
rootDir: params.rootDir,
|
|
327
|
+
filePath,
|
|
328
|
+
mode: params.dirMode,
|
|
329
|
+
});
|
|
349
330
|
}
|
|
350
|
-
const tempPath = path.join(path.dirname(filePath), `.fs-safe-${process.pid}-${randomUUID()}.tmp`);
|
|
331
|
+
const tempPath = path.join(parentGuard?.dir ?? path.dirname(filePath), `.fs-safe-${process.pid}-${randomUUID()}.tmp`);
|
|
351
332
|
let tempExists = false;
|
|
352
333
|
try {
|
|
334
|
+
if (parentGuard) {
|
|
335
|
+
assertSyncDirectoryGuard(parentGuard);
|
|
336
|
+
}
|
|
353
337
|
syncFs.writeFileSync(tempPath, params.content, { flag: "wx", mode: params.mode });
|
|
354
338
|
tempExists = true;
|
|
355
339
|
try {
|
|
@@ -358,8 +342,14 @@ function writeFileSyncAtomic(params) {
|
|
|
358
342
|
catch {
|
|
359
343
|
// Best-effort on platforms that do not enforce POSIX modes.
|
|
360
344
|
}
|
|
345
|
+
if (parentGuard) {
|
|
346
|
+
assertSyncDirectoryGuard(parentGuard);
|
|
347
|
+
}
|
|
361
348
|
syncFs.renameSync(tempPath, filePath);
|
|
362
349
|
tempExists = false;
|
|
350
|
+
if (parentGuard) {
|
|
351
|
+
assertSyncDirectoryGuard(parentGuard);
|
|
352
|
+
}
|
|
363
353
|
try {
|
|
364
354
|
syncFs.chmodSync(filePath, params.mode);
|
|
365
355
|
}
|
package/dist/path.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
export declare function normalizeWindowsPathForComparison(input: string): string;
|
|
3
3
|
export declare function isNodeError(value: unknown): value is NodeJS.ErrnoException;
|
|
4
4
|
export declare function hasNodeErrorCode(value: unknown, code: string): boolean;
|
|
5
|
+
export declare function assertNoNulPathInput(filePath: string, message?: string): void;
|
|
5
6
|
export declare function isNotFoundPathError(value: unknown): boolean;
|
|
6
7
|
export declare function isSymlinkOpenError(value: unknown): boolean;
|
|
7
8
|
export declare function isPathInside(root: string, target: string): boolean;
|
package/dist/path.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAWzB,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,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,CA0BlE;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,
|
|
1
|
+
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAWzB,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,CA0BlE;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"}
|