diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/safe-rm-rf/test_safe_rm_rf.py | 171 |
1 files changed, 171 insertions, 0 deletions
diff --git a/tests/safe-rm-rf/test_safe_rm_rf.py b/tests/safe-rm-rf/test_safe_rm_rf.py new file mode 100644 index 0000000..0cf23c6 --- /dev/null +++ b/tests/safe-rm-rf/test_safe_rm_rf.py @@ -0,0 +1,171 @@ +"""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() |
