A bounded tar archive, optionally gzipped, in both directions.
Two unrelated features accept a tar file a user chose: an Agent Skill bundle and a data-transfer archive. Both had the same gzip inflater, the same listing and extraction calls, and the same two bounds written out twice — which is how two safety-critical copies drift apart while each one's own tests still pass. This is that code once, and writing is here for the same reason: the exporter and the format it writes must not each hold their own idea of what an archive is.
The bound is a parameter rather than a module attribute because the two callers accept different sizes, and the bound is the whole point of the module.
The two bounds
An archive is listed before it is read, so the whole of it can be refused before a byte is expanded:
- the declared total, taken from the headers, refuses a bundle that announces itself as too large; and
- the actual total, checked while inflating and again while extracting, which is what holds when a header lies about what its member holds.
Gzip is undone here rather than by the tar reader, and through the streaming inflater, because a compressed archive is inflated whole before a tar reader could bound it. The tar reader is handed plain bytes, and the inflated total is bounded before it is ever assembled.
Summary
Functions
Packs members into an uncompressed tar binary.
The total size the headers declare across the regular members.
The lower-case hex SHA-256 of binary.
The contents of every regular member, as %{name => binary}.
Every member of binary.
The bytes an archive holds once expanded, bounded by max_bytes.
Writes members to path as a tar, gzip compressed when asked.
Types
@type member() :: %{ name: String.t(), type: :regular | :directory, size: non_neg_integer() }
One member of an archive, as its header describes it.
Functions
Packs members into an uncompressed tar binary.
:erl_tar writes to a path rather than to memory, so the archive is built in a
scratch file and read back; the file is removed whether or not the build
succeeded. The result is the same bytes write/3 would put on disk, which is
what makes an in-memory transfer and a file transfer the same format.
members is a list of {name, content} with name a string.
@spec declared_size([member()]) :: non_neg_integer()
The total size the headers declare across the regular members.
The lower-case hex SHA-256 of binary.
The digest a transfer manifest records and re-checks. It lives here because the checksum is over the bytes that were packed, which is the same question the container answers.
@spec extract(binary(), pos_integer()) :: {:ok, %{optional(String.t()) => binary()}} | {:error, term()}
The contents of every regular member, as %{name => binary}.
Bounded again by what is actually read rather than by what was declared: a header can claim a size its member does not have. A directory contributes no content.
@spec list(binary(), pos_integer(), pos_integer()) :: {:ok, [member()]} | {:error, term()}
Every member of binary.
Refuses a bundle holding more than max_entries members or declaring more than
max_bytes, and refuses the whole archive for any member that is not a regular
file or a directory — a symbolic link, a hard link, or a device entry is caught
from the listing, before its contents are read.
@spec plain_tar(binary(), pos_integer()) :: {:ok, binary()} | {:error, term()}
The bytes an archive holds once expanded, bounded by max_bytes.
Gzip is detected by its magic bytes rather than by a file name, so a .tar.gz
that is not gzip and a tar that happens to be called one are each reported for
what they are.
Writes members to path as a tar, gzip compressed when asked.
compressed is passed rather than inferred from the file name: whether an
archive is compressed is a property of the caller's destination, and the
reading side detects gzip by its content rather than by its extension, so a
name never decides what the bytes are.