fix: honor hardlinks across client mount topology

This commit is contained in:
2026-07-24 16:05:20 +00:00
parent a73bcf870f
commit 57acc90363
13 changed files with 180 additions and 18 deletions
+60 -9
View File
@@ -27,17 +27,28 @@ _ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
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)
try:
relative = candidate.relative_to(self.api_root)
except ValueError as exc:
raise ConfigError("API path is outside its configured root") from exc
if any(part in {"", ".", ".."} for part in relative.parts):
raise ConfigError("API path contains an unsafe component")
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:
@@ -48,10 +59,13 @@ class ServiceConfig:
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)
return RootMapping(
self.api_root, self.local_root, self.local_path_overrides
)
def read_password(self) -> str | None:
return (
@@ -160,7 +174,7 @@ def _service(value: Any, name: str) -> ServiceConfig:
raise ConfigError(f"{name} must be a table")
allowed = {
"endpoint", "api_root", "local_root", "username", "password_file",
"api_key_file", "advertised_addresses",
"api_key_file", "advertised_addresses", "local_path_overrides",
}
_keys(value, allowed, name)
api_root = PurePosixPath(_string(value, "api_root"))
@@ -182,6 +196,7 @@ def _service(value: Any, name: str) -> ServiceConfig:
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,
@@ -189,10 +204,46 @@ def _service(value: Any, name: str) -> ServiceConfig:
if "password_file" in value else None,
_absolute_path(value, "api_key_file")
if "api_key_file" in value else None,
tuple(addresses),
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")