aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/config.py86
-rw-r--r--tests/test_config.py88
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()