Files
archive-clients/src/archive_clients/routes.py
T

110 lines
4.2 KiB
Python

"""Normalize safe, pairwise Syncthing folders into local route records."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any
from archive_clients.config import ConfigError, RootMapping
from archive_control.v1 import route_pb2
def discover_routes(
configuration: dict[str, Any],
local_device_id: str,
roots: RootMapping,
sparse_supported: bool,
observed_at: datetime | None = None,
) -> list[route_pb2.LocalRoute]:
folders = configuration.get("folders")
if not isinstance(folders, list) or not local_device_id:
raise ValueError("Syncthing configuration or local device ID is invalid")
observed_at = observed_at or datetime.now(timezone.utc)
routes = []
for folder in folders:
if not isinstance(folder, dict):
continue
folder_id = folder.get("id")
api_path = folder.get("path")
if not isinstance(folder_id, str) or not folder_id:
continue
if not isinstance(api_path, str):
continue
try:
normalized_api_path = _normalize_api_path(api_path, roots)
local_path = roots.api_to_local(normalized_api_path)
relative = normalized_api_path.relative_to(roots.api_root)
except (ConfigError, ValueError):
continue
if not _lexically_within(
local_path, roots.local_root_for_api(normalized_api_path.as_posix())
):
continue
if relative == PurePosixPath("."):
continue
route = route_pb2.LocalRoute(
route_id=folder_id,
local_relative_path=relative.as_posix(),
local_syncthing_device_id=local_device_id,
writable=bool(folder.get("type") == "sendreceive"),
sparse_supported=sparse_supported,
# Ownership is set only from a durable provisioning journal. A
# folder label is never sufficient deletion attribution.
archive_control_created=False,
)
route.observed_at.FromDatetime(observed_at)
folder_type = folder.get("type")
route.folder_type = {
"sendreceive": route_pb2.SYNCTHING_FOLDER_TYPE_SEND_RECEIVE,
"sendonly": route_pb2.SYNCTHING_FOLDER_TYPE_SEND_ONLY,
"receiveonly": route_pb2.SYNCTHING_FOLDER_TYPE_RECEIVE_ONLY,
}.get(folder_type, route_pb2.SYNCTHING_FOLDER_TYPE_OTHER)
raw_devices = folder.get("devices")
device_ids = [] if not isinstance(raw_devices, list) else [
item.get("deviceID") for item in raw_devices
if isinstance(item, dict) and isinstance(item.get("deviceID"), str)
]
peers = sorted(set(device_ids) - {local_device_id})
route.peer_syncthing_device_ids.extend(peers)
if folder.get("paused") is True:
route.state = route_pb2.ROUTE_STATE_PAUSED
route.detail = "Syncthing folder is paused"
elif folder_type != "sendreceive":
route.state = route_pb2.ROUTE_STATE_UNSUPPORTED
route.detail = "route folder is not sendreceive"
elif (
not isinstance(raw_devices, list)
or len(raw_devices) != 2
or len(device_ids) != 2
or len(set(device_ids)) != 2
):
route.state = route_pb2.ROUTE_STATE_UNSUPPORTED
route.detail = "route folder must contain exactly two devices"
elif local_device_id not in device_ids:
route.state = route_pb2.ROUTE_STATE_UNSUPPORTED
route.detail = "route folder does not include the local device"
else:
route.state = route_pb2.ROUTE_STATE_DISCOVERED
routes.append(route)
return sorted(routes, key=lambda route: route.route_id)
def _lexically_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _normalize_api_path(
api_path: str, roots: RootMapping
) -> PurePosixPath:
"""Resolve Syncthing's home-relative folder notation under api_root."""
candidate = PurePosixPath(api_path)
if candidate.parts and candidate.parts[0] == "~":
candidate = roots.api_root.joinpath(*candidate.parts[1:])
return candidate