fix: normalize existing Syncthing route paths

This commit is contained in:
2026-07-24 12:52:30 +00:00
parent d951e2e4fc
commit 589fd4a41d
8 changed files with 58 additions and 7 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: archive-control-archive
services: services:
archive-client: archive-client:
image: sodium/archive-clients:v0.1.2 image: sodium/archive-clients:v0.1.3
user: "1000:1000" user: "1000:1000"
restart: unless-stopped restart: unless-stopped
command: ["--config", "/etc/archive-control/client.toml"] command: ["--config", "/etc/archive-control/client.toml"]
+1 -1
View File
@@ -2,7 +2,7 @@ name: archive-control-cache
services: services:
archive-client: archive-client:
image: sodium/archive-clients:v0.1.2 image: sodium/archive-clients:v0.1.3
user: "1001:1001" user: "1001:1001"
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
+1 -1
View File
@@ -2,7 +2,7 @@ name: archive-control-cache
services: services:
archive-client: archive-client:
image: sodium/archive-clients:v0.1.2 image: sodium/archive-clients:v0.1.3
user: "1001:1001" user: "1001:1001"
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
+1 -1
View File
@@ -213,7 +213,7 @@ cache/archive routes according to policy.
```yaml ```yaml
services: services:
archive-client: archive-client:
image: sodium/archive-clients:v0.1.2 image: sodium/archive-clients:v0.1.3
user: "1001:1001" user: "1001:1001"
restart: unless-stopped restart: unless-stopped
command: ["archive-client", "--config", "/etc/archive-control/client.toml"] command: ["archive-client", "--config", "/etc/archive-control/client.toml"]
+4
View File
@@ -29,6 +29,10 @@ The adapter accounts for terminology/behavior changes such as paused versus
stopped states. It does not mutate qBittorrent preferences, categories, tags, stopped states. It does not mutate qBittorrent preferences, categories, tags,
limits, queueing defaults, or global save-path behavior. limits, queueing defaults, or global save-path behavior.
Syncthing route validation normalizes both absolute and `~/` folder-path
spellings under the configured sync root before comparing an existing folder;
an equivalent pre-existing pair is adopted without rewriting it.
### Inventory normalization ### Inventory normalization
For each torrent the client derives canonical v1/v2 identity from reliable API For each torrent the client derives canonical v1/v2 identity from reliable API
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "archive-clients" name = "archive-clients"
version = "0.1.2" version = "0.1.3"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["protobuf==7.35.1", "websockets==16.0"] dependencies = ["protobuf==7.35.1", "websockets==16.0"]
+31 -2
View File
@@ -369,8 +369,8 @@ class SyncthingRouteManager:
raise RoutePathConflict("new route path contains unrelated data") raise RoutePathConflict("new route path contains unrelated data")
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
@staticmethod
def _validate_folder( def _validate_folder(
self,
folder: dict[str, Any], folder: dict[str, Any],
api_path: str, api_path: str,
local_device_id: str, local_device_id: str,
@@ -382,7 +382,15 @@ class SyncthingRouteManager:
for item in raw_devices for item in raw_devices
if isinstance(item, dict) and isinstance(item.get("deviceID"), str) if isinstance(item, dict) and isinstance(item.get("deviceID"), str)
} if isinstance(raw_devices, list) else set() } if isinstance(raw_devices, list) else set()
if folder.get("path") != api_path: try:
configured_api_path = self._normalized_folder_api_path(
folder.get("path")
)
except RoutePathConflict as exc:
raise RoutePathConflict(
"existing route ID uses a different path"
) from exc
if configured_api_path != api_path:
raise RoutePathConflict("existing route ID uses a different path") raise RoutePathConflict("existing route ID uses a different path")
if folder.get("type") != "sendreceive": if folder.get("type") != "sendreceive":
raise RouteSetupError("existing route folder is not sendreceive") raise RouteSetupError("existing route folder is not sendreceive")
@@ -391,6 +399,27 @@ class SyncthingRouteManager:
if folder.get("paused") is True: if folder.get("paused") is True:
raise RouteSetupError("existing route folder is paused") raise RouteSetupError("existing route folder is paused")
def _normalized_folder_api_path(self, value: Any) -> str:
"""Normalize Syncthing's absolute and home-relative path spellings."""
if not isinstance(value, str) or not value:
raise RoutePathConflict("existing route folder path is invalid")
candidate = PurePosixPath(value)
if candidate.parts and candidate.parts[0] == "~":
candidate = self.config.api_root.joinpath(*candidate.parts[1:])
if not candidate.is_absolute() or any(
part in {"", ".", ".."} for part in candidate.parts
):
raise RoutePathConflict("existing route folder path is unsafe")
normalized = candidate.as_posix()
try:
self.config.roots.api_to_local(normalized)
except ConfigError as exc:
raise RoutePathConflict(
"existing route folder path is outside the sync root"
) from exc
return normalized
def _wait(self, deadline: float) -> None: def _wait(self, deadline: float) -> None:
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
raise RouteSetupTimeout("route setup timed out") raise RouteSetupTimeout("route setup timed out")
+18
View File
@@ -125,6 +125,24 @@ class SyncthingRouteManagerTests(unittest.TestCase):
self.manager.configure(self.spec, time.monotonic() + 1) self.manager.configure(self.spec, time.monotonic() + 1)
self.assertEqual(self.transport.puts, []) self.assertEqual(self.transport.puts, [])
def test_existing_home_relative_folder_path_is_accepted(self):
self.transport.config["devices"].append({"deviceID": "PEER"})
self.transport.config["folders"].append(
{
"id": "route-1",
"path": "~/routes/route-1",
"type": "sendreceive",
"devices": [{"deviceID": "LOCAL"}, {"deviceID": "PEER"}],
}
)
configured = self.manager.configure(
self.spec, time.monotonic() + 1
)
self.assertEqual(self.transport.puts, [])
self.assertFalse(configured.local_route.archive_control_created)
def test_bidirectional_nonce_and_ack_are_required(self): def test_bidirectional_nonce_and_ack_are_required(self):
configured = self.manager.configure(self.spec, time.monotonic() + 1) configured = self.manager.configure(self.spec, time.monotonic() + 1)
peer_nonce = configured.local_path / ( peer_nonce = configured.local_path / (