tar archives

rusty-js-tar reads the tar archives inside npm's .tgz packages: it parses a ustar/pax archive in memory into entries and can extract them to disk. Extraction sanitizes every entry path at the crate boundary, rejecting .. traversal, absolute paths, and symlink entries so a hostile archive cannot escape the destination directory.

Every install ends the same way: a .tgz comes down off the registry, and something unpacks it, file by file, into a directory. That step is where an entry named package/index.js has to land at package/index.js, and an entry named ../../../.bashrc must not land on your .bashrc. In Node the tar package does that unpacking; in Cruft, rusty-js-tar does. This page covers what that rests on when Cruft is the one unpacking.

Alpha, unaudited. rusty-js-tar (Cruft 0.0.10) is a crate that stands in for the crates.io tar crate. It is not externally audited and has near-zero interop/conformance testing against real-world tarballs. Trust it only where this page cites a test or an enforcing code path. Do not rely on it in production, and do not point it at adversarial archives outside the npm install path.

Reads tar: two capabilities

Node's tar is a general-purpose toolkit: streaming extraction, creating archives, gzip integration, filters, ownership and permission replay, long-name GNU extensions, sparse files. rusty-js-tar implements exactly the slice of tar Cruft's package manager consumes, and nothing else. It parses a ustar/pax archive and covers only the part Cruft's package manager consumes, not the full tar crate API (parse_archive). It is a tarball reader for one caller.

What it gives you:

  • parse_archive(bytes) -> Vec<Entry>: parse an in-memory ustar/pax archive into owned entries (path, kind, data, mode, mtime).
  • pax extended headers for the one field npm needs, the long path override: a path=... pax record renames the following entry. Global (g) pax records are read and skipped.
  • octal and GNU base-256 (high-bit) numeric fields (parse_number).
  • malformed-input rejection: bad checksum, truncated entry, truncated header, non-UTF-8 path, malformed pax record length/newline.
  • build_ustar_archive: a small archive builder, explicitly "for fixtures", not a general writer.

Whole-buffer reading

Node's tar streams. rusty-js-tar takes bytes: &[u8] and returns a Vec<Entry> with every entry's data copied into an owned Vec<u8> (data.to_vec()). The whole archive is in memory, and then a second full copy of every file's bytes is in memory. That is fine for an npm tarball you already downloaded whole; it is not fine as a general model for arbitrarily large or streamed archives. There is no size cap on the archive itself here, so the memory ceiling is "however big the input buffer is." The decompression-bomb concern lives one layer down in the compression crates (which cap output with hostile tests), not in tar.

The dangerous part is the path, and where it gets checked

A tar entry's name is attacker-controlled text. An entry can be named ../../etc/cron.d/x, or /etc/passwd, or (on the wrong OS) C:\Windows\.... If the extractor joins that name under your destination and writes, the write escapes the destination. This is tar-slip / zip-slip. Node's tar defends against it inside its extractor.

The real question is who sanitizes the paths, and whether every caller inherits that.

The parser on purpose does NOT sanitize. parse_archive returns entry.path verbatim: ../, absolute, and link typeflags all pass straight through. That is a deliberate separation-of-concerns choice, but on its own it would leave the traversal defense living entirely in the consumer, so any other consumer would inherit zero protection. The defense is therefore built into the crate itself, where every caller inherits it, via two functions:

  • sanitize_entry_path(path) -> Result<PathBuf, TarError>: walks the path's components and refuses ParentDir (..), RootDir (absolute), Prefix (Windows drive/UNC), any backslash or // or C:-drive shape, returning TarError::UnsafePath. CurDir (.) is dropped; only Normal components survive. The result is always relative with no traversal.
  • extract_archive(bytes, dest) -> Result<usize>: parses, sanitizes every entry, joins the safe relative path under dest, and writes files and directories. Link and other special entry kinds are rejected (TarError::UnsafePath) rather than handed to a caller who might join their raw target.

The defenses are covered by tests: ../evil.js, package/../evil.js, /etc/passwd, C:/temp/evil.js, and package\evil.js all yield UnsafePath; and a real archive with a ../evil.js entry, run through extract_archive, errors and leaves no file escaped. The package manager consumer delegates to extract_archive rather than joining raw parsed paths.

So path traversal on extraction is defended at the crate boundary, with tests, and the defense is not consumer-only.

Memory-safe parsing; narrow conformance

On safety of the parser itself, by inspection: there is no unsafe, no unwrap()/expect() on untrusted bytes, and no panic!/todo!() in the parse path. Bounds are checked before slicing (offset + BLOCK > bytes.len(), data_end > bytes.len()), size is range-checked with usize::try_from, and numeric fields return BadNumber rather than trapping. Malformed headers, checksums, sizes, and pax records all return a typed TarError, not a crash. That is more disciplined than parse-and-hope.

Where the crates.io tar still wins:

  • Format breadth. Node's tar handles GNU long-name/long-link extensions, sparse files, and a fuller pax vocabulary. rusty-js-tar reads ustar + the one pax path field. Feed it a GNU-longname archive and it will not do what tar does.
  • Streaming and writing. No streaming extraction, no real archive creation (the builder is a fixture helper capped at 100-byte ustar names).
  • Maturity. tar (and node-tar) have years of fuzzing, CVE history, and real-world archives behind them. rusty-js-tar has a handful of unit tests over archives it built itself, and no fuzz target of its own (what fuzz coverage exists lives in the compression crate, not in this crate).

Limitations

rusty-js-tar is a single-caller tarball reader. Its parsing is memory-safe and its malformed-input rejection is real and tested. Its path-traversal defense is at the crate boundary, so a second consumer no longer inherits an unsanitized dest.join. But its verification is thin: the tests exercise archives the crate itself constructed, not the messy real-world tarballs the ecosystem produces; there is no in-crate fuzzing; and its format coverage is a deliberate subset. It has never been run against the interop corpus or CVE regression suite that hardens node-tar. Hand it an archive shape outside the npm ustar/pax path and "unverified, untested, do not rely" is the true statement, not "handles tar."