Skip to content

erofs: small cleanups to erofsmount tool - #2426

Open
smoser wants to merge 6 commits into
chainguard-dev:mainfrom
smoser:erofs-review-nits
Open

erofs: small cleanups to erofsmount tool#2426
smoser wants to merge 6 commits into
chainguard-dev:mainfrom
smoser:erofs-review-nits

Conversation

@smoser

@smoser smoser commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The three take-or-leave nits from the review of #2415, all in
pkg/erofsmount/driver_linux.go. One commit each.

1. The interface comment describes an unmount that no longer happens

28acdfd7 dropped the umount(8) requirement from Preflight but left the
driver doc comment saying kernelDriver shells out to it. Text only.

2. Stop at a symlink refusal in fuse mode

fuseDriver.Unmount tries the kernel unmount and falls back to fusermount,
which is right when the mount was simply made by the other tool and wrong when
the kernel refused because the mountpoint is a symlink. fusermount has
carried its own UMOUNT_NOFOLLOW since the CVE-2010-3879 fix, so it refuses
the same path again and the joined error buries the reason:

Error: umount .../decoy-link: refusing to follow a symlink
/usr/bin/fusermount3 -u .../decoy-link: exit status 1: ... Invalid argument

That second line is noise once the first has decided the matter. kernelUnmount
now wraps a sentinel for the symlink case so Unmount can return it as-is.
State-file paths pass checkResolved before reaching a driver, so this shrinks
surface rather than fixing an exposure.

3. Refuse paths fuse-overlayfs cannot round-trip

escapeOverlayPath emits the kernel's escaping to both legs, and review asked
whether fuse-overlayfs honours it. Measured against 1.17 rather than
assumed
— mounting a tree whose path contains each character, escaped and
verbatim, against a clean-path control:

in DEST escaped (today) verbatim (before #2415)
(control) ok ok
, ok broken — split into separate options
: broken broken
\ broken broken

Two things follow. The escaping is a straight win for the comma on this leg and
cannot help with the other two. And it is not the regression it looked
like: a bare backslash was already eaten before the escaping went in, so that
dest fails identically before and after.

What is left is a bad failure mode rather than a wrong mount — fuse-overlayfs
reports cannot resolve path .../dd, naming a path the user never wrote. So
: and \ are refused up front on the fall-back leg, with the reason and a
pointer at --mode=kernel, which handles them correctly. The kernel overlay
attempted just before the fall-back is unaffected: only the tool that cannot
cope declines.

The probe script is not included — it needs a live fuse-overlayfs and the
result is a fact about that tool, not about apko, so it is recorded as a table
in the comment on fuseOverlayUnsupported instead.

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. The fuse-mode symlink test was checked to fail without its
change.

Both new unit tests do run in CI, in the go-tests job on the non-root
runner — where the symlink test passes via the EPERM+lstat path rather than
EINVAL, since the kernel checks may_mount() before path_mounted(). What
never runs anywhere is the e2e fuse leg: hack/test-erofs.sh goes through
sudo, so resolveMode picks kernel. That is one of the two items already
filed against #2408.

Review follow-ups

Four commits addressing the review, one concern each:

  • 06801ac9 — move errSymlinkedMountpoint above kernelUnmount's doc block
    (it had captured the whole UMOUNT_NOFOLLOW rationale as its own godoc), and
    fix the two comments commit 2 made untrue: the lstat is no longer "for the
    message only", and "Both failures are reported" is now conditional. The
    lstat comment records the EPERM-vs-EINVAL detail, so a later cleanup keying
    it on EINVAL does not silently break the fuse leg.
  • 64166080 — run checkFuseOverlayPaths before the fuse-overlayfs
    LookPath, since the refusal is terminal and "not installed" is fixable.
    With that order an empty-PATH test can pin the wiring: raw paths, and
    read-only excluding upper/work. Mutation-checked both ways.
  • cabc8693--mode=kernel (as root); the user on this leg is non-root by
    construction, so the bare suggestion bounces off kernelDriver.Preflight.

On the fifth comment: yes, the refusal is deliberately version-blind rather
than a versioned check — "refuse until re-measured". There is no probe, and
1.17 is what the comment records.

Refs #2408

smoser and others added 3 commits September 1, 2026 06:57
The interface comment still said kernelDriver shells out to umount(8),
which 28acdfd stopped doing when it dropped the matching Preflight
requirement. Unmounts go through umount(2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fuseDriver.Unmount tries the kernel unmount and falls back to
fusermount, which is right when the mount was simply made by the other
tool. It is not right when the kernel refused because the mountpoint is
a symlink: fusermount has carried its own UMOUNT_NOFOLLOW since the
CVE-2010-3879 fix, so it only refuses the same path again, and joining
the two errors buries the reason behind fusermount's wording.

kernelUnmount wraps a sentinel for that case so the caller can tell it
from an ordinary failure, and Unmount returns it as-is. State-file
paths already pass checkResolved, so this shrinks surface rather than
fixing an exposure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
escapeOverlayPath emits the kernel's escaping to both legs, and review
asked whether fuse-overlayfs honours it. Measured against 1.17 rather
than assumed, by mounting a tree whose path contains each character,
escaped and verbatim, against a clean-path control:

  ,  escaped works, verbatim is split into separate options
  :  broken both ways -- \: is not honoured as the kernel honours it
  \  broken both ways -- eaten, so the path never resolves

So the escaping is a straight win for the comma on this leg and cannot
help with the other two. It is also not the regression it looked like:
a bare backslash was already eaten before the escaping went in, so both
before and after, that dest fails.

What is left is a bad failure mode rather than a wrong mount --
fuse-overlayfs reports "cannot resolve path .../dd" naming a path the
user never wrote. Refuse ':' and '\' up front on the fall-back leg,
with the reason and a pointer at --mode=kernel, which handles them
correctly. The kernel overlay attempted just before the fall-back is
unaffected, so only the tool that cannot cope declines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@smoser smoser changed the title erofs: three follow-ups from the #2415 review erofs: small cleanups to erofsmount tool Sep 1, 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.

Nits only — the substance checked out: the sentinel can't strand a real mount (a race-swapped symlink means the mountpoint was already unmounted, and fusermount refuses symlinks itself), and the new check is a raw-string check on exactly the strings that reach fuse-overlayfs's -o, applied only on the leg that can't cope.

One PR-body correction: "none of this executes in CI" is overbroad. The e2e fuse leg never runs (the script goes through sudo → kernel mode), but both new unit tests do run in the go-tests job on the non-root runner — where the symlink test passes via the EPERM+lstat path, not EINVAL (see the inline comment).

Comment thread pkg/erofsmount/driver_linux.go Outdated
// errSymlinkedMountpoint reports a mountpoint whose final component is a
// symlink. UMOUNT_NOFOLLOW refuses those, and callers use this to tell the
// refusal apart from an ordinary unmount failure.
var errSymlinkedMountpoint = errors.New("refusing to follow a symlink")

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 var and its comment landed between kernelUnmount's doc comment and the func — so the whole UMOUNT_NOFOLLOW rationale block above is now godoc for errSymlinkedMountpoint, and kernelUnmount is undocumented. Move the var (with its own comment) above the kernelUnmount comment block, blank line between.

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.

Moved in 06801ac9 — the var and its own comment now sit above the kernelUnmount block, blank line between, so the UMOUNT_NOFOLLOW rationale is godoc for the func again.

Comment thread pkg/erofsmount/driver_linux.go Outdated
Comment on lines +266 to +269
// argument". This is for the message only -- the flag above is what
// provides the guarantee, so an lstat racing it cannot weaken anything.
if fi, lerr := os.Lstat(mp); lerr == nil && fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("umount %s: refusing to follow a symlink", mp)
return fmt.Errorf("umount %s: %w", mp, errSymlinkedMountpoint)

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 is for the message only" is no longer true: fuseDriver.Unmount now branches on the sentinel this lstat produces. Worth rewording, and one detail is worth capturing while doing so: on the non-root leg the errno here is EPERM, not EINVAL — the kernel checks may_mount() (CAP_SYS_ADMIN) before path_mounted(), so a non-root caller never sees EINVAL for a symlink. The errno-blind lstat is therefore what makes the classification work on exactly the leg commit 2 targets; a future cleanup that keys the sentinel on EINVAL would break it silently (the unit test, which runs non-root in CI, would be the only thing that notices). fuseDriver.Unmount's "Both failures are reported." doc line is also now conditionally false.

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.

Reworded in 06801ac9. The comment now says the lstat classifies as well as describes, and records the errno detail explicitly: as root EINVAL, but a non-root caller gets EPERM because the kernel checks may_mount() before path_mounted() — so keying the sentinel on EINVAL would stop classifying on exactly the leg the branch exists for. Unmount's "Both failures are reported" is now conditional on that branch.

if !readOnly {
paths = append(paths, upper, work)
}
if err := checkFuseOverlayPaths(paths...); 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.

Consider running this check before the LookPath above: the path refusal is terminal (installing fuse-overlayfs won't fix a : in the dest) while "not installed" is fixable, and with that order an empty-PATH unit test can pin this wiring — raw paths, readOnly excluding upper/work — the way TestFuseDriverUnmountStopsAtASymlinkRefusal pins Unmount's. Right now TestCheckFuseOverlayPaths covers only the pure function.

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 64166080 — the path check now runs first, with a comment saying why (terminal vs. fixable). TestAssembleOverlayChecksPathsBeforeLookPath uses the empty PATH to pin the wiring: a : in a lowerdir is refused rather than deferred to LookPath, a , is not (which pins that the check sees raw paths — the escaped form carries a backslash and would be refused), and read-only leaves upper/work out of the set. Mutation-checked both ways: reverting the order fails two cases, checking escaped paths fails one.

Comment thread pkg/erofsmount/driver_linux.go Outdated
func checkFuseOverlayPaths(paths ...string) error {
for _, p := range paths {
if i := strings.IndexAny(p, fuseOverlayUnsupported); i >= 0 {
return fmt.Errorf("fuse-overlayfs cannot handle %q in a path (%s); use --mode=kernel, or a dest without it",

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 user on this leg is non-root by construction (resolveMode picks fuse when euid != 0, and the kernel overlay just failed above), so --mode=kernel verbatim bounces off kernelDriver.Preflight's euid check. Maybe "use --mode=kernel (as root), or a dest without it".

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.

Good catch — cabc8693 makes it use --mode=kernel (as root), or a dest without it.

// So escapeOverlayPath earns its keep for the comma and cannot help with the
// other two. Refuse those rather than hand fuse-overlayfs an option string it
// will misread and then fail on with a path the user never named.
const fuseOverlayUnsupported = `:\`

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.

Deliberate that this refuses on every fuse-overlayfs version with no probe, keyed to the 1.17 measurement? Fine if so — the comment records the version — just confirming the intent is "refuse until re-measured" rather than a versioned check.

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.

Deliberate, yes: "refuse until re-measured" rather than a versioned check. There is no probe and no version gate — 1.17 is only what the comment records as the measurement it is keyed to. If a later release fixes : or \, the table and the constant get revisited together.

The errSymlinkedMountpoint var landed between kernelUnmount's doc
comment and the func, so the whole UMOUNT_NOFOLLOW rationale became
godoc for the sentinel and kernelUnmount was left undocumented. Move
the var, with its own comment, above the block.

While there, two comments that commit ce56361 made untrue. The lstat
in kernelUnmount is no longer "for the message only" -- fuseDriver.
Unmount branches on the sentinel it produces -- and Unmount's "Both
failures are reported" is now conditional on that branch.

The lstat comment also records why it must stay errno-blind: as root
the errno is EINVAL, but a non-root caller gets EPERM, because the
kernel checks may_mount() before path_mounted(). So the classification
would silently stop working on the fuse leg, which is the only leg
that branches on it, if a later cleanup keyed it on EINVAL.

Text only, no behavior change.
A path fuse-overlayfs cannot round-trip is terminal -- installing
fuse-overlayfs will not make a ":" in the dest work -- while "not
installed" is fixable, so report the refusal first.

Ordering it this way also makes the wiring testable: with an empty
PATH both the kernel overlay above and the LookPath below fail, so
which error comes back pins the order, which paths are checked, and
in what form. TestCheckFuseOverlayPaths covered only the pure
function. The new test pins that a ":" in a lowerdir is refused
rather than deferred, that a "," is not (the check sees raw paths,
and the escaped form would carry a backslash), and that read-only
leaves upper and work out of the set.

Both directions were mutation-checked: reverting the order and
checking the escaped paths each fail it.
The user reaching this refusal is non-root by construction: resolveMode
picks fuse when euid != 0, and the kernel overlay just failed above. So
"--mode=kernel" verbatim is advice that bounces straight off
kernelDriver.Preflight's euid check.
@smoser
smoser enabled auto-merge (squash) September 1, 2026 21:06
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