All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Case-insensitive containment check accepted a sibling of the destination root sharing a name prefix on macOS/Windows (GHSA-wcmx-7f9h-5mv5):
paths_start_with(crates/exarch-core/src/types/safe_path.rs), used bySafePath's macOS/Windows containment check, compared paths as raw lowercased strings, so/tmp/destevilwas wrongly treated as contained within/tmp/dest("...destevil".starts_with("...dest")is true with no component boundary). The check now comparesPath::components()pairwise, case-folding each segment, matching the already-correct non-macOS Unix behavior except case-insensitively.
-
exarch-cli's--atomic --forceswap path now bundles the pinned temp directory'spin/name/id/parent_displayidentifiers into aTempOrphanRefstruct (#538) instead of threading the same four parameters throughmove_destination_to_backup(7 params, down to 4) anddescribe_final_swap_failure(7 params, down to 4) to reach each of the sixdisclose_if_orphanedcall sites individually. No behavior change. -
Bumped
sevenz-rust2from 0.21.4 to 0.21.5, pulling in a transitivelzma-rust2bump from 0.18.0 to 0.19.0 (#548):sevenz-rust20.21.5 batches AES-CBC block decryption, a 7z-extraction performance improvement on AES-encrypted archives — currently unreachable inexarch-core, since every call site usesPassword::empty()and encrypted 7z archives are rejected before decryption is attempted, so this is a forward-looking perf improvement rather than an observable behavior change. The transitivelzma-rust2bump picks up the out-of-bounds LZ encoder fix that actually landed in 0.18.1 (upstream #107, an encoder-only panic on single-stream encodes above roughly 2 GiB); 0.19.0 itself minor-bumped separately to add sans-I/O LZMA1/LZIP decoders (upstream #109).lzma-rust2's encoder surface is unreachable inexarch-coretoday, since 7z archive creation is unsupported, so the encoder fix is defense-in-depth for a currently-dormant code path. OnlyCargo.lockchanges;sevenz-rust2 = "0.21.4"inCargo.tomlis left as-is since the caret requirement already admits 0.21.5.
0.6.0 - 2026-08-04
-
Reviewed and confirmed the symlinked-destination-root policy outside
--atomic --force(#533): following GHSA-x8wr-7ww2-c94x's fix restricting--atomic --force(below), we audited whether the same rejection should extend elsewhere. It does not:DestDir::new/new_or_create— used by plainextractand by the Rust/Python/Node APIs — continue to accept a symlinked destination root and resolve it viacanonicalize(), matchingtar -C/unzip -d, with containment unaffected since every extracted path is still validated against the canonical root. (--atomicwithout--forcealso reachesDestDirunchanged, but a symlinked destination there still fails end-to-end with an I/O error at the later rename step — a pre-existing, separate limitation, not a rejection this change introduces or a case this policy needed to cover.) Documentation and test change only; no runtime behavior changes. -
extract --atomic --forceresolved the destination-swap rename/remove sequence by path on every call, leaving a TOCTOU window where replacing an intermediate path component with a symlink mid-extraction could redirect the swap outside the intended destination (#526):run_atomic_force_extraction(crates/exarch-cli/src/commands/extract.rs) now pins the destination's parent directory with an open file descriptor (commands::atomic_swap::PinnedDir, Unix only) once, and performs every subsequent rename/remove*at-relative to that descriptor instead of re-walking the path, closing the window for any intermediate path component. Adev/inoidentity recheck immediately before the destructive swap narrows — it does not close — the much smaller remaining window around the final component itself changing between the initial snapshot and the swap; a mismatch aborts the swap with a distinct error instead of proceeding. Non-Unix targets keep the previous path-based behavior (documented residual, not a regression: symlink creation is a privileged operation on Windows). Addsrustix(already present transitively viatempfile/xattr) as a direct, Unix-only dependency ofexarch-cli.Behavior change on Unix:
--atomic --forcenow requires read permission on the destination's parent directory for every invocation, including when the destination does not yet exist (needed to obtain a real directory file descriptor; there is no portable Unix equivalent of Linux's permission-freeO_PATHfor this). It does not require read permission on the destination directory itself — the identity recheck usesstatat, which needs only search permission on the already-open parent. -
extract --atomic --forcefollowed a destination that was itself a symlink (GHSA-x8wr-7ww2-c94x): the destination was resolved by path —exists(),is_dir(), thencanonicalize()— and the swap's parent and entry name were both derived from the canonicalized result, so a symlink at the destination silently retargeted the swap at the directory it pointed to. Because the swap ends inremove_dir_allon the displaced backup, this destroyed the redirected directory's original contents: attacker-directed destructive replacement of any directory writable by the invoking user, requiring only write access to the destination's containing directory and no race to win. The destination's parent is now canonicalized and pinned by a file descriptor, while the destination's own name is taken lexically and never canonicalized; its type is checked withstatat(SYMLINK_NOFOLLOW), so every check and every rename names the same entry inside the same pinned parent.Breaking change:
--atomic --forceonto a destination that is itself a symlink (or, on Windows, a junction or other reparse point) is now rejected on all platforms — pass the resolved target path instead (--atomic --force "$(readlink -f /path/to/link)"). Symlinked intermediate path components remain supported, and a destination that is a symlink to a regular file now reports the symlink error rather than #525's "not a directory". Scope: only--atomic --forceperforms this swap and only it applies this restriction; plainextract,--atomicwithout--force, and the Rust/Python/Node APIs still resolve a symlinked destination root throughDestDir(see #533). -
PartialExtraction-wrapped errors lost their category-specific HINT text (#527):convert_extraction_error(crates/exarch-cli/src/error.rs) special-casedArchiveError::PartialExtractionwith an early return that discarded the per-variantmatchbelow it entirely, replacing every category-specific HINT (e.g.--allow-symlinks, or the "cannot be relaxed via any policy flag" wording for forged-size violations) withPartialExtractionContext's generic one. It now recurses intoconvert_extraction_errorfor the wrapped source first, then layersPartialExtractionContexton top as additional context — preserving both the HINT and (for the variants whose CLI-authored context does not itself re-embed the inner error's data) the #204 guarantee that the inner error text appears exactly once in{:#}output. -
ZIP extraction trusted the archive-declared uncompressed size for zip-bomb ratio detection and quota reservation, but never verified it against what decompression actually produced (GHSA-5j8q-wxg5-hj4r):
formats/zip.rs'sZipEntryAdapter::get_sizesreadsuncompressed_sizestraight from the entry's local/central-directory header — attacker-controlled bytes — and that same declared value fed bothsecurity::zipbomb::validate_compression_ratioandQuotaTracker::reservebefore a single byte was decompressed. The actual copy (copy::copy_with_buffer, invoked fromformats::common::extract_file_with_permit) then read from the real DEFLATE-decompressing stream in a loop with no ceiling tied to the declared/reserved size, so a ZIP entry declaring a smalluncompressed_sizealongside a real DEFLATE stream that inflated to a much larger payload extracted successfully, writing far more than the configuredmax_file_sizeto disk with no warning.copy_with_buffernow takes the declared size as anexpected_sizeparameter and enforces it as a hard streaming ceiling — checked after every buffered read, not only at the end — aborting withArchiveError::SecurityViolationthe instant actual bytes exceed it, and rejecting a short stream (fewer bytes than declared) the same way once EOF is reached; a legitimate encoder never lies about this, so the post-copy check is exact-match, not a tolerance. (SecurityViolationrather thanQuotaExceeded, deliberately:expected_sizehere is the archive's own possibly-forged size, notconfig.max_file_size, so a quota-shaped error would carry the CLI's "raise --max-file-size" hint — wrong advice for a metadata mismatch.)extract_file_with_permit(shared by TAR and ZIP) wraps the write in aTempFileGuardso an aborted copy removes the file it created instead of leaving a partial one on disk; when overwriting a pre-existing destination (--force,skip_duplicates = false) the guard is not armed, since that path already truncates the destination in place before any size check runs and deleting the now-truncated file on abort would only destroy more of what was there, not less.7z's two write paths (
write_file_direct,write_file_with_permit) previously bypassedcopy_with_bufferentirely viastd::io::copy; both now route through it withentry.sizeas the declared size. TAR (via thetarcrate'sEntryreader) and 7z (viasevenz-rust2's bounded reader) already cap how many bytes a single entry's reader yields to its own declared size upstream, so for those two formats the new ceiling is defense-in-depth against a future upstream regression rather than closing a live vulnerability the way it does for ZIP — only the short-stream/mismatch direction of the new check is reachable through them today. Applying it uniformly still closes the structural gap (no format-specific patch needed if that upstream guarantee ever changes) and keeps all three formats' extraction paths consistent. -
exarch-core: 7z'sskip_duplicates = falsepath silently replaced a pre-existing symlink at the destination instead of rejecting it, and quota was reserved before the duplicate-skip decision (#477, #478): the #468 fix below (skip_duplicates.symlink_metadata()) closed the duplicate-detection gap forskip_duplicates = true, but as that entry's own text noted,skip_duplicates = falsewas left unresolved and tracked separately here.process_entry_innernowlstats the destination via a sharedlstat_desthelper before doing anything else with it: withskip_duplicates = false, a symlink there (dangling or live) now fails with the sameELOOPI/O error TAR/ZIP'sO_NOFOLLOWopen produces, instead ofwrite_file_with_permit's temp-file-then-renamesilently unlinking and replacing it —rename(2)itself never followed the symlink even pre-fix, so this was a silent-replacement bug, not a symlink-escape (content never wrote through the link to its target). A regular file or directory at the destination is still overwritten as before; only a symlink is now rejected. Separately, quota (reserve_file, a newEntryValidatormethod mirroring the existingreserve_hardlink) is now reserved after this check and after the duplicate-skip decision, not before: previously every entry's quota was reserved unconditionally viavalidate_entrybefore the destination was even inspected, so an entry skipped as a duplicate permanently consumed its file-count/byte-size allotment (QuotaPermithas noDropimpl to release it). Both fixes apply identically toSevenZArchive::extract's Step 1 pre-validation pass, which previously used neither check, so a symlink-at-destination or a since-skipped duplicate could pass pre-validation and only fail (or over-consume quota) partway through the later extraction pass. NewEntryValidator::validate_entry_pathsplits path validation out ofvalidate_entryso callers needing the destination path before deciding on quota (7z's duplicate check, mirroringreserve_hardlink's existing decoupling for hardlinks) no longer have to reserve quota just to get it. -
A pre-planted symlink at a predictable/checked destination path bypassed a
Path::exists()-style duplicate check, and the subsequent non-exclusive write followed it outside the extraction root (#471, #467): two more instances of the vulnerability class fixed for TAR/ZIP's normal-file write path in #459 below, found in the two write paths that fix did not cover. In 7z'swrite_file_with_permit(formats/sevenz.rs), the temp-file-then-rename write path derived its temp file name from the process PID and a per-process monotonic counter — predictable — and opened it with a plainFile::create, which follows an existing symlink (dangling or not) instead of refusing it; fixed by opening withOpenOptions::create_new, retrying with a fresh counter value onAlreadyExistsup toMAX_TEMP_FILE_CREATE_ATTEMPTS(8) times. In TAR'screate_hardlink(formats/tar.rs),Path::exists()returnsfalsefor a dangling symlink at the hardlink's destination, so a pre-planted one bypassed the duplicate-detection check entirely, and the subsequentstd::fs::copyfollowed it; fixed by opening the destination withOpenOptions::create_newfirst — folding the duplicate check into theopen()call itself — then copying the hardlink target's content into the already-open handle via a newcommon::copy_file_content_with_permit, which replaces the now-removed path-basedcommon::copy_file_with_permit. Both fixes share a newcommon::TempFileGuardRAII cleanup type (hoisted out offormats/sevenz.rs, previously private to that module) so a fallible step between file creation and the operation's success point does not leave a partial artifact behind on the error path. Both preconditions require an attacker-writable destination directory, not a malicious archive alone.create_hardlink's read side had a narrower version of the same class: hardlink targets are validated for containment in a first pass (HardlinkTracker::validate_hardlink, resolving on-disk symlinks as of that point in time) but the two-pass design defers actually reading the target to a later, second pass with nothing re-validating it in between — a plain path-basedFile::open/std::fs::metadatain that second pass would silently follow whatever ended up at the target path by then, which an attacker with write access to the destination could swap out after the first pass validated it (TOCTOU, not an unconditional read: a symlink present before the first pass runs is already rejected there, per #116). The same path-basedstatalso left quota sizing vulnerable to the same swap, independent of the read TOCTOU (bypassing #426's per-hardlink accounting). Both are closed by a newcommon::open_no_follow, which opens the target exactly once withO_NOFOLLOW(Unix), so any symlink present by the second pass fails the open instead of being followed;copy_file_content_with_permitnow takes that already-open handle instead of a path, and the quota reservation is sized from the same handle'sfstatrather than a separatestatcall. A symlink at the target is not automatically treated as an attack, since it is a legitimate archive shape for a hardlink's target to be a symlink created earlier in the same extraction (already first-pass-validated): onopen_no_followreturningELOOP,create_hardlinkre-runs the first pass's ownresolve_through_symlinkscontainment check against the current on-disk state and, if it still resolves inside the destination, opens the resolved path instead of failing outright. -
Dangling symlink at the extraction destination bypassed the duplicate-check and allowed writing outside the destination root (#459, pre-existing, TAR and ZIP): the duplicate-existence check used
Path::exists(), which follows symlinks and returnsfalsefor a dangling one. A symlink already present at the destination path — planted by something other than the archive being extracted, sinceSafeSymlink::validatealready prevents an in-archive symlink entry from escapingdest— was therefore treated as "no duplicate," and a plainFile::createfollowed the link, writing the entry's content outside the extraction root. Fixed by opening the destination file withO_EXCL(viaOpenOptions::create_new, already required for theskip_duplicates=trueduplicate-detection path) and, on Unix, unconditionally withO_NOFOLLOW(OpenOptionsExt::custom_flags): both reject an existing symlink, dangling or not, instead of following it, closing the escape on bothskip_duplicatesvalues (theskip_duplicates=falseoverwrite path previously had no protection at all). Added regression tests planting a dangling symlink at the destination path before extraction for bothskip_duplicatessettings. Precondition is an attacker-writable destination directory, not a malicious archive alone. -
File permissions were applied via a path-based
set_permissions()afteropen(), reopening a TOCTOU window (#460): the sameformats::common::create_file_with_modehelper enforced the sanitized (setuid/setgid-stripped) mode withstd::fs::set_permissions(path, ..), which re-resolvespathfrom the filesystem root rather than operating on the already-open file descriptor, letting a concurrent attacker swap the path for a symlink betweenopen()andset_permissions(). Switched toFile::set_permissions(&file, ..), which applies the mode viafchmodon the open descriptor and cannot be redirected by a later filesystem change. -
listaccepted NUL bytes, empty targets, and missing targets thatextract/verifyalready rejected (#430):list_tar_entriesandlist_zip_readervalidated entry paths for path traversal but not embedded NUL bytes, unlikeSafePath::validate(used byextract). For TAR, this meantexarch list/list_archivesilently returned a NUL-containing path as an ordinary entry instead of rejecting the archive. ZIP was affected differently: thezipcrate's ownenclosed_name()already rejects a NUL-containing name under the default configuration, so that case was already rejected pre-fix — just via the wrong mechanism (PathTraversal, fromenclosed_name()returningNone, rather than a NUL-specific error). Only ZIP'sallow_absolute_pathsfallback path — which reads the raw entry name directly, bypassingenclosed_name()— was genuinely silent pre-fix, the same way TAR was unconditionally. The same asymmetry existed for symlink/hardlink targets relative to the checksextractgained in #424: an empty or NUL-containing target, or (TAR only) a symlink/hardlink entry with no target at all (link_name()returnsNone— no ustar linkname field and no PAXlinkpathoverride), was silently accepted bylist.list_tar_entriesandlist_zip_readernow reject a NUL byte in the entry path (checked before path-traversal, in both formats, for consistent ordering), and a newvalidate_link_targethelper (mirroringSafeSymlink::validate/HardlinkTracker::validate_hardlink's wording, though it only checks emptiness and NUL bytes — it does not givelistfull parity withextract's target validation) rejects an empty or NUL-containingsymlink_target/hardlink_target;list_tar_entriesseparately rejects aNonelink target with the sameInvalidArchivemessageextractuses ("symlink missing target"/"hardlink missing target"). 7z listing was not changed:sevenz_rust2::Archive::readdecodes each entry name as UTF-16 and stops at the first zero code unit while parsing the header, so an embedded NUL cannot reachlist_sevenz_archivein the first place.verify_archive's report-based behavior is preserved:verify_archivelists the archive as a pre-flight step before building its report, andverify_entryalready had its own working graceful handling for all of the above (a NUL-byte entry path viavalidate_path, and an empty/ NUL/missing link target viavalidate_symlink/validate_path, added in #424) — surfacing each as aVerificationIssuerather than aborting. An earlier round of this fix let the new list-level checks abort that pre-flight step beforeverify_entryever ran, silently turning those graceful reports into hardErrresults (and, for the Python/Node bindings, a report object into a raised exception) — caught and reverted before merging.listing_config_for_verifynow also relaxes the list-level NUL-byte/empty/missing-target checks (SecurityConfig::relaxed_for_verify_preflight, a crate-internal config flag, not part of the public builder API) forverify_archive's pre-flight listing call specifically, soverifycontinues to report rather than abort. Bareexarch list/list_archiveis unaffected by this flag and still hard-aborts on all of the above — that hard behavior is the actual #430 fix. -
TAR metadata-entry decompression bomb (#414): GNU long-name (
L), GNU long-link (K), and PAX extended header (x/g) records are buffered fully into memory by thetarcrate's internals before any entry reachesexarch-core's validator or quota tracker, so a crafted record declaring a multi-gigabyte length backed by a tiny compressed stream caused unbounded allocation with no quota enforcement (measured: a 765 KB.tar.gzreached 4.95 GB peak RSS onextract, 3.29 GB onverify, 2.49 GB onlist). AddedSecurityConfig::max_tar_metadata_bytes(default 4 MiB, 16 MiB forSecurityConfig::permissive()) enforced by a newformats::tar_metadata_limitread-budget mechanism: a reader wrapper meters bytes thetarcrate reads while searching for the next entry (headers, long-name/long-link/PAX records, GNU sparse extension blocks) and errors once the budget is exceeded, before any oversized allocation completes. Applied uniformly toextract_archive,list_archive, andverify_archive(includingTarArchive'sArchiveFormattrait methods), since all three previously openedtar::Archiveindependently.An initial version of this fix re-parsed TAR headers in a shadow parser to reject an oversized declared size before the
tarcrate could buffer it. Three rounds of adversarial review each found a fresh case where that shadow parser's belief about entry framing diverged from thetarcrate's own (an untracked PAXsize=override hiding a bomb behind a mis-framed decoy entry; the same bypass again when the overriding PAX header had invalid magic, sincetaronly honors an override when the header isis_recognized_header; and again via a PAX global header drainingtar's override state without draining the shadow parser's mirrored state) — three independent divergences in three rounds, each closed individually but never provably exhaustive. The mechanism actually shipped replaces the shadow parser entirely: it never parses a header, typeflag, magic byte, or PAX record, so there is exactly one parser (thetarcrate's own) and nothing left to diverge from it. Every yielded entry is fully drained (bounded, not run to true EOF — an unbounded drain would let a crafted GNU sparse entry's synthesized zero-padding become a separate unbounded CPU sink) before the budget re-arms for the next gap, so the budget never depends on any declared size, override, or magic validity.A follow-up review found that the drained-on-drop bound above was itself sourced from the caller's
max_file_sizequota — a valueinspection::verify::listing_config_for_verify(theverify_archivepre-listing pass) legitimately relaxes tou64::MAXso metadata-only listing does not false-reject large files. A GNU old-format sparse entry (typeflag 'S') can declare arealsizefield up tou64::MAX(via GNU base-256 encoding) while backed by a single physical block, so the relaxed quota silently defeated the drain bound onverifyspecifically, reintroducing an unbounded, memory-invisible (drained toio::sink, so no corresponding heap growth) CPU-exhaustion hang scaling linearly with the attacker-chosenrealsize(measured: a 113-byte.tar.gzdroveverifyto 3.98 s at a 400 GiBrealsize, with throughput implying au64::MAXvalue would hang for years).Two successive fixes that instead sourced the drain bound from a fixed value (first
max_tar_metadata_bytes, 4 MiB, on the sharedlist/verifypath; then, after that turned out to false-reject any archive with a single entry over ~8 MiB, an "absolute cap" of 16 MiB applied everywhere) were each found to reopen the same class of bug in the opposite direction:list,verify, and evenextract(via the CLI'slist_archivepre-flight) rejected perfectly ordinary archives — a ~765 KB legitimate file was rejected with aSecurityViolation— becauselist/verifynever read entry content at all, so the drain is the only thing consuming a legitimate entry's real bytes to reach the next header, and any fixed cap on total drained output eventually clips some legitimate entry's real content, however generous the cap.The mechanism that actually shipped bounds the drain by synthesized bytes instead of total output:
formats::tar_metadata_limit::BudgetedReadernow tracks a monotonic count of bytes actually read from the underlying reader, andTarEntryGuard::dropdrains in a loop comparing bytes output so far against bytes actually read during the same drain. A legitimate entry's drain consumes real bytes 1:1 with what it outputs, so this "synthetic" gap stays at (or within a read-chunk's rounding of) zero regardless of entry size, and the entry always drains to completion; GNU sparse zero-padding is the opposite —taryields it fromio::repeat(0)with no corresponding read — so the gap grows every iteration and trips a small, fixed, non-configurable cap almost immediately regardless of the declaredrealsize. This closes both directions of the bug from one mechanism, without ever inspecting a header field to tell the two cases apart. -
Symlink/hardlink targets were not validated for embedded NUL bytes or emptiness (#415): the link path was already checked for NUL bytes and emptiness via
SafePath::validate, but the target (linkname) was not — a NUL byte in the target fell through to the OS as a rawio::Errorinstead of a structuredSecurityViolation, an empty target was silently accepted (creating a dangling symlink on platforms that allow it), and a NUL-containing hardlink target could be embedded verbatim into a formatted error message further downstream.SafeSymlink::validateandHardlinkTracker::validate_hardlinknow apply the same NUL-byte and emptiness checks to the target as the link path (types::safe_path::has_null_bytesmadepub(crate)for reuse), without ever embedding the raw target bytes into an error message. A short (<=100 byte) linkname written through thetarcrate's own header field cannot carry an embedded NUL byte or reach an empty value while non-None, so the regression tests use a GNULongLink(K) record's raw payload to smuggle both past the header-field-level shortcuts, exercising the same path a real crafted archive would take. -
TAR extension-filter skip has no cumulative bound on synthesized drain across many small sparse entries (#422): the per-entry synthetic-byte drain cap from #414 bounds the cost of any single unread GNU sparse entry, but when
SecurityConfighas an extension allowlist configured, entries that fail the extension check are skipped beforeQuotaTrackerever runs (intentional, per #421's fix for quota double-counting) — somax_total_size/max_file_countprovided no cumulative bound across many such pre-quota skips (measured: a ~20 MB archive of 20,000 small extension-filtered GNU sparse entries took ~1.41 s to extract, versus ~150 ms for the same archive without an extension filter, which hitsQuotaExceededimmediately). Added a second, cumulative counter toformats::tar_metadata_limit::TarReadBudget: everyTarEntryGuard::drop's own synthesized-byte count is now summed across the whole archive-open operation, andBudgetedEntries::next_entryfails fast with aSecurityViolationonce the sum exceeds a fixed, non-configurable 1 GiB cap (roughly 128 maximally-saturating entries), rather than continuing to drain further entries. The check lives innext_entryitself, so it applies uniformly toextract_archive,list_archive, andverify_archiveregardless of extension-filter ordering — includinglist_archive/verify_archive, which skip every entry unconditionally (they never read entry content at all), not only extension-filtered ones.An initial version of this fix used a much tighter 64 MiB cap (8x the per-entry cap). Adversarial review found that gave any legitimate archive of
list/verify/extension-filtered-extractsparse entries only 8 entries of headroom before false-positiving with aSecurityViolation, sincelist/verifydrain every entry unconditionally. The cap was raised to 1 GiB: worst-case cost stays a fixed, sub-second amount of wastedio::sinkthroughput regardless of entry count, while legitimate multi-entry sparse archives now have generous (~128-entry) headroom. This widens this module's existing documented residual limitation (a legitimate GNU sparse file with real holes larger than the per-entry cap is indistinguishable from an attack when skipped unread) from a single oversized entry to roughly a hundred such entries in aggregate — still an accepted, documented trade-off (P3), not a new bug. -
Bumped
sevenz-rust2from 0.21.3 to 0.21.4, fixing an integer overflow when summing attacker-controlled coder stream counts while parsing a 7z block header (upstream #127). Malformed archives previously could panic in debug builds and bypassed the stream-count bound in release builds; they are now rejected with an error (#397). This also pulls in a transitivelzma-rust2bump from 0.16.5 to 0.18.0 (required by sevenz-rust2's own^0.18dependency), which rearchitects the LZMA2/XZ decoders into a sans-I/O design — no known advisories against either version; audited with no regressions found. -
Release profile aborted on panic, silently disabling FFI panic guards (#395): the workspace
[profile.release]setpanic = "abort", which made everycatch_unwindguard inexarch-pythonandexarch-nodedead code in published wheels and npm packages — a Rust panic insideextract_archive/create_archive/etc. aborted the whole Python or Node.js process instead of surfacing as a catchable exception/error. Removedpanic = "abort"fromCargo.tomlso release builds unwind (this also letsexarch-cli'sDropimpls run cleanup on panic). Added a compile-timeconst _: () = assert!(cfg!(panic = "unwind"), ...)guard near the top of both binding crates'lib.rsso any future reintroduction (workspace profile,.cargo/config.toml, orRUSTFLAGS) fails the build instead of silently reintroducing the vulnerability. Also closed a related gap inexarch-python: the progress-callback branches ofcreate_archive_with_progressandextract_archive_with_progresshad nocatch_unwindat all (only the no-callback branch was guarded) — both branches are now wrapped, mirroringexarch-node's existing helper shape. Added runtime regression coverage of thecatch_unwind-> exception/error conversion itself:exarch-nodegained a Rust-level test that calls the realcatch_panic_as_js_errproduction helper with a panicking closure and asserts a catchableErrorcomes back;exarch-pythongained an end-to-end pytest (test_panic_safety.py) that triggers a real panic through the compiled extension module via apanic-injection-feature-gated test hook and asserts a catchableRuntimeErroris raised instead of the process aborting (the feature is only enabled by CI'stest-pythonjob, never in published wheels).
-
7z extraction was 35-101% slower than
sevenz_rust2::decompress_fileon the same archive, scaling with file count (#492):formats::sevenz::write_file_with_permit_usingalways wrote through a temp-file-then-renameeven when nothing occupied the destination path, costing two extra syscalls (open+rename) per extracted file — profiling attributed 45-54% of extraction CPU time to this path.process_entry_innernow writes directly todest_pathviacommon::create_file_with_mode(sameO_EXCL+O_NOFOLLOWguarantee, mirroring TAR/ZIP'scommon::extract_file_with_permit) when no pre-existing file is found there, and keeps the atomic temp+rename path only for the overwrite case where a decode failure mid-stream must not leave a truncated file behind; both write paths now also buffer output through a 64KiBBufWriter, matching TAR/ZIP'scommon::extract_file_with_permit, and the direct-write path removes whatever it started writing if the copy fails partway, so a decode failure never leaves a truncated file at the final destination either.SevenZArchivealso now retains its already-parsedsevenz_rust2::Archivefromnew()and clones it intoArchiveReader::from_archiveat extraction time instead of re-parsing the archive header a second time (the clone, not a move, keeps a secondextract()call on the same instance working correctly). Re-measured via a newsevenz_vs_referencecriterion bench group (crates/exarch-core/benches/extraction.rs,medium_files.7z/small_files.7z): the gap againstsevenz_rust2::decompress_fileclosed from 35%/101% slower to within noise of parity (andmedium_files.7znow edges ahead). A companionsevenz_overwritebench group exercises the temp+rename path specifically.An earlier version of this fix also threaded
Some(dir_cache)into bothEntryValidator::validate_entry_pathcall sites insevenz.rs(matching TAR) to enable the trusted-parent canonicalize-skip. Adversarial review found this letDirCachetrust a directory that something outside the archive's own entries — e.g. a misbehavingProgressCallback— swapped for a symlink between two entries sharing that parent, turning a hard-erroring symlink escape into a silent, unbounded one; both call sites were reverted toNonebefore merge, so this specific optimization is not part of this change. -
Extracting archives with many small files regressed ~15.8% after #436/#437/#439 (#446, partial recovery):
formats::common::extract_file_with_permit's duplicate-detection now folds the existence check into the file-creationopen()call (see theO_EXCL/O_NOFOLLOWentry under Security below) instead of a separateoutput_path.exists()stat followed by a truncating create — one fewer syscall per extracted file. This is primarily the security fix described below; the syscall reduction is a side-benefit, not the reason it was made. Also added#[inline]toEntryValidator::validate_entry/check_ratioandSecurityConfig'sDeref::deref, on the hot per-entry validation path. Re-verified via a controlled same-session A/B (criterion --save-baseline):many_small_files/10000improved -8.3% to -8.9% (p <= 0.01, reproduced twice);/100and/1000showed no significant change. This is a partial, not full, recovery of the confirmed +15.8% regression — the regression's root cause was not otherwise identified, and full parity against the original CI baseline still needs confirmation on CI hardware rather than local benchmarks (which showed >20% same-commit swings during this investigation). -
many_small_files/file_count_scalingbenchmarks were timingTempDircleanup as part of extraction cost (#446):benchmark_many_small_filesandbenchmark_file_count_scalingincrates/exarch-core/benches/extraction.rstimedTempDir::drop()(recursive deletion of every extracted file) inside criterion'sb.iter(), alongside theZipArchive::extract()call being measured —sampleprofiling attributed ~87% of themany_small_files/10000wall time to this cleanup, not to extraction. Switched both benchmarks tob.iter_custom, excluding onlyTempDir::new()/drop()from the timed window. dhat heap-allocation profiling showed byte-identical allocations across the regression window, and re-measurement on the corrected harness shows no residual regression vs. the ci-076 baseline: the originally reported +15.8% was inflated by this harness bug on top of the real regression already addressed above by #470's syscall reduction. This dev machine's benchmark noise floor (40-80% run-to-run variance under shared load, observed during this investigation) still exceeds the project's 10% regression threshold, and no CI workflow currently runscargo bench(bench-buildonly compiles benchmarks) — the "confirmation on CI hardware" noted above is not currently achievable and should be read as aspirational pending a dedicated benchmark-running job, not a completed step.
-
CI: add
bench-buildjob to.github/workflows/ci.ymlthat compiles theexarch-corecriterion benchmarks (--all-features, covering thetesting-gatedvalidationbench) without running them, catching benchmark compilation breakage in a fast parallel job. -
exarch-node: addcreateArchiveWithProgressandcreateArchiveWithProgressSync, mirroring the existingextractArchiveWithProgressasync/sync pattern andexarch-python'screate_archive_with_progress. Both reuseexarch_core::create_archive_with_progressand the existingNodeProgressAdapterthreadsafe-function bridge — no new core security logic (#455). -
exarch-node: add JS integration tests forextractArchiveWithProgress,createArchiveWithProgress, andcreateArchiveWithProgressSynccovering the per-entry callback shape and theprogress=null/omitted paths, closing a coverage gap where these APIs had no test exercising them (#456). -
Regression test coverage mapping node-tar GHSA vulnerability classes onto the TAR extraction pipeline (#399): GHSA-vmf3 (PAX/GNU long-name/long-link record smuggling and stream re-framing), GHSA-gvwx / GHSA-w8wr (NUL byte and malformed-field handling in PAX and GNU long-name records), and GHSA-23hp (declared-vs-actual entry size mismatches). All 13 cases currently pass — protection is inherited from the
tarcrate dependency, so these lock in that behavior against a future dependency bump. -
Regression test coverage for the
max_tar_metadata_bytesread-budget mechanism (#414):tests/security/tar_metadata_bomb.rscoversextract/list/verify/extract_archiveagainst oversizedL/K/xrecords, a false-positive check for legitimate long paths, and the three historical shadow-parser bypass shapes (untracked PAXsize=override, invalid-magic variant, and PAX-global-header state drain) reconstructed to confirm the budget mechanism rejects all three regardless — none of those shapes are individually meaningful to it any more, since it does not parse headers at all.tests/tar_alloc_bound.rs(its own top-level test binary, sincedhat's counting allocator is process-wide) measures actual peak heap usage — not just the returned error — across the historical shapes and ~100 proptest-generated adversarial archives (random typeflags, magic validity, and declared sizes up to 4 GiB), asserting it stays orders of magnitude below the historical multi-GB measurements regardless of what the archive contains.tests/security/tar_budget_parity.rsguards against the mechanism's one real assumption (thattar's iterator reads less than the budget between yields once every entry is drained) with a proptest comparing budgeted extraction output against a plain, unwrappedtar::Archiveread for well-formed archives with long paths and many files, so a futuretarcrate bump that invalidates the assumption fails loudly here rather than silently rejecting legitimate archives in production. -
Regression test coverage for the synthesized-bytes drain bound above (#414): direct unit tests in
formats::tar_metadata_limitconfirm a legitimate entry (comfortably larger than the synthetic-bytes cap) drains to true completion, and that a real GNU old-format sparse header with an extremerealsizestill stops draining within a small, bounded number of real bytes read.tests/security/tar_metadata_bomb.rsreconstructs the same sparse-header shape and assertsverify_archive,list_archive, andextract_archiveall reject it within a bounded wall-clock time regardless of the claimed size, not just bounded heap usage. A companion test reproduces the exact false-positive size table found during this fix's own review (legitimate archives with a single 3/6/9/12/30/45 MiB entry) againstlist_archive,verify_archive, andextract_archive, and a further test confirmsextractstill fully skips a legitimately large (45 MiB) disallowed-extension entry and continues extracting the rest, checked through bothTarArchive::extractdirectly and the publicextract_archiveAPI.tests/tar_alloc_bound.rswas extended to measurelist_archiveandverify_archivein addition toextract, and its random-archive generator now emits real GNU sparse header fields for typeflag'S'steps instead of an empty (and therefore non-sparse-triggering) header, so the fuzz corpus actually exercises the zero-padding code path this mechanism bounds.A known, accepted residual limitation of the synthesized-bytes design: a legitimate GNU sparse file with a real hole larger than the synthetic-bytes cap is indistinguishable, by pure byte accounting, from the attack shape when skipped unread on
list_archive/verify_archive— both produce output with no corresponding read. This affects those two functions directly and, transitively, theexarchCLI'sextractcommand (which runs its ownlist_archivepre-flight for progress-bar/conflict detection ahead of the actual extraction); it does not affect theextract_archive/TarArchive::extractlibrary functions, which read the entry themselves and never reach the guard's drain unread. Accepted as a documented trade-off (P3 follow-up filed separately) rather than fixed here, since it requires a real hole combined with several MiB of real data to reproduce and this PR has already been through four rounds of adversarial review. Pinned by a dedicated regression test in each oftests/security/tar_metadata_bomb.rs(list_archive/verify_archive/libraryextract) andexarch-cli/tests/cli_tests.rs(the CLI command specifically), so a future change to the cap cannot silently move this threshold without a test noticing. -
Regression test coverage for GHSA-qh76-45cr-8xrc / CVE-2026-61725 (7z Zip-Slip via
sevenz_rust2::decompress()), confirmingSevenZArchive::extractrejects both relative-traversal and absolute-path 7z entries viaEntryValidatorbefore any file is written, since exarch-core never calls the vulnerable upstream convenience API. A further test exercises the extraction-time re-validation layer directly, proving it independently rejects traversal as well (#398). -
Regression test for the #397 coder-stream-count overflow fix (upstream sevenz-rust2 issue #127), using the fixture ported from upstream's own regression test, confirming the malformed archive is now rejected gracefully instead of risking a panic (#397).
-
TAR hardlink extraction bypassed quota tracking entirely (#426):
EntryValidator::validate_entryonly ranQuotaTracker::record_fileforEntryType::File, so hardlink entries (opt-in viaconfig.allowed.hardlinks) never counted againstmax_file_size,max_file_count, ormax_total_size, even thoughTarArchive::create_hardlinkcopies the target's full on-disk bytes viastd::fs::copyfor every hardlink entry. A crafted archive with one file within quota followed by many hardlink entries pointing at it extracted unlimited copies with zero enforcement.EntryValidatorgainedrecord_hardlink, and the TAR second pass now calls it with the target's on-disk size (read viastd::fs::metadata) before copying, routing hardlink bytes and counts through the sameQuotaTrackerinstance used for regular files.
-
exarch-cli:extract --help's--atomictext described a stale swap ordering and omitted the Unix permission requirement--forcenow has (#532): theatomicfield's doc comment incli.rssaid the existing destination is "removed after successful extraction (just before rename), not before" — a description that predates the #519 fix and no longer matchesrun_atomic_force_extraction's actual swap ordering (old destination renamed aside first, new content renamed into place second, old destination removed only once that swap succeeds). It also never mentioned #531's Unix-only change requiring read permission on the destination's parent directory. The doc comment now describes both accurately. No behavior change,--helptext only. -
Duplicate-file, duplicate-symlink, and duplicate-hardlink skip paths now share a
common::checked_increment_files_skippedhelper (#518) instead of repeating the same checked-add-with-overflow-guard increment inline informats::common::extract_file_with_permit,formats::common::create_symlink, andformats::tar::create_hardlink. No behavior change; 7z's analogous site is out of scope since it runs inside asevenz_rust2::Error-returning closure and cannot share this helper's signature. -
formats::sevenz::extract_archivenow builds its duplicate-skip warning via the sharedcommon::push_duplicate_skip_warninghelper (#499), matching the TAR and ZIP handlers instead of re-implementing the singular/plural aggregation inline. No behavior or message change. -
BREAKING:
ArchiveError::to_ffi_messageno longer takes asanitize_paths: boolparameter (#463): the parameter's runtime toggle no longer maps onto anything real once the redaction policy became profile-gated (cfg(debug_assertions)) rather than caller-selected — see the#463/#462entry under### Fixedfor why the policy changed.to_ffi_message()now takes no arguments and always applies the shared policy from the newerror::redactionmodule viaArchiveError::redacted_path(). The method had zero callers anywhere in the workspace, so this has no runtime effect onexarch-python/exarch-node, but any direct Rust API consumer callingto_ffi_message(sanitize_paths)must drop the argument. -
BREAKING:
SecurityConfig::validate()now rejects malformedallowed_extensions/banned_path_componentsentries (#449): a newvalidate_config_entryhelper inexarch-core::security::boundary(re-exported asexarch_core::validate_config_entry, alongsideMAX_CONFIG_ENTRY_LENGTH) is called per-entry fromSecurityConfig::validate(), which now returnsArchiveError::InvalidConfigurationfor any entry that is empty, contains a null byte, or exceeds 255 bytes — configs that previously passedvalidate()with such entries now fail.exarch-python'sadd_allowed_extension/add_banned_componentandexarch-node'saddAllowedExtension/addBannedComponentnow delegate to the same helper instead of duplicating the length/null-byte checks, so both bindings additionally reject empty entries eagerly (previously accepted) and report length in bytes rather than the previously inaccurate "characters" wording for multi-byte input.exarch-node's error messages for these two setters now carry the crate-wideINVALID_CONFIGURATION: invalid configuration:prefix (previously a bare reason string), matching every other error path in the binding. -
exarch-clioutput formatters now write through an injectable writer (#452):OutputFormatter's six trait methods take&mut selfinstead of&self.HumanFormatter<O: Write = Term, E: Write = Term>writes non-error output toOand errors toE(HumanFormatter::newkeeps the stdout/stderr default;HumanFormatter::with_writersinjects custom writers and an explicituse_colorsflag for deterministic tests).JsonFormatter<W: Write = Stdout>gained the same shape (JsonFormatter::stdout()replaces the old unit-struct constructor;JsonFormatter::with_writerinjects a custom writer).create_formatter's signature is unchanged. This unlocks unit tests that capture formatter output into an in-memory buffer instead of requiring a subprocess.HumanFormatterrenders each line into aStringfirst and issues a singlewrite_allper line (output::human::emit_line/emit_blank) rather than callingwriteln!directly on the writer, which would otherwise turn one multi-argument format line into several separate small writes againstconsole::Term(no internal buffering) — output throughput on large manifests is unchanged from the pre-refactorTerm::write_linebehavior. -
CLI size-limit defaults are no longer re-literalized across commands (#450): added
commands::apply_size_limits(config, max_total, max_file)incrates/exarch-cli/src/commands/mod.rs, applying--max-total-size/--max-file-sizeoverrides only when provided and otherwise leavingSecurityConfig::default()'s own limits in place.extract,list, andverifynow route through this helper instead of each hardcoding.unwrap_or(500 * 1024 * 1024)/.unwrap_or(50 * 1024 * 1024). No behavior change. -
BREAKING:
exarch-nodeboolean setters now take a mandatorybooleaninstead of an optional one (#442):SecurityConfig.setAllowSymlinks,setAllowHardlinks,setAllowAbsolutePaths,setAllowWorldWritable,setAllowSolidArchives,setPreservePermissions;CreationConfig.setPreservePermissions,setFollowSymlinks,setIncludeHidden; andExtractionOptions.withSkipDuplicates,withAtomicno longer acceptOption<bool>resolved via.unwrap_or(true). Previously, calling one of these setters with zero arguments silently flipped the flag to the permissivetruestate instead of erroring, violating this project's secure-by-default posture. Callers must now pass an explicitboolean; omitting the argument is a compile-time TypeScript error and a runtime napi error from plain JavaScript. Explicitundefinedornullare rejected the same way, which also catches the more realistic failure pattern of forwarding an optional property (e.g.cfg.setAllowSymlinks(userOpts.allowSymlinks)) that was never actually set. -
BREAKING:
CreationConfigis now a two-state typestate overUnvalidated/Validated(#443): mirrors theSecurityConfigtypestate from #433-#435.CreationConfig<State = Unvalidated>carries a phantom marker (reusing the existingUnvalidated/Validatedmarkers fromcrate::config, not a new pair); the fluentwith_*builders remain available only onCreationConfig<Unvalidated>, andCreationConfig::validate()now consumesselfand returnsResult<CreationConfig<Validated>>instead ofResult<()>. The low-levelcreation::tar::*/creation::zip::*functions andFormatCreator::createnow require&CreationConfig<Validated>, so a forged or unvalidatedcompression_levelcan no longer reachflate2/xz2, closing a panic-based DoS: a hand-builtCreationConfigwithcompression_level: Some(200)previously bypassed theInvalidCompressionLevelcontract enforced only at the two high-level entry points and triggered anassert!/unwrap()panic insideflate2'szlib-rsbackend andxz2respectively, instead of returning an error. Fields are sealed behind a private innerCreationConfigFieldsstruct (#[non_exhaustive]), reachable read-only viaDereffor both typestates but mutable (DerefMut) only forCreationConfig<Unvalidated>.creation::filters::should_skipandcreation::filters::compute_archive_pathalso gained aStatetype parameter, so any external caller invoking them with an explicit turbofish must update it. The top-levelcreate_archive*functions andArchiveCreator::createare unaffected: they still accept&CreationConfig/CreationConfig(defaulting toUnvalidated) and validate internally.exarch-cli,exarch-python, andexarch-nodeneed only the CLI'screatecommand updated (it built aCreationConfigvia struct-literal syntax); both bindings mutate throughDerefMutonUnvalidatedand continue to compile unchanged. -
BREAKING:
ValidatedEntryType::Filenow carries aQuotaPermitcapability token (#436):QuotaTracker::record_fileis renamed toreserveand returnsResult<QuotaPermit>instead ofResult<()>;EntryValidator::record_hardlinkis renamed toreserve_hardlinkfor the same reason.QuotaPermitis a zero-sized, non-Clone/non-Copytoken whose only producer isQuotaTracker::reserve, and constructingValidatedEntryType::Filenow requires one, so aFile-typed validated entry with no quota charge is unrepresentable.extract_file_generic(shared by TAR and ZIP) additionally rejects any non-Fileentry before touching the filesystem, and TAR's hardlink-copy path now consumes its permit by value via a newcopy_file_with_permithelper, so a single reservation cannot be spent twice. This closes the same class of gap as #428 (an unguarded quota-charge path) at the type level instead of by convention; behavior is unchanged for every caller that already went throughEntryValidator::validate_entry. -
BREAKING:
SecurityConfigis now a two-state typestate overUnvalidated/Validated(#433, #434, #435):SecurityConfig<State = Unvalidated>carries a phantom marker; the fluentwith_*builder methods remain available only onSecurityConfig<Unvalidated>, andSecurityConfig::validate()now consumesselfand returnsResult<SecurityConfig<Validated>>instead ofResult<()>. TheArchiveFormattrait (extract,list,verify) and every function downstream of validation inexarch-core(EntryValidator,SafePath::validate,SafeSymlink::validate,QuotaTracker::record_file,validate_symlink,validate_compression_ratio,HardlinkTracker::validate_hardlink,sanitize_permissions, and the per-formatlist/extracthelpers) now require&SecurityConfig<Validated>, so a config that skipped or failed validation can no longer reach extraction, listing, or verification — enforced by the compiler instead of by convention. Fields are sealed behind a private inner struct, reachable read-only viaDereffor both typestates but mutable (DerefMut) only forSecurityConfig<Unvalidated>, so aSecurityConfig<Validated>cannot be mutated back into an invalid state after the fact whilecfg.max_file_size-style field access keeps working unchanged for every existing caller. The top-levelextract_archive*,list_archive,verify_archive, andcreate_archive*functions are unaffected: they still accept&SecurityConfig(defaulting toUnvalidated) and validate internally.SecurityConfig::default()continues to inferUnvalidatedwith no turbofish required.ValidatedandUnvalidatedare re-exported from the crate root.exarch-cli,exarch-python, andexarch-nodeneed no changes: they only ever holdSecurityConfig<Unvalidated>and pass it to the top-level API.ValidatedEntry(security::validator) becomes sealed: its fields are private, its constructor ispub(crate), andsafe_path()/entry_type()/mode()accessors replace direct field access, so it is assemblable only from inside this crate, and in practice only viaEntryValidator::validate_entry().ValidatedEntryTypeis now#[non_exhaustive]; itsSymlink/Hardlinkvariants wrap the already-sealedSafeSymlink/SafePath, so their payloads cannot be forged even from within the crate. No validation logic changed — this is purely a compile-time hardening of the existing runtime checks. -
Extracted the duplicated extension-allowlist check from
formats/zip.rs,formats/tar.rs, andformats/sevenz.rsinto a shared,#[must_use]formats::common::check_extension_allowedhelper (#413). Behavior is unchanged; each call site still returns its own type (Ok(()),Ok(None),Ok(0)) on rejection. Added direct unit tests pinning the exact skip-warning message text so the wording cannot silently drift again. -
BREAKING: Bumped MSRV from 1.93.0 to 1.96.0 (#401): raises the minimum supported Rust version across the workspace. Downstream consumers pinned to an older toolchain must upgrade before taking this release.
rust-versionin the rootCargo.toml, themsrvjob in.github/workflows/ci.yml,clippy.toml, and all README/CONTRIBUTING/spec references were updated accordingly. -
Migrated 151
assert!(matches!(value, Pattern))test assertions inexarch-coreto the now-stableassert_matches!macro (stabilized in Rust 1.96, imported viause std::assert_matches;), which prints the actual value viaDebugon failure instead of just"assertion failed: matches!(...)". 4 sites informats/zip.rsmatching on a non-Debugtype (Result<ZipArchive<_>, _>) were kept asassert!(matches!(..))sinceassert_matches!requires the scrutinee to implementDebug; 7 sites intests/property_tests.rswere left asprop_assert!(matches!(..))since proptest has noassert_matches!-equivalent macro. -
Applied
core::hint::cold_path()(stabilized in Rust 1.95) to the quota-rejection and integer-overflow branches inQuotaTracker::record_file/record_file_checked(crates/exarch-core/src/security/quota.rs), reinforcing the existing OPT-C003 hot/cold path optimization for the optimizer. -
Internal:
exarch-python'sextract_archive,create_archive,list_archive, andverify_archive, andexarch-node's async/syncextract_archive(_sync),create_archive(_sync),list_archive(_sync), andverify_archive(_sync)now route through the existingcatch_panic_as_py_err/catch_panic_as_js_errpanic-catch helpers (#395) instead of each reimplementing the identicalcatch_unwind(...).map_err(...)sequence inline (#454). Pure deduplication — no change to panic-catching semantics or error messages. -
User-visible: in release builds of
exarch-pythonandexarch-node, an I/O error (CoreError::Io) raised by either binding now reports only thestd::io::ErrorKinddescription (e.g. "permission denied") instead of the full underlyingio::Errormessage text; the full message is still shown in debug builds. See the#453entry below for why. -
Deduplicated
PartialExtractionerror-wrapping logic across format handlers (#394): the "wrap the error inArchiveError::PartialExtractionif the report recorded any processed items, otherwise return it as-is" pattern was copy-pasted five times acrosstar.rs,zip.rs, andsevenz.rs. Consolidated intoArchiveError::partial_or(); behavior-equivalent at all current call sites (each site returns the error immediately afterwards, so evaluatingstd::mem::take(report)unconditionally rather than only inside thetotal_items() > 0branch is unobservable). -
Deduplicated FFI boundary path validation between
exarch-pythonandexarch-node(#406): both bindings independently rejected null bytes and paths over 4096 bytes for raw path strings supplied by callers, and the two implementations had drifted (a full-scan fold vs. a short-circuitingcontainsfor the null-byte check, and no consistent check order). Both now call the newexarch_core::validate_raw_path_str(), which ownsMAX_PATH_LENGTH, checks length before scanning for a null byte (rejecting oversized input in O(1) before the O(n) scan runs), and returnsArchiveError::SecurityViolation. Each binding routes that error through its existingconvert_error()— the same converter used for every other security rejection — instead of hand-rolling a bespoke exception. This changes the concrete exception raised for null-byte and path-length rejections: Python now raisesSecurityViolationError(previously a bareValueError) and Node.js error messages now carry theSECURITY_VIOLATION:code prefix (previously unprefixed). Both were already documented as possible outcomes of these checks; acceptable pre-1.0 per the project's no-backward-compatibility policy. -
exarch-node's declared minimum Node.js version no longer matched what CI actually tests:package.json'sengines.nodefield said>= 18, butci.yml'stest-nodejob only ever runs against Node 20 and neither a version matrix nor an.nvmrccovers 18/19. Raisedengines.nodeto>= 20to match, and updated the corresponding requirement notes inREADME.mdandcrates/exarch-node/README.md.
-
exarch-cli:extract --atomic --forcedid not disclose the location of a temp/backup directory left behind by a parent-directory redirect mid-extraction (#530):run_atomic_force_extractioncreates its temp and backup directories with path-based calls (tempfile::tempdir_in), and its best-effort cleanup on failure (std::fs::remove_dir_all) is also path-based — if an intermediate component of the destination's parent is replaced with a symlink while extraction is in progress, that cleanup call can silently target a decoy at the redirected location instead of the real directory, leaving genuine content behind with no indication of where. Every failure site inrun_atomic_force_extractionnow captures each directory's(dev, ino)identity viaPinnedDir::entry_status(fd-relative to the already-pinned parent, so unaffected by the redirect) right after creating it, and after the best-effort cleanup attempt, re-checks that identity: if the directory still exists and matches, its current path is resolved fresh from a freshly opened fd on the entry itself (PinnedDir::open_entry+commands::atomic_swap::current_path, using/proc/self/fdon Linux andF_GETPATHviarustix::fs::getpathon macOS) and disclosed in the error message — critical, since the logical path built from the (possibly still-redirected) parent does not necessarily lead there anymore, confirmed by live-reproducing a persisting mid-extraction redirect and checking that the disclosed path resolves to the real, surviving content rather than the decoy. If no fd-to-path facility is available (any Unix other than Linux/macOS), falls back to naming the(dev, ino)identity with afind -inumpointer instead of a path — Unix only, sinceentry_statusalways reports the identity(0, 0)on other platforms, which would make that pointer meaningless there. Capturing the backup directory's own identity (used only so a later failure to remove it can be disclosed) is itself best-effort: by the time it runs, the destination has already been renamed into the backup's place, so a failure there must not abort an otherwise-successful swap over and above what actually failed — it degrades to skipping that one disclosure opportunity instead. In the ordinary (non-redirected) case, cleanup succeeds and the identity recheck correctly finds nothing, so no directory is disclosed and none is left behind — this is deliberate: an earlier version of this fix instead persisted the temp directory unconditionally, which left one behind on every failed--atomic --force, not just the redirect race; the identity-recheck approach avoids that regression entirely. Non-Unix targets fall back to naming the (possibly stale) logical path unconditionally when content survives, sincePinnedDir::entry_statuscannot distinguish "our" directory from a replacement there (documented residual, same as #526). This does not, and cannot, discover content written directly to a redirect-created decoy directory if the redirect is reverted before this check runs —exarch-core's own per-entry extraction writes are path-based and out of scope for this fix to make fd-relative; that narrower case remains a genuine, undisclosed orphan. Not a security escape (the fd-pinned swap logic from #526/GHSA-x8wr-7ww2-c94x still confines renames/removes correctly) and not new data loss, only a disclosure gap in the error/warning text. -
exarch-core:verify/listreportedPASSfor a TAR archive containing a non-UTF8 entry name that may fail to extract on filesystems requiring UTF-8 names (#528): TAR entry names are stored byte-exact (no lossy conversion) inArchiveEntry.path, butinspection::verify::check_heuristicsnever checked whether that path was valid UTF-8, soverify_manifestcollected no issue for such an entry anddetermine_statusreturnedPasseven though extraction could fail depending on the destination filesystem (e.g. APFS, NTFS — Linux ext4/xfs/btrfs accept arbitrary byte-string names and are unaffected).check_heuristicsnow pushes aMedium-severitySuspiciousPathissue — worded as a portability risk, not a claim about the host runningverify, since an archive is often vetted on one machine before being shipped to another — for any entry whose path is not valid UTF-8, flipping the overallstatustoWarningandsuspicious_entriesaccordingly.security_statusis unaffected, sinceSuspiciousPathwas already excluded fromdetermine_security_status's category filter (this is not a security issue, only a portability one). -
exarch-cli: theArchiveError::Iocontext duplicated the wrapped I/O error's own message, printing both the "I/O error" phrase and the OS error text twice (#528):error.rs'sconvert_extraction_errorbuilt theIoarm's context by re-interpolating the innerio::Error's Display text ("I/O error while processing '{path}': {io_err}"), butArchiveError::Ioitself already displays as"I/O error: {io_err}", and anyhow's{:#}rendering appends that source after the context — doubling both the phrase and the OS error text in--jsonand human-text output alike. Same duplication class as #403'sSecurityViolationfix, different arm. The context no longer re-embeds the inner error's text. -
exarch-cli:extract --atomic --forcedeleted a pre-existing destination directory before extraction was known to succeed, defeating the point of--atomic(#519):commands/extract.rspre-removedoutput_dirwithremove_dir_allso the subsequent rename inexarch-core'sextract_atomic(which refuses to replace an existing directory by design) would succeed — but the removal ran before extraction was attempted, so a failed extraction (security violation, malformed archive, disk full, etc.) left the original destination permanently gone with no recovery, worse than plain--force. The CLI no longer pre-deletes; for this specific combination it now extracts into a temp directory beside the destination (non-atomic), and only after extraction fully succeeds does it perform its own swap: the existing destination is renamed aside to a backup path, the extracted content is renamed into place, and only then is the backup removed. If the final rename into place fails, the backup is renamed back and the CLI reports clearly whether that restore succeeded — including the backup path if it didn't, so the original content can still be recovered manually. A pre-existing destination that exists but is not a directory (e.g. a regular file) is now rejected with an explicit error instead of being silently replaced. Any other--atomic/--forcecombination is unaffected. -
exarch-cli: theSecurityViolationHINT suggested policy flags (--allow-symlinks,--allow-hardlinks,--allow-solid-archives,--banned-component) even for violations none of those flags control — most notably the GHSA-5j8q-wxg5-hj4r declared/decompressed size mismatch, but also roughly ten other reasons such as password-protected archives, unsupported compression methods, or invalid entry paths (#520):error.rs'sconvert_extraction_errorattached the same generic HINT to everySecurityViolationregardless ofreason(a free-formString, not a structured enum). It now checksreasonagainst the known prefixes exarch-core uses for the four categories the flags actually relax and only shows the flag-specific HINT for those; every otherSecurityViolationgets a HINT stating that it cannot be relaxed via any policy flag. -
exarch-core:files_skippedwas incremented via a plain+= 1in six sites across extraction and creation, inconsistent with thechecked_add-based hardening already applied to TAR's ownfiles_skippedcounter (#515):formats/common.rs'scheck_extension_allowed,extract_file_with_permit, andcreate_symlink,formats/sevenz.rs'sprocess_entry_inner, andcreation/zip.rsandcreation/tar.rs's skip sites all used bare+= 1, whileformats/tar.rs:392already usedchecked_add(1).ok_or(ArchiveError::QuotaExceeded { resource: IntegerOverflow })?after #506'sduplicate_skipshardening. The twoResult-returning extraction sites incommon.rsand the one insevenz.rsnow match that samechecked_add+ fail-closed pattern;check_extension_allowed(abool-returning function that cannot propagate?) and thecreation/-side sites now usesaturating_add, matching every sibling counter in those same functions (disallowed_extension_skips, andCreationReport'sfiles_added/directories_added/symlinks_added, none of which have a checked variant). Added a regression test proving thechecked_addextraction path fails closed withQuotaExceeded { resource: IntegerOverflow }atusize::MAXinstead of silently wrapping. -
exarch-core: a symlink pointing at a directory, passed directly as a top-levelcreatesource, crashed with an internal path-normalization error instead of being archived as a link (#512):creation::walker::collect_entriesclassified the directory-walk vs. single-entry branch usingpath.is_dir()(stat, follows symlinks), so a symlink-to-directory source was routed intoFilteredWalker/WalkDirinstead ofEntryType::Symlink.WalkDiralways dereferences its root regardless offollow_links(false), so walking through the symlink root produced an empty relative path for the root entry, later failing withpaths in archives must have at least one component when setting path for "". The branch selector now reuses thesymlink_metadata(lstat) already fetched for existence checking and testsmetadata.is_dir()instead, so a symlink-to-directory source classifies asEntryType::Symlinkby default — consistent with how #510 fixed the analogous symlink-to-file case — without dereferencing the source at all. Whenfollow_symlinksis explicitly enabled, the branch selector additionally checkspath.is_dir()(stat) so the symlink is still walked as a directory, preserving the pre-existing dereferencing behavior for that config instead of regressing it into an I/O error (TAR) or an empty archive (ZIP). Under the default (non-follow) policy, TAR archives the directory symlink as a link entry; ZIP has no on-disk representation for symlinks and continues to skip it with aSkipped symlinkwarning, per the ZIP policy already established in #510 — for a directory symlink this means the entire target tree is omitted from the ZIP archive (exit code 0,files_skipped: 1), so callers who need the tree's contents in a ZIP must pass--follow-symlinks. -
exarch-core: a symlink passed directly as a top-levelcreatesource was silently dereferenced into its target's file content instead of being archived as a link (#510):creation::walker::collect_entries's single-file branch (taken when a source argument is not a directory) usedstd::fs::metadata(stat, follows symlinks) to classify the source and check its existence, so a symlink argument was misclassified asEntryType::Fileand a dangling symlink (target missing) failed existence checks withSourceNotFound, even though the identical directory-walk path already used lstat semantics and archived symlinks under a directory correctly. Both checks now usestd::fs::symlink_metadata(lstat), so a symlink source classifies asEntryType::Symlinkand a dangling symlink is no longer rejected as missing. Because the fix makes ZIP'sEntryType::Symlinkarm reachable for the first time (previously dead code —walkdiralways dereferences directory-walk roots),create_zip_internal_with_progressalso gained thefollow_symlinkshandling ZIP creation was missing: with--follow-symlinksit now embeds the target file's content, matching TAR's existing behavior, instead of silently producing an empty archive withfiles_added: 0and no warning.This is a behavior change for
exarch-core,exarch create <archive> <symlink>, and the Python/Node.js bindings that call it:exarch create a.tar link.txtnow stores a real symlink entry rather than the target's dereferenced content, so extracting that archive requires--allow-symlinks(deny-by-default) where it previously round-tripped without it. -
exarch-core: hardened ZIP'sby_index()/name()error paths insideextract()'s entry loop to route through the same warning aggregation andArchiveError::partial_orwrapping as other failures in the loop:formats/zip.rs's extraction loop opened each entry viaself.inner.by_index(i)?and read its name viazip_file.name()?, both using a bare?that returned the raw error immediately, bypassing the duplicate/disallowed-extension warning aggregation andArchiveError::partial_orwrapping used forprocess_entryfailures a few lines below. In practice this path is not reachable through the public API today:ZipArchive::new()already scans every entry viaby_index()during its password-protection check, so any entry that would failby_index()/name()is caught at open time, beforeextract()'s loop ever runs — the gap would only matter if the underlying reader's data changed between that scan and extraction, or in a future refactor that removes or narrows the open-time scan. Both failure sites now aggregate the same warnings and route throughArchiveError::partial_orbefore returning, via a sharedpush_duplicate_skip_warningshelper (mirroring TAR's existing helper of the same name), giving ZIP the same structural handling as TAR and 7z as defense-in-depth, not as a fix for a demonstrated user-facing bug. -
exarch-core: a mid-archive extraction failure where every entry processed beforehand was skipped (not written) discarded the entire partial report, including its warnings (#505):ArchiveError::partial_oronly wrapped a failure intoPartialExtraction { report, .. }whenreport.total_items()(files_extracted + directories_created + symlinks_created) was nonzero. An archive whose only processed entries before the failure were rejected — e.g. by--allowed-extensionsor as pre-existing duplicates — lefttotal_items()at0, sopartial_orreturned the original error unwrapped, silently droppingreport.warningsandreport.files_skippedeven though both were populated.partial_ornow also treats a report as worth surfacing whenfiles_skipped > 0orwarningsis non-empty, without changingtotal_items()itself (it remains "items written to disk", matching its existing Python binding semantics and tests). -
exarch-cli:extract's human and--jsonoutput never surfacedExtractionReport.warningsorfiles_skipped(#498): both fields are populated correctly byexarch-coreand already exposed by the Python and Node.js bindings, butformat_extraction_resultinoutput/human.rsandoutput/json.rsonly readfiles_extracted/directories_created/symlinks_created/bytes_written/duration, silently dropping any warnings (e.g. capped disallowed-extension or duplicate-skip summaries from #495/#497) and the skipped-file count.format_extraction_resultnow prints aFiles skipped:line and aWarnings:section (mirroringcreate's existing formatter), and the JSONExtractionOutputstruct gainedfiles_skipped/warningsfields (mirroringCreationOutput). -
exarch-cli:extract's pre-flight destination-conflict error listed every conflicting path with no cap (#500): the pre-flight check incommands/extract.rs(run before core extraction, when--force/--atomicare absent) built ananyhow::bail!message listing one line per pre-existing destination file: withmax_file_countdefaulting to 10000, extracting an archive with many same-named entries over a populated destination could dump up to 10000 lines to stderr — the same unbounded-output class already fixed forexarch-core's warning aggregation in #484/#490/#495/#497.conflict_error_messagenow sorts the conflicting paths before listing at most 10 of them and collapsing the remainder into a single... and N moresummary line — sorting first keeps "first 10 shown" a deterministic, reproducible subset rather than whatever order the archive manifest happened to yield. -
exarch-cli:files_skipped/warningswere dropped from the error-path JSON and human output on a mid-archive extraction failure (#503): when extraction stopped partway through (e.g. a symlink escape after some entries already extracted), the partialExtractionReportcarriedfiles_skippedandwarnings, but neitherformat_error'sJsonPartialReport(output/json.rs,output/formatter.rs) norPartialExtractionContext'sDisplayimpl (error.rs, used for human-readable output) surfaced them — onlyfiles_extracted,directories_created,symlinks_created, andbytes_writtenwere reported, silently hiding any disallowed-extension or duplicate skips that happened before the failure. Both paths now includefiles_skippedandwarnings(the human path only when non-empty, mirroring the existing success-path convention), without changing the existing "WARNING: Extraction was stopped..." / "HINT: ..." wording. -
exarch-core: 7z'sduplicate_skipscounter used a plain+= 1instead ofsaturating_add, the only non-saturating skip counter in the codebase (#502): every other skip-counter increment —disallowed_extension_skips(common.rs:452), TAR/ZIP's sharedduplicate_skips(common.rs:704), and TAR'shardlink_duplicate_skips(tar.rs:397) — already usedsaturating_addto avoid wrapping afteru64::MAXskips; 7z's own counter (formats/sevenz.rs:575) was the one site still using bare+= 1. Switched toduplicate_skips.saturating_add(1), matching the existing idiom exactly. -
exarch-pythonandexarch-node: aPartialExtractionerror droppedfiles_skippedandwarningswhen converted to the language-level exception/error (#508): both bindings'convert_error(crates/exarch-python/src/error.rs,crates/exarch-node/src/error.rs) only forwardedfiles_extracted/bytes_writtenfrom theExtractionReportattached toCoreError::PartialExtraction, even thoughexarch-corehas populatedfiles_skipped/warningson that report since #505 — the CLI got the fix in #503, but the bindings were never updated. Python'sconvert_errornow also attachesfiles_skipped(int) andwarnings(list[str]) to the raised exception; Node's now also appendsfilesSkipped=Nand a Rust-Debug-formattedwarnings=[...]fragment to the thrown error's message, following the samekey=valueconvention as the existingfilesExtracted/bytesWrittensuffix. Thewarnings=[...]fragment on the Node side is for human/log inspection only — it is not guaranteed valid JSON and must not beJSON.parsed. -
exarch-core: TAR, ZIP, and 7z pushed one unbounded, path-bearing warningStringper entry rejected by the extension allowlist (#495):common::check_extension_allowed(shared by all three format handlers) pushed a"skipped entry with disallowed extension: {path}"warning directly intoreport.warningsfor every rejected entry, growing the report proportional to archive size with no cap — the same class of issue already fixed for pre-existing-duplicate skips in #484/#490. The function now increments adisallowed_extension_skipscounter instead; each format'sextract()aggregates it into at most one"skipped N entries with disallowed extensions"warning once extraction completes.report.files_skipped's count is unaffected. -
exarch-python: a raising progress callback was silently swallowed during extraction/creation (#489):PyProgressAdapter::on_entry_startdiscarded both the return value and any Python exception fromself.callback.call1(...)vialet _ = ..., so a callback raising to signal an abort (an anomaly check, a quota/policy decision, a cancellation request) had no effect — extraction or creation ran to completion as if nothing happened. The exception is now captured instead of discarded; consistent withexarch-node'sNodeProgressAdapter(#465/#485), the underlyingProgressCallbackcontract has no cancellation signal, so the operation still runs to completion (further callback dispatches are skipped once an exception is captured), and the result is merged once it returns: if the operation otherwise succeeded, the callback's exception now propagates to the caller, carryingfiles_extracted/files_addedandbytes_writtenattributes describing what was written, plus aprogress_callback_error = Truemarker attribute — needed because those two counter attribute names are the same ones a genuine partial extraction/creation failure carries (seeextract_archive's existinghasattr(e, "files_extracted")idiom), so the marker is what tells the two apart; if the operation also failed, the core error stays primary (a raising callback can never mask a security error) with the callback's exception chained onto it via__cause__. -
exarch-core:SafeSymlink::validatedid not explicitly reject Windows drive/UNC prefix or root-relative components in a symlink target, relying only onis_absolute()(#491): a drive-relative target likeC:foo(no backslash after the colon,PrefixwithoutRootDir) and a root-relative target like\evil(RootDirwithoutPrefix) are both notis_absolute()per Rust's definition, which requires both components together, yet each still resolves relative to the current directory/drive — a GHSA-9ppj-qmqm-q256-class bypass.SafeHardlink::validate(security/hardlink.rs, tagH-SEC-2) andSafePath::validatealready carry this explicitComponent::Prefix(_) | Component::RootDirrejection;SafeSymlink::validateonly carried it as an unstated side effect ofPathBuf::push's Windows prefix-without-root replacement behavior inresolve_through_symlinks. Added the same explicit guard so the rejection is deliberate rather than incidental. -
exarch-core: TAR and ZIPskip_duplicates = truepushed one unbounded warningStringper pre-existing-duplicate entry, symlink, or (TAR-only) hardlink (#490):report.warningsgrew by one entry per skipped duplicate, proportional to archive size with no cap — the same class of issue already fixed for 7z in #484/#487.common::extract_file_with_permitandcommon::create_symlink(shared by both formats) and TAR's own inline hardlink duplicate-skip path increate_hardlinknow accumulate counters instead, and each format'sextract()pushes at most two aggregated warnings once extraction completes (one for file/symlink duplicates, plus one for hardlink duplicates on TAR, since ZIP has no hardlink entry type).report.files_skipped's count is unaffected. -
exarch-core: 7zskip_duplicates = falsedeleted a pre-existing destination directory tree instead of failing like TAR/ZIP'sEISDIR(#483): when a 7z file entry's destination path was occupied by a pre-existing directory, extraction calledremove_dir_allon it and wrote a fresh file in its place, recursively discarding the entire tree. TAR/ZIP instead fail withEISDIRviacreate_file_with_modeand leave the directory untouched. 7z now fails the same way instead of deleting anything, propagatingArchiveError::IowithErrorKind::IsADirectorypreserved (routed out-of-band around the lossysevenz_rust2::Errorstring-based conversion, which would otherwise collapse the kind toOtherand risk misclassifying certain destination paths as encryption errors). A pre-existing symlink at the destination — including one pointing at a directory — is handled separately by #477/#478'sELOOPrejection (see### Securitybelow) and never reaches this check. Behavior change: callers relying on 7z silently overwriting a pre-existing destination directory must now handle an extraction failure for that entry instead. -
exarch-core: 7zskip_duplicates = truepushed one unbounded warningStringper pre-existing-duplicate entry (#484):report.warningsgrew by one entry per skipped duplicate, proportional to archive size with no cap. Replaced with a single aggregated warning ("skipped N entries as pre-existing duplicates") emitted once extraction completes, if any entries were skipped this way.report.files_skipped's count is unaffected. TAR/ZIP's equivalent per-entry duplicate-skip warning was fixed the same way in #490. -
exarch-core: 7zskip_duplicatescheck missed a dangling symlink at the destination path (#468):dest_path.exists()follows symlinks and returnsfalsefor a dangling symlink, so a pre-existing dangling symlink occupying an entry's destination silently passed the duplicate check instead of being detected. Withskip_duplicates = truethe entry is now correctly skipped rather than silently replacing the symlink; withskip_duplicates = false, this check's own destination is still replaced viarenamerather than followed, so this specific check is not a symlink-escape vector (unlike TAR/ZIP, which hard-fail viaO_NOFOLLOWhere — that cross-format divergence is tracked separately in #477). This is unrelated to the temp-file creation step earlier in the same write path, which is a separate, open symlink-escape vector tracked as #471. Replaced the check withdest_path.symlink_metadata().is_ok(), matching the same simplification applied toformats::common::create_symlink's equivalent duplicate check. -
sanitize_path_for_error/sanitize_io_error_for_errorduplicated verbatim across bindings (plus a third, unused copy inexarch-coreitself), and over-redacted attacker-authored paths (#463, #462): the profile-gated redaction helpers added by #453 were byte-identical, independently-maintained copies incrates/exarch-python/src/error.rsandcrates/exarch-node/src/error.rs, with no shared source or cross-binding test; a third, never-called copy of the same policy also lived inArchiveError::to_ffi_messageand carried the same bugs. Hoisted the two binding-local helpers into a newexarch-coremodule,error::redaction(re-exported asexarch_core::sanitize_path_for_error,exarch_core::format_entry_path_for_error, andexarch_core::sanitize_io_error_for_error) — both bindings'convert_errorbind the path per match arm and call the correct algorithm directly (preserving the compiler's exhaustiveness guarantee that a newArchiveErrorvariant forces every call site to handle it explicitly), andArchiveError::to_ffi_message(previously the buggy third copy, see### Changedfor its resulting signature break) calls the same two algorithms via a newArchiveError::redacted_path()helper. Only the two redaction algorithms are single-sourced this way — the variant-to-algorithm mapping itself is still applied independently in three places (redacted_path(), and each binding'sconvert_error), guarded by tests in each. While hoisting, fixed over-redaction:PathTraversal,SymlinkEscape, andHardlinkEscapecarry an archive-relative path the attacker authored inside the archive entry, not a host filesystem path (seeexarch-core/src/types/safe_path.rsandsafe_symlink.rs), so redacting them to filename-only in release builds hid nothing from the attacker while destroying the defender's ability to identify the offending entry in redacted logs. These three variants — andInvalidPermissions, which carries the same kind of archive-relative entry path (seeinspection::verify) — now keep the full path in both debug and release builds.SourceNotFound,SourceNotAccessible,OutputExists,UnknownFormat, andIoare unaffected and remain redacted to filename-only (orErrorKinddescription, forIo) in release builds, since those genuinely carry host-derived paths. Behavior change: release-build error messages forPathTraversal,SymlinkEscape,HardlinkEscape, andInvalidPermissionsnow include the full archive entry path where they previously showed only the filename. -
io::Error::other(...)call sites collapsed to the uninformative "other error" message in release builds after #453's redaction fix (#464):#453reducedCoreError::Iomessages to theirstd::io::ErrorKinddescription in release builds to close a host-path leak, which is sound for OS-originatedErrorKinds but degraded everyErrorKind::Othercall site (built viastd::io::Error::other(...)increation::walker,creation::zip, and the I/O-class branch offormats::sevenz's error mapping) to the fixed string "other error", losing all diagnostic value. Addedexarch_core::IoContext, which pairs a static, non-path-bearing summary (e.g. "failed to read entry metadata") with the dynamic detail (which may embed a host path) at each of those call sites.exarch-core's sharedsanitize_io_error_for_error(see the#463/#462entry above, which hoisted it out of both bindings intoerror::redaction) now recognizesIoContextviaio::Error::get_refdowncasting and surfaces its staticcontextin release builds instead of the genericErrorKinddescription, while debug builds continue to show the full detail. Because both bindings call that one shared function, they pick this up without binding-local logic. No host path can leak throughcontext, since it is always a&'static strfixed at the call site. The redaction itself is unit-tested inerror::redaction, and the release-mode behaviour is asserted end-to-end against the compiled bindings byexarch-python'stests/test_error_redaction.pyandexarch-node'stests/error-redaction.test.js, which trigger a real walkdir failure throughcreate_archiveand check that theIoContextsummary survives while the host path and raw OS detail do not. -
exarch-node: a throwing progress callback crashed the process uncatchably (#465):NodeProgressAdapterdispatched the JS progress callback viaThreadsafeFunction::callin fire-and-forgetNonBlockingmode, which routes a JS throw throughnapi_fatal_exception— terminating the process with an uncatchableuncaughtException, even when the call site was wrapped intry/catch. The adapter now awaitsThreadsafeFunction::call_async_catch(viaHandle::block_on, since the dispatch runs on aspawn_blockingworker thread) and captures a JS throw into the adapter instead; the captured error now rejects the returned promise rather than crashing the host process. This covers bothextractArchiveWithProgressand — since the create-side progress API landed in #469 —createArchiveWithProgress, which share the adapter. Because the operation cannot be aborted from a progress callback (theProgressCallbackcontract has no cancellation signal), a callback throw and a core failure can both occur in the same run — neither is discarded. When the core operation also failed, its error stays primary and keeps its error-code prefix (SYMLINK_ESCAPE,QUOTA_EXCEEDED,IO_ERROR, …) at the start of the message with a fixed| progressCallbackError: see causemarker appended, so a throwing callback cannot mask a security violation from callers matching on that prefix. When the operation succeeded, the rejection is prefixedPROGRESS_CALLBACK_ERRORand carriesfilesExtracted=N, bytesWritten=M(extraction) orfilesAdded=N, bytesWritten=M(creation), so callers can still tell what was written to disk. In both cases the original JS exception is preserved as the rejection'scauseproperty, retaining its class and stack; its text and stack are never copied into the message, since the stack embeds an absolute host path (which #453 redacts everywhere else in release builds) and the throw content is attacker-influenced whenever the callback echoes archive entry data — readcausefor the callback's detail rather than parsing it out ofmessage. At the time this landed, throwing a bare primitive (string, number, boolean) from the callback was a known, documented limitation that still crashed the process on both*WithProgressfunctions; see the following entry for the fix.createArchiveWithProgressSynccannot use the awaiting dispatch at all: it runs on the JS thread, so no tokio runtime is entered (Handle::current()panicked) and awaiting a call that only the blocked event loop can deliver would deadlock. It now dispatches unawaited and is documented accordingly — every call arrives after the function has already returned itsCreationReport, so a throw cannot be merged into the result and instead surfaces as an ordinaryuncaughtException, observable viaprocess.on('uncaughtException', …). Because that path never enterscall_async_catch, the primitive-throw crash above never applied to it — string, number, and boolean throws already reacheduncaughtExceptionintact, exactly like anErrorthrow, so it needed no fix and is unaffected by the following entry. -
exarch-node: throwing a bare primitive from a progress callback still crashed the process (#473, follow-up to #465): #465's fix above only coveredError/object throws onextractArchiveWithProgressandcreateArchiveWithProgress— a callback throwing'oops',42, ortruestill crashed the process uncatchably, because thenapi_invalid_argstatus thatcall_async_catch's dispatcher gets back fromnapi_create_reference()on a primitive exception value is escalated tonapi_fatal_exceptionregardless of the exception having already been delivered correctly to the Rust side — an upstream napi-rs 3.12.0 defect that cannot be fixed from this crate. Both functions now wrap the user-suppliedprogresscallback in a small JavaScript shim (built viaEnv::run_script, applied inside a sharedProgressCallback::from_napi_valueimpl before theThreadsafeFunctionis constructed) that catches any synchronous throw and, unless the thrown value is already an object or functionnapi_create_reference()can reference, re-throws a newErrorcarrying the original value ascause— so napi-rs's dispatcher never observes a primitive crossing the callback boundary in the first place. A non-functionprogressargument is now also rejected immediately via aValueTypecheck, instead of running the whole operation to completion first. Does not cover anasyncprogress callback whose returnedPromiserejects with a primitive — atry/catchonly observes synchronous throws — norcreateArchiveWithProgressSync, which was never affected by this class of bug (see the preceding entry). -
exarch-pythonerror messages leaked full absolute paths in release builds, and both bindings leaked host paths embedded inCoreError::Iomessages (#453):crates/exarch-python/src/error.rscalledpath.display()directly and unconditionally for every path-carryingArchiveErrorvariant (PathTraversal,SymlinkEscape,HardlinkEscape,InvalidPermissions,SourceNotFound,SourceNotAccessible,OutputExists,UnknownFormat), unlikeexarch-node's equivalent module which already redacted paths to just the filename in release builds. Added a profile-gatedsanitize_path_for_errorhelper matchingexarch-node's behavior (full path underdebug_assertions, filename only otherwise) and routed every path-carrying variant through it. Separately,CoreError::Iowas found to bypass redaction in both bindings:exarch-core'sDestDirvalidation (e.g. "directory is not writable:{canonical_path}") embeds a fully-canonicalized host path directly in theio::Errormessage text, reachable from every extraction call viaDestDir::new_or_create, and neither binding'sIoarm redacted it. Since the message is free-form text with no structured path field, added asanitize_io_error_for_errorhelper to both bindings that keeps the fullDisplayoutput underdebug_assertionsbut reduces it to just thestd::io::ErrorKinddescription in release builds, closing the same leak class at the one variant that had been missed. Neitherexarch-pythonnorexarch-nodenow leaks internal directory structure in release-build error messages. -
exarch-clihuman-readable sizes >= 1 TB rendered as an inflated GB figure instead of TB (#451):HumanFormatter::format_size(crates/exarch-cli/src/output/human.rs) andprogress::humanize_bytes(crates/exarch-cli/src/progress.rs) were two independent byte-humanization implementations; only theprogresscopy had a TB tier. Consolidated both into a singleoutput::humanize_bytes(crates/exarch-cli/src/output/mod.rs) with the TB-inclusive ladder, soextract/list --long --human-readableoutput for archives or entries at or above 1 TB now shows"... TB"instead of a misleadingly large GB number (e.g.u64::MAXbytes now renders"16777216.0 TB", not"17179869184.0 GB"). -
TAR/ZIP file writes only observed their
QuotaPermitby shared reference instead of consuming it (#445): unlike 7z'swrite_file_with_permit(#440),formats::common's sharedextract_file_genericruntime-guardedValidatedEntryType::File(_)against&ValidatedEntryand never took ownership of the permit. Renamed it toextract_file_with_permit, takingsafe_path: &SafePath,mode: Option<u32>, andpermit: QuotaPermitby value instead of&ValidatedEntry;formats::common::create_directorynarrows to&SafePathfor the same reason (its body only ever readvalidated.safe_path()). TAR's extraction dispatch now matches exhaustively onValidatedEntry::into_parts(), so only theFilearm can even bind aQuotaPermit— a compiler-enforced impossibility, not a runtime check. ZIP's dispatch also callsinto_parts()and moves the permit by value, but retains a runtimelet ValidatedEntryType::File(permit) = entry_type else { return Err(..) }fail-closed guard, since ZIP's file/directory/symlink branches aren't a single exhaustive match; this relies onEntryValidator::validate_entry(&EntryType::File, ..)always producingValidatedEntryType::File, an invariant covered by the existingtest_validate_file_entry. No quota arithmetic or validation behavior changed for either format. -
7z file writes discarded their
QuotaPermitinstead of consuming it (#440): unlike TAR/ZIP, 7z'sValidatedEntryType::File(_)write arm matched the permit and dropped it via_, so the capability-token guarantee introduced in #436 covered TAR and ZIP but not 7z. AddedValidatedEntry::into_parts()(a consuming accessor, sinceQuotaPermitis neitherClonenorCopyandentry_type()only lends a shared reference) and a newwrite_file_with_permithelper informats/sevenz.rsthat takesQuotaPermitby value, mirroringformats::common::copy_file_with_permit. 7z's atomic temp-file-then-rename write now cannot compile without a genuine permit obtained fromEntryValidator::validate_entry. No quota arithmetic or validation behavior changed. -
TAR creation silently dropped empty directories (#400):
create_tar_internal_with_progressincrates/exarch-core/src/creation/tar.rsonly incremented a counter forEntryType::Directoryentries without ever writing a directory header to the TAR stream. All four TAR variants (.tar.gz,.tar.bz2,.tar.xz,.tar.zst) plus plain.tarnow write an explicit directory entry for every directory in the source tree (including empty and nested-empty directories), matching the ZIP handler's existing behavior. As part of this fix,directories_addedinCreationReportnow excludes the archive root itself (consistent with ZIP), so it may report one fewer directory than before for the same source tree. -
bytes_compressedinCreationReportwas inaccurate for every creation format (#402): ZIP never assignedbytes_compressed(always0, causingcompression_percentage()to always report the "perfect compression"100.0fallback). TAR measured the pre-compression TAR stream size (headers + padding, before the gzip/bzip2/xz/zstd encoder), not the actual compressed bytes on disk. Both are now measured from the real on-disk archive file size after the writer/encoder is fully flushed and finished, for ZIP and all TAR variants (including plain.tar).compression_ratio()andcompression_percentage()now reflect real compression results. The now-unusedcrate::io::CountingWriter(crate-internal only) was removed. -
list_archive()re-implemented quota checks instead of reusingQuotaTracker(#396): the three per-format listing functions inexarch-core'sinspection::listmodule each duplicated total-size and file-count quota logic inline, independently of theQuotaTrackerused by extraction. This duplicate implementation was weaker than the original: it never checkedmax_file_sizeper entry, and computed the running total-size check with unchecked+instead ofchecked_add, so a crafted archive with entry sizes nearu64::MAXcould wraptotal_sizein a release build and silently bypassmax_total_sizeduring listing. All three listing functions (TAR, ZIP, 7z) now route every entry through the sameQuotaTrackerused by extraction, closing both gaps. This does not make listing and extraction fully equivalent: extraction'sQuotaTrackeronly recordsEntryType::File, while listing records every entry type (directories, symlinks, hardlinks too) — a pre-existing, unrelated divergence.BREAKING CHANGE:
list_archive()andverify_archive()— and by extension thelist/verifyCLI subcommands and theexarch-python/exarch-nodebindings — now reject any single entry larger thanmax_file_size(default 50 MB) during listing; previously only file count and total size were enforced there.list/verifygain a new--max-file-sizeCLI flag (mirroringextract/create) to raise this limit; there was previously no way to configure it for these two commands.verify_archive()'s internal pre-flight listing pass keepsmax_file_sizeunlimited so an oversized entry still surfaces as aVerificationIssue(Failstatus with an itemized report) via the existing per-entry check inverify_entry, rather than aborting before any report exists —verify's "report, don't hard-fail" contract is preserved. -
SecurityViolationerror text was duplicated for pre-extraction violations (#403):convert_extraction_errorinexarch-clirebuilt the wrappedArchiveError's own Display text as anyhow context, so the reason (e.g. "banned path component: .git") appeared twice in both--jsonand human text output whenever a violation was caught during listing/ pre-validation (banned path components, disallowed symlinks/hardlinks, disallowed solid 7z archives). The reason now appears exactly once, and the CLI's own context adds a hint naming the actual policy flags (--allow-symlinks,--allow-hardlinks,--allow-solid-archives,--banned-component) instead of repeating the source error's text. -
Redundant
unsafe impl SendonPyProgressAdapterinexarch-python(#405): pyo3 0.29'sPy<T>isSendfor allTunconditionally, soPyProgressAdapter { callback: Py<PyAny>, .. }already auto-derivesSend. Removed the manualunsafe impl Sendblock; nounsafecode was actually required. -
Stale generated
exarch-node/index.d.ts(#404): the napi-rs generated type declarations still listed only 2 of the 7 defaultbanned_path_componentsentries in theSecurityConfigdoc comment table, out of sync with the Rust source. Regenerated vianapi build. -
test-pythonCI job uploaded duplicate coverage to Codecov 5x per run: the job'spython-versionmatrix (3.10-3.14) ranpytest --covand uploaded to Codecov under the sameexarch-pythonflag on every leg, even though all 5 legs exercise the same Rust-backed bindings and test suite. Coverage generation and the Codecov upload now run only on the 3.12 leg; the other 4 legs still run the full test suite without coverage instrumentation. Also fixed.github/codecov.yml'safter_n_builds(was1, which raced the real 6-upload count per run and could post PR comments before all uploads landed; now2, matching thecoverageandtest-pythonjobs' single upload each), and added the missingflags: exarch-core, exarch-clito thecoveragejob's upload — those two flags were declared with their own thresholds incodecov.ymlbut never received any tagged data. -
TAR hardlink byte/count accounting used unchecked
+=(#427):TarArchive::create_hardlinkincrementedExtractionReport::files_extracted/bytes_written/files_skippedwith plain+=after copying a hardlink's target bytes, inconsistent withformats::common::extract_file_generic'schecked_add-guardedbytes_writtenaccounting. All three counters increate_hardlinknow use the samechecked_add(..).ok_or(ArchiveError::QuotaExceeded { resource: QuotaResource::IntegerOverflow })pattern; this is not yet a crate-wide guarantee, sinceformats/common.rsandformats/sevenz.rsstill increment their ownfiles_extractedcounters with unchecked+=. -
cargo doc --workspacesilently overwrote one target's docs (#429):exarch-cli's[[bin]]target andexarch-python's[lib]target both used the crate nameexarch, so rustdoc wrote both targets' output to the same path and one silently clobbered the other. The build itself still exited 0 — this is acargo-level warning, not a rustdoc lint, soRUSTDOCFLAGS="-D warnings"never caught it. Renamed theexarch-pythonCargo[lib]target toexarch_pylib; theexarch-clibinary name is unchanged since it is the user-facing name installed viacargo install. Considered and rejecteddoc = falseonexarch-cli's[[bin]]as a smaller alternative fix: it would resolve the collision in one line without touching Python packaging, but sacrifices exarch-cli's own rustdoc output, which the rename preserves for both targets. Also addedmodule-name = "exarch"under[tool.maturin]incrates/exarch-python/pyproject.toml, since maturin otherwise derives the expectedPyInit_*symbol name from the Cargo[lib]name and would no longer find the#[pymodule] fn exarch(...)entry point, breaking the Python extension import. The CIDocumentationjob (.github/workflows/ci.yml) now fails ifcargo doc's output contains an "output filename collision" warning, closing the gap that let this regression ship silently in the first place.
0.5.2 - 2026-07-27
- CLI binary releases:
exarch-clirelease binaries are now built and attached to every GitHub release for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64) asexarch-<version>-<target>.tar.gz/.ziparchives with.sha256checksums. scripts/install.sh: a POSIX-sh installer that downloads, checksum-verifies, and installs the correct prebuiltexarchbinary for the host platform. Also attached to every release.skills/exarch-cli/SKILL.mdandcrates/exarch-cli/README.mdnow document both install methods above as secondary alternatives tocargo install exarch-cli.
verify --jsonprinted two concatenated top-level JSON documents on FAIL (#387): the verification report (withdata.status == "FAIL") was always printed, and then the command bailed with an error thatmain's top-level handler also serialized to stdout, breaking single-document JSON parsing.verify::executenow returns a sentinel error thatmainrecognizes and skips re-printing in--jsonmode; human-readable output still prints the "Archive verification failed" message on stderr as before. The command still exits non-zero on FAIL in both modes.extract --jsonnever populatederror.partial_report(#386):format_errorlooked upPartialExtractionContextviaerror.chain().find_map(downcast_ref), which never matches becauseanyhow::Error::context(...)requires a direct top-leveldowncast_refto see through the context wrapper. Switched toerror.downcast_ref::<PartialExtractionContext>(), so partial extraction progress is now correctly reported in JSON error output.exarch-pythonCI failingruff format --checkonREADME.md: ruff 0.16.0 started formatting Python code blocks embedded in Markdown files, which flagged pre-existing inline-comment spacing and blank-line inconsistencies incrates/exarch-python/README.md. Reformatted the file with the new ruff to match.
anyhow,clap,libc,napi/napi-derive,serde_json,thiserror,time, andtokiobumped to their latest compatible patch/minor releases via automated dependency updates (Cargo.lockonly, no direct manifest changes).@biomejs/biomebumped from^2.5.3to^2.5.5inexarch-node.@napi-rs/clibumped from^3.7.2to^3.7.4inexarch-node.- Refreshed
exarch-pythondev dependencies viauv lock(lock-only, no manifest changes).
0.5.1 - 2026-07-09
-
7z
allow_absolute_pathsbypassed by upstream path check (#374, #375):sevenz-rust20.21.1 added an internal path-safety check insidedecompress_with_extract_fnthat blocked absolute-path entries beforeEntryValidatorever saw them, breaking theallow_absolute_pathsflag for 7z archives.extract_with_callbacknow usesArchiveReader::for_each_entriesdirectly, which has no built-in path check, restoringEntryValidatoras the sole and authoritative guard for 7z path security (traversal, absolute paths, symlinks). -
7z backslash path traversal (#376): Entry names with embedded
\(e.g...\..\x) are now normalized to/-separated paths before validation. Previously, on Unix, such names were treated as a single path component and slipped past traversal detection; they are now correctly rejected asPathTraversalerrors. -
DRY: centralized entry-name normalization (#365): Extracted
formats::common::normalize_entry_nameas the single shared point for\→/normalization. The 7z handler now calls this helper in the pre-validation loop, the extraction callback, and the list/verify path (inspection/list.rs), so that all three operations agree on traversal detection.SafePath::validatedocuments the caller contract that entry names must be normalized beforePathBufconstruction. -
RUSTSEC-2026-0204 (#380): Bumped
crossbeam-epoch(transitive, via criterion'srayon->crossbeam-dequechain) from 0.9.18 to 0.9.20, remediating an invalid pointer dereference in itsfmt::Pointer/fmt::Displayimplementations.
pyo3bumped from0.28.3to0.29.0.sevenz-rust2bumped to0.21.3;napi/napi-derive,anyhow,time,rustc-hash,clap_complete,console, andindicatifbumped to their latest compatible patch/minor releases.- Refreshed transitive dependencies via
cargo update(18 lock entries updated, no direct manifest changes). @biomejs/biomebumped from^2.4.15to^2.5.3inexarch-node.@napi-rs/clibumped from^3.6.2to^3.7.2inexarch-node.pytest-covminimum raised from>=6.0to>=7.0inexarch-python.mypyminimum raised from>=1.0to>=2.0inexarch-python.ruffminimum raised from>=0.8to>=0.15inexarch-python.maturinminimum raised from>=1.0to>=1.14inexarch-python.
0.5.0 - 2026-06-05
detect_formatnow falls back to magic-byte inspection when the file extension is absent, unrecognised, or contradicts the file content. Seven signatures are recognised: ZIP (local-file header, EOCD, split-archive marker), GZIP, BZ2, XZ, Zstd, 7z, and TAR USTAR. When magic bytes and extension disagree, magic takes precedence. Archive creation is unaffected —determine_creation_formatuses extension-only detection so stale on-disk bytes cannot override the caller's intent (#353).
- ZIP extraction with
allow_absolute_paths = true: entries whose raw name begins with/(e.g./etc/passwd) are now written inside the destination directory after the leading slash is stripped (producing<dest>/etc/passwd), consistent with the TAR and 7z behavior introduced in #350. Without the flag the behavior is unchanged — such entries are still rejected withPathTraversal. This alignment was made explicit during theprocess_entryrefactor (#352).
ZipArchive::extract()now callsby_index()exactly once per entry instead of twice. The local file header seek+read was previously performed once for the progress callback and again insideprocess_entry; the two calls are now merged, halving header I/O on archives with many small entries (#341).
- Absolute entry paths (e.g.
/etc/shadow) and Windows drive/UNC paths (e.g.C:\...,\\server\share\...) are now stripped centrally inSafePath::validate_with_contextwhenallow_absolute_pathsis enabled, instead of in each format handler separately. The three per-format pre-stripping workarounds intar.rs,zip.rs, andsevenz.rshave been removed. Also fixes bare-slash entries (/) returningio::Errorinstead ofPathTraversalError(#347, #348). exarch create --quiet --jsonnow emits JSON to stdout instead of suppressing it.--quietno longer silences--jsonoutput for any command (#357).- Node.js
SecurityConfigJSDoc table now lists all 7 defaultbanned_path_components(.git,.ssh,.gnupg,.aws,.kube,.docker,.env); previously only.gitand.sshwere shown, which could mislead users into thinking the remaining five needed to be added manually (#355). exarch list --json -lnow includessymlink_targetandhardlink_targetin the JSON output for symlink and hardlink entries. Previously the fields were populated inexarch-corebut silently dropped by the CLI JSON formatter (#346).exarch list -lnow displays symlink and hardlink targets in the long text format: entries render asl755 0 link.txt -> target.txtinstead of omitting the target (#349).- Python
ExtractionOptionstests no longer unconditionally skip: replaced thepytest.skip(...)guard withpytest.importorskip("exarch")at module level so all 14 round-trip assertions execute when the extension is built (#342). - Roundtrip integration tests now verify extracted file contents against the source data for all supported formats (tar.gz, tar.bz2, tar.xz, tar.zst, zip). Previously, tests only asserted that extraction succeeded and files existed, which would have allowed silent data corruption to go undetected (#335).
- CLI roundtrip tests (
test_roundtrip_tar_gz_single_file,test_roundtrip_zip_directory) now assert extracted file contents match the original source bytes (#335).
-
Python
SecurityConfigbuilder methodsallow_symlinks,allow_hardlinks,allow_absolute_paths,allow_world_writable, andallow_solid_archiveshave been renamed towith_allow_symlinks,with_allow_hardlinks,with_allow_absolute_paths,with_allow_world_writable, andwith_allow_solid_archivesto match thewith_prefix convention used by all other builder methods in the class. Update call sites by prependingwith_to each method name (#354). -
ArchiveCreator::compression_levelnow returnsResult<Self, ArchiveError>instead ofSelf. Call sites must propagate the error with?or handle it explicitly; passing an out-of-range level (0 or >9) now returnsArchiveError::InvalidCompressionLevelinstead of silently clamping or panicking (#308).
extractcommand now exposes three previously hiddenSecurityConfigfields as CLI flags:--max-path-depth <N>(default 32),--banned-component <COMPONENT>(repeatable; replaces the default ban list when provided), and--allow-absolute-paths(flag). Operators can now tune path depth and component ban lists without recompiling (#303).createCLI subcommand:--max-file-size <BYTES>flag (supports K/M/G/T suffixes) skips source files larger than the given threshold during archive creation (#306).createCLI subcommand:--preserve-permissionsflag (default: true) controls whether Unix file permissions are stored in the archive; pass--preserve-permissions=falseto create a portable archive without platform-specific permission bits (#306).- Python and Node.js bindings now expose
ExtractionOptionswithskip_duplicates. Python:ExtractionOptionsclass withwith_skip_duplicates(skip=True)builder. Node.js:ExtractionOptionsclass withwithSkipDuplicates(skip?)builder. Bothextract_archiveandextract_archive_with_progressaccept an optionaloptionsparameter (#313). - Python and Node.js bindings expose
ExtractionOptions.atomic. Python:with_atomic(bool)builder andatomicgetter/setter. Node.js:withAtomic(bool?)builder andatomicgetter. Atomic mode extracts to a staging directory first, then renames it to the destination — the output directory must not pre-exist (#322).
- Python and Node.js bindings: added round-trip tests for
ExtractionOptions.atomicandskip_duplicates— each field is covered by a default-value test and a setter/getter round-trip test. Added# Examplesdoc section to the Pythonwith_atomicmethod (#332). - Added integration tests for
ExtractionOptions::skip_duplicates: coversskip_duplicates=true(first entry kept, duplicate skipped with warning) andskip_duplicates=false(second entry overwrites first) for TAR archives. Documents that thezipcrate 8.x deduplicates entries at parse time, making the flag a no-op for ZIP (#302). - Added 7z integration tests for
skip_duplicates:skip_duplicates=truekeeps the first entry and records a warning;skip_duplicates=falseoverwrites with the last entry (#314).
- Python:
exarch.pyiSecurityConfigandCreationConfigbuilder methods (max_file_size,max_total_size,max_compression_ratio,max_file_count,max_path_depth,max_solid_block_memory,preserve_permissions,compression_level,follow_symlinks,include_hidden,exclude_patterns,max_file_size) were missing thewith_prefix; renamed to match the Rust implementation (with_max_file_size,with_compression_level, etc.) so type checkers accept valid code (#334). - Python:
SecurityConfigandCreationConfigscalar getters (max_file_size,max_total_size,max_compression_ratio,max_file_count,max_path_depth,max_solid_block_memory,preserve_permissions,compression_level,follow_symlinks,include_hidden,exclude_patterns) now return their values correctly instead of a bound method. Builder methods were renamed towith_<field>(e.g.with_max_file_size(...)) to eliminate the PyO3 name collision (#315). - 7z force-overwrite now removes existing file before re-extraction when
skip_duplicates=false; previously it returned aPartialExtractionerror instead of overwriting (#323). - Node.js:
index.d.tsnow declaressetMaxSolidBlockMemory(size: number): thisandget maxSolidBlockMemory(): numberforSecurityConfig; the file is committed to the repository so TypeScript consumers have correct types without building from source (#311). - Node.js:
index.d.tsregenerated to includeExtractionOptionsclass and the fourthoptionsparameter on allextract*signatures; the file was stale after PR #324 (#330). - Node.js:
extractArchiveWithProgressJSDoc corrected — numeric callback arguments (total,current,bytesWritten) were documented asbigintbut NAPI-RS mapsi64tonumber(#326). - Python:
exarch.pyinow declaresallowed_extensionsandbanned_path_componentsas@propertywith setters, replacing bare class-level annotations that did not express read/write semantics (#312). list_archivenow respectsSecurityConfig::allowed.absolute_paths; absolute paths in TAR and 7z archives are accepted during listing when the flag is set (previously silently rejected regardless of config) (#318). The--allow-absolute-pathsCLI flag now consistently applies to both the listing and extraction phases.- ZIP listing with
--allow-absolute-paths: entries whose names returnNonefromenclosed_name()(traversal-after-root patterns like/../etc/passwd) were always rejected withPathTraversalregardless of the flag. The listing side now checks the flag for this case, strips the leading/, and passes the result throughcontains_traversal; bare/or empty-after-strip paths are rejected. True traversal components (..) are still rejected even with the flag set (#325). - ZIP extraction with
--allow-absolute-paths: the extraction path inzip.rspreviously built the entry path from the rawname()string, causingSafePath::validateto see an absolute path and subsequentlydest.join(absolute)to discarddest— resulting inPathTraversaleven when the flag was set. Extraction now usesenclosed_name()with the same fallback strip logic as listing, so the flag works end-to-end for ZIP (#325). - Conflict scan during
exarch extractnow uses the same relative path thatlist_archiveproduces for each entry. Previouslyoutput_dir.join(e.path)silently discardedoutput_dirwhene.pathwas absolute (stdlibPath::joinsemantics), causing conflict checks to probe real filesystem paths instead of the intended destination (#327). verify --strictno longer writes an unstructured message to stderr that bypassed--quietsuppression and--jsonmode. Exit code 2 already conveys the strict-warning condition (#298).ProgressCallback::on_bytes_writtenis now called during extraction for TAR, ZIP, and 7z formats; previously the method was documented but never invoked (#304).ProgressCallback::on_entry_completeis now guaranteed to be called for every entry for whichon_entry_startwas called, including entries that fail mid-extraction; previously a failure left the callback pair unbalanced (#305).- 7z extraction with
skip_duplicates=falsenow overwrites the existing file instead of returning an error. Previously a duplicate entry withskip_duplicates=falsewould fail; now it falls through to the atomic temp+rename overwrite path (#314). list_archivenow reports the correctsymlink_targetfor ZIP symlink entries. PreviouslyArchiveManifestentries were set to the entry's own path instead of reading the actual target from the entry data (where the ZIP spec stores it). The symlink detection mask in the listing path has also been corrected to useS_IFMT & S_IFLNK, matching the extraction path (#336).
0.4.1 - 2026-06-05
verifyCLI command now accepts a--strictflag. When set, a verification report withWarningstatus causes the process to exit with code 2 instead of 0. Without the flag, the previous behaviour (exit 0 on warnings) is unchanged (#269).ValidationReportis now re-exported at the crate root asexarch_core::ValidationReport(was only accessible asexarch_core::security::ValidationReport) (#256).
- CLI:
convert_extraction_errornow has explicit match arms forOutputExists,InvalidPermissions,InvalidCompressionLevel, andSecurityViolation, each producing an actionable message with the relevant path or reason. Previously these variants fell through to a generic wildcard arm (#295). PyProgressAdapterandNodeProgressAdapternow resetbytes_writtento 0 at the start of each entry, eliminating stale values from previous entries (#285).check_permissionsininspection/verify.rsnow passes the actual entry path toInvalidPermissionsinstead of an emptyPathBuf, so error messages include the offending archive entry (#286).- ZIP archives created via the non-progress
create_zippath no longer include a spurious"/"root directory entry. The entry was an artefact of formatting an empty archive path as"{}/"; it has been absent from thecreate_zip_with_progresspath since #289 (#290).
ExtractionErrorrenamed toArchiveErroracross the entire public API (#253). The error type now covers all archive operations (extraction, creation, listing, verification), not just extraction. Update all match arms,useimports, and type aliases:use exarch_core::ArchiveError;. The Python base exception is nowexarch.ArchiveError(wasexarch.ExtractionError).
-
extract_archive_with_progressnow delegates toextract_archive_with_options_and_progress(the canonical implementation) instead of calling the internalextract_impldirectly. All fourextract_archive*convenience wrappers now form a clean delegation chain through the single canonical function (#259). -
Security primitives
validate_path,validate_symlink,sanitize_permissions,validate_compression_ratio,QuotaTracker, andHardlinkTrackerare nowpub(crate)and no longer part of the public API. External benchmarks and integration tests that reference these directly must add--features testing(#281). -
sanitize_permissionsreturn type changed fromResult<u32>tou32— the function never fails; callers no longer need?or.unwrap(). -
Specifications in
specs/updated to replace staleUnsupportedFormatreferences withUnknownFormat { path }(format-detection failures) andInvalidConfiguration(7z creation), matching the post-#255 Rust API. Python exception hierarchy updated to includeUnknownFormatError(UnsupportedFormatError)(#265, #264). -
creation/tar: replace manual entry counter withProgressTracker; addProgressTracker::callback()accessor to enable byte-level progress in nested helpers without lifetime conflicts (#284). -
creation/zip: sameProgressTrackerwiring as tar, removing manualidx + 1counter (#284). -
creation/zip:create_zip_internalnow delegates tocreate_zip_internal_with_progressviaNoopProgress, eliminating ~167 lines of duplicate traversal, compression-option, and file-add logic (#290). -
creation/tar: dead_buffer: &mut [u8]parameter removed fromadd_file_to_tar_with_progress_impl; the two 64 KB heap allocations at the former call sites are eliminated (#291). -
api: collapse five identicalextract_tar*private functions into a single genericextract_tar_with_decoderhelper parametrised by a decoder closure; eliminates ~80 lines of structural duplication (#254). -
sevenz: eliminateRc/RefCellinterior mutability inextract_with_callback; state is now owned by a local context struct, matching thetar.rsandzip.rspatterns (#273, #258). -
sevenz: narrowstd::processimport tostd::process::idto prevent accidental use ofprocess::exitin library code (#270). -
Internal creation helpers (
compression_level_to_*,ProgressReader,ProgressTracker,FilteredEntry,FilteredWalker) are no longer accessible viapub useat the crate root; they remain available withinexarch-corethrough their submodule paths but are internal implementation details. The parent modulescreation::compression,creation::progress, andcreation::walkerare nowpub(crate)(#280). -
sanitize_permissionssignature no longer accepts a_path: &Pathparameter that was unused. Call sites that passed a dummy path must be updated to omit the argument (#279). -
ZIP symlink extraction tests (
test_extract_symlink_via_unix_attributes,test_symlink_disabled_by_default) are no longer ignored; they now use raw ZIP construction with correct unix mode bits to exercise the security-critical symlink detection path (#271). -
test_hardlink_rejectedrewritten to perform a real extraction and assert successful completion, documenting thatValidatedEntryType::Hardlinkis unreachable for any real ZIP entry (#272). -
Removed
test_debug_zip_unix_modedebug test that was permanently ignored. -
ExtractionError::UnsupportedFormathas been removed. All format-detection failures now returnExtractionError::UnknownFormat { path }, which carries the path that could not be identified. Match arms onUnsupportedFormatmust be updated toUnknownFormat { .. }(#255). -
7z archive creation now returns
ExtractionError::InvalidConfigurationinstead ofExtractionError::UnsupportedFormatwhen the output path has a.7zextension, since the format is recognised but creation is unsupported (#255). -
CreationConfig::with_compression_levelnow returnsResult<Self, ExtractionError>instead ofSelf. Call sites must handle the error with?or.unwrap(); the method no longer panics on out-of-range input (#257). The real validation gate isCreationConfig::validate(), which is invoked by the creation pipeline; this change removes the panic from the public builder surface. -
Python:
PartialExtractionErrorhas been removed from the public API. In 0.4.0 it was always raised when extraction failed after some files were already written. Code written against 0.4.0 that usedexcept PartialExtractionErrormust be updated: catch the specific exception type (SymlinkEscapeError,QuotaExceededError, etc.) or useexcept ExtractionErroras the catch-all. To detect whether output was partial, usegetattr(e, "files_extracted", None) is not None(#251). -
Node.js:
SecurityConfignow exposesallowSolidArchivesgetter, consistent with all other boolean permission getters (allowSymlinks,allowHardlinks,allowAbsolutePaths,allowWorldWritable) (#261). -
Python:
UnknownFormatErroris now a distinct exception subclass ofUnsupportedFormatError, raised when an archive format cannot be determined from the file path or magic bytes (CoreError::UnknownFormat). Callers catchingUnsupportedFormatErrorcontinue to work unchanged; callers that need to distinguish "format unknown" from "format known but unsupported" can now catch the narrower type (#260). -
Python:
extract_archive_with_progress(archive_path, output_dir, config, progress)binding added, mirroringcreate_archive_with_progress. The GIL is held when a callback is provided and released otherwise.exarch.pyiand the stub are updated (#263). -
Node.js:
extractArchiveWithProgress(archivePath, outputDir, config?, progress?)async binding added, accepting an optionalThreadsafeFunctionprogress callback with signature(path: string, total: bigint, current: bigint, bytesWritten: bigint) => void(#263). -
CLI:
convert_extraction_errornow has explicit match arms forInvalidConfiguration,SourceNotFound, andSourceNotAccessible, each producing an actionable message with the relevant path or reason. Previously these variants fell through to a generic wildcard arm (#274). -
CLI:
SecurityConfigquota parameters (max_file_count,max_total_size,max_file_size,max_compression_ratio,allow_solid_archives) are now defined once inexecute()and reused for the pre-listing phase, eliminating silent drift if quota defaults change (#267). -
CLI: The four near-identical
run_extractioncall sites inextractare unified into a single call viaBox<dyn ProgressCallback>, removing the copy-paste maintenance burden (#268). -
Node.js: async operations (
extractArchive,createArchive,listArchive,verifyArchive) now wrap the core call withcatch_unwindinsidespawn_blocking, preventing panics inexarch-corefrom crossing the FFI boundary and aborting the Node.js process. Panics are converted to JavaScript errors with a descriptive message (#262). -
Python:
extract_archivenow raises the specific exception type (SymlinkEscapeError,HardlinkEscapeError,QuotaExceededError, etc.) instead of the genericPartialExtractionErrorwhen extraction fails after some files have been written to disk. Thefiles_extractedandbytes_writtenreport attributes from #210 are attached directly to the concrete exception (#251). -
Node.js:
extract_archiveerror messages now begin with the specific error code (SYMLINK_ESCAPE,QUOTA_EXCEEDED, etc.) instead of always prefixingPARTIAL_EXTRACTIONwhen the error occurs after partial output has been written. ThefilesExtractedandbytesWrittenfields are still appended to the message (#251).
0.4.0 - 2026-05-20
- Shell completion generation via
exarch completion <shell>(bash, zsh, fish, powershell, elvish). Output goes to stdout for piping into the appropriate completions directory (#232). --verboseflag now prints one line per extracted entry to stderr, including entry name, size, and type.--quiettakes precedence when both flags are provided (#233).SecurityConfig::allowed_extensionsfilter is now enforced during extraction across all three format handlers (TAR, ZIP, 7z). When the list is non-empty, files whose extension is not in the allowlist are skipped and recorded inExtractionReport::files_skippedwith a warning (#230).extractsubcommand now accepts--allowed-extensions <EXT>(repeatable; comma-separated values also accepted) and passes the parsed list toSecurityConfig::with_allowed_extensions(), exposing the core extension filter at the CLI level (#246).create_archivenow rejects ZIP-family alias extensions (.apk,.jar,.whl,.epub,.war,.ear,.aab,.ipa,.appx,.msix,.vsix,.nbm) when the output format is inferred (i.e.,CreationConfig::formatisNone). SetCreationConfig::format = Some(ArchiveType::Zip)to override (#231).
Archive::opennow returnsSelfinstead ofResult<Self>. Callers must remove?or.unwrap()(#243).SecurityConfig,AllowedFeatures, andExtractionOptionsare now#[non_exhaustive]. External crates can no longer construct these structs via struct literal syntax; useDefault::default()or the new fluent builder methods instead (#221).- Internal modules
copy,io, andtest_utilsinexarch-coreare nowpub(crate)instead ofpub. These were never part of the public API; any external code referencingexarch_core::copy,exarch_core::io, orexarch_core::test_utilsdirectly will no longer compile (#173).
verify_entryinexarch-core::inspection::verifynow callsvalidate_pathonce per entry and caches the result, eliminating a redundant second call (and the associatedcanonicalizesyscalls) for symlink and hardlink entries (#236).- Upgraded
zipdependency from 8.6.0 to 9.0.0-pre2; adaptedZipFile::name()call sites to propagate the newResult<Cow<str>, ZipError>return type (#238). - Refactored
TarArchiveinternal extraction helpers: introduced a privateExtractionContext<'_, '_>struct that groups the six shared parameters (validator,dest,report,copy_buffer,dir_cache,skip_duplicates) previously threaded individually throughprocess_entry(7 params),extract_file(7 params), andcreate_hardlink(5 params). Signatures now acceptctx: &mut ExtractionContext<'_, '_>instead (#222). extract_archive_fullrenamed toextract_archive_with_options_and_progressfor API naming consistency. The old name was ambiguous; the new name describes both parameters the function accepts (#219).- Introduced
FormatCreatortrait inexarch-core::formats::traitsfor archive creation dispatch. The trait mirrorsArchiveFormaton the write side and replaces the manualmatchincreate_archive_with_progresswith six unit struct implementors (TarCreator,TarGzCreator,TarBz2Creator,TarXzCreator,TarZstCreator,ZipCreator) and acreator_for_formathelper (#220). - Added 15 fluent builder methods to
SecurityConfig(with_max_file_size,with_max_total_size,with_max_compression_ratio,with_max_file_count,with_max_path_depth,with_allowed,with_allow_symlinks,with_allow_hardlinks,with_allow_absolute_paths,with_allow_world_writable,with_preserve_permissions,with_allowed_extensions,with_banned_path_components,with_allow_solid_archives,with_max_solid_block_memory) and 2 toExtractionOptions(with_atomic,with_skip_duplicates) (#218). TarArchive::list()andTarArchive::extract()now have///doc comments explaining thatlist()consumes the internal reader (TAR is forward-only) and that callingextract()on the same instance afterward returnsInvalidArchive. Callers must open a fresh instance for extraction (#211).CopyBuffer::size()visibility corrected frompub(crate)topub, consistent with the other items in the crate-internalmod copy. Thepub(crate)module boundary inlib.rsalready enforces the encapsulation; redundantpub(crate)on items inside apub(crate)module triggers theredundant_pub_crateclippy lint (#203).verify_archivenow delegates toverify_manifestafter callinglist_archive, eliminating ~80 lines of duplicated entry-processing logic (#190).ProgressCallback::on_completedoc comment clarified: the method is called only on successful completion; implementors must not use it for cleanup.ArchiveFormattrait extended withfn list()andfn verify()methods, providing a single implementation point for all format operations (#174).
- Removed 5 non-progress public functions (
create_tar,create_tar_gz,create_tar_bz2,create_tar_xz,create_tar_zst) fromexarch-core::creation::tarthat were annotated#[allow(dead_code)]and unreachable from the crate's public surface. The public API already routes throughFormatCreatortrait objects using the_with_progressvariants (#227). - Removed dead
format_successandformat_warningmethods from theOutputFormattertrait and both implementations (HumanFormatter,JsonFormatter). Neither method was called from any command handler (#208). - Removed dead constant
SEVENZ_MAGICand its#[allow(dead_code)]suppression fromformats/detect.rs; the constant was unused in format detection logic (#175).
-
CliProgressbar now receives the actual archive entry count instead of the hardcoded value of 100; byte throughput is shown viaset_messageso that the{pos}/{len} filescounter tracks only entries and does not race with cumulative byte values (#245). -
CliProgressentry count is pre-filtered when--allowed-extensionsis active, so the progress bar reaches 100% even when a subset of entries is extracted (#245, #246). -
ArchiveBuilder::extractnow returnsExtractionError::InvalidConfigurationinstead ofExtractionError::SecurityViolationwhenarchive_pathoroutput_dirare not set. The previous variant causederror_code()to return"SECURITY_VIOLATION"for what is a caller configuration mistake (#235). -
Corrected the
Archive::opendoc-comment which incorrectly claimed the constructor validates file existence. The function is infallible; I/O errors surface onextract()(#237). -
create_tar_zst_with_progressnow callszstd::Encoder::finish()explicitly and propagates any I/O error via?. Previously the encoder relied onDropto calltry_finish(), which silently discarded flush errors and could produce a truncated.tar.zstarchive on disk-full or other I/O failure (#226). -
CLI no longer emits
"HINT: Use --allow-symlinks"when--allow-symlinksis already active and a symlink escape is blocked. The hint is now suppressed when the flag is set, since the escape is a genuine security violation rather than a configuration issue (#213). -
verify_archiveno longer shares a static/tmp/exarch-verifydirectory across concurrent calls. Each invocation now uses an isolatedtempfile::TempDirscoped to its lifetime, eliminating the TOCTOU race and persistent state pollution (#200). -
7z extraction callback now accumulates
bytes_writtenviachecked_addinstead of unchecked+=, preventing silent integer wraparound in release builds and matching the project-wide convention established incopy_with_buffer(#201). -
JSON
messagefield no longer repeats the inner error text forPartialExtractionvariants (HardlinkEscape,SymlinkEscape).PartialExtractionis#[error("{source}")]with#[source], so placing it directly in an anyhow chain caused the inner error display to appear twice in{:#}output.convert_extraction_errornow extracts the inner error and wraps it with a dedicatedPartialExtractionContextcarrier that holds the partial report without re-emitting the inner text (#204). -
JsonFormatter::format_successandformat_warningno longer emit"operation":"unknown"or"operation":"warning"in JSON output. Both methods now accept anoperation: &strparameter propagated through theOutputFormattertrait (#202). -
JSON
messagefield no longer duplicates the path forPathTraversalerrors in--jsonCLI output. The path was embedded in both the anyhow context string and theExtractionError::Displayoutput, causing it to appear twice when formatted with{:#}(#198). -
JSON
messagefield no longer duplicates the path forSymlinkEscapeandHardlinkEscapeerrors in--jsonCLI output. The path was embedded in both the anyhow context string and theExtractionError::Displayoutput, causing it to appear twice when formatted with{:#}(#196). -
SevenZArchive::extractnow fireson_entry_startandon_entry_completeper-entry, interleaved with actual I/O, instead of batching all start events before extraction and all complete events after (#191). -
SevenZArchive::verifynow callsconfig.validate()before any archive I/O, matching the guard applied by the publicverify_archiveentrypoint (#191). -
JSON error output no longer duplicates the error message for
QuotaExceededandZipBomberrors when using--json. Themessagefield previously contained theExtractionError::Displaytext twice due toanyhow's{:#}formatter chaining the context string with the inner error display (#192). -
extract_archive_with_progressnow correctly invokes theProgressCallbackfor all archive formats (TAR, ZIP, 7z). Previously the callback was silently discarded becauseArchiveFormat::extractdid not accept a progress parameter (#170). -
create_archive()now returnsError::UnsupportedFormatinstead ofError::InvalidArchivewhen a.7zoutput path is requested, correctly signaling that 7z creation is not supported (#182). -
ZIP password-protection detection now performs a full linear scan of all entries instead of a 3-sample strategy, preventing false negatives for archives with encrypted entries outside the first/middle/last 100 positions (#171).
-
SecurityConfig::validate()added: construction-time validation rejectsmax_compression_ratio <= 0,max_file_size == 0,max_total_size == 0, andmax_path_depth == 0;extract_archiveandcreate_archivecallvalidate()and return an error for invalid configs (#172). -
CreationConfig::validate()is now called increate_archive_with_progress, ensuring invalid creation configs are caught before any I/O occurs (#180). -
SecurityConfig::validate()now rejectsmax_file_count == 0andmax_solid_block_memory == 0to prevent undefined extraction behavior (#181).
0.3.1 - 2026-05-19
- Raised MSRV from 1.89.0 to 1.93.0 to accommodate
sevenz-rust20.21.0 (required bynt-time0.15) (#163).
extractcommand now correctly applies user-supplied quota flags (--max-total-size,--max-file-size,--max-files,--max-compression-ratio) to the conflict-detection pre-pass. Previously the pre-pass used default limits, causing a spurious quota error for archives larger than 500 MiB even when a higher limit was specified (#166).
- Drop Python 3.9 (EOL October 2025) from the test matrix; add Python 3.14.
- Release workflow updated to build wheels against Python 3.10 minimum.
sevenz-rust20.20.2 → 0.21.0 (#162)assert_cmd2.2.0 → 2.2.2,clap4.6.0 → 4.6.1,clap_complete4.6.2 → 4.6.5,libc0.2.185 → 0.2.186,napi3.8.4 → 3.9.0,napi-build2.3.1 → 2.3.2,napi-derive3.5.3 → 3.5.6,tokio1.51.1 → 1.52.1,zip8.5.1 → 8.6.0 (#161, #164, #165, #167)- Python dev dependencies updated (
maturin1.13.3,mypy2.1.0,pytest9.0.3,pytest-cov7.1.0,ruff0.15.13); minimum Python version raised to 3.10 (3.9 EOL) - Node.js dev dependencies updated (
@biomejs/biome2.4.15,@napi-rs/cli3.6.2); migrated from npm to pnpm
0.3.0 - 2026-04-23
- Extract, list, and verify additional ZIP-based formats. JVM artifacts
(
.jar,.war,.ear), Java-ecosystem packaging (.nar,.nbm), mobile and desktop app bundles (.apk,.aab,.ipa,.appx,.msix), Python wheels (.whl), IDE/browser extensions (.vsix,.xpi), and EPUBs (.epub) now route through the existing ZIP extractor rather than returningUnsupportedFormat. Creation for these extensions is explicitly rejected (mirrors.7z): they all sit on ZIP but require extra structure - signing, manifests, ordering rules - that exarch doesn't produce, so silently emitting a bare ZIP would be misleading. Callers who need the override can setCreationConfig::format = Some(exarch_core::formats::detect::ArchiveType::Zip).
-
detect_formatnow usesis_zip_family_aliasfor ZIP-family extension matching, ensuring the dedicated case-insensitive helper is the single source of truth rather than a duplicated inlinecontainscall. -
detect_formatnow returnsUnsupportedFormatfor bare.gzfiles (no.tarstem) instead of silently routing them toopen_tar_gzand producingInvalidArchiveat runtime..tar.gzand.tgzpaths are unaffected (#155).
- Update
unicode-segmentationfrom 1.13.1 (yanked) to 1.13.2 viacargo update. Pulled transitively throughconvert_case(napi-derive) andindicatif(exarch-cli).cargo deny checknow reports no yanked crates; advisories, bans, licenses, and sources all pass.
0.2.9 - 2026-03-25
- Add regression tests for RUSTSEC-2026-0067 symlink+directory chmod attack
(CVE-2026-33056 / GHSA-j4xf-2g29-59ph). Two new test cases verify that an
archive combining
subdir -> ../external(symlink) followed by a directory entrysubdiris rejected before tar-rs can chmod the external directory — both with default config (symlinks disabled) and withallow_symlinks = true(#132).
-
Confirm and test CVE-2026-24842: hardlink
linkpathvalidation correctly uses the extraction root (dest) as the resolution base, not the entry's parent directory. A crafted entrya/b/c/d/linkwithlinkpath = ../../../../etc/passwdis blocked becausedest/../../../../etc/passwdescapes the root and is detected immediately. The mismatch described in the CVE does not exist in this implementation; added CVE regression testtests/cve/cve_2026_24842.rsto prevent future regressions (#131). -
Fix two-hop symlink chain bypass in
SafeSymlinkandSafeHardlinkvalidation (GHSA-83g3-92jg-28cx variant — #116). String-based..normalization did not account for on-disk symlinks written by earlier archive entries; a second symlink whose target traversed through a previously extracted symlink could redirect subsequent..steps outside the extraction root. The fix replaces string normalization with a component-by-component on-disk walk that callsfs::canonicalizewhenever an on-disk symlink is encountered, verifying containment within the destination directory after every step. Requires--allow-symlinksAND--allow-hardlinks(both non-default) to trigger; hardlink escape is additionally blocked by OS restrictions on macOS for root-owned files.
-
Add CVE-2025-29787 regression test (ZIP symlink zip-slip). exarch is not vulnerable:
SafeSymlink::validaterejects the escaping symlink before it is written to disk, so the follow-on file entry cannot escape the extraction root (#133). -
exarch listandexarch verifynow accept--max-filesand--max-total-sizeflags, mirroringexarch extract. Archives with more than 10 000 entries (e.g. ZIP64 archives) can now be listed or verified by passing--max-files <N>(#122). -
list_archiveandverify_archivenow support 7z archives, consistent with TAR and ZIP (#79). Entries are iterated viasevenz-rust2::Archive::read(no decompression); solid archives are safe to list. Quota limits, path traversal checks, and encryption rejection apply identically to other formats.
-
TAR/ZIP extraction no longer aborts on duplicate entry names; conflicting entries are now skipped with a warning recorded in
ExtractionReport.files_skipped(#129). The newExtractionOptions.skip_duplicatesfield (defaulttrue) controls this behavior. -
Fix
listandverifycrash on valid empty 7z archives (#117) -
Fix
verifyfalse positive [HIGH] for solid 7z archive entries wherecompressed_size=0is a normal artifact of solid block compression (#118) -
Add
--allow-solid-archivesflag to CLIextractcommand (#119) -
--allow-solid-archivesis now propagated to the conflict-detectionlist_archivecall inextract, fixing aSecurityViolationat the list step when solid 7z archives are passed with--allow-solid-archivesbut without--forceor--atomic(#124).--allow-solid-archivesis also exposed in thelistandverifysubcommands. -
Expose
allow_solid_archivesin Python and Node.js bindings (SecurityConfig) (#127) -
TAR hardlink entries now copy file content instead of creating real OS hardlinks, preventing shared-inode corruption when a duplicate entry overwrites a hardlink path (GHSA-2367-c296-3mp2 variant, #130).
-
Upgrade
tardependency to 0.4.45 to address RUSTSEC-2026-0067 (symlinkchmodescape inunpack_in) and RUSTSEC-2026-0068 (PAX size header ignored when base header size is non-zero) (#112) -
SafePath::validateno longer returns a false positivePathTraversalerror for archive root entries (.or./) produced bytar -C /dir .(#113)
0.2.8 - 2026-03-15
- When
--jsonis specified and a command fails, the CLI now emits a structured JSON error object{"operation":"...","status":"error","error":{"kind":"...","message":"..."}}instead of plain text (#87) SecurityConfig.allowed_extensionsandSecurityConfig.banned_path_componentswere missing from Python type stubs (exarch.pyi), causing pyright to reportreportAttributeAccessIssue(#72)- Use
entry.size()instead ofentry.header().size()for TAR quota enforcement to prevent PAX size bypass (#82) - Honor
--forceflag inextractsubcommand; without--force, fail with a clear error listing conflicting files (#77) - Encrypted ZIP archives now correctly report a security violation instead of a misleading "corrupted or malformed" hint (#83)
list -lshowed raw Unix file-type bits (e.g.100644) for ZIP entries instead of normalized permission bits (e.g.644);ArchiveEntry.modenow stripsS_IFREG/S_IFDIRbits from ZIPexternal_attributes(#80)- World-writable files now have the write-other bit stripped by default instead of aborting extraction (consistent with setuid/setgid stripping) (#84)
listquota error message reportedcurrentequal to the limit instead of the actual would-be count (e.g.10000 > 10000instead of10001 > 10000) for both TAR and ZIP archives (#91)listcommand reported a misleading "invalid archive" error for encrypted ZIP archives instead of a security violation; now correctly reportsSecurityViolation: archive is password-protected(#96)- Extracted file permissions now honor the sanitized mode, bypassing the process umask (#97)
listcommand now rejects TAR entries with path traversal (../) and absolute paths, matching ZIP behavior (#104)
PartialExtractionerror variant wrapping the original error and a partialExtractionReportsnapshot when extraction fails after writing files to disk (#89)ExtractionOptionsstruct withatomic: boolfield for controlling extraction behavior (#89)extract_archive_full()andextract_archive_with_options()public API functions acceptingExtractionOptions(#89)--atomicCLI flag: extracts into a temporary directory in the same parent, renames on success, and cleans up on failure to ensure the destination is never in a partial state (#89)- JSON error output includes a
partial_reportfield (files_extracted,directories_created,symlinks_created,bytes_written) when extraction is stopped mid-archive (#89) --allow-world-writableCLI flag andallow_world_writableSecurityConfigoption to opt in to preserving world-writable permissions (#84)- CVE regression tests for CVE-2024-12718 (Python tarfile filter bypass via
./..paths), CVE-2024-12905 (tar-fs symlink chain escape), CVE-2025-48387 (tar-fs hardlink traversal outside destination), and Windows backslash path handling; archives with raw..paths are constructed at the byte level to reproduce real attacker-controlled inputs (#74)
extractnow auto-creates the destination directory (including intermediate directories) if it does not exist, matching behavior oftar,unzip, and7z(#78)- Removed stale
RUSTSEC-2025-0119ignore entry fromdeny.toml; the advisory no longer matches any dependency in the tree (#76) - Updated yanked transitive crates:
js-sys0.3.86 → 0.3.91,wasm-bindgen0.2.109 → 0.2.114,web-sys0.3.86 → 0.3.91 (#75)
0.2.7 - 2026-03-07
- PAX archive extraction fails with
SecurityViolationforXGlobalHeaderentries (#69) - TAR
ContinuousandGNUSparseentry types incorrectly rejected as unsupported list_archive()inconsistently reported PAX metadata as regular files
- Suppress
clippy::needless_bitwise_boolfor intentional constant-time null byte check in exarch-node
0.2.6 - 2026-03-04
- macOS ARM64 wheel no longer embeds a dynamic path to Homebrew's liblzma; xz2 is now statically linked via
xz2/staticfeature (#66)
- Bump
maturinfrom 1.12.3 to 1.12.6 - Bump
biomefrom 2.3.14 to 2.4.5
0.2.5 - 2026-02-20
- Upgrade
zipdependency from 7.x to 8.0 (breaking: removed deprecatedDateTime::to_time()) - Upgrade
tempfiledependency from 3.24 to 3.25 - Replace deprecated
DateTime::to_time()withtime::PrimitiveDateTimeconversion for ZIP timestamps - Add
timeas direct dependency (previously transitive viazip) - Bump
pyo3from 0.28.1 to 0.28.2
0.2.4 - 2026-02-06
- ci-success gate now includes test-python and test-node jobs to prevent merging PRs with failing binding tests (#56)
- Python bindings now support Python 3.9-3.13 with proper CI testing and abi3 wheels (#55)
- Canonicalization optimization —
ValidationContextenables skipping redundantcanonicalize()syscalls during path validation. Trusted-parent fast path (viaDirCache) and symlink-free fast path eliminate ~17% CPU overhead in extraction hot path.
ValidationContexttype for carrying optimization state through extraction pipelineSafePath::validate_with_context()internal method for optimized path validationDirCache::contains()method for trusted-parent lookups
EntryValidator::validate_entry()accepts optionalDirCachereference for trusted-parent optimizationDirCachevisibility elevated topub(crate)for cross-module access
0.2.3 - 2026-02-06
- Python musllinux wheel builds for x86_64 and aarch64 (Alpine Linux support)
- Fix CVE-2026-25727: update
zip7.4.0 to resolve stack exhaustion DoS in transitivetimedependency
- Bump
pyo3to 0.28,clapto latest minor,zipto 7.4.0 - Bump CI actions:
lewagon/wait-on-check-action1.5.0,softprops/action-gh-releasev2,codecov/codecov-actionv5 - Migrate biome config to v2 format
0.2.2 - 2026-01-03
- Directory caching —
DirCachestruct withFxHashSetreduces mkdir syscalls by ~95% - Atomic permission setting —
create_file_with_mode()sets Unix permissions during file creation (1 syscall instead of 2) - Comprehensive benchmark suite comparing with Python tarfile/zipfile and Node.js tar/adm-zip
benchmark_config()helper for stress test scenarios in benchmarks
- TAR extraction throughput: 2,136 MB/s (4x target of 500 MB/s)
- ZIP extraction throughput: 1,444 MB/s (5x target of 300 MB/s)
- Python comparison: 1.10x average speedup (max 1.43x)
- Node.js comparison: 1.75x average speedup (max 4.69x)
- ~8% improvement from atomic permission setting vs separate chmod
- Updated benchmark results in all READMEs with v0.2.2 measurements
- Added
rustc-hashdependency for faster HashSet operations
0.2.1 - 2026-01-03
- Remove unused
extraction/module (stub implementations) - Remove unused
add_file_to_zip_with_progressfunction (superseded by buffer-reusing version) - Clean up verbose comments across core library
- Remove outdated TODO comments
- Code cleanup: -176 lines of dead code and verbose comments
- Improved code maintainability and readability
0.2.0 - 2026-01-02
- 7z format support (extraction only) via
sevenz-rust2crate- LZMA, LZMA2, and BCJ filter support
- Solid archive extraction with configurable memory limits
- Windows symlink detection via reparse point attributes
- Directory junction detection and rejection
- Encrypted archive detection with actionable error messages
- Updated documentation to highlight both extraction and creation capabilities
- Reject encrypted 7z archives by default (no password support for security)
- Reject solid archives exceeding memory limits (default: 100 MB)
- Windows symlink/junction detection prevents escape attacks
- Unix symlinks in 7z archives extracted as regular files (safe default)
- Updated all package READMEs to show extraction and creation examples
- Added 7z format to supported formats tables across all packages
- Clarified 7z limitations (extraction only, no encrypted/solid with high memory)
0.1.2 - 2026-01-01
- CVE test fixtures for path traversal, symlink escape, and hardlink attacks
- FFI panic safety wrapper for Node.js
extractArchiveSyncfunction - Test cleanup (afterEach) to Node.js integration tests
- Enabled CLI extraction integration tests
- ZIP creation root directory bug causing incorrect archive structure
- Python CVE regression tests now fully enabled (7 tests)
- Test infrastructure improvements for better reliability
0.1.1 - 2026-01-01
- Update dependency versions to latest minor releases
- Update Node.js minimum version to 18+
- Add Python 3.13 support
- Fix repository URLs in documentation (rabax → bug-ops)
- Update CLI README roadmap status
0.1.0 - 2026-01-01
- Memory-safe archive extraction with security-first design
- Support for TAR archives with gzip, bzip2, xz, and zstd compression
- Support for ZIP archives with deflate, deflate64, bzip2, and zstd
- Security validation layer with protection against:
- Path traversal attacks (
../and absolute paths) - Symlink escape attacks
- Hardlink escape attacks
- Zip bomb detection (configurable compression ratio limit)
- Permission escalation (setuid/setgid stripping)
- Resource exhaustion (file count and size quotas)
- Path traversal attacks (
SecurityConfigfor customizable security policiesExtractionReportwith detailed extraction statistics- Archive creation with
CreationConfigand progress callbacks - Type-driven safety with
SafePathvalidated path type - Zero unsafe code in core library
- Streaming extraction without full archive buffering
- Performance optimizations: reusable buffers, buffered I/O, SmallVec
extractcommand for secure archive extractioncreatecommand for archive creationlistcommand to view archive contentsverifycommand for integrity and security verification- Human-readable and JSON output modes
- Progress bars with file-level detail
- Shell completions for bash, zsh, fish, PowerShell
- Configurable security options via command-line flags
- PyO3-based Python bindings
extract_archive()function with optionalSecurityConfigcreate_archive()function with optionalCreationConfiglist_archive()andverify_archive()functions- Progress callback support for long-running operations
- Type stubs (
.pyi) for IDE support - Exception hierarchy matching Rust error types
- Support for
pathlib.Patharguments
- napi-rs based Node.js bindings
- Async and sync API variants (
extractArchive,extractArchiveSync) createArchive,listArchive,verifyArchivefunctions- TypeScript definitions included
- Builder-pattern configuration classes
- Non-blocking async operations via tokio
- Default-deny security model (symlinks, hardlinks blocked by default)
- CVE regression tests for known vulnerabilities:
- CVE-2025-4517 (Python tarfile path traversal)
- CVE-2024-12718 (Python tarfile filter bypass)
- CVE-2024-12905 (tar-fs symlink escape)
- CVE-2025-48387 (tar-fs hardlink traversal)
- 42.zip (zip bomb attack)
- TAR extraction: ~500 MB/s throughput
- ZIP extraction: ~300 MB/s throughput
- Path validation: <1 µs per entry
- 64KB reusable copy buffers
- LRU cache for symlink target resolution