Skip to content

fix(apk/fs): make memfs Lstat report symlinks - #2480

Open
Eljees wants to merge 2 commits into
chainguard-dev:mainfrom
Eljees:fix/1543-memfs-lstat-symlink
Open

Eljees wants to merge 2 commits into
chainguard-dev:mainfrom
Eljees:fix/1543-memfs-lstat-symlink

Conversation

@Eljees

@Eljees Eljees commented Sep 8, 2026

Copy link
Copy Markdown

Fixes #1543.

memfs.Lstat called getNode, which resolves symlinks in every path component including the last one. So Lstat on a path that is itself a symlink returned the target's FileInfo and never set the ModeSymlink bit — exactly the caveat spelled out in the comment that used to sit above Lstat. Any caller that checks "is this already a symlink?" before replacing it therefore saw a regular file, which is what the issue reports.

getNodeCountLinks now takes a followFinal flag. getNode (and therefore Stat) passes true and behaves exactly as before; Lstat goes through a variant that passes false, so a symlink in the final component is returned as itself while intermediate components are still resolved — POSIX lstat(2) semantics. Symlink targets encountered during resolution are always followed fully, regardless of the outer call.

Three tests in pkg/apk/fs/memfs_lstat_test.go:

  • Lstat on a symlink reports ModeSymlink — fails on main, passes here;
  • Stat on the same path still resolves to the target (mode and size), so the existing behaviour is pinned;
  • Lstat on a regular file reached through an intermediate directory symlink (/usr/lib64 -> lib) still resolves that intermediate component and does not report a symlink.

go test ./pkg/apk/fs/..., go vet and gofmt are clean.

@Eljees
Eljees force-pushed the fix/1543-memfs-lstat-symlink branch from 6b30fc3 to 20764b2 Compare September 10, 2026 19:47
@coreydaley-cg

Copy link
Copy Markdown
Contributor

Lstat semantics are right and Stat is untouched; the tests pin both. One thing before this can close #1543: every Lstat caller in the tree is an existence check, and the replace logic in install.go:221 still uses Stat and then tests ModeSymlink, which Stat can never report. So this makes Lstat correct without connecting it to the path #1543 is about. Either switch that call to Lstat here, or drop the "Fixes" and leave #1543 open.

Also, the new test file needs the license header the rest of the repo carries.

memfs.Lstat delegated to getNode, which resolves symlinks in every path
component including the last one, so Lstat on a symlink returned the target's
FileInfo and the ModeSymlink bit was never set. getNode keeps Stat semantics
and Lstat now stops at a final symlink, matching lstat(2).

The install path is what makes that reachable. The tar.TypeDir case accepts a
path that already exists as a symlink to a directory, but it asked Stat for the
mode, and Stat resolves the final component, so it could never observe
ModeSymlink: every such path fell through to MkdirAll instead. Ask Lstat for
the entry itself and keep Stat for the target, which still has to be a
directory. The Stat call that decides whether the directory already existed,
and therefore whether its metadata gets set below, is left alone.

Whether the symlink survives is not enough to pin this down, since MkdirAll
over a path that already resolves to a directory is a no-op in memory, so the
test records whether MkdirAll is reached at all.

Fixes chainguard-dev#1543

Signed-off-by: Eljees <3.14hell@gmail.com>
@Eljees
Eljees force-pushed the fix/1543-memfs-lstat-symlink branch from 20764b2 to 51083b5 Compare September 17, 2026 12:53
@Eljees

Eljees commented Sep 17, 2026

Copy link
Copy Markdown
Author

Both points addressed, and you were right that the PR did not reach what #1543 is about.

The install.go side. installAPKFiles's tar.TypeDir case is meant to accept a path that already exists as a symlink to a directory, but it asked Stat for the mode. Stat resolves the final component, so it can never report ModeSymlink, and every such path fell through to MkdirAll instead — the branch was unreachable. It now asks Lstat for the entry itself and keeps Stat for the target, which still has to be a directory. The separate Stat that decides whether the directory already existed, and so whether its metadata gets set below, is untouched: it is about the resolved path, and swapping it would change what happens to a dangling symlink.

The test needed a second look. My first version only asserted that the symlink survived, and it passed with Stat too — MkdirAll over a path that already resolves to a directory is a no-op in memory, so the symlink was still there either way. That is the same weakness you flagged, so I checked it the same way you would: put Stat back, keep every test, and see what fails. Nothing did.

What the branch actually decides is whether MkdirAll is reached at all, so TestInstallAPKFilesKeepsSymlinkToDirectory now records that. With Stat restored it fails:

--- FAIL: TestInstallAPKFilesKeepsSymlinkToDirectory
    install_test.go:741:
        Error:    []string{"lib"} should not contain "lib"
        Messages: an existing symlink to a directory has to be accepted as is, without reaching MkdirAll

and with Lstat it passes. The layout it uses is the merged-usr one apko builds for itself in InitDB: usr/lib is the directory, lib the symlink to it.

The license header is on pkg/apk/fs/memfs_lstat_test.go now.

Also rebased onto current main (a770072); go test ./pkg/apk/apk/ ./pkg/apk/fs/ and gofmt -l are clean on Go 1.27.1.

Upstream added TestLazilyInstallAPKFilesRejectsOutOfRangeOwner at the end of
pkg/apk/apk/install_test.go, where this branch had already added
TestInstallAPKFilesKeepsSymlinkToDirectory. Both tests are kept; nothing
else in the merge needed a decision.

Signed-off-by: Eljees <57435526+Eljees@users.noreply.github.com>
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.

Replaces of symlinks does not appear to work

2 participants