From 48381089d364306dffd3106b87b3f8129ee63c61 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 15 May 2026 01:20:39 +0200 Subject: [PATCH] refactor(left4me-overlay): move uid translation to script-sandbox build left4me-script-sandbox now pre-creates an idmapped bind staging path (--map-users=::1) and points the sandbox's BindPaths at that staging instead of the raw overlay dir. Writes from inside the sandbox (uid l4d2-sandbox) land on disk as left4me, so all overlay content is uniformly left4me-owned end-to-end. left4me-overlay loses ~165 lines of idmap-on-mount logic: the per- lowerdir stat + idmap-bind setup, the bind-umount loop in teardown, the uid lookup helpers, the _is_mountpoint /proc/self/mountinfo parser, and the LEFT4ME_TEST_* env-var stubs. It's back to a simple "validate lowerdirs, mount overlay" shape; gameserver mount path no longer needs to know about producer-side ownership decisions. Verified on kernel 6.12 that the kernel idmap propagates through systemd-run's plain re-bind of the staging path. Tests dropped 4 idmap-on-mount specs and one deploy-artifact regression check; added test_script_sandbox_uses_idmap_staging to pin the new staging path + map flags + trap cleanup. The post-build world-read chmod kludge in the sandbox is also dropped: the web app reads overlay files via its primary uid (left4me). Existing overlays on the test server are sandbox-owned from prior runs and need a one-shot `chown -R left4me:left4me /var/lib/left4me/overlays` during deploy. New overlays produced by the refactored sandbox are left4me-owned from creation. Co-Authored-By: Claude Opus 4.7 --- .../usr/local/libexec/left4me/left4me-overlay | 165 +--------------- .../libexec/left4me/left4me-script-sandbox | 47 +++-- deploy/tests/test_deploy_artifacts.py | 37 ++-- l4d2host/tests/test_overlay_helper.py | 179 ------------------ 4 files changed, 51 insertions(+), 377 deletions(-) diff --git a/deploy/files/usr/local/libexec/left4me/left4me-overlay b/deploy/files/usr/local/libexec/left4me/left4me-overlay index 74ad954..369ed3f 100644 --- a/deploy/files/usr/local/libexec/left4me/left4me-overlay +++ b/deploy/files/usr/local/libexec/left4me/left4me-overlay @@ -29,7 +29,6 @@ shell-quoted) and exit 0 instead of execv. Used by tests. """ import os -import pwd import re import shlex import shutil @@ -55,74 +54,6 @@ def die(msg: str) -> None: sys.exit(1) -def _is_mountpoint( - path: str | Path, - mountinfo_path: str = "/proc/self/mountinfo", -) -> bool: - """Reliable mount-point check that handles same-fs bind mounts. - - `os.path.ismount()` compares `st_dev` of the path against its parent; - bind mounts on the same underlying filesystem share `st_dev` with their - parent, so `os.path.ismount()` returns False for them. The idmap binds - we install on `runtime//idmap/` are exactly that case. - Read /proc/self/mountinfo (field 5 is the mount point) for a check - that works regardless of mount type. The override is for tests only. - """ - abs_path = os.fspath(Path(path).resolve()) - try: - with open(mountinfo_path, "r", encoding="utf-8") as f: - for line in f: - fields = line.split() - if len(fields) >= 5 and fields[4] == abs_path: - return True - except OSError: - pass - return False - - -def _lookup_uid(username: str) -> tuple[int, int]: - """Return (uid, gid) for *username*, dying with a clear message if missing.""" - try: - entry = pwd.getpwnam(username) - except KeyError: - die( - f"required system user {username!r} does not exist; " - "this is a deploy misconfiguration" - ) - return entry.pw_uid, entry.pw_gid - - -def _get_user_ids() -> tuple[int, int, int, int]: - """Return (sandbox_uid, sandbox_gid, left4me_uid, left4me_gid). - - In normal operation, looks up the real system users. In PRINT_ONLY - (test) mode the env vars LEFT4ME_TEST_SANDBOX_UID/LEFT4ME_TEST_SANDBOX_GID/ - LEFT4ME_TEST_LEFT4ME_UID/LEFT4ME_TEST_LEFT4ME_GID may be used to inject - synthetic uids so tests can run without root and without real system - users present. The stubs are intentionally ignored outside PRINT_ONLY - mode so that a misconfigured systemd unit override cannot influence the - real uid mapping. - """ - if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1": - sandbox_uid_env = os.environ.get("LEFT4ME_TEST_SANDBOX_UID") - sandbox_gid_env = os.environ.get("LEFT4ME_TEST_SANDBOX_GID") - left4me_uid_env = os.environ.get("LEFT4ME_TEST_LEFT4ME_UID") - left4me_gid_env = os.environ.get("LEFT4ME_TEST_LEFT4ME_GID") - - if all(v is not None for v in (sandbox_uid_env, sandbox_gid_env, - left4me_uid_env, left4me_gid_env)): - return ( - int(sandbox_uid_env), # type: ignore[arg-type] - int(sandbox_gid_env), # type: ignore[arg-type] - int(left4me_uid_env), # type: ignore[arg-type] - int(left4me_gid_env), # type: ignore[arg-type] - ) - - sandbox_uid, sandbox_gid = _lookup_uid("l4d2-sandbox") - left4me_uid, left4me_gid = _lookup_uid("left4me") - return sandbox_uid, sandbox_gid, left4me_uid, left4me_gid - - def root() -> Path: return Path(os.environ.get("LEFT4ME_ROOT") or DEFAULT_ROOT) @@ -242,79 +173,7 @@ def cmd_mount(name: str) -> None: assert_no_fuse_xattrs(upper) - # Resolve user ids now (fails fast on deploy misconfiguration). - sandbox_uid, sandbox_gid, left4me_uid, left4me_gid = _get_user_ids() - - # Build the final lowerdir list, substituting idmap bind-mount paths for - # any lowerdir owned by l4d2-sandbox. An idmap bind mount makes the kernel - # see the l4d2-sandbox-owned tree as if it were owned by left4me, so that - # overlayfs copy-up produces left4me-owned upperdir entries. - idmap_dir = runtime_name_dir / "idmap" - final_lowerdirs: list[str] = [] - bind_argvs: list[list[str]] = [] - seen_idmap_targets: dict[Path, str] = {} - - for lowerdir in canonical_lowerdirs: - try: - st = os.stat(lowerdir) - except OSError as exc: - die(f"failed to stat lowerdir {shlex.quote(lowerdir)}: {exc}") - if st.st_uid == sandbox_uid: - # This lowerdir needs idmap remapping. - # Include the parent dirname to avoid basename collisions between - # lowerdirs from different allowlist roots (e.g. overlays/foo and - # workshop_cache/foo would otherwise map to the same idmap target). - p = Path(lowerdir) - lowerdir_basename = f"{p.parent.name}_{p.name}" - idmap_target = idmap_dir / lowerdir_basename - - # Belt-and-braces: detect if two different lowerdirs would collide - # on the same idmap target after the parent+name derivation. - if idmap_target in seen_idmap_targets: - die( - f"idmap target collision: lowerdirs {shlex.quote(seen_idmap_targets[idmap_target])}" - f" and {shlex.quote(lowerdir)} both map to {idmap_target}" - ) - seen_idmap_targets[idmap_target] = lowerdir - - if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") != "1": - idmap_dir.mkdir(mode=0o700, exist_ok=True) - idmap_target.mkdir(mode=0o700, exist_ok=True) - - if not _is_mountpoint(idmap_target) or \ - os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1": - # --map-users / --map-groups argument format: - # :: - # The util-linux man page calls these :, which is - # misleading. Empirically (verified on left4.me, kernel 6.12, - # ext4) the FIRST number is the on-disk uid and the SECOND is - # the uid exposed inside the mount. Don't swap them. - bind_argv = [ - MOUNT_BIN, - "--bind", - f"--map-users={sandbox_uid}:{left4me_uid}:1", - f"--map-groups={sandbox_gid}:{left4me_gid}:1", - lowerdir, - str(idmap_target), - ] - bind_argvs.append(bind_argv) - - final_lowerdirs.append(str(idmap_target)) - else: - final_lowerdirs.append(lowerdir) - - print_only = os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1" - - if print_only: - # Emit each bind-mount argv first, then fall through to the overlay argv. - for bind_argv in bind_argvs: - _print_argv(bind_argv) - else: - # Actually exec each bind mount before the overlay mount. - for bind_argv in bind_argvs: - subprocess.run(bind_argv, check=True) - - options = f"lowerdir={':'.join(final_lowerdirs)},upperdir={upper},workdir={work}" + options = f"lowerdir={':'.join(canonical_lowerdirs)},upperdir={upper},workdir={work}" argv = [ MOUNT_BIN, "-t", "overlay", @@ -338,20 +197,8 @@ def cmd_umount(name: str) -> None: str(merged_path.resolve(strict=True) if merged_path.exists() else merged_path), ] - # Collect idmap bind-umount argvs: one per direct subdir of runtime//idmap/. - idmap_dir = runtime_name_dir / "idmap" - bind_umount_argvs: list[list[str]] = [] - if idmap_dir.is_dir(): - for entry in sorted(idmap_dir.iterdir()): - if entry.is_dir(): - bind_umount_argvs.append([UMOUNT_BIN, str(entry)]) - - # PRINT_ONLY: emit the overlay umount argv, then each bind-umount argv, then exit. - # Order matches real execution (overlay first, then idmap binds underneath). if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1": _print_argv(overlay_umount_argv) - for bind_umount_argv in bind_umount_argvs: - _print_argv(bind_umount_argv) sys.exit(0) if merged_path.exists(): @@ -382,16 +229,6 @@ def cmd_umount(name: str) -> None: if work_inner.exists(): shutil.rmtree(work_inner) - # Unwind idmap bind mounts, then remove the idmap directory. Each bind - # is only umounted if it is still a mountpoint (idempotent across partial - # teardowns). _is_mountpoint reads /proc/self/mountinfo because - # os.path.ismount misses same-fs bind mounts. - for bind_umount_argv in bind_umount_argvs: - target = Path(bind_umount_argv[-1]) - if _is_mountpoint(target): - subprocess.run(bind_umount_argv, check=True) - shutil.rmtree(idmap_dir, ignore_errors=True) - def main(argv: list[str]) -> None: if len(argv) != 3 or argv[1] not in ("mount", "umount"): diff --git a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox index 5e6458b..aa533f0 100755 --- a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox +++ b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox @@ -34,13 +34,36 @@ if [[ "${LEFT4ME_SCRIPT_SANDBOX_DRY_RUN:-}" == "1" ]]; then exit 0 fi -# Make sure the sandbox UID owns the overlay dir so the script can write there. -# Idempotent: a no-op when the dir is already l4d2-sandbox-owned (re-run case), -# and corrects the ownership the first time the dir was created by the web app -# under the left4me UID. World-readable so the gameserver process (left4me) -# can read the overlay contents via the kernel-overlayfs lowerdir at runtime. -chown -R l4d2-sandbox:l4d2-sandbox "$OVERLAY_DIR" -chmod 0755 "$OVERLAY_DIR" +# Pre-create an idmapped bind of the overlay dir, then point the sandbox's +# BindPaths at that staging path. The bind translates the sandbox's writing +# uid (l4d2-sandbox) back to left4me on disk, so all overlay content +# (script-built and workshop) is uniformly left4me-owned. Map direction: +# `--map-users=::1` with disk=left4me, mount=sandbox — +# a process inside the bind with uid sandbox sees its uid as itself, and +# writes get translated to disk-uid left4me. Verified on kernel 6.12 that +# idmap propagates through systemd-run's plain re-bind of the staging path. +LEFT4ME_UID=$(id -u left4me) +LEFT4ME_GID=$(id -g left4me) +SANDBOX_UID=$(id -u l4d2-sandbox) +SANDBOX_GID=$(id -g l4d2-sandbox) +STAGING=/var/lib/left4me/tmp/sandbox-idmap-${OVERLAY_ID} + +# trap fires even on errors / signals so the staging bind doesn't outlive +# this invocation. Idempotent if the staging is already gone. +cleanup_staging() { + umount "$STAGING" 2>/dev/null || true + rmdir "$STAGING" 2>/dev/null || true +} +trap cleanup_staging EXIT + +# A leftover staging mount from a SIGKILLed prior run can be reset by +# umounting first, then re-binding fresh on the same path. +umount "$STAGING" 2>/dev/null || true +mkdir -p "$STAGING" +mount --bind \ + --map-users="${LEFT4ME_UID}:${SANDBOX_UID}:1" \ + --map-groups="${LEFT4ME_GID}:${SANDBOX_GID}:1" \ + "$OVERLAY_DIR" "$STAGING" SCRIPT_RC=0 systemd-run --quiet --collect --wait --pipe \ @@ -64,19 +87,11 @@ systemd-run --quiet --collect --wait --pipe \ -p IPAddressDeny="127.0.0.0/8 ::1/128 169.254.0.0/16 fe80::/10 224.0.0.0/4 ff00::/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 fc00::/7" \ -p TemporaryFileSystem="/etc /var/lib" \ -p BindReadOnlyPaths="/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf /etc/ssl /etc/ca-certificates /etc/nsswitch.conf /etc/alternatives ${SCRIPT}:/script.sh" \ - -p BindPaths="${OVERLAY_DIR}:/overlay" \ + -p BindPaths="${STAGING}:/overlay" \ -p WorkingDirectory=/overlay \ -p Environment="HOME=/tmp PATH=/usr/bin:/usr/sbin OVERLAY=/overlay" \ -p MemoryMax=4G -p MemorySwapMax=0 -p TasksMax=512 \ -p CPUQuota=200% -p RuntimeMaxSec=3600 \ -- /bin/bash /script.sh || SCRIPT_RC=$? -# Normalize perms so the web service (left4me uid) can read overlay files -# directly via Python open() — needed by the file tree's download endpoint. -# UMask=0022 above takes care of *new* writes; this catches anything the -# script created with a tighter mode (e.g. cedapug_maps writes its -# .cedapug/manifest.tsv as 0600 by default). -find "$OVERLAY_DIR" -type f ! -perm -o+r -exec chmod o+r {} + 2>/dev/null || true -find "$OVERLAY_DIR" -type d ! -perm -o+rx -exec chmod o+rx {} + 2>/dev/null || true - exit $SCRIPT_RC diff --git a/deploy/tests/test_deploy_artifacts.py b/deploy/tests/test_deploy_artifacts.py index 78f733f..29ccde1 100644 --- a/deploy/tests/test_deploy_artifacts.py +++ b/deploy/tests/test_deploy_artifacts.py @@ -394,26 +394,27 @@ def test_overlay_helper_is_python_with_strict_validation(): assert '"unmount"' not in text -def test_overlay_helper_idmaps_sandbox_owned_lowerdirs(): - """Script-built overlay lowerdirs are owned by l4d2-sandbox. Without an - idmap bind mount, kernel-overlayfs copy-up preserves that ownership and - the gameserver (uid left4me) can't write to copied-up directories like - addons/sourcemod/logs/. The helper must inject an idmap bind for each - sandbox-owned lowerdir before the overlay mount and tear it down after. +def test_script_sandbox_uses_idmap_staging(): + """The sandbox runs as l4d2-sandbox but writes need to land on disk as + left4me, so all overlay content (workshop + script-built) is uniformly + left4me-owned. The helper pre-creates an idmapped bind on a staging + path and points the sandbox's BindPaths at the staging, not at the raw + overlay dir. trap cleans up the staging bind on exit. """ - text = OVERLAY_HELPER.read_text() - # The bind-mount argv uses --map-users / --map-groups (numeric uids). + text = SCRIPT_SANDBOX_HELPER.read_text() + # Idmap mount setup uses --map-users / --map-groups. assert "--map-users=" in text assert "--map-groups=" in text - # Idmapped paths live under runtime//idmap/ and are substituted - # into the lowerdir= string. - assert 'runtime_name_dir / "idmap"' in text - # Test-mode uid stubs are namespaced LEFT4ME_TEST_* and gated on - # PRINT_ONLY=1 so a misconfigured systemd unit can't inject uids. - assert "LEFT4ME_TEST_SANDBOX_UID" in text - assert "LEFT4ME_TEST_LEFT4ME_UID" in text - # Collision guard: two lowerdirs deriving the same idmap target die loudly. - assert "seen_idmap_targets" in text + # Staging path lives under /var/lib/left4me/tmp/sandbox-idmap-. + assert "/var/lib/left4me/tmp/sandbox-idmap-" in text + # BindPaths into the sandbox points at the staging path, not the + # raw overlay dir. + assert 'BindPaths="${STAGING}:/overlay"' in text + # trap registers cleanup so the staging bind doesn't outlive the helper. + assert "trap " in text and "cleanup_staging" in text + # The previous chown-to-l4d2-sandbox approach is gone; overlay dirs + # stay left4me-owned end-to-end. + assert "chown -R l4d2-sandbox" not in text def test_deploy_script_installs_overlay_helper_with_executable_mode(): @@ -659,7 +660,7 @@ def test_script_sandbox_helper_invokes_systemd_run_with_hardening(): assert "/etc/nsswitch.conf" in text assert "/etc/alternatives" in text assert "${SCRIPT}:/script.sh" in text - assert 'BindPaths="${OVERLAY_DIR}:/overlay"' in text + assert 'BindPaths="${STAGING}:/overlay"' in text # IP egress filter: allow public, deny localhost / RFC1918 / link-local / # multicast / CGNAT / ULA. systemd's "more specific rule wins" semantics diff --git a/l4d2host/tests/test_overlay_helper.py b/l4d2host/tests/test_overlay_helper.py index 8044eb0..a098daf 100644 --- a/l4d2host/tests/test_overlay_helper.py +++ b/l4d2host/tests/test_overlay_helper.py @@ -41,11 +41,6 @@ def _run(args: list[str], root: Path, extra_env: dict[str, str] | None = None) - **os.environ, "LEFT4ME_ROOT": str(root), "LEFT4ME_OVERLAY_PRINT_ONLY": "1", - # Inject synthetic user ids so tests work without real system users. - "LEFT4ME_TEST_SANDBOX_UID": str(FAKE_SANDBOX_UID), - "LEFT4ME_TEST_SANDBOX_GID": str(FAKE_SANDBOX_GID), - "LEFT4ME_TEST_LEFT4ME_UID": str(FAKE_LEFT4ME_UID), - "LEFT4ME_TEST_LEFT4ME_GID": str(FAKE_LEFT4ME_GID), } if extra_env: env.update(extra_env) @@ -161,136 +156,6 @@ def test_rejects_empty_lowerdir_entry(tmp_path: Path) -> None: assert "empty entry" in result.stderr -FAKE_SANDBOX_UID = 7001 -FAKE_SANDBOX_GID = 7001 -FAKE_LEFT4ME_UID = 7002 -FAKE_LEFT4ME_GID = 7002 - - -def _setup_instance_with_uid( - root: Path, - name: str = "alpha", - lowerdir_uid: int = FAKE_LEFT4ME_UID, - lowerdir_gid: int = FAKE_LEFT4ME_GID, -) -> Path: - """Like _setup_instance but chowns the lowerdir to a specific uid/gid.""" - overlay_dir = root / "overlays" / "workshop" - overlay_dir.mkdir(parents=True, exist_ok=True) - try: - os.chown(overlay_dir, lowerdir_uid, lowerdir_gid) - except PermissionError: - pass # tests not running as root — uid won't match; that's fine for the "skips idmap" test - (root / "installation").mkdir(parents=True, exist_ok=True) - lowerdirs = [str(overlay_dir), str(root / "installation")] - inst_dir = root / "instances" / name - inst_dir.mkdir(parents=True, exist_ok=True) - (inst_dir / "instance.env").write_text( - f"L4D2_LOWERDIRS={':'.join(lowerdirs)}\n" - ) - runtime = root / "runtime" / name - (runtime / "upper").mkdir(parents=True, exist_ok=True) - (runtime / "work").mkdir(parents=True, exist_ok=True) - (runtime / "merged").mkdir(parents=True, exist_ok=True) - return overlay_dir - - - -def test_mount_idmaps_sandbox_owned_lowerdir(tmp_path: Path) -> None: - """A lowerdir owned by l4d2-sandbox uid triggers an idmap bind mount. - - The overlay lowerdir= string must reference the idmap path, not the raw - overlay path. A mount --bind --map-users/--map-groups argv must be emitted - before the overlay mount argv. - """ - overlay_dir = _setup_instance_with_uid( - tmp_path, lowerdir_uid=FAKE_SANDBOX_UID, lowerdir_gid=FAKE_SANDBOX_GID - ) - try: - os.chown(overlay_dir, FAKE_SANDBOX_UID, FAKE_SANDBOX_GID) - except PermissionError: - pytest.skip("chown requires root — skip on unprivileged runner") - - result = _run(["mount", "alpha"], tmp_path) - assert result.returncode == 0, result.stderr - - lines = [l for l in result.stdout.splitlines() if l.strip()] - assert len(lines) == 2, f"expected 2 argv lines, got: {result.stdout!r}" - - bind_parts = shlex.split(lines[0]) - assert bind_parts[0] == "/bin/mount" - assert "--bind" in bind_parts - assert f"--map-users={FAKE_SANDBOX_UID}:{FAKE_LEFT4ME_UID}:1" in bind_parts - assert f"--map-groups={FAKE_SANDBOX_GID}:{FAKE_LEFT4ME_GID}:1" in bind_parts - assert bind_parts[-2] == str(overlay_dir) - idmap_target = str(tmp_path / "runtime" / "alpha" / "idmap" / "overlays_workshop") - assert bind_parts[-1] == idmap_target - - overlay_parts = shlex.split(lines[1]) - assert overlay_parts[0] == "/bin/mount" - assert overlay_parts[1:3] == ["-t", "overlay"] - options = overlay_parts[5] - assert f"lowerdir={idmap_target}:" in options, \ - f"lowerdir should start with idmap path; got: {options!r}" - assert str(overlay_dir) not in options, \ - f"raw overlay path should not appear in lowerdir; got: {options!r}" - - -def test_mount_skips_idmap_for_left4me_owned_lowerdir(tmp_path: Path) -> None: - """A lowerdir already owned by the left4me uid needs no idmap bind mount.""" - overlay_dir = _setup_instance_with_uid( - tmp_path, lowerdir_uid=FAKE_LEFT4ME_UID, lowerdir_gid=FAKE_LEFT4ME_GID - ) - # Best-effort chown to the left4me uid — skip if not root. - try: - os.chown(overlay_dir, FAKE_LEFT4ME_UID, FAKE_LEFT4ME_GID) - except PermissionError: - # Without root, st_uid is 0 or our own uid; neither matches FAKE_SANDBOX_UID, - # so the helper will correctly skip the idmap bind either way. - pass - - result = _run(["mount", "alpha"], tmp_path) - assert result.returncode == 0, result.stderr - - lines = [l for l in result.stdout.splitlines() if l.strip()] - assert len(lines) == 1, f"expected 1 argv line (no bind mount), got: {result.stdout!r}" - - overlay_parts = shlex.split(lines[0]) - assert overlay_parts[0] == "/bin/mount" - assert "--bind" not in overlay_parts - options = overlay_parts[5] - idmap_subdir = str(tmp_path / "runtime" / "alpha" / "idmap") - assert idmap_subdir not in options, f"idmap path should not appear; got: {options!r}" - assert str(overlay_dir) in options - - -def test_umount_unwinds_idmap_binds(tmp_path: Path) -> None: - """umount emits bind-umount lines for each idmap subdir, after the overlay umount.""" - _setup_instance(tmp_path) - # Pre-seed an idmap subdir as if a previous mount had set it up. - idmap_dir = tmp_path / "runtime" / "alpha" / "idmap" - idmap_dir.mkdir(parents=True) - idmap_sub = idmap_dir / "workshop" - idmap_sub.mkdir() - - result = _run(["umount", "alpha"], tmp_path) - assert result.returncode == 0, result.stderr - - lines = [l for l in result.stdout.splitlines() if l.strip()] - assert len(lines) >= 2, f"expected at least 2 argv lines, got: {result.stdout!r}" - - # First line: overlay umount - overlay_umount_parts = shlex.split(lines[0]) - assert overlay_umount_parts == [ - "/bin/umount", - str(tmp_path / "runtime" / "alpha" / "merged"), - ] - - # Subsequent lines: bind umounts for each idmap subdir - bind_umount_parts = shlex.split(lines[1]) - assert bind_umount_parts[0] == "/bin/umount" - assert bind_umount_parts[-1] == str(idmap_sub) - - @pytest.mark.skipif(sys.platform != "linux", reason="user.* xattrs are Linux-only") def test_rejects_upperdir_with_fuseoverlayfs_xattr(tmp_path: Path) -> None: _setup_instance(tmp_path) @@ -303,47 +168,3 @@ def test_rejects_upperdir_with_fuseoverlayfs_xattr(tmp_path: Path) -> None: result = _run(["mount", "alpha"], tmp_path) assert result.returncode != 0 assert "fuse-overlayfs xattr" in result.stderr - - -def _load_helper_module(): - """Import the helper script as a Python module for unit testing internals. - - The helper file has no .py extension, so importlib needs an explicit - SourceFileLoader rather than auto-detection. - """ - import importlib.util - from importlib.machinery import SourceFileLoader - loader = SourceFileLoader("left4me_overlay", str(HELPER_SOURCE)) - spec = importlib.util.spec_from_loader("left4me_overlay", loader) - assert spec is not None - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def test_is_mountpoint_detects_same_fs_bind_mount(tmp_path: Path) -> None: - """_is_mountpoint reads /proc/self/mountinfo so it works for same-fs bind mounts. - - Regression: os.path.ismount() compares st_dev against the parent, which - silently returns False for same-fs bind mounts. The idmap binds we install - on runtime//idmap/ are exactly that case, so an ismount-based - check skipped umount on stop and re-bound on top on start — accumulating - mount-table entries across stop/start cycles. - """ - helper = _load_helper_module() - - target = tmp_path / "some-bind" - target.mkdir() - abs_target = str(target.resolve()) - - mountinfo = tmp_path / "fake-mountinfo" - # mountinfo column 5 is the mountpoint; build a minimal line that exercises - # the parse without depending on the rest of the format. - mountinfo.write_text( - f"42 1 0:30 / {abs_target} rw,relatime - tmpfs tmpfs rw\n" - f"43 1 0:31 / /some/other/path rw,relatime - tmpfs tmpfs rw\n" - ) - - assert helper._is_mountpoint(target, str(mountinfo)) is True - assert helper._is_mountpoint(tmp_path / "not-a-mount", str(mountinfo)) is False - assert helper._is_mountpoint(target, str(tmp_path / "no-such-file")) is False