110 lines
4.2 KiB
Python
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
|