"""Strict TOML configuration with file-backed secrets.""" from __future__ import annotations import os import re import stat from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any from urllib.parse import urlsplit import tomllib class ConfigError(ValueError): pass _CLIENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") _DURATION = re.compile(r"^([1-9][0-9]*)(ms|s|m|h)$") _FACTORS = {"ms": 0.001, "s": 1, "m": 60, "h": 3600} _ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") @dataclass(frozen=True) class RootMapping: api_root: PurePosixPath local_root: Path local_path_overrides: tuple[tuple[PurePosixPath, Path], ...] = () def api_to_local(self, api_path: str) -> Path: candidate = PurePosixPath(api_path) for api_root, local_root in self.local_path_overrides: relative = _safe_relative(candidate, api_root) if relative is not None: return local_root.joinpath(*relative.parts) relative = _safe_relative(candidate, self.api_root) if relative is None: raise ConfigError("API path is outside its configured root") return self.local_root.joinpath(*relative.parts) def local_root_for_api(self, api_path: str) -> Path: candidate = PurePosixPath(api_path) for api_root, local_root in self.local_path_overrides: if _safe_relative(candidate, api_root) is not None: return local_root if _safe_relative(candidate, self.api_root) is None: raise ConfigError("API path is outside its configured root") return self.local_root @dataclass(frozen=True) class ServiceConfig: endpoint: str api_root: PurePosixPath local_root: Path username: str | None = None password_file: Path | None = None api_key_file: Path | None = None advertised_addresses: tuple[str, ...] = () local_path_overrides: tuple[tuple[PurePosixPath, Path], ...] = () @property def roots(self) -> RootMapping: return RootMapping( self.api_root, self.local_root, self.local_path_overrides ) def read_password(self) -> str | None: return ( _secret(self.password_file, "service password") if self.password_file else None ) def read_api_key(self) -> str | None: return ( _secret(self.api_key_file, "service API key") if self.api_key_file else None ) @dataclass(frozen=True) class ConnectionConfig: registration_timeout: float = 10 heartbeat_interval: float = 15 offline_timeout: float = 45 reconnect_initial: float = 1 reconnect_max: float = 60 reconnect_reset_after: float = 60 reconnect_jitter: bool = True @dataclass(frozen=True) class JobsConfig: stall_after: float = 30 * 60 verification_timeout: float = 30 * 60 poll_interval: float = 1 free_space_reserve_bytes: int = 32 * 1024 * 1024 @dataclass(frozen=True) class BackupConfig: interval: float = 6 * 60 * 60 recent: int = 12 daily: int = 14 weekly: int = 8 @dataclass(frozen=True) class ClientConfig: client_id: str display_name: str role: str control_endpoint: str shared_token_file: Path state_db: Path backup_dir: Path qbittorrent: ServiceConfig syncthing: ServiceConfig connection: ConnectionConfig = ConnectionConfig() jobs: JobsConfig = JobsConfig() backup: BackupConfig = BackupConfig() @classmethod def load(cls, path: Path, mode_override: str | None = None) -> "ClientConfig": try: with path.open("rb") as source: raw = tomllib.load(source) except (OSError, tomllib.TOMLDecodeError) as exc: raise ConfigError("configuration cannot be read") from exc _keys(raw, { "client_id", "display_name", "role", "control_endpoint", "shared_token_file", "state_db", "backup_dir", "connection", "jobs", "backup", "qbittorrent", "syncthing", }, "root") role = mode_override or raw.get("role") if role not in {"archive", "cache"}: raise ConfigError("role/--mode must be archive or cache") client_id = raw.get("client_id") if not isinstance(client_id, str) or not _CLIENT_ID.fullmatch(client_id): raise ConfigError("client_id is invalid") display_name = raw.get("display_name") if not isinstance(display_name, str) or not 1 <= len(display_name) <= 128: raise ConfigError("display_name is invalid") connection = _connection(raw.get("connection", {})) jobs = _jobs(raw.get("jobs", {})) backup = _backup(raw.get("backup", {})) state_db = _absolute_path(raw, "state_db") backup_dir = _absolute_path(raw, "backup_dir") qbittorrent = _service(raw.get("qbittorrent"), "qbittorrent") syncthing = _service(raw.get("syncthing"), "syncthing") for protected in (qbittorrent.local_root, syncthing.local_root): if _is_within(state_db, protected) or _is_within( backup_dir, protected ): raise ConfigError( "state_db and backup_dir must be outside data roots" ) return cls( client_id, display_name, role, _endpoint(raw, "control_endpoint", {"ws", "wss"}), _absolute_path(raw, "shared_token_file"), state_db, backup_dir, qbittorrent, syncthing, connection, jobs, backup, ) def read_shared_token(self) -> str: return _secret(self.shared_token_file, "shared token") def _service(value: Any, name: str) -> ServiceConfig: if not isinstance(value, dict): raise ConfigError(f"{name} must be a table") allowed = { "endpoint", "api_root", "local_root", "username", "password_file", "api_key_file", "advertised_addresses", "local_path_overrides", } _keys(value, allowed, name) api_root = PurePosixPath(_string(value, "api_root")) if not api_root.is_absolute() or ".." in api_root.parts: raise ConfigError(f"{name}.api_root must be absolute and normalized") username = value.get("username") if username is not None and (not isinstance(username, str) or not username): raise ConfigError(f"{name}.username must be a non-empty string") if username is not None: username = _expand(username) addresses = value.get("advertised_addresses", []) if not isinstance(addresses, list) or any( not isinstance(address, str) or not address for address in addresses ): raise ConfigError(f"{name}.advertised_addresses must be a string array") if name == "qbittorrent" and ( username is None or "password_file" not in value ): raise ConfigError("qbittorrent username and password_file are required") if name == "syncthing" and "api_key_file" not in value: raise ConfigError("syncthing api_key_file is required") overrides = _local_path_overrides(value, api_root, name) return ServiceConfig( _endpoint(value, "endpoint", {"http", "https"}), api_root, _absolute_path(value, "local_root"), username, _absolute_path(value, "password_file") if "password_file" in value else None, _absolute_path(value, "api_key_file") if "api_key_file" in value else None, tuple(addresses), overrides, ) def _local_path_overrides( value: dict[str, Any], api_root: PurePosixPath, name: str ) -> tuple[tuple[PurePosixPath, Path], ...]: raw = value.get("local_path_overrides", {}) if name != "syncthing" and raw: raise ConfigError(f"{name}.local_path_overrides is unsupported") if not isinstance(raw, dict): raise ConfigError(f"{name}.local_path_overrides must be a table") parsed: list[tuple[PurePosixPath, Path]] = [] for raw_api_path, raw_local_path in raw.items(): if not isinstance(raw_api_path, str) or not isinstance(raw_local_path, str): raise ConfigError(f"{name}.local_path_overrides entries must be strings") candidate = PurePosixPath(raw_api_path) if not candidate.is_absolute() or ".." in candidate.parts: raise ConfigError(f"{name}.local_path_overrides API path is invalid") if _safe_relative(candidate, api_root) is None: raise ConfigError(f"{name}.local_path_overrides API path is outside root") local = Path(raw_local_path) if not local.is_absolute(): raise ConfigError(f"{name}.local_path_overrides local path is invalid") parsed.append((candidate, local)) return tuple(sorted(parsed, key=lambda item: len(item[0].parts), reverse=True)) def _safe_relative( candidate: PurePosixPath, root: PurePosixPath ) -> PurePosixPath | None: try: relative = candidate.relative_to(root) except ValueError: return None if any(part in {"", ".", ".."} for part in relative.parts): raise ConfigError("API path contains an unsafe component") return relative def _connection(value: Any) -> ConnectionConfig: if not isinstance(value, dict): raise ConfigError("connection must be a table") _keys(value, { "registration_timeout", "heartbeat_interval", "offline_timeout", "reconnect_initial", "reconnect_max", "reconnect_reset_after", "reconnect_jitter", }, "connection") result = ConnectionConfig( registration_timeout=_duration(value.get("registration_timeout", "10s")), heartbeat_interval=_duration(value.get("heartbeat_interval", "15s")), offline_timeout=_duration(value.get("offline_timeout", "45s")), reconnect_initial=_duration(value.get("reconnect_initial", "1s")), reconnect_max=_duration(value.get("reconnect_max", "60s")), reconnect_reset_after=_duration(value.get("reconnect_reset_after", "60s")), reconnect_jitter=value.get("reconnect_jitter", True), ) if result.reconnect_initial > result.reconnect_max: raise ConfigError("reconnect_initial cannot exceed reconnect_max") if result.offline_timeout <= result.heartbeat_interval: raise ConfigError("offline_timeout must exceed heartbeat_interval") if not isinstance(result.reconnect_jitter, bool): raise ConfigError("reconnect_jitter must be boolean") return result def _jobs(value: Any) -> JobsConfig: if not isinstance(value, dict): raise ConfigError("jobs must be a table") _keys( value, { "stall_after", "verification_timeout", "poll_interval", "free_space_reserve_bytes", }, "jobs", ) return JobsConfig( stall_after=_duration(value.get("stall_after", "30m")), verification_timeout=_duration( value.get("verification_timeout", "30m") ), poll_interval=_duration(value.get("poll_interval", "1s")), free_space_reserve_bytes=_positive_int( value.get("free_space_reserve_bytes", 32 * 1024 * 1024), "jobs.free_space_reserve_bytes", ), ) def _backup(value: Any) -> BackupConfig: if not isinstance(value, dict): raise ConfigError("backup must be a table") _keys(value, {"interval", "recent", "daily", "weekly"}, "backup") return BackupConfig( interval=_duration(value.get("interval", "6h")), recent=_positive_int(value.get("recent", 12), "backup.recent"), daily=_positive_int(value.get("daily", 14), "backup.daily"), weekly=_positive_int(value.get("weekly", 8), "backup.weekly"), ) def _duration(value: Any) -> float: if not isinstance(value, str) or not (match := _DURATION.fullmatch(value)): raise ConfigError("duration must look like 15s or 30m") return int(match.group(1)) * _FACTORS[match.group(2)] def _positive_int(value: Any, name: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 1: raise ConfigError(f"{name} must be a positive integer") return value def _string(value: dict[str, Any], key: str) -> str: item = value.get(key) if not isinstance(item, str) or not item: raise ConfigError(f"{key} must be a non-empty string") return _expand(item) def _path(value: dict[str, Any], key: str) -> Path: return Path(_string(value, key)) def _absolute_path(value: dict[str, Any], key: str) -> Path: path = _path(value, key) if not path.is_absolute() or ".." in path.parts: raise ConfigError(f"{key} must be an absolute normalized path") return path def _is_within(path: Path, root: Path) -> bool: try: path.resolve(strict=False).relative_to(root.resolve(strict=False)) return True except ValueError: return False def _endpoint( value: dict[str, Any], key: str, allowed_schemes: set[str] ) -> str: endpoint = _string(value, key) parsed = urlsplit(endpoint) if ( parsed.scheme not in allowed_schemes or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment ): schemes = "/".join(sorted(allowed_schemes)) raise ConfigError(f"{key} must be a credential-free {schemes} endpoint") return endpoint def _expand(value: str) -> str: def replace(match: re.Match[str]) -> str: name = match.group(1) if name not in os.environ: raise ConfigError(f"environment variable {name} is not set") return os.environ[name] expanded = _ENV.sub(replace, value) if "$" in expanded: raise ConfigError("only ${NAME} environment interpolation is supported") return expanded def _secret(path: Path, name: str) -> str: try: metadata = path.stat() exposed = metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) if not stat.S_ISREG(metadata.st_mode) or exposed: raise ConfigError(f"{name} file permissions are unsafe") value = path.read_text(encoding="utf-8").strip() except OSError as exc: raise ConfigError(f"{name} file cannot be read") from exc if not value: raise ConfigError(f"{name} is empty") return value def _keys(value: dict[str, Any], allowed: set[str], name: str) -> None: unknown = sorted(set(value) - allowed) if unknown: raise ConfigError(f"unknown {name} keys: {', '.join(unknown)}")