diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-08 13:55:04 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-08 13:55:04 +0200 |
| commit | b491b772b9f68c0c61a62597a972b6cc7848466e (patch) | |
| tree | b0e80c555af102430cd45cb5180d58c20f156fd3 | |
| parent | ab8be8e3d58a5c2dd8b351110822063b482311e1 (diff) | |
| download | abusectl-b491b772b9f68c0c61a62597a972b6cc7848466e.tar.gz abusectl-b491b772b9f68c0c61a62597a972b6cc7848466e.zip | |
feat: read and validate the config
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
| -rw-r--r-- | abusectl/config.py | 86 | ||||
| -rw-r--r-- | tests/test_config.py | 88 |
2 files changed, 174 insertions, 0 deletions
diff --git a/abusectl/config.py b/abusectl/config.py new file mode 100644 index 0000000..c7ba870 --- /dev/null +++ b/abusectl/config.py @@ -0,0 +1,86 @@ +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Read and validate ~/.config/abusectl/config.toml. + +A key that was skipped at setup is ABSENT from the file, never an empty +string: api_key = "" reads as configured-and-broken and produces a confusing +auth error much later, while an absent key reads as not-configured and the +part that wants it can say so plainly. Later parts of this tool add MISP and +vendor API keys under their own tables in this same file; this module only +reads [general] and leaves other sections alone. + +Not-configured is a distinct condition, not a crash. This parser refuses to +guess which mail relays are the user's own, because guessing wrong means +sending an abuse report about an innocent third party. So a missing config, +or one naming no trusted relays, raises NotConfigured, which the command line +turns into "run `abusectl init`" with its own exit code. +""" + +import ipaddress +import os +import pathlib +import tomllib +from dataclasses import dataclass + + +class NotConfigured(Exception): + """Raised when there is no usable config: missing file, or no trusted + relays named. + + The parser will not guess the trust boundary (which relays are the + user's own), so either condition is treated the same way. The command + line catches this and points the user at `abusectl init` instead of + printing a traceback. + """ + + +DEFAULT_CASES = pathlib.Path( + os.environ.get("XDG_DATA_HOME", str(pathlib.Path.home() / ".local" / "share")) +) / "abusectl" + + +def path() -> pathlib.Path: + """Return the config file path, reading XDG_CONFIG_HOME at call time.""" + base = os.environ.get("XDG_CONFIG_HOME", str(pathlib.Path.home() / ".config")) + return pathlib.Path(base) / "abusectl" / "config.toml" + + +@dataclass(frozen=True) +class Config: + trusted_relays: list[str] + cases: pathlib.Path + + +def load(from_path: pathlib.Path | None = None) -> Config: + source = from_path if from_path is not None else path() + + try: + with open(source, "rb") as f: + data = tomllib.load(f) + except FileNotFoundError: + raise NotConfigured(f"no config at {source}: run `abusectl init`") + + general = data.get("general", {}) + + trusted_relays = general.get("trusted_relays") or [] + if not trusted_relays: + raise NotConfigured(f"no trusted_relays in {source}: run `abusectl init`") + + for relay in trusted_relays: + ipaddress.ip_network(relay, strict=False) + + cases = pathlib.Path(general["cases"]).expanduser() if "cases" in general else DEFAULT_CASES + + return Config(trusted_relays=trusted_relays, cases=cases) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c0a9ac4 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,88 @@ +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Tests for reading and validating ~/.config/abusectl/config.toml.""" + +import os +import pathlib +import tempfile +import unittest +from unittest import mock + +from abusectl import config + + +class TestConfig(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _write(self, text: str) -> pathlib.Path: + path = self.root / "config.toml" + path.write_text(text, encoding="utf-8") + return path + + def test_trusted_relays_and_cases_are_read(self): + path = self._write( + "[general]\n" + 'cases = "~/cases"\n' + 'trusted_relays = ["192.0.2.0/24"]\n' + ) + loaded = config.load(path) + self.assertEqual(loaded.trusted_relays, ["192.0.2.0/24"]) + self.assertEqual(loaded.cases, pathlib.Path.home() / "cases") + + def test_a_missing_file_is_reported_as_not_configured(self): + with self.assertRaises(config.NotConfigured): + config.load(self.root / "absent.toml") + + def test_an_empty_relay_list_is_not_configured(self): + # Present but empty is the same as absent: parse must refuse either + # way rather than guess, so they are one error. + path = self._write("[general]\ntrusted_relays = []\n") + with self.assertRaises(config.NotConfigured): + config.load(path) + + def test_a_malformed_cidr_is_rejected_at_load(self): + # Reported against the file that holds the typo, not later against a + # message that did nothing wrong. + path = self._write('[general]\ntrusted_relays = ["not-a-network"]\n') + with self.assertRaises(ValueError): + config.load(path) + + def test_the_cases_path_has_a_default(self): + path = self._write('[general]\ntrusted_relays = ["192.0.2.0/24"]\n') + self.assertEqual(config.load(path).cases, config.DEFAULT_CASES) + + def test_malformed_toml_is_not_reported_as_not_configured(self): + # A syntax error is a broken file, which is a different problem from + # an absent one and must not be answered with "run abusectl init". + path = self._write("[general\ntrusted_relays = [") + with self.assertRaises(Exception) as caught: + config.load(path) + self.assertNotIsInstance(caught.exception, config.NotConfigured) + + def test_the_config_path_follows_xdg_config_home(self): + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/xdg-probe"}): + self.assertEqual( + config.path(), + pathlib.Path("/tmp/xdg-probe/abusectl/config.toml"), + ) + + +if __name__ == "__main__": + unittest.main() |
