@openclaw/fs-safe/archive extracts ZIP and TAR archives behind one API, with traversal checks, blocked-link-type rejection, and entry-count and byte budgets. When the native binding is available for the current platform, Rust streams ZIP, TAR, gzip, zstd, and bzip2 while TypeScript remains the sole policy owner; every accepted output is created fd-relative in a private staging root. Extraction then merges through the same safe-open boundary used by direct writes — a symlinked entry can't trick the merge into following an out-of-tree path.
The guarded JavaScript fallback uses optional runtime dependencies: jszip for
ZIP and tar for TAR/gzip. The native path does not use those packages. Installs
that omit optional dependencies can still import this subpath and use the
native path or pure path/limit helpers.
Some package managers and CI installs skip optional dependencies
(--no-optional, --omit=optional, or equivalent). If an archive helper throws
that an optional archive dependency is not installed, install jszip and/or
tar explicitly in the consuming package.
import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";await extractArchive({
archivePath: "/srv/uploads/plugin.zip",
destDir: "/srv/workspace/plugins/plugin",
kind: "zip", // optional; resolveArchiveKind() can infer
timeoutMs: 15_000, // hard budget; active destination mutation is joined
stripComponents: 0, // tar-style strip-leading-dirs
entryModes: "clamp", // default; use "preserve" for archive rwx bits
entryFilter: ({ path, kind, size }) => "extract",
onFiltered: "reject-archive", // default; opt into "skip-entry" explicitly
limits: {
maxArchiveBytes: 256 * 1024 * 1024,
maxEntries: 50_000,
maxExtractedBytes: 512 * 1024 * 1024,
maxEntryBytes: 256 * 1024 * 1024,
maxMetaEntryBytes: 1024 * 1024,
maxEntryPathComponents: 256,
},
});type ExtractArchiveOptions = {
archivePath: string; // absolute path to the archive
destDir: string; // absolute destination directory; must already exist
timeoutMs: number; // positive wall-clock budget; <= 0/non-finite disables it
kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
stripComponents?: number; // strip N leading dirs from entry paths
tarGzip?: boolean; // when archive is .tar.gz/.tgz
limits?: ArchiveExtractLimits;
logger?: ArchiveLogger; // { info?, warn? }
entryModes?: "clamp" | "preserve";
entryFilter?: (entry: { path: string; kind: ArchiveEntryKind; size: number }) =>
"extract" | "skip";
onFiltered?: "reject-archive" | "skip-entry";
};entryModes defaults to "clamp": directories become 0o755; files become
0o644, or 0o755 when the archived owner-execute bit is set. "preserve"
keeps archived read/write/execute bits. Both policies strip setuid, setgid, and
sticky bits, and neither applies archived ownership. An explicit zero mode,
including a mode containing only stripped special bits, stays zero under
"preserve". Absent metadata defaults to 0o644 for files and 0o755 for
directories; ZIP UNIX creator records with zero attributes are explicit zero,
while non-UNIX ZIP records use the absent-metadata defaults.
TAR mode fields containing only NUL/ASCII-space padding use absent defaults. Native extraction also recognizes GNU binary modes, including signed values, within node-tar's JavaScript safe-integer range before masking permission bits. Malformed or unsupported mode representations retain existing decoder behavior: native falls back to zero, while JavaScript may default, parse an octal prefix, or reject. These representations are not newly admitted or standardized by the mode repair; raw framing and other numeric-field checks remain unchanged.
Final modes remain separate from private working staging permissions: files
stay 0o600 and directories 0o700 until publication. Files receive their final
mode through the guarded copy's owned writer descriptor. Directories are pinned
before descending and finalized after their children, including empty and
restrictive directories. Explicit accepted directory modes win regardless of
archive order; implicit parents receive 0o755. Existing destination directories
also receive the requested final mode. They are never temporarily widened to
allow child writes; insufficient write/search access still rejects.
Directory mode changes use a retained no-follow read descriptor when possible.
On macOS x64/arm64, read-denied directories can use a retained search descriptor.
On Linux x64/arm64, the Node-only search route retains an O_PATH descriptor and
changes modes through its exact /proc/self/fd/N reference after verifying the
procfs namespace and followed identity. The descriptor stays open through the
operation and verification; original root, ancestor and named-directory checks
still apply. This route trusts host mount-namespace integrity and does not claim
atomic ancestry checks or protection against privileged mount replacement.
Readable directories do not depend on procfs. A Linux search-only directory
needing a mode change requires accessible, genuine procfs; unavailable or
untrusted authority rejects explicitly instead of silently accepting a wrong
mode. Other unsupported search-only routes also fail closed. Windows retains
its existing bounded lack of POSIX mode enforcement.
Native extraction is deliberately split into two phases. Rust first reports an
entry manifest without creating paths. TypeScript validates paths, applies
stripComponents, filters, limits, and mode policy, then passes an explicit
accepted-entry plan back to Rust. Rust owns raw-stream admission, decompression,
and fd-relative mkdirBeneath/exclusive-open writes. This keeps filter policy identical
between native and JavaScript paths rather than reimplementing it in Rust.
ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with entry-path, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with ArchiveFormatError. Harmless separator and dot-component equivalence is allowed only after validation. Ordinary legacy filename decoding remains backend-selected.
stripComponents removes leading nonempty, non-. path components after
normalizing separators. For example, ./pkg/hello.txt with
stripComponents: 1 extracts to hello.txt on both backends. Entries with no
remaining components are skipped before the filter callback, but still count
toward maxEntries and undergo traversal validation. JavaScript TAR extraction
passes node-tar this accepted output path with its own stripping disabled, so
depth checks, collision checks, writes, and mode application agree.
An entryFilter sees the validated canonical effective archive path before
stripping, entry kind, and declared size. On every JavaScript and native
ZIP/TAR backend (including gzip and native zstd/bzip2), backslashes become /,
empty and . components are removed, and trailing separators are removed from
directory paths. For example, ./pkg//state\cache/value is presented as
pkg/state/cache/value, even with stripComponents: 1. Case and Unicode
spelling are preserved. Local PAX path, GNU long-name, and supported ZIP
Unicode Path names use the same canonicalization.
Raw paths undergo traversal, absolute/drive-path, and NUL validation before
canonicalization; normalization cannot turn an unsafe path into an accepted
one. Stripping and output collision checks use this same canonical identity.
Filters that compare exact strings should use canonical pre-strip paths,
including directory names without a trailing /.
Returning "skip" rejects the whole archive unless onFiltered is
explicitly "skip-entry". Runtime values other than "reject-archive" and
"skip-entry" reject before extraction starts instead of falling through to
skip behavior. Path traversal and archive-wide entry-count checks still apply
to skipped entries.
maxEntryBytes and maxExtractedBytes charge only entries accepted after
stripping and filtering. Skipping a large member does not consume these payload
budgets. The separate complete-stream decoded limit still applies to all TAR
content, including skipped or fully stripped members.
For example, a fleet restore can omit regenerated cache entries while rejecting any other policy mismatch by default:
await extractArchive({
archivePath: snapshotPath,
destDir: restoreRoot,
timeoutMs: 30_000,
entryFilter: ({ path: entryPath, kind }) =>
kind === "directory" && entryPath === "state/cache"
? "skip"
: entryPath.startsWith("state/cache/")
? "skip"
: "extract",
onFiltered: "skip-entry",
limits: { maxEntries: 50_000, maxEntryPathComponents: 64 },
});If skipping was not explicitly part of the restore contract, omit
onFiltered; the first "skip" then rejects the complete archive with
ArchiveSecurityError("entry-filtered").
Both TAR implementations finish bounded admission before TypeScript policy evaluation, so a rejected plan never starts extraction. The JavaScript path owns the extraction file stream and aborts node-tar through a pipeline on parser disagreement, validation, or timeout failure, destroying both ends instead of leaving a paused parser to drain indefinitely.
TAR character devices, block devices, and FIFOs are presented to the filter as
kind: "other". Accepted entries of these types reject with
ArchiveSecurityError("entry-link"); an explicit "skip-entry" filter can omit
them. GNU typeflag D (GNUDumpDir) is a directory on both backends, including
its filter kind, canonical path, and directory creation policy. Its declared
body size follows the existing TAR strip/filter payload budgets; dump contents
are not restored as files.
Unsupported logical TAR records, including volume headers (V), Solaris ACL
records (A), inodes (I), continuations (M), and unrecognized typeflags,
still undergo entry counting, raw/effective path validation, stripping, depth
and output collision checks in physical order. Each remaining record reaches
entryFilter once with its canonical pre-strip path, kind: "other", and
declared effective size. A filter skip rejects with "entry-filtered" unless
onFiltered: "skip-entry" is explicit. Accepted unsupported records are safely
omitted and do not consume output payload budgets. This applies even when the
underlying TAR parser suppresses the record. GNU long names describe one such
record and are then cleared; local PAX on unsupported types and GNU sparse
S records retain their existing fail-closed format policy.
If kind is omitted, the helper calls resolveArchiveKind(archivePath) and throws if the extension is not recognized. Pass kind explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite timeoutMs is a wall-clock budget; zero, negative, NaN, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
The destination merge is nontransactional: each file is published atomically,
but completed files and directories can remain when a later copy, post-copy
check, mode application, or deadline fails. This also applies to
mergeExtractedTreeIntoDestination(). Guarded Root.copyIn() owns cleanup for
its operation; the archive merge does not unlink the current destination name
on error because it has no publication receipt proving ownership. A failure
before publication preserves a pre-existing file, and rejection does not grant
authority to delete a substituted file or alias. Failed extraction does not
restore overwritten contents. Active destination mutations and their guarded
cleanup still finish before rejection; no later destination mutation begins.
New directories whose postorder finalization was never reached can retain their
private working mode after failure. Failure cleanup closes retained descriptors;
it does not run a final chmod sweep or roll back the archive. The public merge
helper still derives modes from its external source tree and must be able to
read that source; it never chmods an unreadable external source to admit it.
type ArchiveExtractLimits = {
maxArchiveBytes?: number; // refuse if archivePath stat'd size exceeds this
maxEntries?: number; // refuse before extracting if entry count > this
maxExtractedBytes?: number; // cap total payload bytes accepted after strip/filter
maxEntryBytes?: number; // cap one accepted entry after strip/filter
maxMetaEntryBytes?: number; // refuse one PAX/GNU metadata body above this
maxEntryPathComponents?: number; // bound output path depth after stripComponents
};Defaults exist for each (DEFAULT_MAX_ARCHIVE_BYTES_ZIP, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_EXTRACTED_BYTES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_META_ENTRY_BYTES, DEFAULT_MAX_ENTRY_PATH_COMPONENTS). An explicit zero remains zero rather than selecting the default. maxEntries counts every archive entry, including entries removed by stripComponents or an explicit filter. The path-component default is 256. It is evaluated after stripComponents and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's maxMetaEntrySize; fs-safe passes the same resolved value to node-tar and the native TAR meter.
A limit violation throws ArchiveLimitError. Its constant and string code are:
| Constant | Code |
|---|---|
ARCHIVE_SIZE_EXCEEDS_LIMIT |
archive-size-exceeds-limit |
ENTRY_COUNT_EXCEEDS_LIMIT |
archive-entry-count-exceeds-limit |
EXTRACTED_SIZE_EXCEEDS_LIMIT |
archive-extracted-size-exceeds-limit |
DECODED_SIZE_EXCEEDS_LIMIT |
archive-decoded-size-exceeds-limit |
ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT |
archive-entry-extracted-size-exceeds-limit |
META_ENTRY_SIZE_EXCEEDS_LIMIT |
archive-meta-entry-size-exceeds-limit |
ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT |
archive-entry-path-components-exceeds-limit |
MANIFEST_SIZE_EXCEEDS_LIMIT |
archive-manifest-size-exceeds-limit |
MANIFEST_SIZE_EXCEEDS_LIMIT is an active internal TAR admission limit, shared
by JavaScript and native extraction and bounded reads. Each logical member,
including ignored, filtered, and fully stripped members, charges
64 + 2 * UTF-8 byte length of its effective pre-strip path before emission or
retention. PAX/GNU metadata headers do not themselves charge a member cost.
The allowance is independent of maxArchiveBytes: derive a per-member path
allowance of max(256, min(maxMetaEntryBytes, max(1, maxEntryPathComponents) * 256)),
apply the same 64-byte overhead and doubled path cost, multiply by maxEntries,
and cap the total at 64 MiB using saturating arithmetic. Zero and very large
public limits remain deterministic. There is no public maxManifestBytes
option; this charged manifest budget supplements the decoded and metadata
limits rather than bounding the complete process heap.
Catch and branch on the code to surface a meaningful response to the caller.
Entry policy failures throw ArchiveSecurityError. Its entry-related codes
are "entry-path", "entry-link", and "entry-filtered"; destination-race
codes remain "destination-not-directory", "destination-symlink", and
"destination-symlink-traversal".
- Path traversal: entries with
.., absolute paths, NUL bytes, or Windows drive-relative segments such asC:secretandnested/C:secretare rejected (ArchiveSecurityError). On Windows, path segments containing:are also rejected as alternate data stream names before either backend writes to the filesystem. - Symlink/hardlink entries: rejected by default, including ZIP entries whose Unix mode says symlink while their name ends in a slash or their DOS directory bit is set. An explicit
entryFilterwithonFiltered: "skip-entry"can omit these entries. Some archives ship symlink/hardlink entries that point outside the destination once resolved;extractArchivedoes not follow them. - Ambiguous output names: duplicate names and distinct names that collide after
stripComponents, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order. - TOCTOU during merge: extraction first writes to a private temp dir, then merges into
destDirusing the same boundary checks asroot().write(). Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the security model. - Zip bombs:
maxExtractedBytesandmaxEntryBytesapply to post-decompression bytes, so highly-compressed payloads hit the cap before they exhaust disk. - Corrupt ZIP payloads: streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
- Corrupt gzip streams: truncated compressed bodies, missing trailers, and checksum failures reject before extraction publishes files or an entry read returns bytes, on both JavaScript and native backends.
- Slow-loris archives:
timeoutMsis a hard wall-clock budget for non-mutating work. Extraction is aborted on overrun; if a destination mutation is already in flight, that mutation and rollback are joined before rejection so archive-controlled publication cannot continue afterward. - Metadata bombs: a streaming pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 fixed sizes and validates bounded local PAX bodies before using their size overrides for member framing. Original archive bytes remain unchanged.
Extraction and bounded reads admit the complete decoded TAR stream through the raw meter before either backend's TAR parser runs. This applies to plain TAR, gzip, and native-supported zstd/bzip2, without changing native-mode availability or fallback policy. The existing TypeScript and Rust meters enforce the same framing rules before parser normalization:
- Every nonzero header must have a valid unsigned octal checksum, delimited
within its field. Checksum validation precedes metadata allocation and member
policy. Fixed name, prefix, and linkname fields require strict UTF-8 and NUL
padding. Raw hardlink (
1) and symlink (2) headers require a nonempty linkname; every other type, including PAX/GNU metadata, requires an empty linkname. This check precedes metadata handling and member/filter policy. - Directory (
5), hardlink (1), and symlink (2) raw headers must declare zero body bytes, whether or not local PAX metadata is present. Valid zero-size links remain subject to the existing link/filter policy. - EOF requires two consecutive, complete 512-byte zero blocks at a header boundary. A header after just one zero block, a missing/partial EOF marker, and any nonzero bytes after EOF reject. Additional zero padding after EOF may have any byte length within the decoded ceiling; zero blocks inside a declared member body are payload.
- Headers and padded bodies must be complete. Size fields accept unsigned
octal with ASCII-space/NUL padding or supported positive base-256 encoding;
malformed numbers and non-padding bytes after a NUL reject. Raw sizes and
padded sizes must fit
Number.MAX_SAFE_INTEGER, even with PAX overrides, before member budgets are considered.
Framing failures use ArchiveFormatError("archive-header-invalid"), except
invalid UTF-8 or nonzero bytes after the first NUL in fixed name, linkname,
and USTAR prefix fields, which use ArchiveSecurityError("entry-path").
Missing linknames on links and nonempty linknames on non-links still use the
format error. PAX x and GNU long-name/long-link L/K payloads retain their
existing support and metadata limits; the zero-body rule is not applied to all
non-regular types.
PAX effective sizes still determine regular-member framing. Admission preserves
the input bytes, and all entry/path/byte limits and extraction deadlines remain
in force. Native inspection now completes this admission pass before parsing,
requiring one additional streaming read/decompression pass.
JavaScript admission reports an ordered logical-member manifest from the raw
meter, bounded by entry-count, manifest, and decoded limits. Policy runs once
over that manifest; extraction checks parser-visible members against the
accepted decisions before writing. Original member names and USTAR prefixes
are validated even when overridden, and non-padding bytes after a fixed path
field's NUL terminator reject rather than hiding an unsafe suffix.
Both meters enforce the 255-byte component ceiling under NFC and NFD before
metadata replaces a raw path, including Hangul decomposition expansion.
Native extraction and entry reads also drain their metered readers through
physical EOF after parser traversal, before completing directory modes,
publishing staged files, or returning the requested bytes. Finding the requested
member or reaching the parser's logical EOF cannot bypass trailing validation.
The raw meter enforces maxEntries before consuming each logical member's body,
including members later skipped by filtering or stripping. PAX/GNU metadata
headers do not count as members; their payloads use maxMetaEntryBytes.
The meter does not receive maxEntryBytes or maxExtractedBytes: those payload
budgets apply only after strip/filter acceptance, using declared effective
sizes and excluding block padding. JavaScript's entry checker and the native
accepted-plan builder retain this shared policy. Every TAR admission/parser
pass has a separate absolute decoded ceiling:
maxExtractedBytes + maxArchiveBytes, safely clamped to
Number.MAX_SAFE_INTEGER (768 MiB with defaults). It counts every admitted
decoded byte: headers, bodies, metadata, all block padding, both EOF blocks,
and zero padding after EOF. It bounds complete decoding before parser policy,
including all filtered/stripped content; cumulative metadata and zero tails
cannot bypass this bound. Exceeding this ceiling throws
ArchiveLimitError("archive-decoded-size-exceeds-limit").
The same TypeScript helper derives the ceiling for JavaScript and every native
TAR pass. Before selecting a backend, it caps internal metadata/decoded limits at
Number.MAX_SAFE_INTEGER and logical entry counts at 2^32 - 1. Larger finite
options such as Number.MAX_VALUE remain valid; high-level payload budgets keep
their large values. The decoded ceiling uses clamped maxExtractedBytes and
archive overhead with safe addition. Ordinary limits, including
zero and the existing defaulting/rounding rules, retain their behavior.
There is no new public option. This is an absolute decoded admission
cap, not a decompression-ratio policy; bounded stream/codec read-ahead remains.
After this complete preflight, the JavaScript backend disables node-tar's
independent ratio threshold so it cannot reject data that the native backend
accepts within the same absolute limits.
Extraction and single-entry reads accept one nonempty local POSIX x header
(USTAR or GNU header format) immediately before one regular/contiguous file,
directory, symlink, or hardlink. path, linkpath, and size override that
member only. Effective paths still pass traversal validation before stripping,
then the output paths pass depth and collision checks. The filter receives the
canonical effective pre-strip path, followed by link policy checks. PAX never
permits link creation. Effective sizes drive framing, filters, and the existing output-byte
budgets; maxEntries still counts members, not their metadata headers.
Records must have exact byte lengths, ASCII keys, a final newline, and no
duplicate keys, embedded newlines, or unconsumed bytes. Structural path and
linkpath values and ownership names must be nonempty printable ASCII. A PAX
member's raw name, USTAR prefix, and raw link target must also be printable
ASCII; raw link targets must be present only on links, even when overridden.
Unicode
PAX structural text is deliberately unsupported because the underlying parsers
do not agree when UTF-8 is split across input chunks. size, uid, and gid
must be canonical unsigned decimal safe integers (zero is valid; signs, leading
zeros, fractions, and exponents are not). Padded member sizes must also fit the
safe integer range. Raw and effective directory/link sizes must both be zero;
non-directory paths ending with a separator and linkpath on non-links are
rejected rather than allowing parser-specific type or framing changes.
The descriptive allowlist is mtime, atime, ctime (signed decimal seconds
with optional fractional digits, within JavaScript's Date range), uid, gid,
uname, and gname. These attributes are accepted but not restored to the
destination. LIBARCHIVE.xattr.* and SCHILY.xattr.* with nonempty ASCII
alphanumeric/dot/underscore/hyphen suffixes are also accepted as inert metadata,
never restored as extended attributes. Their values are byte-counted and may
contain NUL or non-UTF8 bytes, including macOS provenance metadata; embedded
newlines are rejected because they can disrupt downstream record parsing.
Global g, old X, old GNU N, empty/dangling/repeated local headers, mixed
PAX/GNU extension chains, unknown keys, charset declarations, ACL extensions,
and all sparse extensions (including GNU.sparse.*, SCHILY.filetype,
SCHILY.realsize, and SCHILY.size) fail closed with
ArchiveFormatError("archive-header-invalid"). GNU sparse extension blocks are still
metered in 512-byte units before rejection, preserving metadata-limit errors
for excessive chains. The per-body maxMetaEntryBytes limit bounds PAX storage
and duplicate-key state; one local header per member prevents local metadata
chains without introducing a new limit or changing defaults.
Both raw meters buffer GNU long-name L and long-link K bodies within
maxMetaEntryBytes before either TAR parser runs. A body must contain a nonempty
UTF-8 name, with either no NUL or exactly one terminal NUL. Embedded NULs,
additional terminal NULs, bytes after a NUL, and invalid UTF-8 reject with
ArchiveFormatError("archive-header-invalid"). The meters preserve original
archive bytes, including the optional terminator and block padding.
One logical member may have at most one L and one K, in either order.
Repeated metadata of either kind, mixed PAX/GNU chains in either direction,
and GNU metadata without a following member reject with the same format error.
Pending metadata is cleared only when its described member is admitted;
metadata records do not count toward maxEntries.
An L name undergoes raw-path validation before parser normalization, stripping,
or filtering; unsafe paths reject with ArchiveSecurityError("entry-path").
The validated name remains pending until its described header arrives. An
effective name ending in / or \ requires raw directory type 5 or D;
other types reject with ArchiveFormatError before filtering, preventing the
parsers from disagreeing about a member's type.
K validates encoding and NUL structure without authorizing link creation.
Normal link/filter policy still governs the described member. Canonical
pre-strip filter paths, decoded-stream ceilings, and physical EOF checks apply
to plain/gzip TAR and native zstd/bzip2 alike.
import { resolveArchiveKind, type ArchiveKind } from "@openclaw/fs-safe/archive";
const kind = resolveArchiveKind("upload.zip"); // "zip"
const tar = resolveArchiveKind("upload.tar.gz"); // "tar"
const zstd = resolveArchiveKind("upload.tar.zst"); // "tar-zstd" when native is available
const unknown = resolveArchiveKind("upload.bin"); // nullRecognizes:
*.zip→"zip"*.tar,*.tar.gz,*.tgz→"tar"*.tar.zst,*.tar.zstd,*.tzst→"tar-zstd"(native only)*.tar.bz2,*.tbz2,*.tbz→"tar-bzip2"(native only)
Returns null for unknown extensions; check the result before calling
extractArchive if the filename is caller-controlled. A recognized zstd or
bzip2 TAR extension with no native binding throws the typed
FsSafeError("helper-unavailable") with installation guidance. This includes
mode: "off"; those two formats have no JavaScript fallback.
For a service whose input contract requires zstd, configure native mode before the first archive call so a packaging mistake fails at the boundary:
import { configureFsSafeNative } from "@openclaw/fs-safe/config";
import { extractArchive } from "@openclaw/fs-safe/archive";
configureFsSafeNative({ mode: "require" });
await extractArchive({
archivePath: "/srv/restore/snapshot.tar.zst",
destDir: "/srv/restore/staging",
kind: "tar-zstd",
timeoutMs: 60_000,
});readArchiveEntry(archivePath, entryPath, { maxBytes, kind? }) reads one
regular-file entry into a bounded Buffer without extracting a tree. It pins
and privately stages the archive input, rejects link, directory, and duplicate
entries, verifies ZIP CRC and declared size,
and throws ArchiveLimitError if the requested entry's output exceeds
maxBytes. ZIP output within that cap must match the declared uncompressed
size exactly; either a shorter or longer payload throws
ArchiveFormatError("archive-header-invalid") before bytes are returned on
both JavaScript and native backends.
For TAR, maxBytes applies only to that requested entry: a larger
unrequested member remains valid within the default archive admission limits.
TAR traversal uses default entry-count, compressed-input, and metadata limits,
plus the 768 MiB decoded ceiling derived from default extracted/archive byte
limits. It does not apply payload budgets to unrequested members. ZIP
inputs retain the archive subpath's 256 MiB compressed-input ceiling.
With a native binding it uses the same Rust decoders as extraction, including
zstd and bzip2 TAR. Without native it retains the JS ZIP/TAR/gzip implementation.
Requested paths and effective member names use extraction's canonical pre-strip
identity: backslashes become /, and repeated separators and . components
are removed after raw-path validation. For example, ./pkg//value and
pkg\value both address pkg/value, including supported GNU/PAX and ZIP
Unicode Path names. Case and Unicode spelling are preserved. Requests ending
in / or \ still reject as non-files. Canonical duplicate members reject
before an unrelated requested entry can be returned.
const rawManifest = await readArchiveEntry(uploadPath, "package/manifest.json", {
maxBytes: 64 * 1024,
});
const manifest = JSON.parse(rawManifest.toString("utf8")) as PluginManifest;
validatePluginManifest(manifest);The archive subpath also exports the helpers extractArchive is built on. Most callers will not need them, but they are stable and documented:
| Function | Purpose |
|---|---|
withStagedArchiveDestination(opts) |
Creates a private staging dir outside the destination, calls your run(stagingDir), then cleans it up. |
mergeExtractedTreeIntoDestination(opts) |
The merge step alone — staged tree → destination through boundary checks. |
prepareArchiveDestinationDir(destDir) |
Canonicalizes and asserts the destination directory. |
prepareArchiveOutputPath({ destinationDir, destinationRealDir, relPath, outPath, originalPath, isDirectory, deadline? }) |
Validates and prepares parents for an already-resolved entry output path. |
loadZipArchiveWithPreflight(buffer, limits?) |
Loads a JSZip from a Buffer or Uint8Array with size/entry-count preflight before unzipping. |
readZipCentralDirectoryEntryCount(buffer) |
Returns the entry count from an already-loaded ZIP Buffer or Uint8Array without decoding payloads. |
createTarEntryPreflightChecker(opts) |
Returns a per-entry checker for use as a tar.x onReadEntry hook. |
These let you build custom extractors that share the same safety machinery — for example, a streaming uploader that wants to refuse archives with too many entries before reading any payloads.
archive-entry exports a handful of low-level helpers for entry-path normalization:
import {
isWindowsDrivePath,
normalizeArchiveEntryPath,
resolveArchiveOutputPath,
stripArchivePath,
validateArchiveEntryPath,
} from "@openclaw/fs-safe/archive";validateArchiveEntryPath(raw, opts)— throwsArchiveSecurityErrorfor.., absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.normalizeArchiveEntryPath(raw)— converts backslashes in the entry path to forward slashes.stripArchivePath(entryPath, n)— normalize separators, drop empty and.components, then strip the leading N components, returningnullif none remain.resolveArchiveOutputPath({ rootDir, relPath, originalPath, escapeLabel? })— combines the validated relative path with the root and rejects escapes using the original archive path for diagnostics.isWindowsDrivePath(value)— detects drive-relative segments such asC:secretornested/C:secretthat should be rejected.
Validate attacker-controlled paths before calling normalization or stripping
helpers. After validation, stripArchivePath(entryPath, 0) returns the canonical
pre-strip identity used by extraction filters (or null for an empty path).
import { extractArchive, ArchiveLimitError, ARCHIVE_LIMIT_ERROR_CODE } from "@openclaw/fs-safe/archive";
try {
await extractArchive({
archivePath: upload.path,
destDir: targetDir,
kind: "zip",
timeoutMs: 30_000,
limits: {
maxArchiveBytes: 100 * 1024 * 1024,
maxEntries: 10_000,
maxExtractedBytes: 200 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
},
});
} catch (err) {
if (err instanceof ArchiveLimitError) {
return reply(413, { code: err.code, message: err.message });
}
throw err;
}const kind: ArchiveKind = mime === "application/zip" ? "zip" : "tar";
await extractArchive({ archivePath, destDir, kind, timeoutMs: 10_000 });import { withTempWorkspace } from "@openclaw/fs-safe/temp";
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "extract-" }, async (ws) => {
await extractArchive({
archivePath: upload.path,
destDir: ws.dir,
timeoutMs: 30_000,
});
await replaceDirectoryAtomic({
stagedDir: ws.dir,
targetDir: "/srv/site/plugin",
});
});- Atomic writes —
replaceDirectoryAtomicfor staged directory replacement. - Temp workspaces — extract into a private workspace and commit as one step.
- Errors —
FsSafeErrorcodes the underlying writes can raise. - Migrating to 0.5 — clamp-default and native-format upgrade checklist.
extractArchivesource.