Skip to content

erofs: share one inode per hardlink, and fix the stale mode-bit comments - #2422

Merged
smoser merged 4 commits into
chainguard-dev:mainfrom
smoser:fix/erofs-hardlinks
Sep 2, 2026
Merged

smoser merged 4 commits into
chainguard-dev:mainfrom
smoser:fix/erofs-hardlinks

Conversation

@smoser

@smoser smoser commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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 erofs: restore multi-layer splitting, with the bugs review found fixed #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

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

Comments inline. What already checks out, verified against the pinned go-erofs source so the threads can skip it: Create takes no mode (files start 0644, so the Chmod stays), Mkdir/Mknod honor setuid/setgid/sticky since the #2412 bump, Link shares the fsInode and maintains nlink while SetNlink only overrides the count, and the mode-bit comment commit is accurate throughout — including the ls.go half. The happy-path tests genuinely pin inode sharing, nlink, and the one-copy size bound. The inline comments are: one real bug in the fallback gate, two semantics questions (probed against tarfs + the writer), and comment/docs drift. The cross-PR items were probed on a merged tree with #2418.

Comment thread pkg/build/erofs.go Outdated
// the same values to the same place.
continue
}
if !errors.Is(err, fs.ErrNotExist) {

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 gate misses go-erofs's ErrNotDirectory sentinel. The writer's resolveEntry is a flat byPath lookup — no symlink following — and classifyMiss returns ErrNotDirectory, not fs.ErrNotExist, when the Linkname routes through a symlink component. Concretely: rootfs with usr/lib/libfoo, symlink lib -> usr/lib (wolfi-baselayout ships bin/lib/lib64/sbin aliases in every image), and a TypeLink with Linkname lib/libfoo. tarfs resolves that fine at unpack (getNode follows symlinks) and the tar layer path works at runtime (link(2) follows intermediate symlink dirs), but this now fails the whole build with link …: not a directory where the old code wrote a working copy. Probed both halves: fsys reads all three names; writeErofs hard-fails; errors.Is(err, erofs.ErrNotDirectory) is true. A scan of 294 wolfi + 8 alpine apks found no such Linkname in the wild (melange records first-seen literal paths), so it's crafted-shaped — but apks are third-party input and the failure is a build abort on input the design says should degrade to a copy. || errors.Is(err, erofs.ErrNotDirectory) restores the fallback, and it can't mask a real error: the copy comes from fsys, which unpack already validated.

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.

@r1.md

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.

Confirmed, and fixed in df761bdd — the gate is now
fs.ErrNotExist || erofs.ErrNotDirectory.

Your reproduction is what the test builds:
TestWriteErofs_HardlinkThroughSymlinkedDirIsCopied lays down
usr/lib/libfoo.so, a lib -> usr/lib symlink, and a TypeLink at
usr/bin/libfoo.so with Linkname lib/libfoo.so. It fails without the
ErrNotDirectory arm with link ...: not a directory, and with it the link
is materialized as a copy (asserted by inode inequality against
usr/lib/libfoo.so).

Agreed it can't mask a real error: the fallback re-reads from fsys, which
unpack already validated.

One thing your comment prompted that went further than the fix: the gate was
still silently exhaustive-looking, and it isn't — see the ErrIsDirectory
thread below, now handled in 4f211eff.

Comment thread pkg/build/erofs.go Outdated
Comment on lines +127 to +131
// A link whose target is not in w is materialized as an independent copy
// instead. That is the cross-layer case: spec §3.7 requires a hardlink
// spanning layers to be materialized or the build to fail, and apko
// materializes. Within a single image it cannot happen, because the walk
// emits every target.

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.

Two problems with this paragraph. First, "layer splitting (#2418) is what puts a link and its target in different writers" doesn't hold: #2418's split walk never calls hardlinkTarget (nothing routes through this function after it lands — thread on that PR), and even once it's ported, tarfs routes a TypeLink name by the target's node — WriteHeader drops the pkg argument for TypeLink, and Package() comes off the shared node — so link and target structurally land in the same writer. The reachable triggers are a Linkname whose target was removed (paths directives), and chained links. Second, chained links also falsify "Within a single image it cannot happen": a Linkname naming another link was deferred, not emitted, so it falls back to a copy iff it sorts before its target — probed: A→B→C yields nlink 2/2/1 where fsys has one inode, nlink 3. That shape is crafted-only (melange/GNU tar always point Linkname at the first-seen regular name) and the silent copy is the forgiving outcome — the tar path fails at extraction on the same input — so I'd fix the wording here, in the PR body's §3.7 framing, and in the mirrored sentence on TestEmitErofsHardlinks_MaterializesWhenTargetIsAbsent; chasing targets through the pending-links slice is optional polish.

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.

@r2.md

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.

You're right on both counts, and the wording is corrected in df761bdd — the
code comment, the PR body's §3.7 framing, and the mirrored sentence on
TestEmitErofsHardlinks_MaterializesWhenTargetIsAbsent. The comment now names
the reachable triggers (a target a paths directive removed, a Linkname
routing through a symlinked directory component, and a chain of links whose
middle name is deferred) instead of claiming a cross-layer cause.

The layering half is now settled rather than predicted: #2418 has landed and
this branch is rebased onto it. I checked the merged tree —
pkg/build/erofs_layers.go calls emitErofsEntry at all four of its emit
sites and never calls hardlinkTarget/emitErofsHardlinks — so the split
walk doesn't route through this function at all, exactly as you said, and a
layered build materializes every hardlink in every layer. docs/erofs.md now
says that outright.

I left the pending-links chase alone, since you called it optional polish.

Comment thread pkg/build/erofs.go Outdated
Comment on lines +112 to +113
// cleaned rather than trusted verbatim. Anything that still escapes the image
// root is caught by the writer's own path check when the link is created.

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.

There is no writer-side path check that catches this: go-erofs cleanPath silently re-roots escapes (probed: Linkname ../etc/passwdLink("/../etc/passwd") → links /etc/passwd), and checkPath only rejects duplicates. The actual backstop is that tarfs unpack fails on .. Linknames before writeErofs ever runs. Reword — or reject targets that still contain .. after the Clean — so the documented mechanism matches what runs; as written it invites a non-tarfs FullFS caller to rely on a check that isn't there.

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.

@r3.md

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.

Took your second option — rejecting rather than rewording alone — since the
"documented mechanism that isn't there" was the actual problem.

hardlinkTarget now returns false for a target still holding .. after the
path.Clean, and its comment says such a target is "rejected here and nowhere
else", naming both reasons: cleanPath silently re-roots escapes, and
checkPath only rejects duplicates. So a non-tarfs FullFS caller no longer
has an absent check to lean on.

The table in TestHardlinkTarget pins the boundary, including the case that
is not an escape:

  • /../etc/passwdetc/passwd, accepted — rooted, so the Clean absorbs the
    .. the way the kernel would
  • ../etc/passwd, usr/bin/../../../etc/passwd, .. → rejected

Both escaping rows were checked to fail without the guard.

Comment thread pkg/build/erofs.go
if cerr := ctx.Err(); cerr != nil {
return cerr
}
err := w.Link(erofsAbsPath(l.target), erofsAbsPath(l.path))

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.

Writer.Link only rejects directory targets, so two shapes silently bind the link name to whatever inode sits at the target path in the writer, diverging from the fsys being serialized: (a) a Linkname landing on a symlink — tarfs resolves the final component at unpack, so fsys reports a regular file with the resolved content, but the image gets a shared symlink, dangling whenever its target is relative and the link name lives elsewhere (probed: other/myshusr/bin/shbusybox; ReadFile works on fsys, ENOENT on the image); (b) a package replacing a hardlinked target — tarfs keeps the old node on the link dirent, but Link binds the name to the replacement inode (probed: fsys reads "A", image reads "B"). Both match what the tar layer path produces at runtime via link(2) — the pre-#2422 copies were the outlier — so if converging on tar/runtime semantics is the intent, a comment and a pinning test would nail that down; copying instead would preserve fsys fidelity at the cost of a new erofs-vs-tar divergence.

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.

@r4.md

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.

Converging on tar/link(2) semantics is the intent, so I took the
comment-and-pinning-test option rather than copying.

emitErofsHardlinks' doc comment now states the rule directly: the new name
binds to whatever the target path holds in the image being written, not to the
node fsys resolved, which is what link(2) does when the same rootfs is
replayed from a tar layer — and the independent copies apko wrote before were
the outlier.

Your shape (a) is pinned by
TestWriteErofs_HardlinkOntoSymlinkSharesTheSymlink, built as you probed it:
usr/bin/busybox, a relative usr/bin/sh -> busybox symlink, and a
TypeLink at opt/mysh naming usr/bin/sh. It asserts that fsys reads
regular-file content through opt/mysh, that the image has a symlink there
sharing usr/bin/sh's inode with nlink 2, and that reading it through the
image errors — "the shared symlink dangles, exactly as link(2) would leave
it". So the divergence is now deliberate and load-bearing rather than
incidental.

I didn't add a separate fixture for shape (b); it's the same binding rule with
a different final inode, and the comment covers it.

Comment thread pkg/build/erofs_hardlink_test.go Outdated

// Mode and ownership come with the shared inode, so the link names must
// report what the target had rather than a default.
require.Equal(t, fs.FileMode(0o755), statOf(t, img, "usr/bin/[").Mode.Perm())

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.

"mode, ownership, timestamps and xattrs came with it" is pinned only for mode perms. Cheap to give hardlinkFS's target a uid/gid and an xattr and assert them through the link names.

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.

@r5.md

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.

Done in df761bdd. hardlinkFS's target now carries a uid, a gid and an
xattr (1234/5678, user.apko.test), set via a SCHILY.xattr. PAX record
plus an explicit ChownWriteHeader doesn't carry uid/gid onto the node,
apk chowns separately, which cost me a debugging round.

TestWriteErofs_HardlinksShareOneInode now asserts mode, uid, gid and the
xattr through every name. All four differ from what the writer would produce
on its own (0644, uid 0, gid 0, no xattrs), so the assertions fail if a name
ever stops sharing the inode.

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.
- **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.
- **Hardlinks are shared only within a layer.** Names pointing at one inode in the source rootfs point at one inode in the image, so the data is stored once and `st_nlink`/`st_ino` identity survives. A link whose target ends up in a *different* layer cannot share that inode and is written as an independent copy instead, which is the materialize-or-fail choice spec §3.7 leaves to the producer. apko can only recognize a hardlink when the rootfs was assembled from unpacked apks; one read back off disk reports the two names as unrelated files, and each gets a copy.

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.

"Names pointing at one inode in the source rootfs point at one inode in the image" holds for the single-layer path only, and the exceptions in the code threads cut against "is written as an independent copy instead" (a symlink-routed Linkname currently fails the build; a symlink target becomes a shared symlink; a chained link may copy). Also a heads-up: this bullet is where #2418 conflicts — whichever PR rebases second needs merged wording along the lines of "inodes are shared in single-layer builds; a layered build currently materializes every hardlink as a copy".

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.

@r6.md

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.

Thanks for the heads-up — it played out exactly as you predicted. #2418 landed
first, so this PR rebased second and owns the merged wording. The bullet is
now:

Hardlinks are shared in single-layer builds. [...] A layered build
materializes every hardlink as a copy, in every layer.

which I verified against the merged tree rather than assuming (see the
cross-layer thread: erofs_layers.go never calls into this path). I also took
upstream's overlay-data/device roles line, since #2418 made mine stale.

The exceptions list is reworded too, and your newer comment on it is handled
in 4f211eff.

smoser added a commit to smoser/apko that referenced this pull request Sep 1, 2026
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
smoser added a commit to smoser/apko that referenced this pull request Sep 1, 2026
Review on chainguard-dev#2422 turned up one real bug in the fallback gate, one
comment documenting a check that does not exist, and several claims
about when a hardlink cannot be shared that are wrong.

The gate only recognized fs.ErrNotExist. go-erofs's resolveEntry
returns ErrNotDirectory when the target routes through a symlinked
directory component -- /lib -> /usr/lib, which every wolfi image has --
because the writer's lookup is a flat path map that follows nothing.
tarfs resolves such a Linkname at unpack and the tar layer path
resolves it at runtime, so apko was aborting the build on input that
used to produce a working copy. Both errors now fall back to a copy.

hardlinkTarget claimed the writer catches a Linkname escaping the image
root. It does not: go-erofs cleanPath re-roots "/../etc/passwd" to
"/etc/passwd" and checkPath only rejects duplicates. The only backstop
was tarfs refusing such names at unpack, which says nothing about a
non-tarfs FullFS caller. A target still holding ".." after the Clean is
now reported as not-a-hardlink, leaving the walk to write it from fsys.

The rest is wording. The copy fallback is not the cross-layer case:
layer splitting routes a TypeLink by its target's node, so a link and
its target land in the same writer. What does reach it is a target a
`paths` directive removed, a target behind a symlinked directory, and a
chain of links whose middle name is still deferred. Separately, Link
binds the new name to whatever sits at the target path in the image
rather than to the node fsys resolved, so a Linkname landing on a
symlink shares the symlink -- which is what link(2) does on the tar
layer path. Both are now stated in the code and in docs/erofs.md, and
pinned by tests, as is the mode/uid/gid/xattr set a link name inherits.
mattmoor
mattmoor previously approved these changes Sep 2, 2026

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

One meta item first: the six review replies here are literal placeholders — each body is just @r1.md@r6.md. Looks like the reply tooling posted the filenames instead of their contents, so the threads carry no readable disposition for anyone following along. Worth reposting the real text (the commit speaks for itself, but the threads don't).

ccedcfb verified by re-running the probes from the review: the merged-usr shape (lib -> usr/lib, Linkname lib/libfoo) now degrades to a full-content copy with the right mode where it previously aborted the build, and the new regression test drives it through writeErofs; hardlinkTarget now rejects ..-after-Clean with all four shapes in the test table (and the cleanPath re-rooting claim checks out against the pinned go-erofs); the reworded fallback comment names the real triggers and drops the cross-layer claim, with the mirrored test comment fixed; the shared-symlink binding is declared and pinned including nlink and the dangling read; and uid/gid plus the xattr are asserted through every link name. The copy fallback also behaves end-to-end for the one real-build-reachable trigger — install, paths-style Remove of the target, then writeErofs — producing the full original bytes, mode, and mtime under the link name. Two nits inline.

Found while probing, pre-existing and untouched by this PR (separate-issue material): tarfs link() accepts a Linkname of /, which shares the root dirent and sends fs.WalkDir into a cycle before any hardlink logic runs.

Comment thread pkg/build/erofs.go
Comment on lines +162 to +167
// ErrNotDirectory rather than ErrNotExist is what the writer
// reports when the target routes through a symlinked directory:
// its lookup is a flat path map that follows nothing, while tarfs
// resolved the same name at unpack. Both mean there is no inode
// here to share, so both fall back to a copy.
if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, erofs.ErrNotDirectory) {

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.

One shape still aborts, and the comment reads as if ErrNotExist/ErrNotDirectory were exhaustive: tarfs's link() has no dir-target check, so a crafted apk whose Linkname resolves to a directory unpacks fine and Writer.Link returns ErrIsDirectory here (probed: link dirlink -> target: link /target: is a directory). Failing closed looks right — runtime link(2) refuses directory hardlinks too, and the pre-PR behavior of silently duplicating the directory tree was worse — but one line marking the abort deliberate would keep it from reading as an oversight.

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.

Verified both halves independently before touching it: Writer.Link returns
ErrIsDirectory for a directory target (mkfs.go:730, after resolveEntry
succeeds), and pkg/tarfs's link() does no type check on the target at all
(fs.go:684-706 — it only checks the parent, getNode, and duplicate
children), so a crafted apk really does get that far.

Marked deliberate in 4f211eff, and I added
TestEmitErofsHardlinks_DirectoryTargetAborts so the claim is pinned rather
than just asserted in a comment: it puts a directory at the target path and
requires errors.Is(err, erofs.ErrIsDirectory). Mutation-checked — adding
ErrIsDirectory to the fallback gate makes it fail with "An error is expected
but got nil".

Agreed on the reasoning: link(2) refuses a directory hardlink too, and the
old behavior of duplicating the subtree was worse.

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.
- **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.
- **Hardlinks are shared within the one layer apko writes.** Names pointing at one inode in the source rootfs point at one inode in the image, so the data is stored once and `st_nlink`/`st_ino` identity survives. A link name binds to whatever sits at the target path in the image, which is what `link(2)` does when the same rootfs is replayed from a tar layer. A link whose target is not in the image at all — removed by a `paths` directive, reached through a symlinked directory component, or itself another link not yet written — is materialized as an independent copy instead, the materialize-or-fail choice spec §3.7 leaves to the producer. apko can only recognize a hardlink when the rootfs was assembled from unpacked apks; one read back off disk reports the two names as unrelated files, and each gets a copy.

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.

Wording nit: "not in the image at all" slightly overstates the symlinked-directory case — that inode is in the image under its real path; only the literal-Linkname lookup misses it. The code comment states this precisely; fine to mirror that here.

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.

Fair — fixed in 4f211eff. That case is a lookup miss, not an absence. It now
reads "A link whose target the writer cannot find under the Linkname it was
handed", with the reason inline for the symlinked-directory row ("which its
flat path lookup does not follow"), mirroring the code comment.

The comment on the Chmod in emitErofsEntry described it as a workaround
for a bug in the pinned go-erofs, to revisit once a release containing
erofs/go-erofs#41 was out. chainguard-dev#2412 bumped the pin past that merge, so the
description is wrong on both halves: probing the pinned version shows
Mkdir now honours setuid/setgid/sticky, and FileInfo.Mode() reports
them.

The Chmod stays, for a reason that has nothing to do with chainguard-dev#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 this
hands them mode.Perm() and lets the single Chmod cover all three
rather than splitting the rule across three call sites.

pkg/erofsmount/ls.go still reads *erofs.Stat off Sys(), because
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 asserts FileInfo.Mode() equals
Stat.Mode for every case it covers -- regular files with setuid and
setgid, a sticky directory, a char device and a symlink -- which pins
the half that changed.

Refs chainguard-dev#2408
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
Review on chainguard-dev#2422 turned up one real bug in the fallback gate, one
comment documenting a check that does not exist, and several claims
about when a hardlink cannot be shared that are wrong.

The gate only recognized fs.ErrNotExist. go-erofs's resolveEntry
returns ErrNotDirectory when the target routes through a symlinked
directory component -- /lib -> /usr/lib, which every wolfi image has --
because the writer's lookup is a flat path map that follows nothing.
tarfs resolves such a Linkname at unpack and the tar layer path
resolves it at runtime, so apko was aborting the build on input that
used to produce a working copy. Both errors now fall back to a copy.

hardlinkTarget claimed the writer catches a Linkname escaping the image
root. It does not: go-erofs cleanPath re-roots "/../etc/passwd" to
"/etc/passwd" and checkPath only rejects duplicates. The only backstop
was tarfs refusing such names at unpack, which says nothing about a
non-tarfs FullFS caller. A target still holding ".." after the Clean is
now reported as not-a-hardlink, leaving the walk to write it from fsys.

The rest is wording. The copy fallback is not the cross-layer case:
layer splitting routes a TypeLink by its target's node, so a link and
its target land in the same writer. What does reach it is a target a
`paths` directive removed, a target behind a symlinked directory, and a
chain of links whose middle name is still deferred. Separately, Link
binds the new name to whatever sits at the target path in the image
rather than to the node fsys resolved, so a Linkname landing on a
symlink shares the symlink -- which is what link(2) does on the tar
layer path. Both are now stated in the code and in docs/erofs.md, and
pinned by tests, as is the mode/uid/gid/xattr set a link name inherits.
Two review nits, both about wording that overstates what the code
does.

The copy fallback recognizes fs.ErrNotExist and ErrNotDirectory, and
the comment read as if those were the only outcomes. They are not:
Writer.Link returns ErrIsDirectory when the Linkname resolves to a
directory, and tarfs's link() has no type check on the target, so a
crafted apk gets that far and aborts the build. Failing closed is
right -- link(2) refuses a directory hardlink too, and copying would
duplicate the subtree -- so say so rather than leaving it looking
like an unhandled case. TestEmitErofsHardlinks_DirectoryTargetAborts
pins it, and fails if ErrIsDirectory is added to the fallback gate.

The docs bullet said such a target is "not in the image at all",
which overstates the symlinked-directory case: that inode is in the
image under its real path, and only the literal-Linkname lookup
misses it. Reworded to match the code comment.

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

Re-approving after the force-push to 4f211ef dismissed the earlier approval.

Verified the new head locally (worktree at 4f211ef, rebased on main with #2418/#2426 in):

  • Both round-2 nits are addressed: the ErrIsDirectory abort is now marked deliberate with TestEmitErofsHardlinks_DirectoryTargetAborts pinning it against being added to the fallback gate, and the docs bullet now reads as a lookup miss rather than an absence.
  • The inode-sharing commit (c5e1d519 + df761bdd) folds in all of the round-1 findings: the fallback gate is fs.ErrNotExist || erofs.ErrNotDirectory, hardlinkTarget rejects targets still holding .. after the Clean, the link-binds-to-image-node semantics are stated in the code and docs and pinned by TestWriteErofs_HardlinkOntoSymlinkSharesTheSymlink, and the parity test asserts mode, uid, gid and the xattr through every name.
  • Full erofs/hardlink test suite green at this head locally, including the seven new tests.

The StepSecurity Harden-Runner failure is the app's own network-anomaly annotation — this diff touches no workflows or dependencies, the check isn't present on the sibling PRs' heads, and every real CI leg (EROFS, Go Tests, ci, verify, CodeQL, Build Images) passed at 4f211ef — so I'm not treating it as blocking.

@smoser
smoser merged commit 6b874b7 into chainguard-dev:main Sep 2, 2026
25 of 26 checks passed
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