erofs: restore multi-layer splitting, with the bugs review found fixed - #2418
Conversation
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
aefbc92 to
299e345
Compare
mattmoor
left a comment
There was a problem hiding this comment.
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.
| if err := emitAncestors(owner, absPath); err != nil { | ||
| return err | ||
| } | ||
| if err := emitErofsEntry(owner.w, absPath, fpath, info, fsys, buf); err != nil { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| gw.emitted[anc] = true | ||
| continue | ||
| } | ||
| if err := emitErofsEntry(gw.w, anc, dirFsysPath[anc], info, fsys, buf); err != nil { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| return &erofsGroupWriter{ | ||
| path: f.Name(), | ||
| file: f, | ||
| w: erofs.Create(f, erofs.WithBuildTime(sec, nsec)), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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" { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| - **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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| { "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. |
There was a problem hiding this comment.
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.
|
|
||
| ```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 |
There was a problem hiding this comment.
Nit: plain jq pretty-prints multi-line; jq -c produces the one-object-per-line output the Expected block shows.
| # | ||
| # 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
|
|
||
| if d.IsDir() { | ||
| if absPath == "/" { | ||
| // The root of every EROFS image exists implicitly; still set |
There was a problem hiding this comment.
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.
…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)
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 thefive 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/erofsmountand the CLI, this one ispkg/build— they meet only inhack/test-erofs.sh.1. Restore, unchanged
pkg/build/erofs_layers.goand its test come back exactly as they were,and the
layering+format: erofsrejection leavesImageConfiguration.ValidateandbuildLayers. This commit deliberatelyrestores 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 aPackage()method. tarfs,which is what a real build walks, returns a fresh
*tar.HeaderfromSys()and hangsPackage()off the FileInfo itself — the receiversplitLayersasserts on. So it never succeeded outside a fixture: everyfile 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 apackageToWritermiss into an error ratherthan a silent fall back to
top.TestSplitErofsLayerscould not catch this: it drove the split throughapkfs.NewMemFS(), which implementsPackage()nowhere, so the fixturehad zero package-owned files by construction and passed identically with
and without routing. It is replaced by
erofs_layers_test.go, whichbuilds its fixture with
pkg/tarfsand installs files throughWriteHeaderwith an*apk.Package, the way an apk install does. Itsrouting 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 whosesubtree holds no file was therefore never written anywhere:
/tmp,/run,/var/empty, every empty dir and every mount point were absentfrom 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
splitErofsLayersclosed or removed the per-layertemp files, and each go-erofs
Writeralso holds an unlinked spool fdthat only
Closereleases. The CLI happened to be bounded by itsMkdirTemp/RemoveAllwrapper; a library caller accumulated both untilprocess exit. The per-writer state moves into an
erofsGroupWritertypewith
finish/discard, and a deferred sweep discards every writerunless the function reaches its return. Still no upstream abort API, so
discardwrites an image it then removes.5. Build time
newErofsGroupWriterpassedWithBuildTimeonly for a non-zero buildtime — the exact case
erofsBuildTime's comment describes, wherego-erofs stamps
time.Now()fromCloseand a library caller getsdifferent layer digests every build. It now uses
erofsBuildTime, sameas
writeErofs. apko's own CLI was never affected.6. Drive it from
hack/test-erofs.shThe 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
layeringand adds, after the existingcomparisons:
digest== thesha256 of each blob;
fsck.erofson every layer, and a kernel mount of every layer stackedas read-only overlayfs lowerdirs;
apko erofs lson the layered OCI directory diffed against thatoverlay mount —
Stack's merge only becomes reachable for apko's ownoutput now that splitting is back, and this is the first thing to
compare it with what the kernel assembles;
installed db, which is exactly the shape the routing bug produced;
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 lockoutputso a package published between them cannot make them differ. The unpack
uses
--numeric-owner; without it GNU tar resolves each header'suname/gname against the runner's
/etc/passwdand invents ids forlp,mail,newsanduucp.The layered section runs last, after #2415's
apko erofs mount/apko erofs umountchecks, and reuses their helpers —fail,assert_mounted,normalize_ls,tree_listing. Theircleanupalreadyunmounts 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_whitespacefunction so the layered listing gets it too.Not in this PR
Whiteout support in
Stack— the last item in section 2 — is really thelayer-horizon fix from section 5, and is reader-side and independently
testable. Left for its own PR.
Verification
gofmt -lclean,golangci-lint run -nreports 0 issues,go build ./...andGOOS=darwin go build ./...both succeed, andSOURCE_DATE_EPOCH=0 go test ./...passes.shellcheckis clean on thescript.
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-basebuild produces five layers whose mergedapko erofs lslisting is identical to the single-layer build's, andwhose 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