runlocally

runlocally engineering notes

Extract RAR/7z

How Extract RAR/7z is built

By Geppetto · · Open Extract RAR/7z →

These are the engineering notes for Extract RAR/7z: the technologies it is built on, what each one is, and how it is used in the tool.

Tech used

The formats: TAR, RAR and 7z

Three different container designs, side by side. TAR (“tape archive”) is the simplest: entries are concatenated one after another, each preceded by a fixed 512-byte header (name, size, mode, timestamp), with no index — reading it means walking the stream from the start, header then data, header then data. TAR is also commonly filtered through a separate compressor afterward (.tar.gz, .tar.bz2), which is why “gzipped tar” is two independent steps layered together rather than one format.

7z is closer in spirit to ZIP’s central-directory design (see the Create ZIP notes) but stores its index as its own compressed, serialized structure, and it supports solid compression — several files packed into one compressed block, which improves the ratio on many small similar files at the cost of needing to decompress more than just the target entry to reach one that is not first in its block.

RAR is a proprietary format from WinRAR; only reading it is openly documented and implemented outside WinRAR, not writing it. This tool never generates .rar files — it only reads existing ones, using RAR4 and RAR5 readers built independently for that purpose (more on that below).

libarchive: one API, pluggable formats and filters

libarchive is a C library (the same engine behind the tar/bsdtar command most Unix systems ship) built around one idea: separate what container format an entry index looks like from what compression squeezed the bytes. A format reader (ZIP, TAR, 7z, RAR, ISO, CPIO, and others) walks headers and hands raw entry bytes to a filter chain (gzip, bzip2, xz/LZMA, or none), and the two compose independently — the same TAR reader works whether the stream is plain, gzipped, or bzip2’d, because the filter layer decompresses first and the format reader never has to know which filter ran. archive_read_support_format_all() and archive_read_support_filter_all() simply register every reader and every filter, and the library’s own probing figures out which ones actually match the bytes in front of it.

WebAssembly + libarchive.js

As in the HEIC to JPG notes, WebAssembly is what makes running an existing native library in the browser possible instead of standing up a server to do it. libarchive.js is libarchive compiled to WASM via Emscripten, built directly from libarchive’s own official release source (not a reimplementation), and wrapped in a small JS/TypeScript API: Archive.open(file) loads a file’s bytes into the WASM heap once, then getFilesArray() walks the format reader to list entries and CompressedFile.extract() decompresses one entry on demand — the same “read the index cheaply, decompress lazily” shape as the Unzip notesZipReader.

Everything above runs inside a Web Worker — see the HEIC to JPG notes for why heavy work belongs off the main thread. Talking to a Worker normally means hand-rolling postMessage/onmessage pairs and matching up responses to requests. Comlink (Apache-2.0) removes that by wrapping the Worker in a Proxy: calling archive.getFilesArray() from the main thread looks like an ordinary (async) method call, but under the hood each property access and function call is serialized into a postMessage, executed on the object living inside the Worker, and the result — or a thrown error — is shipped back and resolved on the original Promise. libarchive.js uses it internally for exactly this: the Archive/ArchiveReader classes on the main thread are Comlink proxies for the real reader running next to the WASM module inside the Worker.

Site shell

Same static Astro + Preact island and Service-Worker PWA shell as the other tools (see the HEIC to JPG notes).

Implementation & operational notes

The WASM and its Worker are vendored as a matched, external pair. libarchive.js ships a prebuilt worker-bundle.js that, at runtime, locates its .wasm with new URL("libarchive.wasm", import.meta.url) — resolved against wherever the worker script itself is actually served from, not a build-time bundler reference. Rather than let a bundler re-process and re-hash that third-party worker file (and risk it drifting out of sync with its own .wasm), a small script (scripts/vendor-libarchive.mjs, run via predev/prebuild) copies both files verbatim, together, into public/extract-rar-7z/vendor/libarchive/. Archive.init({ workerUrl: '/extract-rar-7z/vendor/libarchive/worker-bundle.js' }) then points at that pair explicitly, so the relative lookup inside the worker resolves correctly regardless of what the surrounding app’s bundler does — and the .wasm ships as a genuine external asset (content-type: application/wasm), not inlined as base64.

A vendored worker still gets partially bundled anyway — harmlessly. Vite statically detects the new Worker(new URL('./worker-bundle.js', import.meta.url)) pattern anywhere it appears in the module graph, including inside the unused fallback branch of a dependency’s getWorker() — so a second, Vite-processed copy of worker-bundle.js ends up in the build output alongside the vendored one. Because this build’s code always passes an explicit workerUrl, that fallback branch — and the chunk Vite built for it — is never actually reached at runtime; it is inert extra weight in dist/, not a functional issue.

Listing is a flat file index, not a folder tree. libarchive.js’s getFilesArray() walks directory entries only to build up each file’s path prefix; it does not surface directories (or symlinks) as rows of their own. The tool lists files by full path (docs/notes.txt) rather than as an expandable tree — a deliberate simplification that falls directly out of what the library’s listing API returns.

Encryption detection is archive-level, not per-entry. archive_read_has_encrypted_entries() reports whether any entry in the archive is encrypted after peeking its first header, but libarchive.js does not expose a per-entry encrypted flag the way the Unzip notesZipReader does for ZIP. So instead of listing everything and marking individual locked rows, an archive with any encrypted entry is rejected as a whole, with a clear message, before listing starts. The one edge this doesn’t cleanly cover: a RAR archive with header encryption (an opt-in, separate setting from the far more common “encrypt the file data” case) fails to parse even its first entry header, which surfaces identically to a genuinely empty or unreadable archive rather than the specific “password protected” message — a known, narrow gap rather than a silent success.

Errors cross the Worker boundary as stable codes, not English strings. Following this codebase’s existing convention (introduced for PDF handling, issue #63), the engine throws an error object carrying a machine code (errArchiveEncrypted, errInvalidArchive, …), which Comlink forwards across the Worker boundary intact; the UI resolves that code to the current locale’s string. Nothing English-only reaches the screen regardless of which language the page is in.

One Archive class, two separate builds. libarchive.js ships a browser build (dist/libarchive.js, using the DOM Worker) and a Node build (dist/libarchive-node.mjs, using worker_threads) — and because each is bundled by the library’s own build step as a fully self-contained file, they are two different class objects, not one shared singleton configured two ways. That made it possible to unit-test this tool’s listing/extraction/error-classification logic for real, against real fixture archives, entirely in Node (no browser, no mocking) by swapping in the Node build for tests while the browser build ships to users.

Try it / source