From b0af5349af3cdfbc3f22fd42d9b5d664e2b9e0f5 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sat, 19 Sep 2026 16:04:03 -0400 Subject: [PATCH] Add generic endpoint list parsing --- dns_sync/config.py | 75 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 49 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/test_config.py diff --git a/dns_sync/config.py b/dns_sync/config.py index 4f3b3ed..79ce412 100644 --- a/dns_sync/config.py +++ b/dns_sync/config.py @@ -1,9 +1,84 @@ import os from dataclasses import dataclass +from urllib.parse import urlsplit +from .adapters import Endpoint from .normalizer import normalize_hostname, normalize_ipv4 +def parse_endpoint_list( + plugin: str, + value: str, + default_port: int, +) -> tuple[Endpoint, ...]: + if not 1 <= default_port <= 65535: + raise RuntimeError( + "Endpoint default port must be between 1 and 65535" + ) + + raw_addresses = value.split(",") + if any(not item.strip() for item in raw_addresses): + raise RuntimeError( + f"{plugin.upper()} endpoint list contains an empty address" + ) + + endpoints: list[Endpoint] = [] + identities: set[str] = set() + + for raw_address in raw_addresses: + address = raw_address.strip().rstrip("/") + + if "://" in address: + raise RuntimeError( + f"{plugin.upper()} endpoint {raw_address!r} must not " + "include a URL scheme" + ) + + if any(character.isspace() for character in address): + raise RuntimeError( + f"{plugin.upper()} endpoint {raw_address!r} " + "contains whitespace" + ) + + parsed = urlsplit(f"//{address}") + if ( + not parsed.hostname + or parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + ): + raise RuntimeError( + f"Invalid {plugin.upper()} endpoint address: " + f"{raw_address!r}" + ) + + try: + port = parsed.port or default_port + except ValueError as exc: + raise RuntimeError( + f"Invalid {plugin.upper()} endpoint port: " + f"{raw_address!r}" + ) from exc + + host = parsed.hostname.lower() + normalized_address = ( + f"[{host}]:{port}" if ":" in host else f"{host}:{port}" + ) + endpoint = Endpoint(plugin, normalized_address) + + if endpoint.identity in identities: + raise RuntimeError( + f"Duplicate {plugin.upper()} endpoint: {endpoint.address}" + ) + + identities.add(endpoint.identity) + endpoints.append(endpoint) + + return tuple(endpoints) + + def env_bool(name: str, default: bool) -> bool: value = os.getenv(name) if value is None: diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f140245 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,49 @@ +import unittest + +from dns_sync.config import parse_endpoint_list + + +class EndpointParsingTests(unittest.TestCase): + def test_comma_separated_addresses_use_default_port(self): + endpoints = parse_endpoint_list( + "npm", + "192.168.1.3, 192.168.1.4:8081,192.168.1.5", + 81, + ) + + self.assertEqual( + tuple(endpoint.identity for endpoint in endpoints), + ( + "npm://192.168.1.3:81", + "npm://192.168.1.4:8081", + "npm://192.168.1.5:81", + ), + ) + + def test_duplicate_is_rejected_after_normalization(self): + with self.assertRaisesRegex(RuntimeError, "Duplicate NPM"): + parse_endpoint_list( + "npm", + "192.168.1.3,192.168.1.3:81", + 81, + ) + + def test_empty_entry_is_rejected(self): + with self.assertRaisesRegex(RuntimeError, "empty address"): + parse_endpoint_list( + "npm", + "192.168.1.3,,192.168.1.5", + 81, + ) + + def test_url_scheme_is_rejected(self): + with self.assertRaisesRegex(RuntimeError, "must not include"): + parse_endpoint_list( + "npm", + "http://192.168.1.3:81", + 81, + ) + + +if __name__ == "__main__": + unittest.main()