"""Tests for the safe_rm_rf guard helper in the archsetup installer. safe_rm_rf is a defensive wrapper around `rm -rf`: it refuses to delete a path unless the path is absolute, free of '..', deeper than a bare top-level dir, strictly inside a caller-supplied allowed prefix, and a real directory (not a symlink). On the happy path it delegates to `rm -rf`. These tests exercise the REAL function body, extracted from the `archsetup` script at run time (not a copy), with a stub `error_warn` standing in for the installer's logger. The delete runs against real temp dirs the test creates and tears down, so the rm path is genuinely exercised. Run from repo root: python3 -m unittest tests.safe-rm-rf.test_safe_rm_rf """ import os import shutil import subprocess import tempfile import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") class SafeRmRfHarness(unittest.TestCase): """Source safe_rm_rf out of the real archsetup script and invoke it.""" def setUp(self): self.tmp = tempfile.mkdtemp(prefix="safe-rm-rf-test-") # A bash wrapper that extracts just the safe_rm_rf function from the # real installer and invokes it with the test's args. Sourcing the # sed-extracted function means we test the production code path, not a # reimplementation. The helper is self-contained (it prints its own # refusal reasons), so no logger stub is needed. self.wrapper = os.path.join(self.tmp, "run.sh") with open(self.wrapper, "w") as f: f.write( "#!/bin/bash\n" 'ARCHSETUP="$1"; shift\n' "source <(sed -n '/^safe_rm_rf() {/,/^}/p' \"$ARCHSETUP\")\n" 'safe_rm_rf "$@"\n' ) os.chmod(self.wrapper, 0o755) def tearDown(self): shutil.rmtree(self.tmp, ignore_errors=True) def run_guard(self, target, prefix): return subprocess.run( ["bash", self.wrapper, ARCHSETUP, target, prefix], capture_output=True, text=True, timeout=10, ) def make_dir(self, *parts): path = os.path.join(self.tmp, *parts) os.makedirs(path, exist_ok=True) return path # ----------------------------------------------------------------------------- # Normal cases # ----------------------------------------------------------------------------- class TestSafeRmRfNormal(SafeRmRfHarness): def test_real_dir_under_prefix_is_deleted(self): prefix = self.make_dir("src") target = self.make_dir("src", "repo") result = self.run_guard(target, prefix) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertFalse(os.path.exists(target), "target should be deleted") self.assertTrue(os.path.isdir(prefix), "prefix must survive") def test_nested_dir_with_contents_is_deleted(self): prefix = self.make_dir("src") target = self.make_dir("src", "repo", "sub") with open(os.path.join(target, "file.txt"), "w") as f: f.write("data") result = self.run_guard(os.path.join(prefix, "repo"), prefix) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertFalse(os.path.exists(os.path.join(prefix, "repo"))) # ----------------------------------------------------------------------------- # Boundary cases # ----------------------------------------------------------------------------- class TestSafeRmRfBoundary(SafeRmRfHarness): def test_target_already_absent_succeeds_quietly(self): prefix = self.make_dir("src") target = os.path.join(prefix, "never-existed") result = self.run_guard(target, prefix) self.assertEqual(result.returncode, 0, msg=result.stderr) def test_target_equal_to_prefix_is_refused(self): prefix = self.make_dir("src") result = self.run_guard(prefix, prefix) self.assertNotEqual(result.returncode, 0) self.assertTrue(os.path.isdir(prefix), "prefix must not be deleted") def test_symlink_to_dir_under_prefix_is_refused(self): prefix = self.make_dir("src") real = self.make_dir("src", "real") link = os.path.join(prefix, "link") os.symlink(real, link) result = self.run_guard(link, prefix) self.assertNotEqual(result.returncode, 0) self.assertTrue(os.path.isdir(real), "symlink target must survive") self.assertTrue(os.path.islink(link), "symlink itself must survive") def test_prefix_lookalike_is_refused(self): # target shares a textual prefix but is not inside it: # /.../srcX is NOT under /.../src prefix = self.make_dir("src") sibling = self.make_dir("srcX") result = self.run_guard(sibling, prefix) self.assertNotEqual(result.returncode, 0) self.assertTrue(os.path.isdir(sibling), "lookalike sibling must survive") # ----------------------------------------------------------------------------- # Error cases # ----------------------------------------------------------------------------- class TestSafeRmRfErrors(SafeRmRfHarness): def test_empty_target_is_refused(self): prefix = self.make_dir("src") result = self.run_guard("", prefix) self.assertNotEqual(result.returncode, 0) def test_empty_prefix_is_refused(self): target = self.make_dir("src", "repo") result = self.run_guard(target, "") self.assertNotEqual(result.returncode, 0) self.assertTrue(os.path.isdir(target), "target must survive a bad call") def test_relative_path_is_refused(self): result = self.run_guard("foo/bar", "/var/lib/archsetup") self.assertNotEqual(result.returncode, 0) def test_root_is_refused(self): result = self.run_guard("/", "/var/lib/archsetup") self.assertNotEqual(result.returncode, 0) def test_bare_top_level_dir_is_refused(self): result = self.run_guard("/home", "/home") self.assertNotEqual(result.returncode, 0) self.assertTrue(os.path.isdir("/home"), "/home must never be touched") def test_path_with_dotdot_is_refused(self): prefix = self.make_dir("src") # textually inside the prefix but contains a traversal segment sneaky = os.path.join(prefix, "..", "src", "repo") result = self.run_guard(sneaky, prefix) self.assertNotEqual(result.returncode, 0) def test_path_outside_prefix_is_refused(self): prefix = self.make_dir("src") outside = self.make_dir("other") result = self.run_guard(outside, prefix) self.assertNotEqual(result.returncode, 0) self.assertTrue(os.path.isdir(outside), "outside dir must survive") if __name__ == "__main__": unittest.main()