aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-20 21:53:58 -0400
committerCraig Jennings <c@cjennings.net>2026-05-20 21:53:58 -0400
commitcb209a2d01f5c17024738b490c8fa109959b5303 (patch)
treef62a37e3b1ee3ffacaf3085676cc213fe8bff0c6 /tests
parentc6c7a48b81e5592e1f37947a5532dc202ab701e3 (diff)
downloadarchsetup-cb209a2d01f5c17024738b490c8fa109959b5303.tar.gz
archsetup-cb209a2d01f5c17024738b490c8fa109959b5303.zip
fix(installer): guard constructed-path rm -rf deletes
Three rm -rf sites in archsetup delete paths built from variables: $state_dir for --fresh, and $source_dir/$prog_name for the git and AUR clone-retry cleanups. If a path variable were empty or malformed (preflight skipped, a degenerate git URL), the delete could expand to a top-level or otherwise unintended directory. I added a safe_rm_rf <path> <allowed_prefix> helper that refuses to run unless the target is absolute, free of '..', deeper than a bare top-level dir, strictly inside the allowed prefix, and a real directory rather than a symlink. On the happy path it delegates to rm -rf, so successful installs are unchanged. The helper is self-contained and defined before the top-level --fresh handler, which runs before the logging helpers exist. I covered the guard with unit tests under tests/safe-rm-rf/ that source the real function and exercise normal, boundary, and error cases against temp directories.
Diffstat (limited to 'tests')
-rw-r--r--tests/safe-rm-rf/test_safe_rm_rf.py171
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()