SFEP-0071
In-Process .tar.gz Reader and Writer (sfn/archive)
- Status
- Accepted
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect; human review
- Tracking
- SFN-753
SFEP-0071 — In-Process .tar.gz Reader and Writer (sfn/archive)
1. Summary
Add a new pure-Sailfin library capsule, sfn/archive, implementing CRC-32
(RFC 1952 §8), DEFLATE decompression and compression (RFC 1951), gzip framing
(RFC 1952), and POSIX ustar archive reading and writing — with a mandatory
path-traversal guard on extraction. The capsule retires every process.run
invocation of external tar from the toolchain: one read site
(compiler/src/cli/commands/toolchain.sfn:418) and three write sites
(compiler/src/cli/commands/package.sfn:259, :586, :759). It introduces no
new builtin, no new intrinsic, and no compiler-pass change, so it lands without
a seed cut. Beyond removing the last hard dependency blocking a shell-free
native Windows toolchain, it makes sfn package output byte-reproducible:
the writer stores computed canonical modes and zeroed mtime/uid/gid rather than
host observations, so the same tree yields the same archive bytes and the
.sha256 sidecar becomes a real content identity rather than a per-build
accident.
2. Motivation
2.1 The Windows bootstrap is broken at the unpack step
sfn toolchain install downloads a release asset, verifies its signed manifest
and SHA-256 digest, and then shells out:
compiler/src/cli/commands/toolchain.sfn:418 let tar_rc = process.run(["tar", "-xzf", tarball, "-C", extract_dir]);Native TLS shipped (SFN-340/341/808/811/824), so the fetch half is already
in-process — _download_to at toolchain.sfn:252 is a thin wrapper over
http.download with no curl. tar is the only remaining external program on
the seed-acquisition path. A Windows user who cannot unpack a seed cannot
bootstrap at all, so this single process.run is the critical-path blocker for
the chain SFN-753 → SFN-493 (de-shell seed acquisition) → SFN-57 (native Windows seed + release leg) → SFN-55/SFN-58.
The writer side is symmetric and already flagged in-tree
(compiler/src/cli/commands/package.sfn:86): “native Windows host: the three
tar -czf sites remain unmigrated pending in-process .tar.gz writing
(SFN-753).”
2.2 The restriction-vs-power test
Removing a dependency is, on its own, a restriction — it forbids something
(spawning tar) and hands the payer nothing. Two attached powers make this
proposal pass the test in CLAUDE.md:
- A stock-Windows install runs the whole toolchain. No MSYS, no Git Bash, no
System32\tar.exeversion roulette. That is a product capability, not a policy. - Reproducible artifacts.
tar -czfstores the host mtime in every ustar header and in the gzip MTIME field, so two builds of an identical tree produce different bytes and different digests. Our writer computes what it stores (§3.7), sosfn packageoutput is bit-identical across rebuilds and hosts. That is the “same program yields the same bits” pillar applied to the distribution artifact.
2.3 Why the status quo cannot be patched
tar.exe has shipped in Windows since 10 1803, and invoking it was the obvious
cheap fix. The owner has ruled for full self-containment; the external-tar
option is retired, not deferred (§6.1). It was rejected on product grounds — a
stock-Windows install must run the whole toolchain — not on pillar grounds:
tar was never on the sfn build -p compiler path, so it never contaminated a
capability manifest.
3. Design
3.1 Placement: a library capsule, not runtime source
sfn/archive lands at stdlib/archive/, modelled on
capsules/sfn/crypto/ — pure Sailfin, capsule.toml with
[build] entry = "src/mod.sfn" and kind = "library", tests in a sibling
tests/ directory.
Three facts settle this against the runtime/ alternative:
- No new compiler capability is required (§3.2), so the seed-dependency
carve-out in
.claude/rules/seed-dependency.md— runtime source that calls a compiler capability the seed lacks must land alone,seed-blocker— does not apply and must not be invited. Runtime source is compiled by the pinned seed; capsule source is compiled by the freshly built compiler in the samemake compilepass. Choosingruntime/would convert a zero-gate change into a seed-gated one for no benefit. - Link surface.
compiler/src/build/runtime_selection.sfnselects runtime modules per program; capsule modules are staged only when reachable (compiler/src/capsule_resolver/reachability.sfn:166-168— a bare capsule spec names only the entry module, and SFN-833 filters the rest). A program that never importssfn/archivepays nothing. - Test discovery is free.
make test-capsules(Makefile:420-435) walks every nestedtests/directory undercapsules/, andworkspace.toml:22-27declares members ascapsules/sfn/*— a glob the pinned seed already expands (capsule_resolver/workspace.sfn::_cr_expand_member_globs). A new capsule undercapsules/sfn/needs noworkspace.tomledit and noMakefileedit.
The one manifest change outside the capsule is a dependency row in
compiler/capsule.toml (§5.2).
Module layout
stdlib/archive/ capsule.toml src/mod.sfn // barrel: the public API and nothing else src/bytes.sfn // libc-backed byte buffer + cursor; the only unsafe interior src/error.sfn // ArchiveError + ArchiveErrorKind src/crc32.sfn // RFC 1952 §8 CRC-32, table-driven src/inflate.sfn // RFC 1951 decompressor (stored / fixed / dynamic) src/deflate.sfn // RFC 1951 compressor (fixed Huffman + greedy LZ77) src/gzip.sfn // RFC 1952 framing, read + write src/ustar.sfn // header encode/decode, checksum, typeflags, GNU/PAX src/path_guard.sfn // the extraction path policy — pure, no ![io] src/tar_read.sfn // streaming iteration + extraction to disk ![io] src/tar_write.sfn // tree walk + archive creation ![io] tests/*_test.sfnThe layering is load-bearing, not cosmetic: crc32 + deflate + inflate are
exactly the pieces a future .zip container or an sfn/http
Content-Encoding: gzip path would reuse unchanged (§10).
Every module stays inside the ~1,500-line soft budget
(.claude/rules/code-style.md); inflate.sfn is the largest at an estimated
~800 lines.
3.2 No new builtin, no new intrinsic — verified
The capsule needs three things the language does not give it directly: read a
byte at a raw address, write a byte at a raw address, and read/write a file as
bytes rather than as a NUL-terminated string. All three have proven,
already-in-tree spellings that resolve against the pinned 0.9.5 seed
(bootstrap.toml:16):
| Need | Spelling | Proven at |
|---|---|---|
| Byte read | load_byte(addr: int) -> int builtin |
runtime/sfn/string.sfn:126; lowering at compiler/capsules/codegen-llvm/src/byte_load.sfn. Seed 0.7.0-alpha.41 carries it, so 0.9.5 does. |
| Byte read (fallback) | sailfin_intrinsic_pointer_read_i32(ptr) & 255 |
capsules/sfn/crypto/src/rand.sfn:57 — from capsule code. Over-reads 3 bytes, so the buffer carries 4 bytes of slack (same file, :38-41). |
| Byte write | memset(p, value, 1) via extern fn memset |
stdlib/os/src/mod.sfn:296 — from capsule code. |
| Byte write (faster) | masked word RMW on *i64 |
runtime/sfn/string.sfn:890 (_num_put_byte). Requires 8-aligned, multiple-of-8 storage — the owned_buf_new discipline. |
| File bytes in/out | extern fn fopen/fread/fwrite/fclose/malloc/realloc/free |
compiler/src/build/fs.sfn:774-858 (_read_file_bytes); capsules/sfn/crypto/src/rand.sfn:30-32 declares malloc/free from a capsule. |
A plain extern fn over a libc symbol resolves against the pinned seed and
needs no seed cut; a new builtin is a compiler-emitted sentinel plus a registry
row the seed cannot resolve (E0420) and would force one. That reasoning is
already written down at compiler/src/build/fs.sfn:9-17, and this design
follows it exactly.
Implementation step 0 is a feasibility probe, not an assumption. load_byte
and the masked-word-store idiom have never been spelled from capsule source —
only from runtime/ and compiler/src/. Phase A1 opens with a throwaway
capsule module that calls both and runs sfn check on it. If either does not
resolve from a capsule, the fallbacks in the table above are already proven from
capsule code (rand.sfn), so the outcome is a constant-factor difference, never
a blocker and never a seed-blocker predecessor.
3.3 The byte container: ByteBuf, not int[]
int[] is what sfn/crypto uses for binary (string_to_bytes at
capsules/sfn/crypto/src/bits.sfn:19), and it is the right call there — hash
inputs are small. It is the wrong call here: an int element is 8 bytes, so a
100 MB member costs 800 MB before push growth copies, against a shared 8 GiB
process cap (.claude/rules/compiler-safety.md).
src/bytes.sfn defines a libc-backed buffer addressed by i64, mirroring the
OwnedBuf shape at runtime/sfn/memory/ownedbuf.sfn:114-119 (capsules already
re-declare that struct locally — stdlib/os/src/mod.sfn:77,
capsules/sfn/http/src/server.sfn:50 — so re-declaring is the house pattern,
not a new one):
// A growable, libc-backed byte buffer. `addr` is the storage address (an// unsafe interior — never handed out past the owner), `len` the bytes// written, `cap` the bytes allocated. Capacity is always a multiple of 8// with 8 bytes of slack past `cap`, so a masked word store at any index is// in-bounds — the `owned_buf_new` discipline (ownedbuf.sfn file header).struct ByteBuf { addr: i64; len: int; cap: int;}
// A non-owning read cursor over a byte range. `pos` is the read offset;// bounds are checked on every access, so a truncated archive errors instead// of reading past the allocation.struct ByteCursor { addr: i64; len: int; pos: int;}Ownership is manual (bytes_free), matching stdlib/os::owned_buf_free
and capsules/sfn/crypto/src/rand.sfn’s malloc/free discipline. Every
public entry point owns its allocations end to end and frees on every exit path
including errors; no ByteBuf crosses the capsule boundary. SFEP-0064’s
reclamation seam is the eventual home for this discipline, not a prerequisite.
int[] interop stays available at the test boundary only
(bytes_from_int_array / bytes_to_int_array), so published RFC vectors can be
written as literal arrays.
3.4 API shape: streaming, with whole-buffer helpers for vectors
Peak memory must not scale with archive size. The seed tarball is small (~4.4 MB
binary, 6.1 MB unpacked for 0.9.5) but sfn package trees are unbounded, and
the fan-out budgets in .claude/rules/compiler-safety.md leave no room for an
archive-sized transient.
tar is strictly sequential — a 512-byte header, then ceil(size/512)*512 bytes
of body — and DEFLATE is a streaming format with a 32 KiB history window. So the
whole pipeline streams with O(window + chunk) ≈ 100 KiB peak, independent of
archive size. The extraction path never materializes a member in memory; it
copies decompressed bytes straight to the output FILE* in 64 KiB chunks.
Public surface (src/mod.sfn). Every export carries a module prefix.
Imported names are matched without provenance across the compiler tree — that is
why sfn/http is deliberately not a compiler dependency, because its get
collides with sfn/cli’s (compiler/capsule.toml:63-68, revisit at SFN-893).
A bare extract or create from this capsule would be the same landmine.
// --- Streaming primitives -------------------------------------------
// Open `path` as a gzip stream. Holds the FILE*, a 64 KiB input chunk, a// 32 KiB history window, and the inflate state. The caller closes with// `gzip_reader_close`, which is safe to call on a failed open.fn gzip_reader_open(path: string) -> Result<GzipReader, ArchiveError> ![io];
// Fill up to `n` decompressed bytes at `dst`. Returns the count read, `0`// at end of stream, and an error for a corrupt or truncated stream — a// short read is never silently treated as EOF.fn gzip_read(r: GzipReader, dst: i64, n: int) -> Result<int, ArchiveError> ![io];
fn gzip_reader_close(r: GzipReader) -> void ![io];
fn gzip_writer_open(path: string, opts: DeflateOptions) -> Result<GzipWriter, ArchiveError> ![io];
fn gzip_write(w: GzipWriter, src: i64, n: int) -> Result<int, ArchiveError> ![io];
// Flush the final block, then the CRC-32 and ISIZE trailer. A writer// dropped without `gzip_writer_finish` leaves an invalid stream, so the// tar layer finishes on every exit path.fn gzip_writer_finish(w: GzipWriter) -> Result<int, ArchiveError> ![io];
// --- Archive layer ---------------------------------------------------
// Extract `archive_path` beneath `dest_dir`. Every member is checked// against the path policy (§3.6) before any filesystem call; the first// rejection aborts and leaves no further members written. Returns a// summary the caller can print.fn targz_extract(archive_path: string, dest_dir: string, opts: ExtractOptions) -> Result<ExtractSummary, ArchiveError> ![io];
// Create `archive_path` from `root_dir`. `entries` are paths relative to// `root_dir`; an empty list means the whole tree. Members are emitted in// the given order, or in sorted order for a whole-tree walk, so the output// is reproducible.fn targz_create(archive_path: string, root_dir: string, entries: string[], opts: CreateOptions) -> Result<CreateSummary, ArchiveError> ![io];
// List members without writing anything. Backs `sfn package --list` and// the interop tests.fn targz_list(archive_path: string) -> Result<ArchiveEntry[], ArchiveError> ![io];
// --- Pure codec layer (no effects — this is why it is separated) -----
fn crc32(cursor: ByteCursor) -> int;
fn inflate_all(src: ByteCursor, max_output: int) -> Result<ByteBuf, ArchiveError>;
fn deflate_all(src: ByteCursor, opts: DeflateOptions) -> Result<ByteBuf, ArchiveError>;
fn ustar_decode_header(block: ByteCursor) -> Result<UstarHeader, ArchiveError>;
fn ustar_encode_header(h: UstarHeader, out: ByteBuf) -> Result<ByteBuf, ArchiveError>;
// Validate a member name against the extraction path policy. Pure, so the// whole rejection table is a unit test with no filesystem.fn archive_check_member_path(name: string, host_is_windows: boolean) -> Result<string, ArchiveError>;inflate_all / deflate_all take an explicit max_output / implicit cap and
exist for RFC vectors and small in-memory uses. They are not what targz_*
call.
Options and summaries:
struct ExtractOptions { // Materialize symlink members as copies instead of links. Forced true // on Windows, where `fs.symlink` is a documented no-op (§3.5). copy_instead_of_link: boolean; // Apply stored permission bits via `fs.set_perms`. No-op on Windows. apply_modes: boolean; // Refuse an archive whose total decompressed size exceeds this, so a // decompression bomb fails closed rather than filling the disk. max_total_bytes: int; max_members: int;}
struct CreateOptions { // Paths (relative to `root_dir`) to mark executable regardless of what // the host reports. `sfn package` knows its binaries; Windows cannot // answer `access(X_OK)` meaningfully. exec_paths: string[]; link_policy: LinkPolicy; // Preserve | Dereference deflate: DeflateOptions;}
struct DeflateOptions { // `Fixed` today; `Dynamic` is the §6.7 follow-up and changes no // signature. `Stored` exists for the incompressible fallback and for // debugging a suspected encoder bug against a known-good container. strategy: DeflateStrategy;}3.5 Symlink and hard-link policy
The seed tree ships a real symlink sfn -> sailfin (ustar typeflag 2), and
Windows cannot create one without elevation or Developer Mode. SFN-493 already
states the intent: prefer a copy over a link on that host.
Reading (typeflag 2, symlink):
- The link target is validated by the same path policy as the member name
(§3.6). An absolute target, a target containing a
..component, or an empty target is a hard error — not a skip. Extracting a link to/etcfrom a downloaded archive is precisely the escalation the guard exists to stop. - On POSIX with
copy_instead_of_link == false:fs.symlink(target, path)(stdlib/fs/src/mod.sfn:147). - On Windows, or with
copy_instead_of_link == true: copy the target’s bytes. Because tar order is not guaranteed to place the target before the link, link members are deferred — collected during the main pass and replayed after it. A target still missing after the deferred pass is a hard error, never a dangling stub: a seed store whosesfnis missing is a silent bootstrap break, and silence is the worst outcome here.
Reading (typeflag 1, hard link): treated identically to a symlink under
copy_instead_of_link semantics on every host — the target’s content is
copied. We never produce hard links, copying is always correct by content, and
this avoids link(2) entirely.
Writing:
LinkPolicy.Preserve(default): a symlink whose target is relative and escape-free is emitted as typeflag2withsize = 0and the target in thelinknamefield. An absolute or escaping target is a hard error — we refuse to write an archive our own extractor would reject.LinkPolicy.Dereference: the pointed-to content is written as a regular file. This is what a Windows staging tree produces naturally, since the links there are already copies.- Hard links are never emitted; a second path to the same inode becomes a second regular member. We do not stat inodes.
Symlink detection on the write path uses fs.read_link
(stdlib/fs/src/mod.sfn:155, readlink(2), "" for a non-symlink) —
fs.is_directory follows symlinks and cannot answer this.
3.6 The extraction path policy (mandatory)
Extraction runs with the user’s privileges over content fetched from the
network. The policy is enforced in src/path_guard.sfn, is pure (so its
whole rejection table is a unit test with no filesystem), and runs on the raw
header bytes before any filesystem call.
A member name — and any link target that will be materialized — is accepted only if all of the following hold:
- Non-empty after ustar
prefix+/+namejoining, and at most 4096 bytes. - Contains no NUL byte within the joined name (ustar fields are NUL-padded; the name is the bytes up to the first NUL — a NUL after that point is padding and fine, a NUL inside is a truncation attack).
- Not absolute: does not begin with
/; does not begin with\; does not begin with\\(UNC); and byte 1 is not:(a drive-letter prefix such asC:x). The Windows-shaped checks run on every host — an archive is extracted on whatever host downloads it, and a rule that only fires on Windows is a rule that was not tested on Linux. - Contains no
\anywhere. Rejected outright rather than normalized:a\..\..\xsplits into one component under a POSIX-only/split, passes rule 5, and then escapes on Windows. - Split on
/: no component equals... Components equal to.and empty components (from//) are dropped. A trailing/is accepted only for typeflag5. - On Windows only: no component is a reserved device name —
CON,PRN,AUX,NUL,COM1–COM9,LPT1–LPT9— compared case-insensitively and ignoring any extension. Accepted on POSIX, where they are ordinary names. - After joining to
dest_dir, the result is lexically beneathdest_dir. Rules 3–5 already guarantee this; the check runs anyway and a failure is reported as an internal invariant violation, because a guard that can only be right is a guard nobody notices going wrong. - Parent directories are created without following an existing symlink. Combined
with the §3.5 rule that a symlink member’s target may not escape, this closes
the “member
ais a symlink to/etc, membera/bwrites through it” hole that rules 1–7 alone do not.
Deliberately not used: sfn/path::normalize (stdlib/path/src/mod.sfn:93).
A normalizer’s job is to collapse .. into a shorter path; the guard’s job is
to notice .. and refuse. Routing a security check through a convenience
function that resolves the thing being checked is how these bugs happen. The
capsule declares no dependency on sfn/path.
Error surface. The capsule returns Result<T, ArchiveError> — not a
Diagnostic. A Diagnostic carries an E0xxx code and a source span over
user source (.claude/rules/code-style.md); a downloaded tarball is neither.
Result<T, string> is the established capsule convention
(capsules/sfn/crypto/src/aead_aes_gcm.sfn:119), and this is the structured
version of it:
enum ArchiveErrorKind { Io, Truncated, BadMagic, BadChecksum, UnsupportedFeature, CorruptStream, PathRejected, LinkRejected, LimitExceeded,}
struct ArchiveError { kind: ArchiveErrorKind; // Human-readable, already fully specific — the CLI prints it verbatim. message: string; // The offending member name, or "" for a stream-level failure. member: string; // Byte offset into the archive, or -1 when not meaningful. offset: int;}The CLI consumer owns the user-facing code. compiler/src/cli/commands/toolchain.sfn
renders any ArchiveError from an install-path extraction as E0615 —
“archive member rejected or archive malformed during toolchain extraction”. The
E05xx–E06xx range is build/check tooling with CLI-homed precedent (E0612
in cli/commands/publish.sfn, E0614 in cli/commands/build.sfn); E0600 and
E0610–E0614 are taken, so E0615 is the next free number. E11xx is not
available — it is numerical/behavioural contracts, with E1100–E1114
reserved by SFEP-0062 (docs/style-guide.md:229). The style-guide table gains
an E0615 entry in the same PR that allocates it.
3.7 Round-trip fidelity and mode bits, reconciled
SFN-753’s acceptance criteria ask for a byte-identical round-trip “including modes”, and Windows has no executable bit. The reconciliation: round-trip identity is defined over archive bytes, not over host filesystem state, and it holds on every host because the writer stores a computed canonical mode rather than an observed one.
Canonical mode:
| Member | Stored mode |
|---|---|
Directory (5) |
0755 |
Regular file (0), executable |
0755 |
Regular file (0), not executable |
0644 |
Symlink (2) |
0777 (what GNU tar stores) |
“Executable” is decided by, in order: membership in CreateOptions.exec_paths;
otherwise fs.is_executable (stdlib/fs/src/mod.sfn:123, access(X_OK))
on POSIX; otherwise false. sfn package already knows which staged entries are
binaries, so the Windows answer is supplied, not guessed.
Everything else that varies by host is zeroed: uid = 0, gid = 0,
uname = "", gname = "", mtime = 0 unless the caller supplies one, and the
gzip header carries MTIME = 0, XFL = 0, OS = 255 (unknown). This is what
makes §2.2’s reproducibility claim true.
On extraction, fs.set_perms applies the stored mode masked to 0777 on POSIX
and is a documented no-op on Windows (stdlib/fs/src/mod.sfn:93-100). The
extractor forces the executable bit for any 0755 member on POSIX — that bit is
the entire reason the extracted sfn runs.
So the three claims, stated precisely:
targz_createover the same tree, twice, on any host: byte-identical archives.targz_createaftertargz_extractof an archive we wrote: byte-identical to the original archive on POSIX. On Windows, identical except that symlink members become regular files (§3.5) — an intended, documented divergence, and the reasonLinkPolicy.Dereferenceexists.- Content bytes of every regular member: identical on every host, always.
3.8 ustar conformance: what we read, what we write, what we refuse
Read-supported:
- ustar POSIX.1-1988 (
magic == "ustar\0",version == "00"), withprefix+/+namejoining. - v7 old-tar (all-NUL magic): accepted;
prefixignored. - GNU (
magic == "ustar \0"): accepted as ustar, butprefixis not joined — GNU does not use that field. - Header checksum: verified. Both the unsigned-byte sum (standard) and the signed-byte sum (historical writers) are accepted; a mismatch against both is a hard error. This is the standard leniency and it is what makes us readable-from rather than brittle.
- Typeflags
0and\0(regular),5(directory),2(symlink),1(hard link) per §3.5. - GNU
L/Klong-name extensions: read-supported. They are how GNU tar encodes a path longer than 100 bytes that does not split intoprefix/name, and a real published tarball can contain them. TheL/Kbody is a NUL-terminated path, buffered to at most 4096 bytes (longer is a hard error) and applied to the next header. - PAX per-file extended headers (typeflag
x): read-supported for thepath,linkpath, andsizekeywords only. Every other keyword is skipped. bsdtar — the defaulttaron macOS — emitsxrecords routinely, so refusing them would leave us unable to read a Mac-produced archive. - PAX global headers (typeflag
g): read and skipped. No keyword we honour is legitimately global. - Two-block EOF marker: the first all-zero header block ends the archive. A present second zero block is verified; an absent one is accepted and noted in the summary, matching GNU tar’s tolerance for a stream cut at a block boundary. Non-zero trailing data after the marker is a hard error.
Hard errors on read:
- Typeflags
3(char device),4(block device),6(FIFO),7(contiguous). No Sailfin artifact contains one, and materializing a device node out of a downloaded archive is exactly the shape the path guard exists to prevent.7could safely alias to regular; failing closed is the honest answer for a typeflag we never write. - GNU base-256 numeric fields (high bit set in byte 0 of a numeric field):
detected and rejected with a specific message. They only appear for values
exceeding octal range — a member above 8 GiB — and
ExtractOptions.max_total_bytesalready fails such an archive closed. - A member
sizethat would take total output pastmax_total_bytes, or a member count pastmax_members(decompression-bomb guard). - Any block read that returns short of 512 bytes mid-archive (truncation).
What we write: ustar magic = "ustar\0", version = "00"; typeflags 0,
5, 2 only; names split into prefix/name when longer than 100 bytes;
L/K never emitted — a path that cannot be split is a hard error, and no
sfn package or seed-tree path comes close; no PAX records; unsigned checksum;
exactly two zero blocks followed by NUL padding to a 10240-byte (20-block)
record boundary, matching GNU tar’s default.
3.9 The DEFLATE encoder: fixed Huffman first, and why
We take the fixed-Huffman first cut, with greedy LZ77 matching and a stored-block fallback. Concretely, per block:
- Run a greedy LZ77 matcher over a 32 KiB window with a hash chain (a
head[1 << 15]+prev[1 << 15]pair ofinttables ≈ 512 KiB, allocated once per stream), producing literal/length/distance symbols. - Encode with the RFC 1951 §3.2.6 fixed code tables.
- If the encoded block would be larger than the raw input, emit a stored block instead — so incompressible input never grows by more than 5 bytes per 65535-byte block.
Fixed Huffman without LZ77 would be a bug, not a simplification. The fixed literal code is near-flat (8 bits for 0–143, 9 bits for 144–255), so a literals-only stream expands binary data by roughly 12.5%. The matcher is what produces compression under the fixed code; it is not the optional half.
Expected ratio on a compiler tarball: roughly 2.5–3x versus gzip -6’s ~3.5x.
The output is valid RFC 1951 and universally readable — GNU tar, bsdtar, 7-Zip,
Python tarfile, and every browser will read it.
Upgrade path: dynamic Huffman (RFC 1951 §3.2.7) is a self-contained addition
inside src/deflate.sfn: histogram the literal/length and distance symbols,
build canonical length-limited codes, emit the code-length alphabet, and pick the
smaller of fixed/dynamic/stored per block. It changes no signature — it is
DeflateStrategy.Dynamic behind CreateOptions.deflate. It is filed as a
follow-up (§9 phase A6) and does not gate SFN-753. The decompressor supports
dynamic Huffman from day one regardless, because we must read gzip -6 output.
3.10 Worked example — the sfn toolchain install cutover
Before (compiler/src/cli/commands/toolchain.sfn:416-425):
let extract_dir = tmp + "/extract";process.run(["mkdir", "-p", extract_dir]);let tar_rc = process.run(["tar", "-xzf", tarball, "-C", extract_dir]);if tar_rc != 0 { print("error: failed to extract " + asset + " (tar exit " + int_to_string(tar_rc) + ")"); process.run(["rm", "-rf", tmp]); return 1;}After:
// Extraction runs with the user's privileges over network-fetched bytes,// so the path policy is not optional and a rejection aborts the install// rather than skipping a member (SFEP-XXXX §3.6).let extract_dir = tmp + "/extract";fs.mkdir(extract_dir);let opts = ExtractOptions { copy_instead_of_link: host_is_windows(), apply_modes: true, max_total_bytes: 1073741824, max_members: 65536};let extracted = targz_extract(tarball, extract_dir, opts);if extracted.is_err() { let e = extracted.unwrap_err(); print("error [E0615]: failed to extract " + asset); print(" " + e.message); if e.member.length > 0 { print(" member: " + e.member); } process.run(["rm", "-rf", tmp]); return 1;}The surrounding mkdir -p / rm -rf / cp -f / chmod shell-outs in this
function are SFN-493’s other half, not this proposal’s scope; the fs.mkdir
swap above is incidental to removing the tar call.
4. Effect & capability impact
No change to the effect system, the taxonomy, or capability enforcement. The capsule is a consumer of the existing model, not an extension of it.
- The pure codec layer —
crc32.sfn,inflate.sfn,deflate.sfn,ustar.sfn(header encode/decode),path_guard.sfn,bytes.sfn— is effect-free. That separation is deliberate: the entire path-guard rejection table and every RFC vector are testable without![io], which is what makes the security-relevant half cheap to test exhaustively. - Only
tar_read.sfn,tar_write.sfn, and thegzip_*stream openers carry![io]. No![net]: the capsule never fetches. Effect lists are single-effect here, so the alphabetical rule (.claude/rules/code-style.md) is trivially met. stdlib/archive/capsule.tomldeclares[capabilities] required = ["io"]. Note thatcapsules/sfn/crypto/capsule.tomldeclaresrequired = []while exporting a![rand]function, so capsule-manifest enforcement is evidently not yet capsule-wide; the implementer declares["io"]as the honest answer and matches the crypto precedent if the resolver rejects it (E0403).- The compiler’s own manifest does not widen.
compiler/capsule.tomlalready declaresrequired = ["clock", "io", "net"]; the extraction path is![io], which is inside it. Removing theprocess.run(["tar", ...])calls in fact narrows real-world reach — the compiler stops handing an argv to an external program with the user’s full authority — even though the declared manifest is unchanged. That is the capability model working as intended, and it is worth saying out loud on a proposal whose headline is “removes a dependency.”
5. Self-hosting impact
5.1 No compiler pass changes
Lex, parse, AST, typecheck, effects, emit (.sfn-asm), lowering, and rendering
are all unchanged. The capsule is ordinary Sailfin source using constructs
the pinned 0.9.5 seed already compiles: structs, enums, Result<T, E> + ?,
extern fn over libc, load_byte, and sailfin_intrinsic_pointer_read_i32.
No new builtin, no new intrinsic, no new syntax (§3.2).
5.2 No seed cut
Per .claude/rules/seed-dependency.md: make compile self-hosts against the
seed pinned at bootstrap.toml:16 (0.9.5). A capsule under capsules/sfn/ is
compiled by the freshly built compiler within the same pass, so a capability
and its consumer bundle cleanly and there is no gate — this is exactly the case
the rule says to bundle. The carve-out (a capability consumed by runtime
source, where the pinned seed does the compiling) does not apply: nothing in
runtime/ imports sfn/archive, and nothing should.
Compiler-source changes are confined to:
| File | Change | Phase |
|---|---|---|
compiler/capsule.toml |
add "sfn/archive" = "*" under [dependencies] |
A3 |
compiler/src/cli/commands/toolchain.sfn:416-425 |
replace the tar -xzf call |
A3 |
compiler/src/cli/commands/package.sfn:259, :586, :759 |
replace the three tar -czf calls |
A5 |
compiler/src/cli/commands/package.sfn:86 |
delete the workaround comment naming SFN-753 | A5 |
docs/style-guide.md:224 |
add the E0615 entry |
A3 |
workspace.toml needs no edit — members includes capsules/sfn/* and the
pinned seed expands the glob (workspace.toml:15-19). The Makefile needs no
edit — test-capsules walks nested tests/ directories under capsules/
(Makefile:415-435).
5.3 Build-time cost
Reachability filtering means the capsule’s modules are staged only for programs
that name it (compiler/src/capsule_resolver/reachability.sfn:166-168). After
A3 the compiler imports it, so make compile gains ~8 modules of compile time —
comparable to a slice of sfn/crypto’s 33-38 modules, which is a known and
tolerated cost. Every other program pays nothing.
5.4 Name collisions
Imported names are matched without provenance across the compiler tree; that is
why sfn/http cannot be a compiler dependency (compiler/capsule.toml:63-68,
SFN-893). Every sfn/archive export therefore carries a module prefix
(targz_*, gzip_*, ustar_*, crc32*, inflate_*, deflate_*,
archive_*) — no bare read, write, extract, create, list, or open.
This is a hard review criterion on A1, because the cost of getting it wrong
surfaces only at A3 when the compiler first imports the capsule.
6. Alternatives considered
6.1 Invoke Windows’ bundled System32\tar.exe — retired by owner ruling
Decided: rejected. This option is retired, not deferred. The reasoning is
product, not pillar: a stock-Windows install must run the whole toolchain, and
delegating unpack to a host binary means the bootstrap works only on Windows 10
1803+ with a bsdtar whose version and PAX behaviour we do not control. It was
never a pillar violation — tar is not on the sfn build -p compiler path and
never contaminated a capability manifest — so the rejection rests entirely on
self-containment. It also solves only half the problem: sfn package still needs
a writer, and tar.exe cannot be argv-driven into producing reproducible
output (§2.2).
6.2 Link zlib / libarchive through the C ABI
Rejected. It reintroduces a C dependency the project explicitly retired
(CLAUDE.md: “no Python, no C runtime, no fixup scripts”), and the per-platform
link matrix is strictly harder than the code it saves: MSVC ships no system
zlib, so Windows — the target that motivates this work — would need a vendored
build. ~2,500 lines of pure Sailfin against a frozen 1996 spec with published
vectors is the cheaper and more durable side of that trade.
6.3 Ship an uncompressed .tar seed asset
Rejected. 4.4 MB becomes ~14 MB per asset per target, it invalidates every
published .sha256 and the existing installer, and it does nothing for
sfn package, whose consumers expect .tar.gz. It also merely defers the
decompressor, since we must still read historical .tar.gz seeds.
6.4 Invent a simpler Sailfin-native container format
Rejected on “boring syntax wins” applied to formats. Published seed tarballs are
.tar.gz; the GitHub release UI, curl | tar, and every user’s muscle memory
expect it. A bespoke format would require us to ship and read .tar.gz anyway
for the historical assets, so it is strictly additive work.
6.5 Put the implementation in runtime/
Rejected. Nothing forces it there — no new intrinsic is needed (§3.2) — and
choosing runtime/ converts a zero-gate change into a seed-gated one, because
the pinned seed compiles working-tree runtime source
(.claude/rules/seed-dependency.md). It would also add ~2,500 lines to the
runtime link-selection surface (compiler/src/build/runtime_selection.sfn) for
every program, and forfeit the automatic make test-capsules discovery.
6.6 Whole-buffer-only API
Rejected on the memory budget. A whole-buffer extract peaks at compressed +
decompressed + written tree simultaneously; sfn package trees are unbounded and
the fan-out budgets in .claude/rules/compiler-safety.md reserve 2.5–3 GiB per
job. Streaming costs roughly 150 extra lines and makes peak memory independent
of archive size (§3.4). The whole-buffer helpers survive for RFC vectors, where
inputs are bytes, not megabytes.
6.7 Dynamic Huffman in the first cut
Deferred, not rejected. It is a pure ratio improvement behind an existing
options field with no signature change (§3.9). Shipping fixed Huffman first gets
the Windows unblock sooner, and the decompressor supports dynamic Huffman from
day one regardless because it must read gzip -6. Filed as phase A6.
6.8 int[] as the byte container
Rejected: 8 bytes of storage per byte plus push growth copies. A 100 MB member
would need ~1.6 GB peak against a shared 8 GiB cap. int[] interop survives at
the test boundary so RFC vectors stay readable as array literals (§3.3).
7. Stage1 readiness mapping
This proposal adds no language construct, so several checklist rows are satisfied by “nothing changed” rather than by new work. Stating that explicitly is the honest form — an unqualified tick would imply new pipeline coverage that does not exist.
- Parses — n/a: no new syntax. The capsule is ordinary Sailfin the pinned 0.9.5 seed already parses.
- Type-checks / effect-checks — the capsule must pass
sfn checkon every module, with the pure layer proven effect-free and![io]confined totar_read.sfn/tar_write.sfn/ the stream openers. - Emits valid
.sfn-asm— n/a: no emitter change. Covered transitively by the capsule compiling. - Lowers to LLVM IR — n/a: no lowering change. The one novelty is
load_byteand a masked word store spelled from capsule source, which the §3.2 probe settles in phase A1. - Regression coverage — §8. The path guard and inflate carry the exhaustive tables; interop tests are the acceptance evidence.
- Self-hosts —
make compileafter A3 (compiler imports the capsule) and again after A5;make checkbefore declaring the epic done. -
sfn fmt --checkclean — every.sfnunderstdlib/archive/and the touchedcompiler/src/cli/commands/*.sfn. - Documented in
docs/status.md+ spec —docs/status.mdgains ansfn/archiverow;E0615is added todocs/style-guide.md:224. No spec chapter: this is a library, not a language feature.
The proposal reaches Accepted on owner approval of this design and
Implemented only when phases A1–A5 are all merged, make check is green,
and the four tar call sites are gone from compiler/src/.
8. Test plan
8.1 Unit — stdlib/archive/tests/ (auto-discovered by make test-capsules)
crc32_test.sfn— the zlib/RFC 1952 check valuecrc32("123456789") == 0xCBF43926; empty input== 0; a 1 MiB generated pattern against a precomputed constant; incremental-vs-whole equivalence.inflate_test.sfn— RFC 1951 coverage: stored block; fixed-Huffman block; dynamic-Huffman block; back-reference at distance 1 (run-length fill); distance at the 32768 window edge; the length-258 / distance-32768 maxima; a stream ending mid-symbol (errors, does not hang — guard counters per.claude/rules/code-style.md); an over-subscribed code-length tree (rejected, does not crash); an incomplete tree; a distance code pointing before the start of output.gzip_test.sfn— RFC 1952 framing: minimal header;FNAME,FCOMMENT,FEXTRA,FHCRCpresent individually and together; bad magic;CM != 8; CRC-32 mismatch; ISIZE mismatch; a concatenated multi-member stream (cat a.gz b.gzis legal and must read as one logical stream).ustar_test.sfn— checksum accepted under both the unsigned and signed sums; rejected on mismatch;prefix+namejoin; GNULandKlong names; PAXpath/linkpath/size; PAXgskipped; the two-block EOF marker and the single-block tolerance; each of typeflags3/4/6/7rejected; a base-256 numeric field rejected with its specific message.path_guard_test.sfn— the full rejection table, filesystem-free because the guard is pure:/etc/passwd,../x,a/../../x,a/./b(accepted, normalizes toa/b),C:\x,c:x,\\server\share\x,a\..\..\x, a name with an embedded NUL, a 4097-byte name,CONandcom1.txtunder the Windows flag (rejected) and under POSIX (accepted), a symlink target of/etc, a symlink target of../.., and an empty symlink target.deflate_test.sfn—inflate(deflate(x)) == xover: empty; 1 byte; 65535 bytes (the stored-block boundary); 65536; a highly repetitive buffer; an incompressible pseudorandom buffer (must fall back to stored and never grow by more than 5 bytes per 65535-byte block); a buffer containing all 256 byte values; and a buffer whose best match sits exactly at the window edge.bytes_test.sfn—ByteBufgrowth,ByteCursorbounds rejection at both ends, and the byte round-trip through whichever read/write spelling the §3.2 probe selected.
8.2 E2E — compiler/tests/e2e/*_test.sfn
Sailfin test files driving subprocesses via process.run_capture — never
bash (.claude/rules/no-bash-e2e.md). Each nested build threads
clean_runner_env(nested_runner_scratch("<label>")) and each tool probe follows
the existing tool_present("tar") skip pattern at
compiler/tests/e2e/sfn_package_test.sfn:211, so a host without GNU tar skips
rather than fails.
archive_gnu_tar_interop_test.sfn— our writer’s output is listed (tar -tzf) and extracted (tar -xzf) by the hosttar, and bybsdtarwhen present, with content compared byte for byte. This is the “GNU tar and bsdtar can both read our writer’s output” acceptance criterion.archive_reads_gnu_tar_test.sfn— the hosttar -czfarchives a fixture tree containing a nested directory, an executable file, a non-executable file, a >100-byte path (forcingprefixorL), and a relative symlink; we extract it and compare content, modes, and link shape.archive_reads_published_seed_test.sfn— extract the actual pinned seed tarball and assert thesailfinbinary and thesfn -> sailfinlink materialize correctly. Gated on the asset already being present in the local seed store; the test never downloads.archive_roundtrip_reproducible_test.sfn—targz_createover the same tree twice yields byte-identical archives, and the SHA-256 matches across the two runs. This is the §2.2 reproducibility claim under test.archive_path_traversal_reject_test.sfn— a hand-built malicious archive (constructed with our own writer’s low-level header encoder, bypassing the writer’s own refusal) containing../escape,/etc/escape, and a symlink to/tmpis rejected, and nothing is written outsidedest_dir— asserted by checking the sibling directory is empty afterward, not merely by checking the return code.sfn_package_no_tar_test.sfn— runsfn packagewith aPATHcontainingclangand its linker but nottar, and assert a valid archive is still produced. This is the product claim, tested directly.sfn_toolchain_install_no_tar_test.sfn— the extraction half of the same, against a locally-staged archive with no network.
8.3 Test oracles that deliberately keep using tar
compiler/tests/e2e/sfn_package_test.sfn:89 and
compiler/tests/e2e/build_hash_matches_sha256sum_test.sfn:87 invoke tar as a
verification oracle. Those stay. Cross-checking our output against the real
GNU tar is the point of an interop test, and replacing the oracle with our own
implementation would make the test assert only that we agree with ourselves.
8.4 Commands
build/bin/sfn check stdlib/archive/src/*.sfnbuild/bin/sfn fmt --check stdlib/archive/src/*.sfn stdlib/archive/tests/*_test.sfnmake compile # after A3 and after A5build/bin/sfn test stdlib/archive/testsmake test-capsulesbuild/bin/sfn test compiler/tests/e2e/archive_gnu_tar_interop_test.sfnbuild/bin/sfn test compiler/tests/e2e/sfn_package_no_tar_test.sfnmake check # before declaring the epic done9. Phasing
Reader-first: the reader half is what unblocks SFN-493 → SFN-57, and the writer
half serves sfn package, which no Windows user needs before they can bootstrap.
Per the decomposition discipline, a capability is bundled with its single consumer. A3 and A5 each pair a capability with the only call sites that will ever use it, in one PR. Nothing here is seed-gated (§5.2), so no split would buy a seed cut back — and none should be manufactured.
| Phase | Scope | Size | Blocked by |
|---|---|---|---|
| A1 | Capsule skeleton (capsule.toml, mod.sfn), bytes.sfn, error.sfn, crc32.sfn, gzip.sfn header parse only; the §3.2 byte-primitive probe; crc32_test.sfn, bytes_test.sfn, gzip_test.sfn. Establishes the prefixed-export rule (§5.4). |
S/M | — |
| A2 | inflate.sfn — full RFC 1951 decompression (stored, fixed, dynamic), wired to gzip_read. inflate_test.sfn. Not bundled with anything: it is the largest correctness surface in the epic and is independently testable against published vectors. |
M | A1 |
| A3 | ustar.sfn read, path_guard.sfn, tar_read.sfn, and the toolchain.sfn:418 cutover + compiler/capsule.toml dependency + E0615 allocation. Bundled: the ustar reader’s only consumer is the extractor, whose only consumer today is that one call site. ustar_test.sfn, path_guard_test.sfn, and the four read-side E2E tests. |
M | A2 |
| A4 | deflate.sfn — fixed Huffman + greedy LZ77 + stored fallback — and gzip.sfn write. deflate_test.sfn. |
M | A1 |
| A5 | ustar.sfn write, tar_write.sfn tree walk, and the three package.sfn cutovers (:259, :586, :759) plus deleting the workaround comment at :86. Bundled for the same reason as A3. Interop + reproducibility E2E tests. |
M | A3, A4 |
| A6 | Dynamic Huffman behind DeflateStrategy.Dynamic. Follow-up — does not gate SFN-753. |
S/M | A4 |
Acceptance criteria, per phase:
- A1 —
make test-capsulesgreen;crc32("123456789") == 0xCBF43926; the RFC 1952 header field matrix parses;make compileunaffected (the capsule is not yet a compiler dependency); the probe’s outcome recorded in a//comment inbytes.sfnnaming which spelling was chosen and why. - A2 — every §8.1 inflate vector passes; a truncated stream and an over-subscribed tree both error rather than hang or crash; decompressing a 100 MB fixture holds peak RSS to window + chunk.
- A3 —
compiler/src/cli/commands/toolchain.sfncontains noprocess.runnamingtar;archive_reads_published_seed_test.sfnandarchive_path_traversal_reject_test.sfnpass;make compilegreen;E0615present indocs/style-guide.md. This is the phase that closes out SFN-493’s tar half. - A4 —
inflate(deflate(x)) == xover the §8.1 corpus; incompressible input never grows by more than 5 bytes per 65535-byte block. - A5 — all three
tar -czfsites gone; GNU tar and bsdtar both read our output; twotargz_createruns are byte-identical;sfn_package_no_tar_test.sfnpasses;make checkgreen.
10. Future considerations
.zipfor Windows distribution. A.zipwriter reusescrc32.sfnanddeflate.sfnunchanged and needs only a new container module (local file headers + central directory). This is the strongest single argument for the layered module split in §3.1, and worth remembering before anyone proposes collapsingcrc32intogzip.Content-Encoding: gzipinsfn/http.inflate.sfnis the whole requirement; the HTTP client currently cannot accept a compressed response.- Registry artifacts.
sfn publish/sfn addwill move capsule tarballs; a reproducible writer means a capsule’s content digest is stable across publishers, which is a precondition for any future content-addressed cache (SFEP-0040 adjacency). - Reclamation. The manual
bytes_freediscipline is exactly the pattern SFEP-0064’s reclamation seam exists to absorb. When that lands,ByteBufshould be among the first adopters; until then, the discipline is enforced by review and by the fact that every allocation is confined to one module. - Dynamic Huffman ratio parity (A6) matters most for release-asset size across N targets; measure before assuming the ~15% is worth a session.
- Zstd is not on this path. It would need its own decompressor and buys nothing until we control both ends of a distribution channel.
11. References
- SFN-753 — in-process
.tar.gzreader/writer (this proposal’s tracking issue) - SFN-493 — de-shell seed acquisition (the direct consumer of phase A3)
- SFN-57 / SFN-55 / SFN-58 — native Windows seed, release leg, and follow-ons
- SFN-340 / SFN-341 / SFN-808 / SFN-811 / SFN-824 — native TLS (done; the
reason
curlis no longer a blocker) - SFN-893 — provenance-aware name resolution (why every export is prefixed)
- SFEP-0021 — Native Windows Self-Host, §4.3 bootstrap sequence
- SFEP-0048 — Native crypto (the house model for a pure-Sailfin capsule implementing a frozen spec against published vectors)
- SFEP-0064 — Reclamation seam (future home for the manual free discipline)
- SFEP-0062 — Numerical contracts (reserves
E1100–E1114; whyE11xxis unavailable here) .claude/rules/seed-dependency.md— bundle-vs-split and the runtime carve-out.claude/rules/compiler-safety.md— the 8 GiB cap and the fan-out budgets.claude/rules/no-bash-e2e.md— E2E tests are*_test.sfn- RFC 1950 (zlib), RFC 1951 (DEFLATE), RFC 1952 (gzip) — the frozen specs, with the CRC-32 check value and the fixed Huffman tables
- POSIX.1-1988 ustar and the GNU tar manual’s “Basic Tar Format” chapter —
header layout, the
L/Kextensions, and PAXx/grecords