tar — archives in-language: tar_list, tar_unpack, tar_pack
Archives are read and written by the language itself, so a verified artifact
can be unpacked without an external tar or zstd. Inside a MixOS machine
there is no such tool at all: /bin/sh is mix. On other hosts the same
builtins avoid depending on a distribution's import tooling. A signed
manifest pins a digest, hash_file compares against it, and tar_unpack
consumes the SAME verified file. No C codec ships in the binary: zstd is the
pure-Rust RFC 8878 implementation (structured-zstd), and gzip framing is
flate2 over its Rust backend.
One-line help for each builtin is mix builtins tar_list (or tar_unpack,
tar_pack).
Codecs and the artifact profile
codec is explicit on every entry point: "zstd" (default), "gzip",
"none". The builtin decoder is the universal reader, so the artifact
profile below is a contract that any encoder must satisfy:
- one tar, one zstd frame —
tar_unpack/tar_listdrain the stream to true EOF: the zstd content checksum (XXH64, verified whenever the frame carries one;tar_packalways writes it) / gzip CRC are verified there, any non-zero decoded bytes after the tar end are refused, and raw input left unconsumed (a concatenated second zstd frame) is refused. Gzip is read member after member, as RFC 1952 allows (pigz, orcat a.gz b.gz): a tar split across several members unpacks, every member's CRC is checked, and an empty trailing member is harmless. A second whole tar in a later member is still non-zero data after the first tar's end and is refused. Trailing bytes that are not a gzip member, including zeros, raiseARCHIVE_STREAM_CORRUPT; - no dictionaries;
- zstd levels are
1..=22(default 7). Level 22 is the largest the builtin packs, because its 2^27 (128 MiB) window is exactly the decoder's ceiling. An artifact produced by an external zstd must not use--longbeyond that window, and must decode withtar_listbefore it is trusted.
For artifacts that people unpack with stock tools, use codec:"gzip"
(tar -xzf works everywhere). Gzip levels clamp to 9.
The builtins
| Call | Result |
|---|---|
tar_list(path[, opts]) | list of {name,size,mode,uid,gid,kind,mtime} — streaming, nothing extracted, stream verified; kind is file, dir, symlink, hardlink, metadata (a PAX global header) or other |
tar_unpack(path, dest[, opts]) | receipt {files,dirs,symlinks,hardlinks,metadata,bytes,xattrs_restored,entries,implicit_dirs,trailing_padding,codec,warnings} |
tar_pack(source, path[, opts]) | receipt {files,dirs,symlinks,bytes,capabilities,codec,level} |
opts keys are validated strictly — unknown keys raise.
tar_unpack is safe by default, and staged
destmust not exist (a dangling symlink there counts). A bare name such as"out"means the current directory. Extraction happens in a private0700staging sibling with an unpredictable name, held by descriptor from creation to commit: every member is created relative to it, oneO_NOFOLLOWcomponent at a time, so a staging name swapped in a writable parent cannot redirect a write. It is renamed into place only after the whole stream verifies, withRENAME_NOREPLACE, so anything that appears atdestin the meantime is never replaced. A kernel or filesystem withoutRENAME_NOREPLACEmakes the commit fail closed: the unpack raises and the staging tree is rolled back. Failures restore traversable/writable staging-directory modes before removing the tree through the same descriptor. A cleanup failure is reported alongside the original error, with the staging name, rather than silently discarded.dest's parent must have no untrusted writer. It must be owned by the caller's effective uid or by root, and must not be writable by group or other unless it has the sticky bit (as/tmp, where only an entry's owner may rename or remove it). Anything else is refused before staging is created, with nothing written. Another writer there could swap the staging directory between its creation and its use, or between the final identity check and the rename; refusing such a parent closes those windows. The new staging directory is also checked to be the caller's own,0700and empty before anything is extracted into it.- Member names are validated on the resolved path (GNU longname and PAX
smuggling land there): no absolute paths, no
..components, UTF-8 only (invalid bytes raise — the extractor never guesses what got extracted). A leading./is accepted and dropped, so GNU tar'star -C dir .and./namearchives unpack (hardlink targets too). A bare./(or.) entry names the extraction root: it must be a directory, and only its mode, owner and mtime are applied, todestitself.tar_listshows it as.. - Device, fifo and unknown entry types are refused. GNU sparse entries
are refused by both the lister and extractor. A PAX global header (the
first record
git archivewrites) is archive metadata: it is counted in the receipt'smetadataand nothing is extracted for it. - A corrupt or truncated stream (zstd frame checksum, gzip CRC or a cut-off
stream, including an empty input or truncated zstd header) raises the
structured code
ARCHIVE_STREAM_CORRUPT. Match the code in the optional secondcatchbinding, not the message text. - Symlink targets must be relative and stay inside
dest, judged from the link's own directory:demo/lib/alias -> ../bin/demois fine,demo/x -> ../../yescapes and is refused...may only lead the target (../../a/b), never follow a name (a/../bis refused): a..applied to a directory that is really a symlink would climb from wherever that link points. A target may end at another symlink, but never pass through one, in either order: a link whose target walks through an existing symlink is refused, and so is a symlink created where an earlier link's target walks.tar_packapplies the same rules, so a tree it packs always unpacks. A leading./and interior.components are dropped from the written target; a target of only.is written as.. Nothing is ever extracted through a symlink the archive created. Hardlinks may only target an earlier regular file (hardlink-to-hardlink is refused). numeric_ownerrestores uid/gid through the entry's descriptor, never following a link. It defaults to true for root and false for everyone else, as GNU tar does (--same-owner/--no-same-owner): an ordinary user's files keep their own uid and gid, because a non-root chown could still hand a file to any group the caller belongs to, and the archive should not choose which. Withnumeric_owner:true, a non-root caller gets the capability limit silently (the owner is skipped) rather than an error. Any other owner failure (an unmapped uid in a user namespace, a root-squashing filesystem) is listed in the receipt'swarnings. When an entry does not end up with the owner the archive named (not restored, or restoring it failed), its setuid/setgid bits andsecurity.capabilityare dropped (also listed inwarnings): a special bit is only ever kept on the owner the archive named. Mode and mtime failures raise.- Modes are restored as the archive gives them, as GNU tar does for root:
a
0777directory or0666file stays world-writable. Unpack untrusted archives into a tree whose modes you then check, or repack them. xattrs(default true) restoresSCHILY.xattr.*records by hand, and by default ONLY theuser.*namespace.security.capabilityalso needskeep_special_bits:true(a capability is a privilege grant exactly like a suid bit, and suid/sgid are stripped from modes by the same rule). Every other namespace,trusted.*, POSIX ACLs (system.posix_acl_*, which can grant any user write access) and the rest ofsecurity.*(SELinux labels, IMA), needsprivileged_xattrs:true. Restored xattrs are applied last, after owner and mode, because chown and data writes clearsecurity.capability.max_xattr_bytes(default 16 MiB, at most 1 GiB) bounds the admitted xattr names and values held for the whole archive until the post-pass applies them; one archive of many small records cannot exhaust memory.- Directory and symlink metadata (mode, mtime, owner) is applied in a
children-first post-pass, so directory mtimes stick. A
./root entry's metadata is applied after the commit, through the descriptor of what is nowdest: until then the staging directory stays the caller's own and0700, so an archive-named owner can never rename it in a sticky parent, and a restrictive root mode cannot lock the caller out of the post-pass. The tree is already in place by then, so a failure there is awarningsentry rather than an error. When several./entries appear, the last one wins. - Limits:
max_entries(200k, counting both members and every parent directory created implicitly for a member whose directory the archive does not list; the receipt'simplicit_dirsreports those),max_bytes(64 GiB of file bytes),max_name(4096), andmax_stream_bytes(16 GiB). The last bounds the decoded stream, including headers, metadata, file data and all trailing zero padding, across parsing and final verification. An entry's declared size must fit the remainingmax_bytesbefore its file is created, and copying also enforces that remaining budget. - Each GNU longname, GNU longlink or PAX extension payload is limited to
64 KiB, independently of
max_stream_bytes, before buffering. GNU name/link payloads additionally obeymax_nameplus one terminating NUL. Oncemax_entriesis reached, the next extension header is refused before reading its body.tar_listacceptsmax_entries,max_nameandmax_stream_bytestoo.
tar_pack
Deterministic (sorted, parents-first) walk of a source directory:
numeric owner, mtime and mode are preserved, and suid/sgid are stripped
unless keep_special_bits:true. Only that same opt-in captures
security.capability into a SCHILY.xattr PAX record, so capability
round-trips require opting in at both ends (root-only to apply on unpack).
The source directory must not be a symlink. Directory traversal retains
opened directory descriptors; children are opened with O_NOFOLLOW, with
metadata and capabilities read from the opened file. Queued directories
remain pinned if their pathnames are replaced. A detected inode replacement
between classification and opening raises. Device and fifo members are
refused. The output file is created new (0600) and is never overwritten.
An output inside the source directory is refused before it is created, and
the walk also refuses the output's own inode if it reaches it another way.
A file whose size changes while it is packed raises rather than writing a
header that disagrees with its data. Two packs of an unchanged tree are
byte-identical.
Round-trip
tar_pack("/var/tmp/stage", "/var/tmp/app.tar.zst", {codec:"zstd", level:11})
$receipt = tar_unpack("/var/tmp/app.tar.zst", "/var/tmp/app")
-- receipt.xattrs_restored counts applied records; capabilities only when
-- keep_special_bits was set on BOTH ends.