@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +39 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +2 -4
  26. package/dist/file-store-boundary.d.ts.map +1 -1
  27. package/dist/file-store-boundary.js +11 -7
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +60 -53
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/home-dir.d.ts.map +1 -1
  44. package/dist/home-dir.js +38 -33
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/json-document-store.d.ts +2 -0
  49. package/dist/json-document-store.d.ts.map +1 -1
  50. package/dist/json-document-store.js +7 -3
  51. package/dist/json-durable-queue.d.ts +45 -0
  52. package/dist/json-durable-queue.d.ts.map +1 -0
  53. package/dist/json-durable-queue.js +200 -0
  54. package/dist/json.d.ts +35 -5
  55. package/dist/json.d.ts.map +1 -1
  56. package/dist/json.js +69 -2
  57. package/dist/local-roots.d.ts.map +1 -1
  58. package/dist/local-roots.js +17 -3
  59. package/dist/lock-config.d.ts +10 -0
  60. package/dist/lock-config.d.ts.map +1 -0
  61. package/dist/lock-config.js +12 -0
  62. package/dist/move-path.d.ts +1 -0
  63. package/dist/move-path.d.ts.map +1 -1
  64. package/dist/move-path.js +195 -7
  65. package/dist/output.d.ts +13 -0
  66. package/dist/output.d.ts.map +1 -0
  67. package/dist/output.js +69 -0
  68. package/dist/path-stat.d.ts +4 -0
  69. package/dist/path-stat.d.ts.map +1 -0
  70. package/dist/path-stat.js +15 -0
  71. package/dist/path.d.ts.map +1 -1
  72. package/dist/path.js +4 -3
  73. package/dist/pinned-write.d.ts.map +1 -1
  74. package/dist/pinned-write.js +44 -19
  75. package/dist/private-temp-workspace.d.ts.map +1 -1
  76. package/dist/private-temp-workspace.js +28 -15
  77. package/dist/regular-file.d.ts.map +1 -1
  78. package/dist/regular-file.js +52 -8
  79. package/dist/replace-directory.d.ts.map +1 -1
  80. package/dist/replace-directory.js +5 -4
  81. package/dist/replace-file.d.ts +1 -1
  82. package/dist/replace-file.d.ts.map +1 -1
  83. package/dist/replace-file.js +93 -31
  84. package/dist/root-impl.d.ts.map +1 -1
  85. package/dist/root-impl.js +62 -53
  86. package/dist/safe-path-segment.d.ts +9 -0
  87. package/dist/safe-path-segment.d.ts.map +1 -0
  88. package/dist/safe-path-segment.js +51 -0
  89. package/dist/secret-file.d.ts.map +1 -1
  90. package/dist/secret-file.js +28 -7
  91. package/dist/sibling-temp.d.ts +1 -0
  92. package/dist/sibling-temp.d.ts.map +1 -1
  93. package/dist/sibling-temp.js +32 -7
  94. package/dist/sidecar-lock.d.ts +2 -0
  95. package/dist/sidecar-lock.d.ts.map +1 -1
  96. package/dist/sidecar-lock.js +93 -13
  97. package/dist/store.d.ts +1 -0
  98. package/dist/store.d.ts.map +1 -1
  99. package/dist/store.js +1 -0
  100. package/dist/temp-target.d.ts.map +1 -1
  101. package/dist/temp-target.js +7 -4
  102. package/dist/test-hooks.d.ts +6 -0
  103. package/dist/test-hooks.d.ts.map +1 -1
  104. package/dist/text-atomic.d.ts +7 -0
  105. package/dist/text-atomic.d.ts.map +1 -1
  106. package/dist/text-atomic.js +3 -2
  107. package/dist/trash.d.ts.map +1 -1
  108. package/dist/trash.js +48 -13
  109. package/docs/advanced.md +10 -0
  110. package/docs/atomic.md +33 -5
  111. package/docs/config.md +32 -3
  112. package/docs/index.md +3 -2
  113. package/docs/json-store.md +4 -0
  114. package/docs/json.md +34 -0
  115. package/docs/output.md +92 -0
  116. package/docs/sidecar-lock.md +13 -12
  117. package/docs/store.md +33 -0
  118. package/docs/temp.md +10 -2
  119. package/docs/testing.md +10 -0
  120. 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)`).
@@ -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":"AAIA,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,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWhE;AAWD,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiBnF;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"}
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"}
@@ -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) {
@@ -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";
@@ -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":"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
+ {"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"}
@@ -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: params.relPath,
96
+ relPath,
69
97
  originalPath: params.originalPath,
70
98
  });
71
99
  if (params.isDirectory) {
72
- await fs.mkdir(params.outPath, { recursive: true });
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 parentDir = path.dirname(params.outPath);
81
- await fs.mkdir(parentDir, { recursive: true });
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: parentDir,
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 fs.chmod(destinationPath, params.mode).catch(() => undefined);
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) {
@@ -1 +1 @@
1
- {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAYA,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;AAiOpC,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"}
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"}