fix: honor hardlinks across client mount topology
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user