Skip to content

Add erofs support to apko. - #2249

Merged
smoser merged 33 commits into
chainguard-dev:mainfrom
smoser:feat/apko-erofs
Aug 18, 2026
Merged

smoser merged 33 commits into
chainguard-dev:mainfrom
smoser:feat/apko-erofs

Conversation

@smoser

@smoser smoser commented May 26, 2026

Copy link
Copy Markdown
Contributor

Implement apko writing of erofs images according to the spec at
https://github.com/erofs/erofs-image-spec (spec.md on main; section numbers
in the code and docs refer to it). The spec is still in its draft phase — no
tagged release, and it says media types, annotation keys and the chunk-index
layout may change before the first stable one — so EROFS support here is
experimental.

The docs/erofs.md file in the pr explains the use and erofs image layout.

Scope: this PR adds the single-image writer (--format=erofs, one layer) and
apko erofs ls. Multi-layer splitting and apko erofs mount/umount were
descoped after review — see #2408 — and land next, in that order. Combining
layering with format: erofs is rejected during config validation rather than
silently producing a single layer.

I've opened a draft PR showing the value of erofs in images.
See chainguard-dev/melange#2605 for example use of erofs support in apko by melange.

Related:

Review round 2 (2026-08-18)

Addressing @mattmoor's review at 46ac139. Descoped, per his suggestion, the two
areas where the findings were concentrated; the rest is fixed here.

Fixed in this PR:

  • Whiteouts now use the encoding the spec mandates. Stack detected
    deletions by filename (.wh.NAME, .wh..wh..opq), which §8.1 item 9 forbids
    in EROFS images outright; §3.6 requires a char device with rdev 0 plus
    trusted.overlay.opaque="y". The comment justifying the old encoding claimed
    it matched go-erofs Writer.Merge, but Merge consumes .wh. entries from
    its source and never writes them into an image — so no producer emits them and
    the reader was honoring an encoding that cannot conformantly exist. Whiteouts
    are interpreted only when the stack has two or more layers, since §7 step 6
    lets a lone layer be mounted directly, applying no overlay semantics.
  • ReadOCILayers accepts the role shapes the spec allows. Role-absent
    non-final layers and overlay-lower final layers are both conformant (§2.3,
    §3.8 rule 1, §7 step 1) and were both rejected. device and overlay-data are
    now refused as unsupported compositions rather than as malformed images.
  • The build time is always stamped. WithBuildTime was passed only for a
    non-zero timestamp, and go-erofs stamps time.Now().Unix() when it is absent —
    so a library caller passing a zero time.Time got a different digest on every
    build. It cannot be passed verbatim (a zero time.Time has a negative Unix
    seconds value that wraps through uint64), so zero and pre-epoch clamp to
    epoch 0.
  • os.features reaches the index platform descriptor. §5.4 is a MUST in two
    places and only the config half was implemented, so consumers filtering on the
    index — before fetching any config — never saw the signal.
  • The default output is named .erofs, not apko-<arch>.tar.gz.
  • CI installs erofs-utils, so the fsck.erofs cross-checks actually run.
    Without it every one degraded to a t.Log and TestWriteErofs_FsckErofs
    skipped, leaving go-erofs reading its own output as the only always-on
    validation.
  • Hardlink materialization and the go-erofs v0.3.1 caveats are written down: the
    pinned version predates Fix setuid/setgid/sticky bits dropped on read and in Mkdir erofs/go-erofs#41, so FileInfo.Mode() misreports
    setuid/setgid/sticky and callers must read *erofs.Stat.Mode off Sys().
  • Dropped erofsLayer.LayerPath(), which had no callers. The exported
    state/driver surface went away with the mount descope, which also means the
    package no longer has a build-tag-dependent API — everything left is pure Go
    and builds everywhere.
  • Spec citations corrected: the "PR implement entrypoint #1" pointer (merged 2026-06-01) now names
    spec.md, and four places that presented apko's producer convention as a spec
    rule now say which is which.

Moved to #2408, with the code preserved on the feat/apko-erofs-full branch of
smoser/apko so nothing is reconstructed from scratch:

  • Package routing never matching on the real tarfs path, and dir-only subtrees
    being dropped from every layer — plus the tarfs-backed test that would have
    caught the first, and the temp-file/fd leak on error paths.
  • State-file trust in Unmount, the rerun instruction that cannot work, the
    writable-by-default mount that discards upper, and a Driver seam so the
    orchestration is testable at all.

Not done here: a size measurement on a hardlink-heavy image. I had no such image
on hand, so docs/erofs.md states the arithmetic — one full copy per link,
rounded to the block size — rather than a number I did not measure.

@smoser
smoser force-pushed the feat/apko-erofs branch 4 times, most recently from 4516981 to 268396a Compare May 27, 2026 15:29
@smoser
smoser requested a review from tulilirockz May 27, 2026 15:35
@smoser
smoser force-pushed the feat/apko-erofs branch from 268396a to 3af8dec Compare May 27, 2026 15:48
@smoser

smoser commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

fyi @raharper, @rchincha

Comment thread docs/apko_file.md Outdated
Comment thread docs/erofs.md
@smoser

smoser commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

I think this is ready for some eyes to review and some testing of it. See the description and linked PRs that i put together to show its usefulness.

@raharper raharper left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid and complete with extensive testing. a couple comments/questions I'm interested in see feedback.

Comment thread pkg/build/build.go Outdated
Comment thread pkg/build/erofs.go Outdated
Comment thread pkg/build/erofs.go Outdated
Comment thread pkg/build/erofs_layers.go Outdated
Comment thread pkg/build/erofs_test.go Outdated
Comment thread pkg/build/erofs_test.go Outdated
Comment thread pkg/build/erofs_test.go Outdated
Comment thread pkg/build/layers.go Outdated
Comment thread pkg/erofsmount/mount_linux.go Outdated
Comment thread pkg/erofsmount/source.go
smoser and others added 11 commits August 17, 2026 14:20
Emit OCI image layers as EROFS filesystem images (application/vnd.erofs)
instead of tar+gzip. Selected via `--format=erofs` on `apko build` /
`apko publish` or `format: erofs` in apko.yaml. Tracks the draft
erofs/erofs-image-spec (PR chainguard-dev#1).

Single-layer and multi-layer (layering) builds are supported. Multi-layer
emits each non-final group with `org.erofs.role=overlay-lower` per spec
§3.8 and a per-group partial `usr/lib/apk/db/installed` so per-layer
scanners still work. Manifests declare `erofs` in os.features per §5.4.

Uses github.com/erofs/go-erofs (Apache-2.0, pure Go) for the writer.
Reproducibility via SOURCE_DATE_EPOCH. Tests cover roundtrip via
erofs.Open, byte-identical determinism, the full ImageLayoutToLayer
dispatch, OSFeatures plumbing, and end-to-end validation via
`fsck.erofs` (skipped when the binary isn't on PATH). `+zstd`, dm-verity,
and chunk indexes are not implemented in this round.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step-by-step guide for producing EROFS images with --format=erofs,
inspecting the layer blob without root (fsck.erofs / dump.erofs /
fsck.erofs --extract), mounting it (kernel mount or erofsfuse),
pulling layer blobs from a registry, and assembling multi-layer
images via overlayfs. Includes the current limitations (no +zstd,
dm-verity, chunk index) and links from apko_file.md.

All commands shown were verified against a real `apko build` of
examples/wolfi-base.yaml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an `apko erofs` command group that wraps the EROFS mount workflow:
`mount` accepts a raw blob or an OCI image directory (auto-detected, or via
`erofs:`/`oci:`/`oci-dir:` prefixes), `umount` reads a per-mount state file
to unwind every layer, and `ls` produces a `tar tvf`-style listing without
leaving mounts behind. The new pkg/erofsmount library handles source
parsing, OCI layout reading, kernel/FUSE drivers with kernel-overlay-over-
fuse fallback to fuse-overlayfs, and state-file teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`apko erofs ls` now opens each EROFS layer blob directly with go-erofs
and walks a layered fs.FS in user space, instead of mounting the layers
and walking the merged mountpoint. This removes the kernel/FUSE dependency
for `ls` (works on darwin/windows too), eliminates the mount log noise,
and is faster.

Introduces a reusable pkg/erofsmount.Stack: a layered fs.FS implementing
fs.ReadDirFS/StatFS/ReadLinkFS with full AUFS-style overlay semantics —
.wh.NAME whiteouts hide siblings, .wh..wh..opq markers hide all lower-
layer entries in a directory, ancestor whiteouts hide whole subtrees,
type-mismatch in a higher layer shadows lower contents. apko's writer
never emits whiteouts (it splits one rootfs into groups, doesn't merge),
so 15 unit tests synthesize the whiteout cases via testing/fstest.MapFS.

Mount and Unmount remain Linux-only since they genuinely need the kernel
or FUSE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
writeERofs was the only identifier in the repo using mid-word
acronym-style "ERofs"; everywhere else treats it as a word ("Erofs").
Rename writeERofs / writeERofsViaMkfs and the related test names so the
codebase is uniform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apko_file.md still claimed EROFS layers were uncompressed and that +zstd
was unimplemented; the +ALGO variants have shipped since the format field
was first documented. Rewrite the format list to enumerate raw and
compressed variants, mention the uncompressed-digest annotation, and
note the mkfs.erofs runtime dependency.

erofs.md's manual-overlay reference snippet had two bugs that prevented
it from running end-to-end: $ROOT/../../blobs/sha256/$MANIFEST
double-traversed the OCI layout, and the lowerdir chain hard-coded a
four-layer count with explicit lower0/lower1 references that wouldn't
generalize. Rewrite the loop to derive $BLOBS and $MANIFEST cleanly and
accumulate $LOWERS as it mounts.

Also fix two small accuracy bugs: --arch on apko takes Go arches
(amd64, arm64), not uname -m output (x86_64, aarch64) — replace with
--arch=host, which is what the YAML examples in the same file use. And
on Debian/Ubuntu erofsfuse ships inside the erofs-utils package; the
separate erofsfuse package only exists on Wolfi/Alpine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The application/vnd.erofs media type and the org.erofs.role / overlay-lower
/ org.erofs.uncompressed-digest annotation strings lived as parallel
unexported consts in pkg/build/erofs.go and pkg/erofsmount/oci.go with
a "keep in sync" comment guarding the duplicate. Promote them to a
single set of exported constants in pkg/build/types/erofs.go so both
the writer and the reader/mount tools reference the same source, and
test fixtures lock to the same strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mount(8) since util-linux 2.29 autodetects when the source is a regular
file and allocates a loop device with O_AUTOCLEAR, freeing it on umount.
Asking for "-o loop" explicitly relies on a separate code path whose
cleanup semantics differ across util-linux releases and busybox builds —
on older or non-GNU versions the loop device can leak after umount.

Drop "loop" from the argv. Keep "-o ro" to document intent (EROFS is
intrinsically read-only, but the explicit flag tells a reader who is
copy-pasting the equivalent shell command that we never plan to write).
Update the matching tests and the two "doing it manually" snippets in
docs/erofs.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
st.Mounts is recorded overlay-first then per-layer mounts in LIFO order.
If the overlay umount fails, every subsequent layer umount returns EBUSY
because the overlay still pins them — the previous loop collected and
errors.Join()'d every one of those, giving the user a long block of
identical "device busy" noise where only the first error described the
real problem.

Return on the first failed umount with a single error that names which
mountpoint the user needs to clear; leave the remaining mounts and the
state file in place so a follow-up `apko erofs umount` finishes the job.
Deliberately do not fall back to `umount -l`: lazy unmount would let the
process exit with the user believing things were torn down while the
mounts and pinned files quietly persist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@smoser

smoser commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

I pulled the compression out. it is now separate at #2406.
When this lands, we can look to re-add compression as shown that way or via erofs/go-erofs#45

@mattmoor mattmoor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a deep pass over this at head 46ac139. The single-image writer path (--format=erofs producing one layer) held up well under scrutiny: close-before-hash ordering, xattr and special-bit handling, and byte-determinism all check out, and the fixes from the earlier review round are all present at head. apko erofs ls is also in good shape.

I found two significant bugs in the multi-layer split path (package routing never matches on the real tarfs path, and dir-only subtrees are dropped from every layer) and two in the umount path (state-file trust, and a rerun instruction that cannot work). Details inline. Given where those are concentrated, one option is to descope: land the single-image format plus ls now, and follow up with the layering split and mount/umount hardening.

Cross-cutting notes that don't anchor to one line:

  • melange coordination: melange#2605 currently pins a fork of apko and passes erofs+zstd[,level=N] format strings that this head rejects after the #2406 split; its compressed mode is dead until #2406 merges and melange re-pins. Worth a note on that PR.
  • Exported surface: StatePath/WriteState/LoadState/RemoveState/StateSchemaVersion (state.go) and Driver/NewDriver/ResolveMode (driver_linux.go) have no callers outside the package, and the linux-only exports give the package a build-tag-dependent API. Consider unexporting before this ships as a library surface. Similarly erofsLayer.LayerPath() has no callers.
  • Test seam for the mount plane: Mount/Unmount construct their Driver internally, so the 305 lines of orchestration in mount_linux.go (cleanup LIFO, state lifecycle, unmount policy) can't be unit-tested with a fake driver. driver_linux_test.go covers only the argv builders; nothing anywhere executes the orchestration.

Things I checked that came back clean, so others can skip them: determinism of the single-image path (go-erofs sorts xattr keys before serialization; no UUIDs or randomness; SOURCE_DATE_EPOCH plumbed through), OCI blob path traversal in erofsmount (ggcr's Hash parsing forecloses it), regressions to tar-path users from the shared-file changes (the rwosfs char-device seeding fix only changes behavior for DirFS over trees that already contain real device nodes, which no in-tree flow constructs), and the go-erofs supply chain (official erofs org, sumdb-verified tag, containerd pins the identical v0.3.1).

Comment thread pkg/build/erofs_layers.go Outdated
Comment thread pkg/build/erofs_layers.go Outdated
Comment thread pkg/build/erofs_layers.go Outdated
Comment thread pkg/build/erofs_layers.go Outdated
Comment thread pkg/build/erofs.go Outdated
Comment thread pkg/erofsmount/stack.go Outdated
Comment thread pkg/erofsmount/mount_linux.go Outdated
Comment thread pkg/erofsmount/mount_linux.go Outdated
Comment thread pkg/erofsmount/mount_linux.go Outdated
Comment thread pkg/build/erofs_test.go
smoser added 9 commits August 18, 2026 08:31
Matt's review found two bugs concentrated in the mount plane: Unmount
trusts the on-disk state file and hands its recorded paths straight to
`umount` (typically as root) with no validation, and the "rerun once
they are no longer busy" instruction in the partial-failure path cannot
succeed, because the rerun's first step is to unmount `merged`, which
already came down.

Both want more than a spot fix. The state file needs containment
checks, the unmount loop needs to tolerate an already-unmounted entry
or rewrite state as it goes, and the 305 lines of orchestration have no
test seam at all -- Mount/Unmount construct their Driver internally, so
nothing in-tree ever executes the cleanup LIFO or the state lifecycle.
Doing that properly is its own change.

So drop the mount plane here and land it next, hardened, on top. What
remains in this PR is the single-image writer and `apko erofs ls`, both
of which the review found sound.

Removed: mount_linux.go, driver_linux.go, state.go, stub_other.go and
their tests, plus the `apko erofs mount` and `apko erofs umount`
subcommands. Ls loses the Options bag it only ever read Arch out of,
and takes an arch string directly; that also drops the `--mode` flag
it documented as "accepted for symmetry with 'mount' and ignored".

With the Linux-only files gone the package no longer has a
build-tag-dependent API, which was a separate review note: everything
left is pure Go and builds everywhere.

The mount and overlay-assembly recipes in docs/erofs.md now show the
plain mount/erofsfuse commands instead of `apko erofs mount`.

The full pre-descope tree is preserved on the feat/apko-erofs-full
branch.
Matt's review found two bugs in splitErofsLayers, both of which mean
the layered output is not the image it claims to be.

The package-routing type assertion is on the wrong receiver. It asks
info.Sys() for a Package() method, but tarfs -- which is what real
builds walk -- returns a fresh *tar.Header from Sys(); Package() is a
method on the FileInfo itself, which is what the tar split path
asserts on. So the assertion never succeeds in a real build: every
file lands in the top layer, each group layer holds only ancestor
directories plus a partial installed db (that case keys on the path
string, so it still fires), and per-layer scanners then read a db
claiming packages whose files are not there. That inverts the whole
point of the split.

TestSplitErofsLayers cannot catch it: it drives the split through
MemFS, which implements Package() nowhere, so the fixture has zero
package-owned files by construction and the test passes identically
with and without routing. Fixing this properly means a tarfs-backed
test, which is more than a follow-on hunk.

Second, directories whose subtree contains no non-directory entry are
never emitted into any writer. They are recorded during the walk and
materialized only by emitAncestors, which runs for non-dir entries, and
no post-walk pass flushes the rest. Empty dirs and dir-only chains
(/tmp, /run, /home, /var/empty, mount points) are therefore absent from
the mounted union. Both siblings get this right: the tar split writes
every walked dir into its owning layer, and writeErofs Mkdirs each one
unconditionally.

There is also a temp-file and fd leak on every error path out of
splitErofsLayers, which needs an upstream Abort API to close cleanly.

So drop the split here and land it next with the tarfs-backed test that
actually exercises routing. Requesting `layering` together with
`format: erofs` is now rejected during config validation instead of
silently producing a single layer; BuildLayers keeps a guard for
library callers that skip validation.

The full pre-descope tree is preserved on the feat/apko-erofs-full
branch.
Stack detected deletions by filename -- `.wh.NAME` and `.wh..wh..opq`
-- which is the OCI tar layer convention, and the one thing spec §3.6
says an EROFS layer must not use. §8.1 item 9 forbids `.wh.`-prefixed
names in EROFS images outright, and §3.6 mandates the kernel's own
overlayfs encoding instead: a whiteout is a character device with rdev
0, and an opaque directory carries `trusted.overlay.opaque="y"`.

The comment justifying the old encoding claimed it matched go-erofs
Writer.Merge. It does not: Merge *consumes* `.wh.` entries from its copy
source and, in its own words, "the whiteout entries themselves are not
added to the image" (mkfs.go:118). So no producer emits `.wh.` names
inside an EROFS image -- not apko's writer, not go-erofs -- and the
reader was honoring an encoding that cannot conformantly exist.

Nothing apko produces today contains a whiteout at all, so this is
latent for our own images; it was wrong for anyone else's. `ls` would
have shown a conformant image's whiteout devices as live entries while
hiding nothing, and hidden entries the kernel would have shown.

Dropping the tar convention also makes `ls` more faithful on malformed
input: a file literally named `.wh.foo` is now reported as the filename
it is, which is what the kernel does. §3.6 requires consumers not to
fail on meaningless whiteouts, so ignoring them is sanctioned.

Detection goes through the accessor interfaces go-erofs documents on its
FileInfo (`Rdev() uint64`, `GetXattr(string) (string, bool)`) rather
than asserting `*erofs.Stat`, so the logic stays fs.FS-generic and
testable. The cheap DirEntry.Type() check gates the Info() call, so only
a char device costs an inode read.

Whiteouts are interpreted only when the stack has two or more layers.
§7 step 6 lets a lone EROFS layer be mounted directly as a root
filesystem, and a direct mount applies no overlay semantics, so a rdev-0
char device in a single-layer image is reported as the device it is.

mergeDir collapses as a result: a whiteout occupies the name it deletes,
so one pass over the entries suffices where the tar encoding needed
separate live and tombstone maps. A layer can no longer hold both a live
entry and its own tombstone, so that ambiguity disappears too.

Tests: the fixture needed rdev and xattrs, which fstest.MapFS cannot
express, so overlayFS wraps it and exposes the same accessors go-erofs
does. TestStack_Whiteouts_RealErofsLayers then runs the same rules
against images go-erofs actually wrote -- covering the case where the
fixture and the real reader drift apart -- and confirms both that a
rdev-0 Mknod reads back as a whiteout and that Setxattr of
trusted.overlay.opaque round-trips.
ReadOCILayers required every non-final layer to carry
org.erofs.role=overlay-lower and the final layer to carry none. That is
apko's own producer convention, not the spec's rule, and it rejected two
conformant shapes: a non-final layer with no role, and a final layer
carrying overlay-lower.

Per the spec, org.erofs.role is OPTIONAL on any layer descriptor (§2.3),
§3.8 rule 1 says the final entry's role is "either absent or
overlay-lower", and §7 step 1 classifies "role overlay-lower or absent"
as an overlay lower at any position. Both spellings become the same
lowerdir, so both are now accepted wherever they appear.

The two roles apko does not implement -- device and overlay-data -- are
refused as unsupported compositions rather than as malformed images, and
an unrecognized role value is refused as unknown. With those checks in
place, §3.8 rule 1 holds by construction, so the separate positional
pass is gone.

TestReadOCILayers_BadRoleOrder asserted the behavior being removed, so
it becomes TestReadOCILayers_SpecValidRoleShapes over the three legal
shapes, plus coverage of the unsupported and unknown roles.

The role constants grow device and overlay-data, and their doc comments
stop attributing apko's convention to spec §3.8. Also drops a pointer to
"PR chainguard-dev#1", which merged.

While here: the "multiple manifests match arch" error told the user to
pass --tag, a flag no apko erofs subcommand has. A tag is a SOURCE:TAG
suffix; the error now says that and lists what is available.
writeErofs passed erofs.WithBuildTime only when buildTime was non-zero.
When the option is absent, go-erofs v0.3.1 stamps time.Now().Unix() into
the superblock from Writer.Close (mkfs.go:602), so a caller who left the
timestamp zero got a different digest on every build -- and got it
silently, from the one code path that looks like it is asking for a
default.

apko's own CLI never hits this, because options.Default sets
SourceDateEpoch to time.Unix(0, 0), which is not IsZero. Library callers
build their own options, so they hit it exactly where they would least
expect to.

The option is now always passed. It cannot be passed verbatim, though: a
zero time.Time has a large negative Unix seconds value that wraps when
converted to uint64, so erofsBuildTime clamps a zero or pre-epoch
timestamp to epoch 0. That matches what an unset SOURCE_DATE_EPOCH
already means to apko, and it is reproducible either way.

The test pins the zero case to an explicit epoch build rather than
comparing two zero-time builds to each other: the wall-clock default
has one-second granularity, so back-to-back builds would agree
anyway and the test would pass against the bug.
erofs/erofs-image-spec §5.4 is a MUST in two places: the image config's
top-level os.features, and the index platform descriptor's os.features
when the image is referenced from an index. §6 and §8.1 producer
requirement 8 reinforce it, and §8.2 item 1 has consumers refuse an
image whose os.features they do not implement.

Only the config half was implemented. apko always emits an index, so a
consumer that filters on the index platform -- selecting a manifest
before fetching any config, which is the point of the index -- never
saw the signal.

The obvious place to fix it, Architecture.ToOCIPlatform, has no config
to read. generateIndexWithMediaType already holds the v1.Image, so it
reads ConfigFile().OSFeatures and copies it onto the descriptor's
platform. That is format-agnostic on purpose: os.features describes the
image whatever produced it, and nothing else in apko sets it today.

An image whose config declares no features still gets no OSFeatures
field, so tar builds are byte-identical to before; the test pins that
alongside the erofs case, since an empty-but-present slice would change
every index digest.
With --format=erofs and no explicit output path, the layer blob landed
at TempDir()/apko-<arch>.tar.gz -- TarballFileName's spelling. The bytes
are a raw EROFS filesystem image: not a tar, not gzipped. Anything that
sniffs by extension is misled, and the name reads as a bug to anyone
looking in the temp directory.

Options.LayerFileName takes the layer format and picks the extension,
delegating to TarballFileName for tar so the two spellings cannot drift.
An explicitly given TarballPath is still honored verbatim; this only
affects the generated default.
Three things were true of the code but written down nowhere, so the next
contributor had to rediscover them.

Hardlinks are materialized as independent copies. apko's FullFS surfaces
a hardlink as an ordinary second dirent -- its linkness is visible only
in the *tar.Header from Sys(), which is how the tar layer path preserves
it -- and go-erofs v0.3.1 has no API to point two names at one NID.
There is no open issue or PR proposing one. SetNlink exists, but it only
sets the reported link count without sharing the inode, so using it
would make the metadata lie rather than save any space. Spec §3.7
permits materializing links (a producer must materialize or fail), so
this is conformant; it is the size and st_nlink/st_ino consequences that
deserve recording. Documented as a limitation too, without a
measurement: no hardlink-heavy image was on hand to measure, and the
arithmetic (one full copy per link, rounded to the block size) is the
honest statement.

The Chmod after Mkdir/Mknod/Create is a workaround for
erofs/go-erofs#41, which merged 2026-08-02 -- after v0.3.1 was tagged
2026-07-21. So the pinned version has neither half of the fix. This
Chmod covers the write half; the read half means FileInfo.Mode() from
the pinned reader misreports setuid/setgid/sticky, and code that needs a
mode must read *erofs.Stat.Mode off Sys() instead, which is what
pkg/erofsmount/ls.go already does. Nothing said so, so the next caller
of FileInfo.Mode() would have been bitten invisibly.

Also: docs pointed at "PR chainguard-dev#1" of the spec, which merged; they now cite
spec.md on main, and state that the draft phase is the spec's own
description of itself rather than implying apko is behind. The
os.features comment notes where the other half of §5.4 lives. Drops
erofsLayer.LayerPath(), which has no callers.
Several EROFS writer tests validate their output twice: once by reading
it back with go-erofs, and once with fsck.erofs from erofs-utils. The
second check is deliberately optional, so contributors without the
package still get a green build.

No workflow installed it, so that "optional" meant "never runs
anywhere". Every fsck leg degraded to a t.Log -- invisible, since make
test does not pass -v -- and TestWriteErofs_FsckErofs skipped outright.
The only always-on validation of a written image was go-erofs reading
its own output, so a spec misunderstanding shared by writer and reader
would sail through green.

Installing the package in the Go Tests job activates all of it. The step
asserts fsck.erofs reached PATH rather than trusting apt's exit code,
because a silent install failure would look exactly like a pass. It runs
unconditionally, not gated on changed paths: a writer/reader mismatch
can arrive with a go-erofs bump as easily as with an apko change.

harden-runner runs with egress-policy: block, so the Ubuntu archive
hosts are added to allowed-endpoints. This job had no apt step before,
hence no precedent for which mirror hostname the runner resolves;
archive, azure.archive and security are all listed.

A privileged job that builds with --format erofs, mounts the result with
the kernel, and fscks the mountpoint would close the loop end to end.
That belongs with the mount/umount follow-up, which is where the code it
would exercise now lives.
@smoser

smoser commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the depth here, @mattmoor — the two multi-layer findings were both
real and neither was reachable by the existing tests, which is the worst
combination. Took the descope you suggested: this PR is now the single-image
writer plus apko erofs ls, with layering and mount/umount tracked in #2408 and
landing in that order. The pre-descope tree is preserved on
feat/apko-erofs-full in my fork, so nothing gets reconstructed from memory.

Per-comment replies are inline. The three cross-cutting notes:

Exported surface. All of StatePath/WriteState/LoadState/RemoveState/
StateSchemaVersion and Driver/NewDriver/ResolveMode left with the mount
descope, and erofsLayer.LayerPath() is dropped. A knock-on benefit worth
naming: with the Linux-only files gone the package has no build-tag-dependent API
at all — everything left is pure Go and builds everywhere, which was the part of
that note I liked least about the old shape. Ls also lost the Options bag it
only ever read Arch out of and now takes a plain arch string, so Mode and
Options can come back package-private when mount does. Noted in #2408.

Test seam for the mount plane. Agreed, and it's a prerequisite in #2408 rather
than a nice-to-have: a Driver injection point, so the cleanup LIFO, state
lifecycle and unmount policy are exercised by something. Right now nothing
anywhere runs them, which is how two bugs sat in 305 lines undisturbed.

melange coordination. Not done yet — I'd rather not comment on melange#2605
until this lands and I know what it's re-pinning to. It's recorded in #2408 under
the compression item so it doesn't get lost: melange passes
erofs+zstd[,level=N] strings that current apko rejects after the #2406 split, so
its compressed mode is dead until #2406 merges and melange re-pins off my fork.

Also worth flagging two things you didn't ask about but that changed:

  • Your WithBuildTime suggestion couldn't be taken literally. Passing a zero
    time.Time through uint64 wraps (Unix() is −62135596800), so zero and
    pre-epoch clamp to epoch 0 instead. And my first test for it was vacuous —
    go-erofs's default has one-second granularity, so back-to-back builds match
    regardless. It now pins zero against an explicit epoch build.
  • The .wh. comment's claim that the encoding "matches go-erofs Writer.Merge"
    was simply wrong, which removed the only argument for keeping it. Merge
    consumes .wh. from its source and never writes such names into an image, so I
    deleted the tar handling outright instead of keeping it as a documented extra.

Verified before pushing: go test ./... green, golangci-lint run -n clean, and
a real wolfi-base build with --format=erofs produces os.features: ["erofs"]
on both the index platform descriptor and the config — the §5.4 half that was
missing — with apko erofs ls reading it back with sticky bits and device numbers
intact.

Two things surfaced the moment the fsck cross-checks first ran anywhere,
which is a decent argument for the previous commit.

TestWriteErofs_FsckErofs passed --xattrs to fsck.erofs. The erofs-utils
that Debian and Ubuntu ship rejects it outright ("unrecognized option
'--xattrs'"), so the test failed on the runner. The flag was decoration:
the comment claimed "with xattr verification" but nothing below it
asserts on xattrs -- TestWriteErofs_Xattrs covers those by reading the
image back with go-erofs. Dropped, with a note to stick to flags the
distro packages understand. docs/erofs.md told readers to run the same
command, so it loses the flag too, plus a line about checking --help
since availability varies by release.

newErofsLayerFile is unused now that splitErofsLayers has gone to the
layering follow-up; the single-image path creates its output file
directly. Removed. It is preserved on feat/apko-erofs-full along with
its caller.

Worth noting for anyone else running the linter locally: a stale
golangci-lint cache reported "0 issues" here while CI failed on that
unused function. `golangci-lint cache clean` first, or trust CI.

@mattmoor mattmoor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the descoped scope. All in-scope fixes from the review verified at 32214b1: always-stamped build time with the pre-epoch clamp, os.features on both the config and the index platform descriptor, spec-legal role shapes accepted, the §3.6 whiteout encoding in Stack pinned against real go-erofs layers, the .erofs default name, the documented hardlink and go-erofs v0.3.1 caveats, and erofs-utils running the fsck cross-checks in CI (which caught the --xattrs portability issue on their first run, a good sign the check earns its keep). The descope is clean: no dangling references to the mount plane or the splitter anywhere, and #2408 accurately tracks the deferred findings.

Two non-blocking notes left on reopened threads: the index digest change for tar builds atop base images whose config carries os.features deserves an explicit ack or a pinning test, and the ls merged view has two pre-existing kernel-overlayfs divergences worth a docs sentence or a #2408 item.

@smoser
smoser merged commit 35dcb7e into chainguard-dev:main Aug 18, 2026
25 checks passed
smoser added a commit that referenced this pull request Aug 19, 2026
erofs: first batch of #2408 follow-ups

The #2408 follow-ups that touch merged `main` only, so none of them wait
on the descoped mount/umount or layering code coming back. Two behavior
changes, two tests, two rewords.

Both overlay tombstone checks now fail closed

`isOpaqueDir` returned false whenever `statOn` failed, so a layer whose
xattr region could not be read was treated as one that hides nothing.
That fails *open*: the entries an opaque directory was meant to hide
from lower layers get listed anyway, and nothing tells the caller the
answer was a guess. Layers come from an OCI image that may be untrusted,
so a damaged or hostile one should not be able to widen the merged view
by being unreadable. It now returns `(bool, error)` and propagates
anything that is not `fs.ErrNotExist` -- absent is the one benign case,
since a layer that lacks the directory genuinely is not opaque there.

`isWhiteout`, a screen above it, had the same shape: false on an
`e.Info()` error. That failure mode is milder -- an unreadable char
device is treated as live, so it occupies the name rather than leaking
lower entries -- but it is still a guess about what the merged view
shows, made from an inode that could not be read. It gets the same
`(bool, error)` treatment, so the two checks no longer sit next to each
other with opposite error philosophies.

Both callers, `lookup` and `mergeDir`, already returned errors.

The opacity test puts the unreadable layer in the *middle* of three on
purpose: that is the only position where `isOpaqueDir` is the first
thing to fail. With the broken layer on top, `lookup`'s ancestor stat
raises first and the fixture would pass either way. Against the old code
it reports `ReadDir(etc) = [foo secret], <nil>` -- the lower layer's
`secret` leaking into the merged view. The whiteout test fails the
matching way, listing the char device as a live `secret`.

`application/vnd.erofs+zstd` gets an accurate error

A compressed layer is a spec-legal EROFS image apko cannot read yet, but
it hit the mediaType check and was told the command "only handles EROFS
images". Now any `application/vnd.erofs+<codec>` names its codec and
points at #2406.

Matching on the suffix rather than adding a media-type constant is
deliberate: the draft spec's set of codecs isn't something apko should
pin down in `pkg/build/types` to produce one error message.

`os.features` propagation from a base image config, pinned

`generateIndexWithMediaType` copies the finished config's `os.features`
onto the index platform descriptor for every format, and
`BuildImageFromLayers` DeepCopies the base image's config. So a plain
*tar* build on a base image that already declares `os.features` surfaces
them on the index descriptor, changing that index's digest. Spec §5.4
asks for this and #2249's squash message calls it out, but the test
added there covered only the empty-base case.

Also `require.Empty` -> `require.Nil` for the "tar builds declare
nothing" assertion, which is what it means to check -- that the
propagation doesn't invent an empty slice. Harmless either way:
`os.features` carries `omitempty` in go-containerregistry, so an
empty-but-present slice can't change a digest.

Two claims softened to match reality

§3.7 was cited too strongly. Its materialize-or-fail rule governs
*cross-layer* hardlinks; for links within one layer the spec is silent,
so apko materializing them is conformant but not blessed by that
section. #2249's squash message has this right; `pkg/build/erofs.go` and
`docs/erofs.md` did not.

`docs/erofs.md` promised kernel parity for `apko erofs ls` ("the merged
view the kernel would assemble"). It approximates it, and diverges in
two corners: a middle-layer whiteout at a directory's own name doesn't
cut off lower layers when a higher layer recreates the directory, and
opacity isn't inherited by descendant directories. Both need 2+ layers,
so neither is reachable for an image apko produces today. The doc now
names them and links #2408, where the algorithm fix (a per-directory
layer horizon) is tracked.

`go test ./...` passes, `golangci-lint run -n` reports 0 issues,
`gofmt -l` is clean.

Refs #2408

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
smoser added a commit that referenced this pull request Aug 19, 2026
First of the section 1 items from #2408, and the one that makes the rest
of
them testable.

### Why

Every EROFS check that exists today runs unprivileged and reads the
image back
with go-erofs — the same library that wrote it. That arrangement cannot
see a
misunderstanding shared by our writer and our reader. `fsck.erofs`
appears in
`pkg/build/erofs_test.go` as an optional second opinion, but nothing
anywhere
has ever handed an apko-produced EROFS layer to the **kernel** driver.

The pre-descope tree in #2249 didn't either — grepping `.github/` on
`feat/apko-erofs-full` for "erofs" returns nothing. So this is new work
rather
than a restore.

### What it does

`hack/test-erofs.sh` follows the recipe `docs/erofs.md` already
publishes:
build into an OCI layout with `--format=erofs`, resolve the layer blob
through
`index.json` and the manifest, then

1. assert the layer mediaType is `application/vnd.erofs`, and that the
blob's
   filename matches sha256 of its own bytes — the layer is stored
uncompressed, so `digest == diffID`, and a compression step creeping
into
   this path fails here;
2. `fsck.erofs -d3` and `dump.erofs` — the C implementation's opinion;
3. `mount -t erofs -o ro` — the kernel's opinion;
4. compare `apko erofs ls` against the mounted tree: path, mode string,
   uid/gid and symlink target, for every entry.

Step 4 is the point of the job. On `examples/wolfi-base` it covers **395
entries**, including a sticky directory (`tmp`), five char devices with
rdev,
237 symlinks and several non-root uid/gid pairs. setuid and setgid
aren't
present in that fixture; the writer's handling of those stays covered by
the
optional `fsck.erofs` checks in `pkg/build/erofs_test.go`.

The comparison is verified to have teeth: fed a listing with the sticky
bit
rewritten to `drwxrwxrwx`, the job fails with

```
-drwxrwxrwt 0/0 tmp
+drwxrwxrwx 0/0 tmp
'apko erofs ls' disagrees with the kernel about the layer contents
```

That is the class of bug the go-erofs bump in #2412 was about — `Mkdir`
dropping setuid/setgid/sticky on write and `FileInfo.Mode()`
misreporting them
on read.

### Job details

`erofs` is a module rather than built in, and the runner image doesn't
ship
every module, so the workflow tries `modprobe` first and only downloads
`linux-modules-extra-$(uname -r)` if the module is genuinely absent. It
fails
in that step rather than inside the script, where an absent driver would
read
as a mount bug. There is no silent skip — an unavailable driver is a red
job,
not a quiet pass.

The script installs nothing itself, so it stays usable locally. Package
installation lives in the workflow.

`sudo` on `ubuntu-latest` is already established practice here:
`go-tests.yaml` installs `erofs-utils` the same way.

### Verification

Ran end-to-end in a privileged `ubuntu:24.04` container against a real
kernel
mount, not just dry-read: `fsck.erofs` reports "No errors found", 395
entries
agree, umount is clean. Plus the negative test above.

`shellcheck -e SC2129` clean (matching the actionlint config),
`actionlint`
clean, `zizmor --persona pedantic --config .github/zizmor.yml` reports
no
findings.

### Not in this PR

The rest of section 1 of #2408: restoring `apko erofs mount`/`umount`
with
state-file containment, partial-unmount recovery, a read-only default
and a
`Driver` test seam. That work is deliberately second — this job only
exercises
what is already on `main`, so it can land green now, and once it is here
the
restored `mount` has somewhere to be tested. It will extend this job
rather
than add another.

Refs #2408

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
smoser added a commit to smoser/apko that referenced this pull request Aug 19, 2026
Restores the `apko erofs mount` and `apko erofs umount` surface that
1fe041e descoped so chainguard-dev#2249 could land as a writer plus `ls`: the
mount_linux.go orchestration, the kernel/fuse driver, the mount state
file, the non-linux stubs, and their tests.

Nothing here had callers outside the package, so it comes back private:
the driver interface, newDriver, resolveMode, the mode type and its
constants, mountState, stateSchemaVersion, statePath, writeState,
loadState and removeState.  Options is replaced by an exported
MountOptions holding plain strings, which is all the CLI needs to say:

    Mount(ctx, src, dest string, opts MountOptions) error

Mount returns only an error now.  It used to hand back *MountState,
but that type is private and the CLI discarded it anyway.

Ls keeps the signature it has on main -- a plain arch string, not an
Options bag -- and does not get its `--mode` flag back.  That flag was
documented as "accepted for symmetry with mount and ignored", which is
not worth restoring.

This commit deliberately restores behavior that is known to be wrong;
the rest of the PR fixes it.  In this state Unmount still passes
whatever paths the state file names to umount as root without checking
them, a partial unmount cannot be recovered by rerunning the command,
and the default mount is writable and discards what was written on
umount.

The docs' overlayfs section is not restored: it was removed by
e5778f1, the layering descope, and its recipe only works once
multi-layer splitting returns.

Refs chainguard-dev#2408

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
smoser added a commit that referenced this pull request Aug 21, 2026
Section 1 of #2408: restores the mount/umount subcommands descoped from
#2249, with the four bugs that review found fixed, and four more found
in review here.

`apko erofs mount SOURCE DEST` mounts a raw EROFS blob or an OCI image
directory, choosing a kernel mount or erofsfuse by effective UID, and
`apko erofs umount DEST` tears it back down from a state file it wrote.

The restored surface is package-private: Mount, Unmount and MountOptions
are the only new exports, with the driver interface, the mode type and
the state-file helpers all unexported. A driverFactory seam lets the
~300 lines of orchestration run against a fake, which nothing could do
before.

Fixes carried over the descope:

- Validate the state file before unmounting. Unmount handed every path
  it named to umount as root, having checked only JSON shape and schema
  version, so anyone able to write to dest could have root unmount
  /home. Entries are now whitelisted to the paths a mount creates, and
  unmounting goes through umount(2) with UMOUNT_NOFOLLOW, so a symlink
  planted at <dest>/merged cannot redirect it either -- umount(8)
  canonicalizes, which no check-then-exec can defend against. A
  symlinked parent is caught by a separate check whose residual race is
  documented rather than papered over.
- Make a partial unmount recoverable. Entries are dropped as they come
  down and the file is rewritten with what is left, so a rerun does not
  restart at a mountpoint that already came down and fail there.
- Mount read-only by default. --read-only becomes --rw, so the zero
  value is the safe one, and umount uses os.Remove on upper rather than
  os.RemoveAll: it cannot delete a non-empty directory, so writes made
  through a mount survive by construction.

And from review here: overlayfs option paths are escaped, fuse-mode
umount reports both failures instead of swallowing the kernel one, and
dest is claimed with O_CREATE|O_EXCL rather than stat-then-write.

hack/test-erofs.sh drives all of it against a real kernel in the
privileged EROFS job -- read-only and --rw round trips, a raw blob, and
three tampered-state cases including a symlink aimed at a live tmpfs,
which is the only thing that actually exercises UMOUNT_NOFOLLOW.

Refs #2408
smoser added a commit to smoser/apko that referenced this pull request Sep 1, 2026
go-erofs grew Writer.Link, which gives a second name the same fsInode
as the first and keeps nlink for us. apko was still writing every link
as an independent copy, because when chainguard-dev#2249 landed there was no API for
it -- SetNlink sets the reported count without sharing the inode, so it
would only have made the metadata lie.

A hardlink reaches the writer as an ordinary second dirent; its
linkness is only in the *tar.Header from Sys(), which pkg/tarfs fills
in from the apk it unpacked. hardlinkTarget reads it there, the same
place the tar layer path finds it via tar.FileInfoHeader. A rootfs read
back off disk (apkfs.MemFS, rwosfs) records nothing, so those keep
getting a copy per name, as before.

Links are held back until the walk finishes. Writer.Link needs the
target to exist, and fs.WalkDir is lexicographic, so /usr/bin/[ arrives
long before /usr/bin/coreutils. A link whose target is missing from the
image falls back to a copy: that is the cross-layer case, where §3.7
requires materialize-or-fail and apko materializes. It cannot happen in
a single-layer image, but layer splitting is what puts a link and its
target in different writers.

Nothing is re-applied to a shared inode -- mode, ownership, timestamps
and xattrs came with it.

Tests build their fixture with pkg/tarfs, because apkfs.MemFS cannot
express a hardlink, and cover the shared inode and nlink, the
materialize fallback against a writer that lacks the target, and
Linkname cleaning. Against the previous code the three names report
nlink 1 with distinct inodes, and three extra names for a 64K file grow
the image by exactly 192K.

Refs chainguard-dev#2408
smoser added a commit that referenced this pull request Sep 2, 2026
#2418)

Section 2 of #2408: brings back the EROFS layer split, descoped in e5778f1 so
#2249 could land as a single-layer writer plus `ls`, with the five bugs review
found fixed on top.

#2415 has since merged, and this is rebased on it. The code is still
disjoint — that PR is `pkg/erofsmount` and the CLI, this one is
`pkg/build` — they meet only in `hack/test-erofs.sh`.

### 1. Restore, unchanged

`pkg/build/erofs_layers.go` and its test come back exactly as they were,
and the `layering` + `format: erofs` rejection leaves
`ImageConfiguration.Validate` and `buildLayers`. This commit deliberately
restores the known-broken behavior; the four after it are the fix, and
that delta is the point of the PR.

### 2. Package routing — the bug that inverted the whole thing

The type assertion asked `info.Sys()` for a `Package()` method. tarfs,
which is what a real build walks, returns a fresh `*tar.Header` from
`Sys()` and hangs `Package()` off the FileInfo itself — the receiver
`splitLayers` asserts on. So it never succeeded outside a fixture: every
file landed in the top layer, each group layer held only ancestor
directories plus a partial installed db (that case keys on the path
string, so it still fired), and per-layer scanners read a db naming
packages whose files were not there.

Assert on `info`, and turn a `packageToWriter` miss into an error rather
than a silent fall back to `top`.

`TestSplitErofsLayers` could not catch this: it drove the split through
`apkfs.NewMemFS()`, which implements `Package()` nowhere, so the fixture
had zero package-owned files by construction and passed identically with
and without routing. It is replaced by `erofs_layers_test.go`, which
builds its fixture with `pkg/tarfs` and installs files through
`WriteHeader` with an `*apk.Package`, the way an apk install does. Its
routing assertions fail against the previous code.

### 3. Directory-only subtrees

Directories were recorded during the walk and materialized only by
`emitAncestors`, which runs for non-directory entries. A directory whose
subtree holds no file was therefore never written anywhere: `/tmp`,
`/run`, `/var/empty`, every empty dir and every mount point were absent
from the merged view. Each directory is now emitted into its owning
writer as it is walked, which is what both siblings already do.

### 4. Temp files and fds

No error return out of `splitErofsLayers` closed or removed the
per-layer
temp files, and each go-erofs `Writer` also holds an unlinked spool fd
that only `Close` releases. The CLI happened to be bounded by its
`MkdirTemp`/`RemoveAll` wrapper; a library caller accumulated both until
process exit. The per-writer state moves into an `erofsGroupWriter` type
with `finish`/`discard`, and a deferred sweep discards every writer
unless the function reaches its return. Still no upstream abort API, so
`discard` writes an image it then removes.

### 5. Build time

`newErofsGroupWriter` passed `WithBuildTime` only for a non-zero build
time — the exact case `erofsBuildTime`'s comment describes, where
go-erofs stamps `time.Now()` from `Close` and a library caller gets
different layer digests every build. It now uses `erofsBuildTime`, same
as `writeErofs`. apko's own CLI was never affected.

### 6. Drive it from `hack/test-erofs.sh`

The privileged job from #2414, extended by #2415, only built and mounted
a single layer, because that was all apko could produce. The script now
builds the same config again with `layering` and adds, after the
existing
comparisons:

- layer roles and mediaTypes across the manifest, and `digest` == the
  sha256 of each blob;
- `fsck.erofs` on every layer, and a kernel mount of every layer stacked
  as read-only overlayfs lowerdirs;
- `apko erofs ls` on the layered OCI directory diffed against that
  overlay mount — `Stack`'s merge only becomes reachable for apko's own
  output now that splitting is back, and this is the first thing to
  compare it with what the kernel assembles;
- every non-final layer must hold a regular file other than the partial
  installed db, which is exactly the shape the routing bug produced;
- the merged tree diffed against a **tar** build of the same config
  unpacked in layer order. The tar split is the reference for what
splitting must preserve, and a directory that reaches no layer shows up
  here and nowhere else. Both builds resolve from one `apko lock` output
so a package published between them cannot make them differ. The unpack
  uses `--numeric-owner`; without it GNU tar resolves each header's
  uname/gname against the runner's `/etc/passwd` and invents ids for
  `lp`, `mail`, `news` and `uucp`.

The layered section runs last, after #2415's `apko erofs mount` /
`apko erofs umount` checks, and reuses their helpers — `fail`,
`assert_mounted`, `normalize_ls`, `tree_listing`. Their `cleanup`
already
unmounts everything under the workdir deepest-first, so the layer and
overlay mounts need no separate bookkeeping. The one existing line this
touches is the whitespace-in-paths guard, lifted into a
`check_ls_whitespace` function so the layered listing gets it too.

### Not in this PR

Whiteout support in `Stack` — the last item in section 2 — is really the
layer-horizon fix from section 5, and is reader-side and independently
testable. Left for its own PR.

### Verification

`gofmt -l` clean, `golangci-lint run -n` reports 0 issues, `go build
./...` and `GOOS=darwin go build ./...` both succeed, and
`SOURCE_DATE_EPOCH=0 go test ./...` passes. `shellcheck` is clean on the
script.

Each fix was checked to fail without its change: the four new tests are
red against the restore commit and green after their own commit.

Locally, a layered `wolfi-base` build produces five layers whose merged
`apko erofs ls` listing is identical to the single-layer build's, and
whose non-top layers are 0.7–7.4 MB rather than the directories-only
skeletons the routing bug produced.

Refs #2408

🤖 Generated with [Claude Code](https://claude.com/claude-code)
smoser added a commit to smoser/apko that referenced this pull request Sep 2, 2026
go-erofs grew Writer.Link, which gives a second name the same fsInode
as the first and keeps nlink for us. apko was still writing every link
as an independent copy, because when chainguard-dev#2249 landed there was no API for
it -- SetNlink sets the reported count without sharing the inode, so it
would only have made the metadata lie.

A hardlink reaches the writer as an ordinary second dirent; its
linkness is only in the *tar.Header from Sys(), which pkg/tarfs fills
in from the apk it unpacked. hardlinkTarget reads it there, the same
place the tar layer path finds it via tar.FileInfoHeader. A rootfs read
back off disk (apkfs.MemFS, rwosfs) records nothing, so those keep
getting a copy per name, as before.

Links are held back until the walk finishes. Writer.Link needs the
target to exist, and fs.WalkDir is lexicographic, so /usr/bin/[ arrives
long before /usr/bin/coreutils. A link whose target is missing from the
image falls back to a copy: that is the cross-layer case, where §3.7
requires materialize-or-fail and apko materializes. It cannot happen in
a single-layer image, but layer splitting is what puts a link and its
target in different writers.

Nothing is re-applied to a shared inode -- mode, ownership, timestamps
and xattrs came with it.

Tests build their fixture with pkg/tarfs, because apkfs.MemFS cannot
express a hardlink, and cover the shared inode and nlink, the
materialize fallback against a writer that lacks the target, and
Linkname cleaning. Against the previous code the three names report
nlink 1 with distinct inodes, and three extra names for a 64K file grow
the image by exactly 192K.

Refs chainguard-dev#2408
smoser added a commit that referenced this pull request Sep 2, 2026
…nts (#2422)

Two loose ends from #2408, both falling out of the go-erofs bump in
#2412. Rebased onto main now that #2418 (multi-layer splitting) has
landed; the two overlap only in one `docs/erofs.md` bullet, merged
here.

### 1. The mode-bit comments are stale

`pkg/build/erofs.go` described the `Chmod` after `Mkdir`/`Mknod`/
`Create` as a workaround for the pinned go-erofs, to revisit "once a
release containing erofs/go-erofs#41 is out". #2412 bumped the pin
past that merge, so both halves of the claim are now wrong. Probing
the pinned version:

- `Mkdir` with setuid/setgid/sticky: claimed to drop them, actually
  keeps them.
- `FileInfo.Mode()` on read: claimed untrustworthy for those bits,
  actually reports them.

**The `Chmod` still has to stay**, for a reason that has nothing to
do with #41 and is not going away: `Writer.Create` takes no mode
argument at all, so every regular file starts life `0644`. `Mkdir`
and `Mknod` do take one, but apko hands them `mode.Perm()` and lets
the single `Chmod` cover all three rather than splitting the rule
across three call sites. So this is a comment change, not a code
change.

`pkg/erofsmount/ls.go` keeps reading `*erofs.Stat` off `Sys()` --
`fs.FileInfo` has nowhere to put a uid or a device number -- but its
comment justified that with the setuid claim, which no longer holds.

`TestWriteErofs_SpecialModeBits` now also asserts `FileInfo.Mode() ==
Stat.Mode` for every case it covers (setuid and setgid regular files,
a sticky directory, a char device, a symlink), which pins the half
that changed.

### 2. Hardlinks point at one inode

go-erofs grew `Writer.Link(oldname, newname)`, which gives a second
name the same `fsInode` as the first and maintains `nlink`. apko was
still materializing every hardlink as an independent copy, because
when #2249 landed there was no API for it -- `SetNlink` sets the
reported count without sharing the inode, so it would only have made
the metadata lie.

A hardlink reaches the writer as an ordinary second dirent; its
linkness lives only in the `*tar.Header` from `Sys()`, which
`pkg/tarfs` fills in from the apk it unpacked. `hardlinkTarget` reads
it from there -- the same place the tar layer path finds it via
`tar.FileInfoHeader`. A rootfs read back off disk (`apkfs.MemFS`,
`rwosfs`) records nothing, so those keep getting a copy per name,
exactly as before.

Four details worth review:

- **Links are held back until the walk finishes.** `Writer.Link`
  needs the target to already exist and `fs.WalkDir` is
  lexicographic, so `/usr/bin/[` arrives long before
  `/usr/bin/coreutils`.

- **A link whose target the writer cannot find under the Linkname it
  was handed falls back to a copy.** Spec §3.7 leaves
  materialize-or-fail to the producer and apko materializes. This is
  *not* a cross-layer case: the #2418 split walk never calls into
  this path, so a layered build materializes every hardlink in every
  layer regardless. What reaches the fallback in a single-layer
  build: a target a `paths` directive removed, a Linkname routing
  through a symlinked directory component (the writer's lookup is a
  flat path map and follows nothing, while tarfs resolved the name at
  unpack), and a chain of links, whose middle name is deferred rather
  than emitted and so may not exist yet when the last one is linked.

- **A directory target aborts the build, deliberately.** `Writer.Link`
  returns `ErrIsDirectory`, and `pkg/tarfs`'s `link()` has no type
  check on the target, so a crafted apk gets that far. `link(2)`
  refuses a directory hardlink too, and the copy fallback would
  duplicate the whole subtree.

- **A link name binds to the target path in the image, not to the
  node the rootfs resolved.** A Linkname landing on a symlink shares
  the symlink; a target a later package replaced binds to the
  replacement. That is what `link(2)` does when the same rootfs is
  replayed from a tar layer, so the two layer formats agree -- the
  independent copies apko used to write were the outlier. A Linkname
  that still holds `..` after the Clean is rejected outright and
  written from `fsys` instead; nothing downstream catches it
  (go-erofs's `cleanPath` re-roots `/../etc/passwd` to `/etc/passwd`,
  and `checkPath` only rejects duplicates).

Nothing is re-applied to a shared inode -- mode, ownership,
timestamps and xattrs came with it, and the tests assert mode, uid,
gid and an xattr through every link name.

### Verification

`gofmt -l` clean, `golangci-lint run` reports 0 issues, `go build
./...` and `GOOS=darwin go build ./...` both succeed,
`SOURCE_DATE_EPOCH=0 go test ./...` passes. `git rebase --exec`
confirms every commit in the series builds and vets on its own.

The behavioural tests were checked to fail without their fix. Making
`hardlinkTarget` always return false:

```
Error: Not equal:            Messages: link count
Error: "196608" is not less than "32768"
       three hardlinks grew the image by 196608 bytes, which looks
       like copied data
```

196608 is exactly three more copies of the 64K fixture file. With the
change the three names share one inode, report `nlink` 3, and
`fsck.erofs` accepts the image.

Likewise: reverting the `ErrNotDirectory` arm fails
`TestWriteErofs_HardlinkThroughSymlinkedDirIsCopied` with `link ...:
not a directory`; dropping the `..` rejection fails
`TestHardlinkTarget`; and adding `ErrIsDirectory` to the fallback gate
fails `TestEmitErofsHardlinks_DirectoryTargetAborts`.

Refs #2408

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants