feat: add transfer service adapters

This commit is contained in:
2026-07-23 03:39:33 +00:00
parent 2ec90b6a2d
commit 68db3ec4c5
5 changed files with 610 additions and 4 deletions
+139
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import stat
import time
import uuid
from dataclasses import dataclass
@@ -12,6 +13,11 @@ from typing import Any, Protocol
from urllib import error, parse, request
from archive_clients.config import ConfigError, ServiceConfig
from archive_clients.transfer import (
TransferIntegrityError,
UnsafeTransferPath,
load_published_transfer,
)
from archive_control.v1 import route_pb2
@@ -91,6 +97,98 @@ class ConfiguredRoute:
created_folder: bool
@dataclass(frozen=True)
class SyncthingTransferStatus:
fraction_complete: float
bytes_complete: int
bytes_total: int
complete: bool
needed_items: int
class SyncthingTransferObserver:
"""Observe one exact Archive Control job prefix within a route folder."""
def __init__(
self,
transport: SyncthingTransport,
folder_id: str,
job_relative_path: str,
local_job_directory: Path,
):
relative = PurePosixPath(job_relative_path)
if (
not job_relative_path
or relative.is_absolute()
or any(part in {"", ".", ".."} for part in relative.parts)
):
raise UnsafeTransferPath("Syncthing job prefix is unsafe")
self.transport = transport
self.folder_id = folder_id
self.job_relative_path = relative.as_posix()
self.local_job_directory = local_job_directory
def rescan(self) -> None:
self.transport.post(
"/rest/db/scan?"
+ parse.urlencode({
"folder": self.folder_id,
"sub": self.job_relative_path,
})
)
def status(self) -> SyncthingTransferStatus:
published = load_published_transfer(self.local_job_directory)
total = 0
for entry in published.manifest.files:
total += _verified_job_file_size(
self.local_job_directory,
entry.payload_relative_path,
entry.logical_bytes,
)
for artifact in published.manifest.artifacts:
total += _verified_job_file_size(
self.local_job_directory,
artifact.payload_relative_path,
artifact.logical_bytes,
)
completion = self.transport.get_json(
"/rest/db/completion?"
+ parse.urlencode({"folder": self.folder_id})
)
raw_fraction = completion.get("completion")
if (
isinstance(raw_fraction, bool)
or not isinstance(raw_fraction, (int, float))
or not 0 <= float(raw_fraction) <= 100
):
raise RouteSetupError("Syncthing completion response is invalid")
need = self.transport.get_json(
"/rest/db/need?"
+ parse.urlencode({
"folder": self.folder_id,
"page": 1,
"perpage": 1000,
})
)
needed_names = _needed_names(need)
prefix = self.job_relative_path.rstrip("/") + "/"
relevant = {
name for name in needed_names
if name == self.job_relative_path or name.startswith(prefix)
}
fraction = float(raw_fraction) / 100
complete = fraction == 1 and not relevant
return SyncthingTransferStatus(
fraction,
total if complete else int(total * fraction),
total,
complete,
len(relevant),
)
class SyncthingRouteManager:
def __init__(
self,
@@ -315,6 +413,47 @@ def _by_key(
return matches[0] if matches else None
def _verified_job_file_size(
job_directory: Path,
relative_path: str,
expected_bytes: int,
) -> int:
relative = PurePosixPath(relative_path)
if (
not relative_path
or relative.is_absolute()
or any(part in {"", ".", ".."} for part in relative.parts)
):
raise TransferIntegrityError("manifest payload path is unsafe")
current = job_directory
for component in relative.parts[:-1]:
current = current / component
metadata = current.lstat()
if not stat.S_ISDIR(metadata.st_mode):
raise TransferIntegrityError(
"manifest payload parent is not a directory"
)
metadata = (current / relative.name).lstat()
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size != expected_bytes:
raise TransferIntegrityError(
"manifest payload does not match its declared size"
)
return metadata.st_size
def _needed_names(value: dict[str, Any]) -> set[str]:
result: set[str] = set()
for key in ("progress", "queued", "rest"):
items = value.get(key, [])
if not isinstance(items, list):
raise RouteSetupError("Syncthing need response is invalid")
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
raise RouteSetupError("Syncthing need item is invalid")
result.add(item["name"])
return result
def _nonce_name(client_id: str) -> str:
return f".archive-control-route-nonce.{client_id}"