ZipRar: Engine Routing and Bidirectional Path Safety in a Zero-Upload Browser Archiver

Romi Nur Ismanto
Independent AI Research Lab, Jakarta, Indonesia
hello@rominur.com
September 2026

Abstract

ZipRar, deployed at ziprar.rominur.com, compresses and extracts ZIP, RAR, 7Z and the whole TAR family — along with ISO, CAB, CPIO, XAR and the other containers libarchive can read — without a server. There is no upload endpoint, no storage bucket and no serverless function in the deployment; the build output is static files, and every byte of archive work happens inside the tab. The interesting engineering is not that this is possible but what it costs, because no single library covers the format matrix. The application therefore routes three engines behind one interface: fflate, a pure-JavaScript implementation, writes ZIP and provides a fast path for reading it; libarchive compiled to WebAssembly and run in a worker reads everything else, including RAR v4 and v5 and encrypted entries; and 7-Zip compiled to WebAssembly writes 7Z with AES and encrypted headers, and serves as a single-stream compressor for bzip2, xz and zstd. Between them sits a hand-written ustar TAR writer, because the missing capability was not compression but the container. This paper describes four decisions. First, the routing table itself, and the reason a format-to-engine dispatch beats a lowest-common-denominator library. Second, a 512-byte ustar writer including the checksum convention that requires the checksum field to be filled with spaces before it is computed, and the name/prefix split that lets paths exceed one hundred characters. Third, path sanitisation applied in both directions: the same function that protects extraction from Zip Slip also runs over every path written into a new archive, so the tool cannot author the attack it defends against. Fourth, the discipline required at the WebAssembly boundary — copying bytes out of the module heap before it can be reused, and unlinking every MEMFS entry after each run — without which a long session leaks the user's files into a virtual filesystem that is never garbage collected. The application states one asymmetry plainly rather than hiding it: RAR can be read but not written, because the encoder is proprietary.

Keywords: archive formats, client-side computation, WebAssembly, libarchive, 7-Zip, fflate, TAR/ustar, Zip Slip, path traversal, data minimisation, deflate, zstd, xz, bzip2, React, Vite

1. Introduction

Online archive tools are among the most-used utilities on the web and among the least examined. The transaction they propose is simple and almost never stated: to compress or open your files, hand them to a stranger's server. For a folder of holiday photographs that is a small thing. For a folder of contracts, medical records, source code or identity documents it is a disclosure, and the fact that it is framed as a convenience does not make it less of one.

Every capability a desktop archiver has is now available inside a browser tab. Deflate has a mature JavaScript implementation, libarchive and 7-Zip both compile to WebAssembly, and the File and Directory Entries API can walk a dropped folder. What has been missing is not capability but assembly: the pieces have different APIs, different performance characteristics, different memory behaviour, and non-overlapping format coverage. ZipRar is an exercise in that assembly, and this paper is about the seams.

The deployment makes the privacy claim checkable rather than promised. The Vercel configuration declares a Vite build with dist as its output directory and nothing else — there is no API route, no server function, no environment variable holding a credential. A reader who wants to verify that files are not uploaded does not need to audit a backend, because there is no backend to audit; the network tab is the audit.

2. Three Engines Behind One Interface

No single library spans the required matrix. fflate is small and fast but speaks only the deflate family. libarchive reads almost everything but is a reader in this context. 7-Zip writes 7Z and the strong stream compressors but is an Emscripten port with a command-line interface and a virtual filesystem, which makes it the heaviest of the three to invoke. The design consequence is a dispatch table rather than an abstraction layer.

Table 1. Engine selected per operation, and why.
OperationEngineReason
Create ZIPfflatePure JavaScript, no wasm load, level 0–9 exposed to the user.
Create TAR / TAR.GZown writer + fflate gzipThe container was the missing piece, not the compression.
Create 7Z7-Zip wasmOnly engine here that writes 7Z, with AES and header encryption.
Create TAR.BZ2 / .XZ / .ZSTown writer + 7-Zip wasmTAR built in JavaScript, then 7-Zip used purely as a single-stream compressor.
Read ZIP (unencrypted)fflateFast path; avoids loading a wasm module for the common case.
Read anything elselibarchive wasm (worker)RAR v4/v5, 7z, zipx, ISO, CAB, CPIO, XAR, LZH, encrypted entries.

The resulting flow for a compression job is short, and every stage is local:

drop files → walk directory entries → sanitise paths → build container → compress → Blob → download → wipe

The wasm assets are not fetched from a CDN. The Vite configuration copies libarchive's worker bundle and .wasm, and 7-Zip's .wasm, out of node_modules into the public directory as a side effect executed when the config module loads, and both engines are pointed at those local paths through workerUrl and a locateFile hook. This keeps the application self-contained — a tool whose selling point is that nothing leaves the machine should not depend on a third-party host to run at all — at the cost of a build step that reaches into a dependency's internals.

Both wasm engines are also loaded lazily and memoised. libarchive is initialised on first use behind an initialized flag; 7-Zip is instantiated once behind a cached promise, so concurrent callers share one module rather than racing to construct two. A user who only ever creates ZIP files never downloads either.

3. Writing TAR by Hand

TAR is the one format implemented from scratch, and the choice is deliberate: it is the container the other libraries do not write, it is trivially specified, and having it in-process means the bzip2, xz and zstd paths need 7-Zip only as a compressor over a byte stream rather than as an archiver.

The writer emits standard 512-byte ustar blocks: name, octal mode, uid and gid, size, mtime, type flag, the ustar magic and version, and the optional prefix field. Two details in the format are easy to get wrong and are worth stating.

The first is the checksum. The header checksum is defined as the sum of every byte of the header with the checksum field itself treated as eight spaces. The implementation therefore fills bytes 148–155 with 0x20, sums the block, and only then writes the six-digit octal value followed by a NUL and a space. Computing the sum over a zero-filled field — the intuitive order — produces an archive that every conforming extractor rejects.

header.fill(32, 148, 156)          // checksum field = spaces
let sum = 0
for (let i = 0; i < BLOCK; i += 1) sum += header[i]
writeBytes(header, 148, sum.toString(8).padStart(6, '0'), 6)
header[154] = 0                    // NUL
header[155] = 32                   // space

The second is path length. The name field holds one hundred bytes, which a nested folder exceeds easily. ustar splits longer paths across a 155-byte prefix and the 100-byte name, joined by an implied separator, and the split must fall on a path separator. The implementation scans backwards from the end of the path for a / that leaves a name of at most 100 and a prefix of at most 155, and when no such point exists it throws a named error rather than silently truncating — a truncated path is a corrupted archive that appears to succeed, which is the worse failure. The stream is closed with the two zero blocks the format requires.

4. Path Safety in Both Directions

Archive extraction has a classical vulnerability: an entry whose stored path contains ../ segments or an absolute prefix can, in a naive extractor, be written outside the destination directory. The class is old enough to have a name in the vulnerability taxonomy and a nickname in the tooling community, and it is still found regularly.

A browser tool that hands results to the user as downloads is not exposed in the same way a server-side unpacker is. The application nonetheless sanitises every path, and — the part that is not standard practice — it sanitises on both sides of the operation.

Table 2. Rules applied by safeArchivePath, on entries read from an archive and on paths written into one.
RuleHandlingThreat addressed
Backslash separatorsNormalised to /Windows-authored paths escaping POSIX checks.
Leading ./ and /StrippedAbsolute-path writes.
Any .. or . segmentEntry rejected outrightDirectory traversal (Zip Slip).
NUL byte in a segmentEntry rejected outrightTruncation attacks against C-string consumers.
Empty resultEntry rejected outrightDegenerate and placeholder entries.

Rejection returns null and the entry is skipped rather than repaired. Collapsing a/../b to b would preserve more files, but it also silently rewrites a path the archive author chose, and an entry that needs traversal to make sense is one the user is better off not receiving.

Running the same function on the way out — over every path added to a new ZIP, TAR or 7Z — means the tool cannot author a traversal archive even if handed unusual input, such as a file dragged in with a crafted webkitRelativePath. A tool that defends against a malicious archive while remaining able to produce one has solved half the problem.

A separate, cosmetic filter removes platform noise on extraction: the __MACOSX/ tree, .DS_Store, Thumbs.db, desktop.ini, and AppleDouble ._ companions. It is a display default rather than a security control, and it is a checkbox, because those files are occasionally the ones the user came for.

5. The ZIP Fast Path and Its Fallback

ZIP is the overwhelmingly common case, and loading a WebAssembly module to read one is disproportionate. When a file's name ends in .zip and no password was supplied, the extractor first attempts fflate's synchronous unzip. On success the entries are sanitised, directory records are skipped, junk is filtered, and each payload becomes a File.

The fallback is what makes the optimisation safe: the attempt is wrapped in a try whose catch is empty apart from a comment, and control falls through to libarchive. Encrypted ZIPs, ZIPX with non-deflate methods, and archives using features fflate does not implement all take the slower path without the user seeing an error. The failure of the optimisation is invisible, which is the property a fast path should have.

The libarchive branch returns a nested object tree rather than a flat list, with File instances at the leaves. A recursive flattener walks it, accumulating path segments and sanitising the joined result at each leaf. Extracting several archives at once adds one more rule: when more than one archive is selected, every entry is prefixed with its source archive's base name, so that two archives each containing README.md produce two distinct results rather than one overwriting the other.

6. Crossing the WebAssembly Boundary

The 7-Zip module is an Emscripten build driven by callMain over an in-memory filesystem, which means invoking it looks like scripting a command-line tool that happens to live in the page. Three habits keep that from leaking.

Input files are written into MEMFS, with parent directories created idempotently by a small mkdirp whose per-segment mkdir failure is swallowed because “already exists” is the expected case. The command is then assembled as an argument vector — a -t7z -y out.7z plus the entry names — with -p<password> and -mhe=on spliced in when a password is set, the latter encrypting the archive headers so that the file names are hidden too and not merely their contents. Passing arguments as a vector rather than a command string also means a filename containing spaces or quotes is never re-parsed as syntax.

The output is read back and immediately copied into a freshly allocated array, because a view returned from the module points into a heap that later calls may grow or reuse; a retained view is a use-after-free waiting for the next invocation. Every file written — inputs and output alike — is then unlinked, each in its own try, so one failure does not abandon the rest. MEMFS is not garbage collected, so without this a session that compresses several folders keeps every byte of every one of them resident in the module heap.

The same copying discipline appears when results become downloadable: bytes are copied into a new array before the Blob is constructed, rather than wrapping a view over wasm memory. The exit code is checked, and a zero-length output is treated as a failure with its own message even when the code was zero — because an archiver that reports success and produces an empty file is worse than one that reports an error.

7. Collecting a Dropped Folder

Dropping a folder is the natural gesture for an archiver, and it is the part of the browser platform that most rewards care. A drop event exposes DataTransferItem objects whose webkitGetAsEntry() yields a filesystem entry that may be a directory; directories are walked recursively, with path prefixes accumulated so that the archive preserves the structure the user dropped.

The subtlety is in the directory reader. readEntries is not guaranteed to return every child in one call — implementations return them in batches — and code that calls it once silently loses everything past the first batch. The implementation therefore pumps: it calls readEntries repeatedly, accumulating results, and stops only on an empty batch. This is the single most common defect in drag-and-drop folder handling, and it fails in the least visible way possible, by producing an archive that looks complete.

When entry objects are unavailable the code falls back to the plain FileList, which loses directory structure but keeps the drop working. Files added across several drops are merged with de-duplication on a path-and-size key, so dropping the same folder twice does not produce doubled entries.

8. Memory Is the Real Limit

A tool with no server has no server-side limit, and the constraint moves to the tab. Everything is held in memory at once: the source bytes, the container under construction, the compressed output, and whatever the wasm heap holds.

The application handles this by warning rather than by failing late. A selection above roughly 350 MB triggers a notice before work begins. Errors are translated into advice: a message matching buffer exhausted becomes a suggestion to use ZIP or TAR.GZ, which are the cheapest paths, and anything mentioning a password or encryption becomes an instruction to enter one and retry — a translation that matters because libarchive reports a missing password as a generic read failure.

An auto-wipe option, on by default, clears source files and results from memory once a download has been triggered, with a notice saying so. It is a modest mechanism — dropping references and letting the collector work — but it is the correct default for a tool whose premise is that files should not linger anywhere, and it keeps a long session from accumulating every archive the user has touched.

9. What RAR Cannot Do

RAR is asymmetric here and the interface says so. Extraction supports both v4 and v5. Creation is not offered, because the RAR compression algorithm is proprietary to RARLAB and no open implementation of the encoder exists; the format appears in the reader's list and is absent from the writer's, and the documentation explains why in one sentence and points to 7Z as the alternative when strong compression and a password are what the user actually wants.

This is worth stating because the alternative is common and worse. A tool that lists RAR among its output formats and quietly produces a ZIP, or that fails at the end of a long job with an opaque error, spends the user's time to avoid an awkward sentence in its documentation.

Table 3. Format coverage, read versus write.
FormatExtractCreateNote
ZIPYesYesLevels 0–9; ZIPX and encrypted variants via libarchive.
7ZYesYesOptional password with encrypted headers.
RAR v4 / v5YesNoEncoder is proprietary; no open implementation exists.
TAR, TAR.GZYesYesHand-written ustar writer; gzip via fflate.
TAR.BZ2, .XZ, .ZSTYesYesContainer in JavaScript, stream compression via 7-Zip.
ISO, CAB, CPIO, XAR, LZH, AR, WARCYesNoRead-only through libarchive.

10. Conversion as a Side Effect

Because extraction produces in-memory files and the ZIP writer consumes in-memory files, format conversion falls out of the design without being built. Extracted results can be repacked to a single ZIP in one step, which means a RAR the user cannot create can still be turned into a ZIP they can — and the conversion, like everything else, happens without the archive leaving the machine. It is a small illustration of a general property: when each stage of a pipeline is an ordinary value rather than a side effect on a server, compositions appear that nobody implemented.

11. Limits

The memory ceiling is the honest one. Because there is no streaming path, archive size is bounded by what the tab can hold, and multi-gigabyte archives belong in a desktop tool. Streaming would relax this — the ZIP and TAR formats both permit it — but not for 7-Zip, whose interface here is filesystem-based.

Compression runs on the main thread apart from libarchive's worker, so a large 7Z or xz job makes the interface unresponsive while it runs. Progress reporting is honest about what it can see: during extraction through libarchive the total entry count is not known in advance, so the reported total is the running count, and the bar advances rather than filling toward a known end.

Timestamps are preserved from the browser's view of each file and permissions are not: TAR entries are written with a fixed mode and zero uid and gid, because the browser does not expose the real ones. Round-tripping a Unix tree through the tool therefore loses executable bits. Finally, correctness rests on the upstream engines — the repository carries a small ZIP round-trip check rather than a format conformance suite, and the exotic containers in Table 3 are exercised by libarchive's own testing, not by this project's.

12. Conclusion

ZipRar is a demonstration that the standard bargain of the online utility — upload your files to use the feature — is no longer technically necessary for this class of tool. The work that replaces it is not exotic: choose the right engine per format instead of a lowest common denominator, write the one container nobody else writes, sanitise paths on the way out as well as on the way in, and be disciplined at the WebAssembly boundary about copying bytes and unlinking files.

Two of those generalise. Sanitising in both directions costs one function call and removes an entire class of behaviour from the tool, on the principle that a program should not be able to author the attack it defends against. And treating a wasm module as a foreign process — copy what you read out of it, clean up what you wrote into it — is the difference between a page that runs all afternoon and one that must be reloaded after the third archive.

References

  1. PKWARE Inc. “APPNOTE.TXT — .ZIP File Format Specification.” Version 6.3.10, 2022.
  2. Deutsch, P. “DEFLATE Compressed Data Format Specification version 1.3.” RFC 1951, 1996.
  3. Deutsch, P. “GZIP file format specification version 4.3.” RFC 1952, 1996.
  4. IEEE and The Open Group. “POSIX.1-2017, pax — ustar Interchange Format.” 2018.
  5. GNU Project. “GNU tar Manual: Basic Tar Format and Checksumming.” 2026.
  6. Seward, J. “bzip2 and libbzip2: A Program and Library for Data Compression.” 2019.
  7. Collin, L., and Tukaani Project. “XZ Utils and the .xz File Format Specification 1.2.1.” 2024.
  8. Collet, Y., and Kucherawy, M. “Zstandard Compression and the application/zstd Media Type.” RFC 8478, 2018.
  9. Pavlov, I. “7-Zip and the 7z Format Specification.” 7-Zip documentation, 2026.
  10. libarchive Project. “libarchive: Multi-format Archive and Compression Library.” Documentation, 2026.
  11. 101arrowz. “fflate: High Performance (De)compression in an 8kB Package.” Project documentation, 2026.
  12. W3C. “WebAssembly Core Specification 2.0.” W3C Recommendation, 2025.
  13. Zakai, A. “Emscripten: An LLVM-to-JavaScript Compiler.” OOPSLA, 2011.
  14. W3C. “File and Directory Entries API.” Working Draft, 2026.
  15. W3C. “File API.” Working Draft, 2026.
  16. WHATWG. “HTML Living Standard — Drag and Drop.” 2026.
  17. Snyk Security Research Team. “Zip Slip: A Widespread Critical Archive Extraction Vulnerability.” 2018.
  18. MITRE. “CWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’).” Common Weakness Enumeration.
  19. MITRE. “CWE-409: Improper Handling of Highly Compressed Data (Data Amplification).” Common Weakness Enumeration.
  20. Ismanto, R. N. “ZipRomeo: A Native macOS Archive Utility for Apple Silicon.” 2026.
  21. Ismanto, R. N. “Pikapiku: Client-Side Chunking and a No-Retention Schema for Long-Form Transcription on Serverless Infrastructure.” 2026.