From 0f5b1e38f448b554b13e6f6189cb8b7fbd22a95e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 8 Sep 2026 13:57:11 +0200 Subject: fix: reject non-list relays and empty cases values An empty cases string resolved to Path("") = cwd, scattering evidence wherever the command happened to run. A string trusted_relays (easy to hand-write without brackets) iterated as characters, failing on '1' with an error naming nothing findable in the file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R --- abusectl/config.py | 22 ++++++++++++++++++++-- tests/test_config.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/abusectl/config.py b/abusectl/config.py index c7ba870..38f5254 100644 --- a/abusectl/config.py +++ b/abusectl/config.py @@ -74,13 +74,31 @@ def load(from_path: pathlib.Path | None = None) -> Config: general = data.get("general", {}) - trusted_relays = general.get("trusted_relays") or [] + trusted_relays = general.get("trusted_relays", []) + # A string is iterable, so an unbracketed hand-edit like + # trusted_relays = "192.0.2.0/24" would otherwise validate its + # characters one at a time and fail with an error naming nothing the + # user can find in their file. Reject it up front instead. + if not isinstance(trusted_relays, (list, tuple)): + raise ValueError( + f"trusted_relays in {source} must be a list, got " + f"{type(trusted_relays).__name__}" + ) if not trusted_relays: raise NotConfigured(f"no trusted_relays in {source}: run `abusectl init`") for relay in trusted_relays: + if not isinstance(relay, str): + raise ValueError( + f"trusted_relays entry in {source} must be a string, got " + f"{type(relay).__name__}: {relay!r}" + ) ipaddress.ip_network(relay, strict=False) - cases = pathlib.Path(general["cases"]).expanduser() if "cases" in general else DEFAULT_CASES + # An empty or whitespace-only value is a skipped answer, same as an + # absent key: it must not resolve to Path("") and scatter evidence into + # whatever directory the command happened to run from. + raw_cases = general.get("cases", "") + cases = pathlib.Path(raw_cases).expanduser() if raw_cases.strip() else DEFAULT_CASES return Config(trusted_relays=trusted_relays, cases=cases) diff --git a/tests/test_config.py b/tests/test_config.py index c0a9ac4..6fa8619 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -76,6 +76,35 @@ class TestConfig(unittest.TestCase): config.load(path) self.assertNotIsInstance(caught.exception, config.NotConfigured) + def test_an_empty_cases_value_falls_back_to_the_default(self): + # Empty is the same as absent, per this module's own rule: writing + # Path("") would put evidence in whatever directory the command + # happened to run from. + path = self._write( + '[general]\ntrusted_relays = ["192.0.2.0/24"]\ncases = ""\n' + ) + self.assertEqual(config.load(path).cases, config.DEFAULT_CASES) + + def test_a_whitespace_cases_value_falls_back_to_the_default(self): + path = self._write( + '[general]\ntrusted_relays = ["192.0.2.0/24"]\ncases = " "\n' + ) + self.assertEqual(config.load(path).cases, config.DEFAULT_CASES) + + def test_a_string_trusted_relays_is_rejected_clearly(self): + # Easy to write by hand without the brackets. Iterating the string + # validates single characters and reports an error naming nothing + # the user can find in their file. + path = self._write('[general]\ntrusted_relays = "192.0.2.0/24"\n') + with self.assertRaises(ValueError) as caught: + config.load(path) + self.assertIn("must be a list", str(caught.exception)) + + def test_a_relay_entry_that_is_not_a_string_is_rejected_clearly(self): + path = self._write("[general]\ntrusted_relays = [42]\n") + with self.assertRaises(ValueError): + config.load(path) + def test_the_config_path_follows_xdg_config_home(self): with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/xdg-probe"}): self.assertEqual( -- cgit v1.2.3