"""Step definitions for overlay_sandbox_coverage_r3.feature. Exercises uncovered lines in overlay.py including _is_overlayfs_available, real overlay mode, _mount_overlay, _unmount_overlay, commit/rollback error paths, and cleanup edge cases. All step text uses the ``oscov3`` prefix to avoid AmbiguousStep errors. """ from __future__ import annotations import contextlib import io import os import shutil import subprocess import tempfile from unittest.mock import MagicMock, patch from behave import given, then, when from cleveragents.infrastructure.sandbox.overlay import ( OverlaySandbox, _is_overlayfs_available, ) from cleveragents.infrastructure.sandbox.protocol import ( SandboxCommitError, SandboxCreationError, SandboxRollbackError, SandboxStateError, SandboxStatus, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _cleanup_patcher(context, attr_name): """Stop a patcher stored on context and remove the attribute.""" patcher = getattr(context, attr_name, None) if patcher is not None: with contextlib.suppress(RuntimeError): patcher.stop() # =================================================================== # Group 1: _is_overlayfs_available() steps # =================================================================== @given("oscov3 the lru_cache for _is_overlayfs_available is cleared") def given_oscov3_clear_lru_cache(context): """Clear the lru_cache so the function executes fresh.""" _is_overlayfs_available.cache_clear() context.oscov3_error = None context.oscov3_result = None # Track patchers for cleanup context.oscov3_patchers = [] @given('oscov3 /proc/filesystems is mocked to contain "{content}"') def given_oscov3_proc_fs_content(context, content): """Mock /proc/filesystems to contain specific text (no 'overlay').""" # os.path.isfile returns True for /proc/filesystems p1 = patch( "cleveragents.infrastructure.sandbox.overlay.os.path.isfile", return_value=True, ) p1.start() context.oscov3_patchers.append(p1) context.add_cleanup(p1.stop) # builtins open returns the content p2 = patch( "builtins.open", return_value=io.StringIO(content), ) p2.start() context.oscov3_patchers.append(p2) context.add_cleanup(p2.stop) @given("oscov3 os.path.isfile is mocked to return False for /proc/filesystems") def given_oscov3_isfile_false(context): """Mock os.path.isfile to return False.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.os.path.isfile", return_value=False, ) p.start() context.oscov3_patchers.append(p) context.add_cleanup(p.stop) @given("oscov3 open is mocked to raise OSError for /proc/filesystems") def given_oscov3_open_raises(context): """Mock open and os.path.isfile so that reading /proc/filesystems raises.""" p1 = patch( "cleveragents.infrastructure.sandbox.overlay.os.path.isfile", return_value=True, ) p1.start() context.oscov3_patchers.append(p1) context.add_cleanup(p1.stop) p2 = patch( "builtins.open", side_effect=OSError("mocked OSError"), ) p2.start() context.oscov3_patchers.append(p2) context.add_cleanup(p2.stop) @given("oscov3 the system is mocked as root with overlay in proc filesystems") def given_oscov3_root_with_overlay(context): """Mock system as root with overlay present in /proc/filesystems.""" p1 = patch( "cleveragents.infrastructure.sandbox.overlay.os.path.isfile", return_value=True, ) p1.start() context.oscov3_patchers.append(p1) context.add_cleanup(p1.stop) p2 = patch( "builtins.open", return_value=io.StringIO("nodev\toverlay\n"), ) p2.start() context.oscov3_patchers.append(p2) context.add_cleanup(p2.stop) p3 = patch( "cleveragents.infrastructure.sandbox.overlay.os.geteuid", return_value=0, ) p3.start() context.oscov3_patchers.append(p3) context.add_cleanup(p3.stop) @given("oscov3 subprocess.run is mocked for successful mount probe") def given_oscov3_mount_probe_success(context): """Mock subprocess.run for mount probe: mount returns 0, umount succeeds.""" mount_result = MagicMock() mount_result.returncode = 0 umount_result = MagicMock() umount_result.returncode = 0 call_count = {"n": 0} def mock_run(cmd, **kwargs): call_count["n"] += 1 if call_count["n"] == 1: return mount_result return umount_result p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=mock_run, ) p.start() context.oscov3_patchers.append(p) context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked for failed mount probe") def given_oscov3_mount_probe_fail(context): """Mock subprocess.run for mount probe: mount returns non-zero.""" mount_result = MagicMock() mount_result.returncode = 1 p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", return_value=mount_result, ) p.start() context.oscov3_patchers.append(p) context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked to raise OSError during probe") def given_oscov3_probe_oserror(context): """Mock subprocess.run to raise OSError during mount probe.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=OSError("mocked OSError"), ) p.start() context.oscov3_patchers.append(p) context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked to raise TimeoutExpired during probe") def given_oscov3_probe_timeout(context): """Mock subprocess.run to raise TimeoutExpired during mount probe.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="mount", timeout=5), ) p.start() context.oscov3_patchers.append(p) context.add_cleanup(p.stop) @when("oscov3 _is_overlayfs_available is called") def when_oscov3_is_overlayfs_called(context): """Call _is_overlayfs_available and store the result.""" try: context.oscov3_result = _is_overlayfs_available() except Exception as exc: context.oscov3_error = exc finally: # Always clear cache after test to not affect other tests _is_overlayfs_available.cache_clear() @then("oscov3 the result should be False") def then_oscov3_result_false(context): """Assert result is False.""" assert context.oscov3_result is False, ( f"Expected False, got {context.oscov3_result}" ) @then("oscov3 the result should be True") def then_oscov3_result_true(context): """Assert result is True.""" assert context.oscov3_result is True, f"Expected True, got {context.oscov3_result}" # =================================================================== # Group 2: Real overlay in create # =================================================================== @given("oscov3 a test directory is initialised") def given_oscov3_test_directory(context): """Create a temporary directory with test files.""" context.oscov3_tmpdir = tempfile.mkdtemp(prefix="oscov3-test-") context.add_cleanup(shutil.rmtree, context.oscov3_tmpdir, True) context.oscov3_original = os.path.join(context.oscov3_tmpdir, "original") os.makedirs(context.oscov3_original) # Create a test file with open(os.path.join(context.oscov3_original, "existing.txt"), "w") as fh: fh.write("original content") context.oscov3_error = None context.oscov3_sandbox = None context.oscov3_commit_result = None context.oscov3_mount_mock = None context.oscov3_unmount_mock = None @given("oscov3 a sandbox is created with _use_real_overlay forced to True") def given_oscov3_sandbox_real_overlay(context): """Create sandbox and force _use_real_overlay to True before create().""" context.oscov3_sandbox = OverlaySandbox( resource_id="res-oscov3", original_path=context.oscov3_original, ) context.oscov3_sandbox._use_real_overlay = True @given("oscov3 _mount_overlay is mocked to succeed") def given_oscov3_mount_overlay_mocked(context): """Mock _mount_overlay to be a no-op.""" p = patch.object(context.oscov3_sandbox, "_mount_overlay") context.oscov3_mount_mock = p.start() context.add_cleanup(p.stop) @when('oscov3 the sandbox create is called for plan "{plan_id}"') def when_oscov3_create_called(context, plan_id): """Call create() on the sandbox.""" try: context.oscov3_sandbox.create(plan_id) except Exception as exc: context.oscov3_error = exc @then('oscov3 the sandbox should be in "{state}" state') def then_oscov3_sandbox_state(context, state): """Assert the sandbox is in the expected state.""" assert context.oscov3_sandbox.status.value == state, ( f"Expected {state}, got {context.oscov3_sandbox.status.value}" ) @then("oscov3 _mount_overlay should have been called") def then_oscov3_mount_called(context): """Assert _mount_overlay was invoked.""" assert context.oscov3_mount_mock is not None context.oscov3_mount_mock.assert_called_once() # =================================================================== # Group 3: Commit edge cases # =================================================================== @given('oscov3 a sandbox is created and activated for plan "{plan_id}"') def given_oscov3_sandbox_active(context, plan_id): """Create a sandbox and activate via get_path.""" context.oscov3_sandbox = OverlaySandbox( resource_id="res-oscov3", original_path=context.oscov3_original, ) context.oscov3_sandbox.create(plan_id=plan_id) context.oscov3_sandbox.get_path("existing.txt") @given("oscov3 merged_dir is forced to None") def given_oscov3_merged_dir_none(context): """Force the sandbox _merged_dir to None.""" context.oscov3_sandbox._merged_dir = None @when("oscov3 commit is attempted") def when_oscov3_commit_attempted(context): """Attempt commit, catching errors.""" try: context.oscov3_commit_result = context.oscov3_sandbox.commit() except Exception as exc: context.oscov3_error = exc @then('oscov3 a SandboxStateError should have been raised with "{msg}"') def then_oscov3_state_error_with_msg(context, msg): """Assert a SandboxStateError was raised with expected message.""" assert context.oscov3_error is not None, ( "Expected SandboxStateError but none raised" ) assert isinstance(context.oscov3_error, SandboxStateError), ( f"Expected SandboxStateError, got {type(context.oscov3_error).__name__}" ) assert msg in str(context.oscov3_error), ( f"Expected '{msg}' in '{context.oscov3_error}'" ) @given('oscov3 a file "{filename}" is added to the sandbox') def given_oscov3_file_added(context, filename): """Create a file in the sandbox merged directory.""" merged = context.oscov3_sandbox._merged_dir filepath = os.path.join(merged, filename) os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "w") as fh: fh.write("test content for " + filename) @given("oscov3 backup_directory is mocked to raise RuntimeError") def given_oscov3_backup_raises(context): """Mock backup_directory to raise RuntimeError.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.backup_directory", side_effect=RuntimeError("backup failed"), ) p.start() context.add_cleanup(p.stop) @then("oscov3 a SandboxCommitError should have been raised") def then_oscov3_commit_error(context): """Assert a SandboxCommitError was raised.""" assert context.oscov3_error is not None, "Expected SandboxCommitError" assert isinstance(context.oscov3_error, SandboxCommitError), ( f"Expected SandboxCommitError, got {type(context.oscov3_error).__name__}" ) @given("oscov3 copy2 is mocked to fail after backup succeeds") def given_oscov3_copy2_fails_after_backup(context): """Mock shutil.copy2 to raise OSError (backup_directory is NOT mocked).""" p = patch( "cleveragents.infrastructure.sandbox.overlay.shutil.copy2", side_effect=OSError("disk full during copy"), ) p.start() context.add_cleanup(p.stop) @given("oscov3 safe_restore is mocked to raise RuntimeError") def given_oscov3_safe_restore_fails(context): """Mock safe_restore to raise RuntimeError (simulates restore failure).""" p = patch( "cleveragents.infrastructure.sandbox.overlay.safe_restore", side_effect=RuntimeError("restore failed"), ) p.start() context.add_cleanup(p.stop) # =================================================================== # Group 4: Rollback edge cases # =================================================================== @when("oscov3 rollback is attempted") def when_oscov3_rollback_attempted(context): """Attempt rollback, catching errors.""" try: context.oscov3_sandbox.rollback() except Exception as exc: context.oscov3_error = exc @given("oscov3 _use_real_overlay is forced to True") def given_oscov3_force_real_overlay(context): """Force the sandbox to think it has real overlay support.""" context.oscov3_sandbox._use_real_overlay = True @given("oscov3 the sandbox is committed successfully") def given_oscov3_committed(context): """Commit the sandbox (should succeed).""" context.oscov3_commit_result = context.oscov3_sandbox.commit() assert context.oscov3_sandbox.status == SandboxStatus.COMMITTED @given("oscov3 subprocess.run is mocked for umount and mount during rollback") def given_oscov3_subprocess_umount_mount(context): """Mock subprocess.run to handle umount (check=True) and _mount_overlay.""" # For COMMITTED rollback with real overlay: # 1. umount is called via subprocess.run with check=True (line 437-441) # 2. _mount_overlay is called which also uses subprocess.run umount_result = MagicMock() umount_result.returncode = 0 mount_result = MagicMock() mount_result.returncode = 0 p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", return_value=mount_result, ) p.start() context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked to raise CalledProcessError for umount") def given_oscov3_umount_raises_cpe(context): """Mock subprocess.run to raise CalledProcessError (umount failure).""" p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=subprocess.CalledProcessError( returncode=1, cmd=["umount", "/tmp/merged"] ), ) p.start() context.add_cleanup(p.stop) @when("oscov3 rollback is called") def when_oscov3_rollback_called(context): """Call rollback and catch any errors.""" try: context.oscov3_sandbox.rollback() except Exception as exc: context.oscov3_error = exc @then("oscov3 a SandboxRollbackError should have been raised") def then_oscov3_rollback_error(context): """Assert a SandboxRollbackError was raised.""" assert context.oscov3_error is not None, "Expected SandboxRollbackError" assert isinstance(context.oscov3_error, SandboxRollbackError), ( f"Expected SandboxRollbackError, got {type(context.oscov3_error).__name__}" ) @given("oscov3 subprocess.run is mocked for unmount and mount during active rollback") def given_oscov3_subprocess_active_rollback(context): """Mock subprocess.run for ACTIVE rollback with real overlay. _unmount_overlay calls subprocess.run with check=True. _mount_overlay also calls subprocess.run with check=True. """ result = MagicMock() result.returncode = 0 p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", return_value=result, ) p.start() context.add_cleanup(p.stop) # =================================================================== # Group 5: Cleanup # =================================================================== @given('oscov3 a sandbox is created for plan "{plan_id}"') def given_oscov3_sandbox_created(context, plan_id): """Create and initialise a sandbox.""" context.oscov3_sandbox = OverlaySandbox( resource_id="res-oscov3", original_path=context.oscov3_original, ) context.oscov3_sandbox.create(plan_id=plan_id) @given("oscov3 _unmount_overlay is mocked") def given_oscov3_unmount_mocked(context): """Mock _unmount_overlay.""" p = patch.object(context.oscov3_sandbox, "_unmount_overlay") context.oscov3_unmount_mock = p.start() context.add_cleanup(p.stop) @when("oscov3 cleanup is called") def when_oscov3_cleanup(context): """Call cleanup on the sandbox.""" try: context.oscov3_sandbox.cleanup() except Exception as exc: context.oscov3_error = exc @then("oscov3 _unmount_overlay should have been called") def then_oscov3_unmount_called(context): """Assert _unmount_overlay was called.""" assert context.oscov3_unmount_mock is not None context.oscov3_unmount_mock.assert_called_once() @given("oscov3 a pre_commit_backup directory exists") def given_oscov3_pre_commit_backup_exists(context): """Create a pre_commit_backup directory and set it on the sandbox.""" backup_dir = tempfile.mkdtemp(prefix="oscov3-backup-", dir=context.oscov3_tmpdir) # Put a file in it so rmtree has something to clean with open(os.path.join(backup_dir, "backup.txt"), "w") as fh: fh.write("backup data") context.oscov3_sandbox._pre_commit_backup = backup_dir context.oscov3_backup_path = backup_dir @then("oscov3 pre_commit_backup should be None") def then_oscov3_backup_none(context): """Assert _pre_commit_backup is None after cleanup.""" assert context.oscov3_sandbox._pre_commit_backup is None @given("oscov3 pre_commit_backup is set to a non-existent path") def given_oscov3_backup_nonexistent(context): """Set _pre_commit_backup to a path that does not exist.""" context.oscov3_sandbox._pre_commit_backup = os.path.join( context.oscov3_tmpdir, "nonexistent-backup-dir" ) # =================================================================== # Group 6: _mount_overlay / _unmount_overlay # =================================================================== @given("oscov3 _original_path contains a comma") def given_oscov3_original_path_comma(context): """Set _original_path to contain a comma.""" context.oscov3_sandbox._original_path = "/tmp/path,with,commas" @given("oscov3 _upper_dir contains a comma") def given_oscov3_upper_dir_comma(context): """Set _upper_dir to contain a comma.""" context.oscov3_sandbox._upper_dir = "/tmp/upper,dir" @when("oscov3 _mount_overlay is called directly") def when_oscov3_mount_overlay_direct(context): """Call _mount_overlay directly and catch errors.""" try: context.oscov3_sandbox._mount_overlay() except Exception as exc: context.oscov3_error = exc @then('oscov3 a SandboxCreationError should have been raised with "{msg}"') def then_oscov3_creation_error_msg(context, msg): """Assert a SandboxCreationError was raised with expected message.""" assert context.oscov3_error is not None, "Expected SandboxCreationError" assert isinstance(context.oscov3_error, SandboxCreationError), ( f"Expected SandboxCreationError, got {type(context.oscov3_error).__name__}" ) assert msg.lower() in str(context.oscov3_error).lower(), ( f"Expected '{msg}' in '{context.oscov3_error}'" ) @given("oscov3 subprocess.run is mocked for successful mount") def given_oscov3_subprocess_mount_ok(context): """Mock subprocess.run to succeed for mount.""" result = MagicMock() result.returncode = 0 p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", return_value=result, ) p.start() context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked to raise CalledProcessError for mount") def given_oscov3_subprocess_mount_cpe(context): """Mock subprocess.run to raise CalledProcessError.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=subprocess.CalledProcessError( returncode=1, cmd=["mount", "-t", "overlay"] ), ) p.start() context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked to raise TimeoutExpired for mount") def given_oscov3_subprocess_mount_timeout(context): """Mock subprocess.run to raise TimeoutExpired.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="mount", timeout=30), ) p.start() context.add_cleanup(p.stop) @given("oscov3 subprocess.run is mocked to raise OSError for mount") def given_oscov3_subprocess_mount_oserror(context): """Mock subprocess.run to raise OSError.""" p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", side_effect=OSError("no such file"), ) p.start() context.add_cleanup(p.stop) @then("oscov3 no error should have been raised") def then_oscov3_no_error(context): """Assert no error was raised.""" assert context.oscov3_error is None, ( f"Expected no error, got {context.oscov3_error}" ) @given("oscov3 subprocess.run is mocked for successful umount") def given_oscov3_subprocess_umount_ok(context): """Mock subprocess.run to succeed for umount.""" result = MagicMock() result.returncode = 0 p = patch( "cleveragents.infrastructure.sandbox.overlay.subprocess.run", return_value=result, ) p.start() context.add_cleanup(p.stop) @when("oscov3 _unmount_overlay is called directly") def when_oscov3_unmount_direct(context): """Call _unmount_overlay directly and catch errors.""" try: context.oscov3_sandbox._unmount_overlay() except Exception as exc: context.oscov3_error = exc