diff --git a/deploy/production/lithium/compose.yaml b/deploy/production/lithium/compose.yaml index 42471b9..df7d537 100644 --- a/deploy/production/lithium/compose.yaml +++ b/deploy/production/lithium/compose.yaml @@ -2,7 +2,7 @@ name: archive-control-archive services: archive-client: - image: sodium/archive-clients:v0.1.2 + image: sodium/archive-clients:v0.1.3 user: "1000:1000" restart: unless-stopped command: ["--config", "/etc/archive-control/client.toml"] diff --git a/deploy/production/x1/compose.yaml b/deploy/production/x1/compose.yaml index 209bbf0..40e4438 100644 --- a/deploy/production/x1/compose.yaml +++ b/deploy/production/x1/compose.yaml @@ -2,7 +2,7 @@ name: archive-control-cache services: archive-client: - image: sodium/archive-clients:v0.1.2 + image: sodium/archive-clients:v0.1.3 user: "1001:1001" restart: unless-stopped network_mode: host diff --git a/deploy/production/x2/compose.yaml b/deploy/production/x2/compose.yaml index 4c99de7..14cdedd 100644 --- a/deploy/production/x2/compose.yaml +++ b/deploy/production/x2/compose.yaml @@ -2,7 +2,7 @@ name: archive-control-cache services: archive-client: - image: sodium/archive-clients:v0.1.2 + image: sodium/archive-clients:v0.1.3 user: "1001:1001" restart: unless-stopped network_mode: host diff --git a/docs/deployment-and-usage.md b/docs/deployment-and-usage.md index 3e235ef..c2608d7 100644 --- a/docs/deployment-and-usage.md +++ b/docs/deployment-and-usage.md @@ -213,7 +213,7 @@ cache/archive routes according to policy. ```yaml services: archive-client: - image: sodium/archive-clients:v0.1.2 + image: sodium/archive-clients:v0.1.3 user: "1001:1001" restart: unless-stopped command: ["archive-client", "--config", "/etc/archive-control/client.toml"] diff --git a/docs/service-apis.md b/docs/service-apis.md index b0d3355..c488305 100644 --- a/docs/service-apis.md +++ b/docs/service-apis.md @@ -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, 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 For each torrent the client derives canonical v1/v2 identity from reliable API diff --git a/pyproject.toml b/pyproject.toml index 40cd1f8..c97ce5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "archive-clients" -version = "0.1.2" +version = "0.1.3" requires-python = ">=3.11" dependencies = ["protobuf==7.35.1", "websockets==16.0"] diff --git a/src/archive_clients/syncthing.py b/src/archive_clients/syncthing.py index a749b30..01c6be1 100644 --- a/src/archive_clients/syncthing.py +++ b/src/archive_clients/syncthing.py @@ -369,8 +369,8 @@ class SyncthingRouteManager: raise RoutePathConflict("new route path contains unrelated data") path.mkdir(parents=True, exist_ok=True) - @staticmethod def _validate_folder( + self, folder: dict[str, Any], api_path: str, local_device_id: str, @@ -382,7 +382,15 @@ class SyncthingRouteManager: for item in raw_devices if isinstance(item, dict) and isinstance(item.get("deviceID"), str) } 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") if folder.get("type") != "sendreceive": raise RouteSetupError("existing route folder is not sendreceive") @@ -391,6 +399,27 @@ class SyncthingRouteManager: if folder.get("paused") is True: 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: if time.monotonic() >= deadline: raise RouteSetupTimeout("route setup timed out") diff --git a/tests/test_syncthing.py b/tests/test_syncthing.py index 69bf1c7..b40336f 100644 --- a/tests/test_syncthing.py +++ b/tests/test_syncthing.py @@ -125,6 +125,24 @@ class SyncthingRouteManagerTests(unittest.TestCase): self.manager.configure(self.spec, time.monotonic() + 1) 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): configured = self.manager.configure(self.spec, time.monotonic() + 1) peer_nonce = configured.local_path / (