Skip to content

erofs: restore multi-layer splitting, with the bugs review found fixed - #2418

Merged
smoser merged 12 commits into
chainguard-dev:mainfrom
smoser:fix/erofs-follow-ups-2408-2
Sep 2, 2026
Merged

erofs: restore multi-layer splitting, with the bugs review found fixed#2418
smoser merged 12 commits into
chainguard-dev:mainfrom
smoser:fix/erofs-follow-ups-2408-2

Conversation

@smoser

@smoser smoser commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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

Reverts the descope in 1fe041e's sibling e5778f1, bringing back
pkg/build/erofs_layers.go, its MemFS-driven test, and the docs section,
and dropping the `layering` + `format: erofs` rejection from
ImageConfiguration.Validate and the matching guard in buildLayers.

This commit deliberately restores the known-broken behavior; the four
after it fix what review found. Splitting it the other way would hide
the fixes inside a 300-line addition, and the point of this PR is that
delta.

newErofsLayerFile comes back with it: pkg/build/erofs.go on main lost
it along with its only caller.

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

Assert on info, and turn a packageToWriter miss into an error.
splitLayers panics there; either way it has to be loud, because falling
back to top hides a grouping bug behind an image that looks fine and is
laid out wrong.

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 a
*apk.Package, the way an apk install does. Its routing assertions fail
against the previous code; the image-validity and role-annotation
assertions the old test made are carried over.

Refs chainguard-dev#2408
Directories were recorded during the walk and materialized only by
emitAncestors, which runs for non-directory entries. A directory whose
subtree contains no file at all was therefore never written to any
layer: /tmp, /run, /var/empty, every empty dir and every mount point
were absent from the merged view. Both siblings get this right --
splitLayers writes every directory it walks, and writeErofs Mkdirs each
one unconditionally.

Emit each directory into its owning writer as it is walked, keeping the
recorded metadata so the other writers can still recreate it as an
ancestor. tarfs attaches no package to a directory, so in practice they
all land in the top layer, which is where splitLayers puts them too.

Refs chainguard-dev#2408
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 happens to be bounded by its
MkdirTemp/RemoveAll wrapper; a library caller accumulated both until
process exit.

The per-writer state moves out of the function body into an
erofsGroupWriter type with finish and discard, and a deferred sweep
discards every writer unless the function reaches its return. go-erofs
still has no abort -- Close is the only thing that frees the spool fd,
and it writes the image out on the way -- so discard writes an image it
then removes, which costs some IO on a path that is already failing.

The unused pkgs field goes with the restructure; nothing ever read it.

Refs chainguard-dev#2408
newErofsGroupWriter passed WithBuildTime only for a non-zero build
time, which is the exact case erofsBuildTime's comment describes:
go-erofs stamps time.Now().Unix() into the superblock from Close when
the option is absent, so a library caller who left the timestamp zero
got different layer digests on every build. The unguarded
uint64(buildTime.Unix()) also wrapped for a pre-epoch time.

Use erofsBuildTime, which clamps both cases to epoch 0, so the split
path matches writeErofs. apko's own CLI was never affected:
options.Default sets SourceDateEpoch to time.Unix(0, 0).

Refs chainguard-dev#2408
The privileged job from chainguard-dev#2414 only ever built and mounted a single
layer, because that was all apko could produce. It now builds the same
config a second time with `layering`, and checks the split the only way
a unit test cannot: against a real kernel.

Added after the existing single-layer comparison:

- Layer roles and mediaTypes across the manifest, and digest == the
  sha256 of each blob, so a compression step cannot creep in unnoticed.
- fsck.erofs on every layer, and a kernel mount of every layer, stacked
  as read-only overlayfs lowerdirs in OCI order reversed.
- `apko erofs ls` on the layered OCI directory, diffed against the
  overlay mount. Stack's merge is only 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. That is exactly the shape the routing bug produced:
  ancestor directories and a db, no package files.
- 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; 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.

Cleanup tracks a list of mountpoints rather than a single one and comes
down in reverse, so a failure part-way through the stack does not wedge
the job.

Refs chainguard-dev#2408
The first CI run of the new comparison found a real difference and it
was in the harness, not in apko: GNU tar prefers a header's uname/gname
over its uid/gid when extracting as root, so lp, mail, news, uucp and
man resolved to the *runner's* ids (7/7, 8/8, 9/9, 10/10, 6/12) instead
of the image's (4/7, 8/12, 9/13, 10/14, 13/15). The EROFS side, which
carries only numbers, was right in every case.

Refs chainguard-dev#2408
@smoser
smoser force-pushed the fix/erofs-follow-ups-2408-2 branch from aefbc92 to 299e345 Compare September 1, 2026 11:07

@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.

No verdict yet — comments inline. Verification notes so the threads don't re-litigate what already checks out: the restore commit is byte-identical to e5778f17^; 5 of the 6 new test functions fail against the restore commit as advertised (only the carried-over LayersAreValidImages passes); the grouping semantics match the tar path (same Package() interface on the same receiver, unowned→top, miss→loud); and the cleanup machinery probed clean — no double-close, no leak on partial construction, discard-on-success correct, layer order deterministic under -count=2. The inline comments are parity/efficiency items plus one cross-PR follow-up with #2422.

Comment thread pkg/build/erofs_layers.go
if err := emitAncestors(owner, absPath); err != nil {
return err
}
if err := emitErofsEntry(owner.w, absPath, fpath, info, fsys, buf); err != nil {

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.

Once #2422 lands, this walk still materializes every hardlink as a full copy — including when link and target land in the same writer — because nothing here goes through hardlinkTarget/the deferred-Link pass; only writeErofs does. Probed on a merged tree of the two PRs: a group layer holding a 64K file plus two extra names comes out 204800 bytes with three nlink=1 inodes, vs 73728 with one nlink=3 inode from the single-layer build. The tar split doesn't pay this (links ride as zero-size TypeLink headers), and hack/test-erofs.sh's erofs-vs-tar diff can't catch it, since copied content compares equal. Fine as a follow-up — collect an erofsHardlink slice per owning writer during the walk and run emitErofsHardlinks per writer before finish() — but can we get it filed before layering+erofs is user-reachable, so a layered coreutils doesn't silently lose #2422's benefit?

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Filed as #2451, with your measurement and the same shape you describe: a per-writer erofsHardlink slice collected during the split walk, emitErofsHardlinks before each finish(), and cross-layer targets staying materialized since Writer.Link can only bind within one image (§3.7).

Agreed it belongs before the combination is user-reachable — this PR is what removes the rejection, so #2451 is the gate on the release that ships both, not on this merge.

Comment thread pkg/build/erofs_layers.go Outdated
gw.emitted[anc] = true
continue
}
if err := emitErofsEntry(gw.w, anc, dirFsysPath[anc], info, fsys, buf); err != nil {

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.

splitLayers deliberately normalizes ancestor-dir ModTime in group layers (layers.go, "we can improve deduplication without having any real effect on the image…") because merged-view dir mtimes are last-installer-wins — without that, a group layer's bytes depend on packages outside the group. This path re-emits ancestors with faithful mtimes, so an identical package group can hash to different layer digests across images. Note SOURCE_DATE_EPOCH doesn't clamp fs entry mtimes (it feeds the superblock, scripts.tar, installed db, and config), so this bites at the default epoch too — probed: varying only a shared dir's mtime changes the erofs group-layer digest while the tar group's diffid stays stable. Worth mirroring the normalization when replicating ancestors into non-owner writers, or a comment acknowledging the divergence.

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ccabf99. emitAncestors now takes the triggering entry's mtime and stamps it on every ancestor it creates, via a FileInfo wrapper that forwards Sys() so uid/gid still come from the source header — the same thing alignStacks does for the tar path, for the same reason.

The owning writer is unaffected: a directory is emitted into its owner when the walk reaches it, with its real mtime, and that is the copy that wins on merge.

TestSplitErofsLayers_AncestorModTimeIsNormalized builds the same group twice varying only a shared directory's mtime and compares layers[0].Digest(). Red against the parent commit.

Good catch on SOURCE_DATE_EPOCH not covering it — I'd assumed it did, and the test would have passed for the wrong reason if it had.

Comment thread pkg/build/erofs_layers.go Outdated
return &erofsGroupWriter{
path: f.Name(),
file: f,
w: erofs.Create(f, erofs.WithBuildTime(sec, nsec)),

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.

go-erofs also takes WithTempDir: the spool for regular-file data goes to os.CreateTemp(tempDir, …) — unlinked, held until Close — and with the default it lands in the system temp dir. A layered build holds len(groups)+1 writers open at once, so that's roughly a rootfs of scratch on a volume the caller can't choose, while tmpdir is right here: erofs.Create(f, erofs.WithBuildTime(sec, nsec), erofs.WithTempDir(tmpdir)). Same nit applies to writeErofs, pre-existing.

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4072a40, both call sites. writeErofs takes a tmpdir and the CLI passes bc.o.TempDir() — the same directory it already writes the layer files into — so the spool follows the build's temp dir rather than the system one.

Comment thread pkg/build/erofs_layers.go
// installed db containing only its own packages, so per-layer
// scanners (Trivy, Snyk, etc.) can identify the layer's contents.
// This matches splitLayers' behavior for tar layers.
if strings.TrimPrefix(absPath, "/") == "usr/lib/apk/db/installed" {

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.

Latent edge, unreachable today: if the installed db ever carried a non-nil Package() (routing it to a group), this special case writes the partial db into that group's writer and marks it emitted, and then the normal path below emits the full db into the same writer at the same path — go-erofs errors on duplicate paths, where the tar sibling tolerates the identical shape (last-wins). Regular files have no owner.emitted guard the way directories do at L202. A guard before the emit at L245 — or a comment — would future-proof it.

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Guarded in e3a34d2: an owner.emitted[absPath] check before the regular-file emit, mirroring the directory guard at L202.

It keeps the partial db in that hypothetical, which is also what tar's ordering picks — splitLayers writes f first and the per-group partial db after, so last-wins lands on the partial there too.

// walks. apkfs.NewMemFS() cannot stand in for it here: its FileInfo has no
// Package() method at all, so every file in a MemFS fixture is unowned by
// construction and package routing is never exercised.
func tarfsFixture(t *testing.T, entries []entry) apkfs.FullFS {

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.

The fixtures are regular files + dirs only, so symlink routing is pinned only by the CI script — and tarfs does attach Package() to symlinks, so a package-owned symlink exercises ownerOf in a way nothing here does. A symlink entry in tarfsFixture would be cheap. (Package-owned directories are impossible through tarfs — WriteHeader TypeDir goes through MkdirAll with no entry — so that branch genuinely can't be driven harder.)

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 9f21b6c. entry gained a link field, tarfsFixture sends it through the same WriteHeader path with the owning package attached, and the routing test now carries a package-owned usr/bin/one-compat.

Checked it isn't vacuous: forcing ownerOf to return top for symlinks makes the assertion fire.

Left package-owned directories alone for the reason you gave — WriteHeader TypeDir goes through MkdirAll and records no entry, so there's nothing to attach a package to.

Comment thread hack/test-erofs.sh
if [ "${i}" -lt "$((nlayers - 1))" ]; then
found=$("${sudo[@]}" find "${lmnt}" -type f \
! -path "${lmnt}/usr/lib/apk/db/installed" -print -quit)
[ -n "${found}" ] || fail "layer ${i} holds no package files"

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.

This assumes every group contributes at least one regular file besides the partial db — true for wolfi-base, but a symlink-only package group would false-fail if someone points the script at another yaml (the usage line invites it). Fine as-is if that's understood; a comment on the assumption would help the next person.

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Understood, and documented in 842083b — the comment now names the assumption and the -type f -o -type l relaxation for anyone pointing the script at another yaml.

Comment thread docs/erofs.md Outdated
- **No chunk index.** Lazy-loading runtimes (per spec §3.4) won't get an index; reads are sequential.
- **No `overlay-data` or `device` roles.** apko emits one unannotated EROFS layer; `org.erofs.role` is never set.
- **No `overlay-data` or `device` roles.** Only `overlay-lower` (and unannotated final) layers are emitted.
- **Hardlinks become independent copies.** go-erofs has no API to point two names at one inode, so each link costs another full copy of the file's data (rounded up to the block size) and `st_nlink`/`st_ino` identity is lost. Spec §3.7's materialize-or-fail rule covers *cross-layer* hardlinks; within a single layer the spec is silent, so this is conformant but not blessed by that section. Either way, a hardlink-heavy image will be larger as EROFS than as tar, where extra links are zero-byte entries.

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.

This bullet is where #2422 conflicts, and neither side's wording survives the pair: post-merge reality is "inodes are shared in single-layer builds; a layered build currently materializes every hardlink as a copy" (see the thread on the routing walk). Also its rationale is already stale — the pinned go-erofs has had Writer.Link with shared-inode semantics since the #2412 bump. Whichever PR rebases second owns the merged wording.

@smoser smoser Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

842083b drops the stale half now, independent of #2422: the bullet blamed go-erofs for having no API, which stopped being true at the #2412 bump. It now says apko's writer materializes each link, which is a statement about apko rather than the library.

On the merge: agreed, whoever rebases second owns it. If that ends up being this PR I'll take the bullet to "inodes are shared in single-layer builds; a layered build still materializes every hardlink as a copy" and point it at #2451.

splitLayers overwrites the mtime of every directory alignStacks
replicates into a layer with the mtime of the file that pulled it in.
The comment there explains why: packages share directories, only one
package's mtime survives into the merged view, and that winner is
whichever package happened to install last -- so copying it faithfully
into a group layer makes the group's bytes depend on packages outside
the group, and an otherwise identical group hashes differently from one
image to the next.

The EROFS split replicated ancestors with their own mtimes, so it did
not get that. SOURCE_DATE_EPOCH does not paper over it either: it feeds
the superblock, scripts.tar, the installed db and the config, not
per-entry mtimes, so this bites at the default epoch too.

emitAncestors now takes the triggering entry's mtime and stamps it on
each ancestor it creates, via a FileInfo wrapper that forwards Sys() so
uid/gid still come from the source header. The owning writer is
unaffected -- a directory is emitted into its owner when the walk
reaches it, with its real mtime, and that is the copy that wins on
merge.

Refs chainguard-dev#2408
Directories already carry an owner.emitted guard; regular files did not.
The installed db is the one path written twice: the special case above
puts a partial db into every group writer, and the normal path then
emits the full one into the file's owner. That owner is top today,
which the group loop skips, so the two never collide.

If the db ever carried a non-nil Package() it would collide, and the
two siblings disagree about what happens then -- tar takes the last
write, go-erofs errors on the duplicate path. Guard it and keep the
partial db, which is what tar's ordering already picks.

Refs chainguard-dev#2408
go-erofs buffers regular file data in a file it creates under its
configured temp dir, unlinks, and holds open until Close. Neither
writeErofs nor the layer split passed WithTempDir, so that landed in
the system temp dir rather than the one the caller chose -- and a
layered build has len(groups)+1 writers open at once, so it is roughly
a rootfs of scratch on a volume nobody sized for it.

writeErofs takes a tmpdir; the CLI passes bc.o.TempDir(), the same
directory it already writes the layer files into. splitErofsLayers had
one in hand all along.

Refs chainguard-dev#2408
The fixture held regular files and directories only, so symlink routing
was pinned by hack/test-erofs.sh and nothing else. tarfs does attach
Package() to a symlink -- it goes through the same WriteHeader path as
a regular file -- so it exercises ownerOf, and a package's compat links
drifting to the top layer would have gone unnoticed here.

Add a link field to the fixture entry and a package-owned symlink
alongside pkg1's files. Package-owned *directories* stay uncovered:
tarfs turns a TypeDir header into MkdirAll and records no entry, so
that branch cannot be driven from this side.

Refs chainguard-dev#2408
The hardlink limitation bullet blamed go-erofs for having no API to
share an inode. That stopped being true with the chainguard-dev#2412 bump: the pinned
version has Writer.Link. apko just does not use it yet, which is what
the bullet should say. chainguard-dev#2422 is changing that for single-layer builds,
and whichever of the two rebases second owns the merged wording.

The "every non-top layer holds a package file" assertion assumes each
group contributes a regular file besides the partial installed db.
True for the default config, but the usage line invites pointing the
script at another yaml, where a symlink-only group would false-fail.
Say so.

Refs chainguard-dev#2408

@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.

The response commits all verify. The normalization picks the same triggering-entry mtime the tar path does, and the wrapper preserves everything else — probed a setgid+sticky, xattr'd, chowned ancestor replicated into a group writer: only the mtime differs. The digest-stability test fails at the previous head; the once-per-writer guard lands on the partial db exactly as tar's write ordering does; WithTempDir covers both call sites with the right value; and the symlink fixture fires under the ownerOf-to-top mutation.

Second-pass sweeps came back clean where it matters: all 12 commits build and pass tests individually on darwin and GOOS=linux, so the series bisects; layer assembly is right end to end (diffID == digest for the uncompressed media type, one history entry per layer same as the tar path, role annotations ride the ggcr Addendum, os.features set); empty/metapackage groups produce a valid partial-db-only layer with counts intact; and the one genuinely dangerous merge case — the partial installed db in every group layer vs the full db on top — resolves correctly: Stack's lookup is strictly topmost-wins over manifest order (probed with a 3-layer partial/partial/full fixture), agreeing with the kernel. Remaining inline comments are docs/script nits plus one theoretical note; none blocking. Thanks for filing #2451 for the split-path hardlink port.

Found while probing, pre-existing and format-neutral (separate-issue material): tarfs WriteHeader(TypeDir) drops setgid/sticky and uid/gid from tar dir headers — both the tar and erofs paths serialize the degraded node state equally.

Comment thread docs/erofs.md
{ "mediaType": "application/vnd.erofs", "role": null }
```

Each layer is independently mountable as an EROFS filesystem, and each carries its own partial `usr/lib/apk/db/installed` so per-layer scanners (Trivy, Snyk, Grype) can identify the packages it contributes.

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.

Nit: the final layer carries the full installed db, not a partial one — the partial-db loop covers only groups, and the top layer contributes no packages. Something like "each package-group layer carries a partial usr/lib/apk/db/installed …; the final layer carries the full db" would match what splitErofsLayers writes.

Comment thread docs/erofs.md

```sh
MANIFEST=$(jq -r '.manifests[0].digest | split(":")[1]' out-layered/index.json)
jq '.layers[] | {mediaType, role: .annotations["org.erofs.role"]}' out-layered/blobs/sha256/$MANIFEST

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.

Nit: plain jq pretty-prints multi-line; jq -c produces the one-object-per-line output the Expected block shows.

Comment thread hack/test-erofs.sh
#
# This assumes every group contributes at least one regular file besides
# that db, which holds for the default config. A group of packages that
# ship only symlinks would false-fail here; relax it to "-type f -o -type

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.

The note could also mention metapackage groups (zero files at all) — they false-fail the same way as symlink-only ones. Probed that the split itself handles them fine: the layer holds exactly the partial db plus ancestors, stays a valid mountable EROFS image, and the layer count stays len(groups)+1.

Comment thread hack/test-erofs.sh
normalize_ls <"${workdir}/ls-layered.raw" >"${workdir}/from-apko-layered"
tree_listing "${merged}" >"${workdir}/from-kernel-layered"

if ! diff -u "${workdir}/from-kernel-layered" "${workdir}/from-apko-layered"; then

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.

Optional hardening, not for this PR necessarily: normalize_ls keeps mode/owner/path and tree_listing has no size either, so a wrong-layer pick of usr/lib/apk/db/installed — the one path present in every layer with different content but identical name/mode/owner — would pass every diff in this script. I verified Stack is strictly topmost-wins (and TestStack_Override_TopWins pins it at unit level), so this is belt-and-suspenders: carrying the size column for regular-file lines on both sides would let the end-to-end check see it.

Comment thread pkg/build/erofs_layers.go

if d.IsDir() {
if absPath == "/" {
// The root of every EROFS image exists implicitly; still set

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.

Root is now the one replicated path that keeps its faithful mtime in every writer. tarfs's root mtime is the zero time today, so Chtimes is skipped and every writer gets the uniform build time — but that's an assumption, not an invariant: a package data tar carrying a ./ directory header appears to reach WriteHeader (the install loop only skips bare dot names without a /) and would touch the root's mtime, recoupling every group digest to whichever package last did so. Worth either normalizing "/" like the other replicated ancestors or a comment pinning the assumption.

@smoser
smoser merged commit 7ccff1f into chainguard-dev:main Sep 2, 2026
23 checks passed
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.

2 participants