Skip to content

bubblewrap: Use the new mount API for overlay mounts - #805

Open
filbranden wants to merge 8 commits into
containers:mainfrom
filbranden:overlay-new-mount-api
Open

filbranden wants to merge 8 commits into
containers:mainfrom
filbranden:overlay-new-mount-api

Conversation

@filbranden

Copy link
Copy Markdown

Fixes #804.

Investigated, implemented and tested with the help of Claude Code.

What

Overlay mounts (--overlay, --ro-overlay, --tmp-overlay) are now built with fsopen() + one fsconfig("lowerdir+") per layer + fsmount() + move_mount(), instead of passing every layer in a single mount(2) options string.

This removes the limit on how many --overlay-src layers bubblewrap can stack.

Why

mount(2) takes its options as one string, and copy_mount_options() keeps at most one page of it. Everything past that is dropped, usually in the middle of a path. A truncated layer path normally still names a real directory — commonly an ancestor of the other layers, because container layer stores put every layer under one parent. overlayfs then correctly reports an overlap that the caller never asked for:

bwrap: Can't make overlay mount on /dest with options lowerdir=...: \
    Overlay directories may not overlap

The error is accurate and completely misleading. The overlap is manufactured by the truncation; the layers the user passed are all distinct.

This was hit in the wild with bubblewrap 0.12.0 mounting a 31-layer container image. At the time each entry was a full absolute path (~130 bytes), so the string ran past 4096 bytes at 31 layers. c77dd38 ("setup: Use O_PATH fds and disallow symlink targets") shortened each entry to a /proc/self/fd/N magic symlink, which moves the ceiling to roughly 200 layers, but the limit is still there and still reports itself as a bogus overlap.

fsconfig(FSCONFIG_SET_STRING, "lowerdir+", ...) appends one layer per call and accumulates the list in the kernel, so there is no options string to overflow. The only remaining bound is overlayfs's own OVL_MAX_STACK.

Implementation

The overlay case in setup_newroot() previously walked the SETUP_OVERLAY_SRC ops and appended to a StringBuilder as it went. The first commit of the series splits that apart: the walk now just collects the layer fds, and a helper does the mounting. That makes room for a second helper without threading #if guards through the walk.

overlay_mount_fsconfig() does:

  • fsopen("overlay", FSOPEN_CLOEXEC)
  • fsconfig(FSCONFIG_SET_STRING, "upperdir"/"workdir", ...) when there is an upper layer
  • fsconfig(FSCONFIG_SET_STRING, "lowerdir+", ...) once per layer, in the same order the options string used
  • fsconfig(FSCONFIG_SET_FLAG, "userxattr")
  • fsconfig(FSCONFIG_CMD_CREATE)
  • fsmount(..., MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV), the same restrictions MS_NOSUID | MS_NODEV applied before
  • move_mount(mount_fd, "", dest_fd, "", MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_T_EMPTY_PATH)

overlay_mount_legacy() is the previous mount(2) code, unchanged apart from taking its layers as an array.

Only the fsopen/fsconfig/fsmount/move_mount wrappers are new. They follow mount_setattr_wrapper() exactly: #ifndef __NR_* fallback numbers on the same three architectures, ENOSYS where the number is unknown. The constants are guarded on FSOPEN_CLOEXEC, the same way glibc's <sys/mount.h> guards them, because FSCONFIG_* are enumerators rather than macros and cannot be tested individually.

Fallback

overlayfs only gained lowerdir+, and new mount API support generally, in Linux 6.7. fsopen() itself has existed since 5.2, so on 5.2–6.6 the call succeeds and the failure only appears at FSCONFIG_CMD_CREATE, where EINVAL is indistinguishable from a genuinely bad configuration. Rather than guess, overlay_mount_fsconfig() returns false on any failure and the caller falls back to overlay_mount_legacy(). That costs one extra mount(2) in the error case and keeps the diagnostics the existing path already produces — the ELOOP message in particular is unchanged.

-Dassume_kernel=6.7.0 compiles the fallback out, matching what -Dassume_kernel=5.12.0 already does for mount_setattr. --debug-opt=force-overlay-fallback forces the mount(2) path so it can still be tested on a kernel that does not need it, mirroring force-mount-setattr-fallback.

Two fixes on the way

  • The overlay error messages printed Can't make overlay mount on (null). The layer walk advances op to the last SETUP_OVERLAY_SRC, and those ops carry only a source, no destination.
  • When the options string does not fit in one page, the mount(2) path now refuses the mount and says so, instead of letting the kernel truncate and overlayfs blame an overlap. The threshold comes from sysconf(_SC_PAGESIZE), since the page is not 4 KiB everywhere.

Testing

On Linux 6.12, x86_64, unprivileged:

  • meson test passes, in a default build and with -Dassume_kernel=6.7.0.
  • The existing test-run.sh overlay assertions pass unchanged. They pin layer precedence (--overlay-src lower1 --overlay-src lower2 must read b from lower2), which is what confirms repeated lowerdir+ appends keep the same order the colon-separated list had.
  • New test-sandbox.py tests mount 250 lower layers, and check that the mount(2) path refuses the same 250 layers with the new message. The existing --tmp-overlay and --ro-overlay tests are also run again under --debug-opt=force-overlay-fallback; all the fallback tests skip when -Dassume_kernel has compiled that path out.
  • Before wiring anything into bubblewrap, a standalone reproducer confirmed the chain works for an ordinary unprivileged user inside unshare(CLONE_NEWUSER | CLONE_NEWNS): 300, 400 and 499 layers all mount and every layer is visible; layer 500 fails with EINVAL, which is overlayfs's OVL_MAX_STACK. The same program shows the equivalent mount(2) options string at 300 layers is 8118 bytes and fails. Happy to share it.

Relationship to other work

This is a third use of the new mount API in bubblewrap, after #754 and #756 brought in mount_setattr(). It is related to #755 but does not implement or close it: that issue is about open_tree() + move_mount() for --[ro-]bind-fd, which is a different problem (a path-lookup race, not a length limit) and a different part of the API. fsopen() creates a new mount rather than cloning an existing one, so none of the cross-namespace constraints that apply to open_tree() apply here.

The loop that collects the lower layers advances op to the last
SETUP_OVERLAY_SRC op. Those ops hold only a source, so the error messages
printed "Can't make overlay mount on (null)".

Save the destination before the loop runs.

Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
Collect the layers first, then mount. The walk over the SETUP_OVERLAY_SRC
ops no longer builds the options string as it goes, so a second way to
mount an overlay can reuse the same layer list.

No change in behaviour.

Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
These syscalls are part of the same "new mount API" as mount_setattr().
Provide the syscall numbers on the same architectures, and return ENOSYS
where the number is unknown.

glibc guards its whole fsopen() group on FSOPEN_CLOEXEC, and the
FSCONFIG_* names are enumerators rather than macros, so guard the
constants the same way instead of testing each name.

Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
mount(2) takes every lower layer in one options string, and the kernel
keeps at most one page of it. The rest is cut off, often in the middle of
a path, so overlayfs rejects the mount for an overlap that the caller
never asked for. With /proc/self/fd paths this caps an overlay at roughly
200 layers.

fsconfig() appends one layer per call and builds no string, so the layer
count is bounded only by overlayfs itself. Mount the overlay with
fsopen(), one fsconfig("lowerdir+") per layer, fsmount() and move_mount().

overlayfs supports this from Linux 6.7 on. Older kernels keep the mount(2)
path. Build with -Dassume_kernel=6.7.0 to compile the fallback out, and
run with --debug-opt=force-overlay-fallback to exercise it on a kernel
that does not need it.

Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
The kernel keeps at most one page of the mount(2) options string and drops
the rest. A path cut in half normally still names a real directory, an
ancestor of the other layers, so overlayfs reports an overlap that the
caller never asked for.

Refuse the mount and name the real problem. Take the page size from
sysconf(), because it is not 4 KiB everywhere.

Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
250 lower layers do not fit in the mount(2) options string but stay well
under the overlayfs maximum. Check that they mount, and that the mount(2)
path refuses them with a clear message.

Also run the existing overlay tests again with
--debug-opt=force-overlay-fallback, so the mount(2) path keeps its
coverage on kernels that no longer need it.

Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
Signed-off-by: Filipe Brandenburger <filbranden@gmail.com>
Signed-off-by: Filipe Brandenburger <filbranden@gmail.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.

Overlay mounts have an undocumented limit on the number of --overlay-src layers, and exceeding it reports a misleading error

1 participant