#!/usr/bin/env python3 """Create and restore a marked ZIP backup of Alfred preferences over WebDAV.""" from __future__ import annotations import argparse import base64 import json import logging import os import shutil import socket import stat import tempfile import time import urllib.error import urllib.parse import urllib.request import zipfile from datetime import datetime, timezone from pathlib import Path, PurePosixPath PACKAGE_NAME = "Alfred.alfredpreferences" ARCHIVE_NAME = f"{PACKAGE_NAME}.sync-backup.zip" MARKER_NAME = ".alfred-sync-backup" FORMAT_VERSION = 1 DEFAULT_WEBDAV_URL = ( "https://u201686:T6672ICVoWAedECH@u201686.your-storagebox.de/" ) DEFAULT_REMOTE_PATH = "backup/macs/alfred" WEBDAV_TIMEOUT = 30 LOGGER = logging.getLogger("sync-alfred") class SyncError(RuntimeError): pass class ProgressIO: """Wrap a readable binary file and report transfer progress once per second.""" def __init__( self, file_obj, *, total: int | None = None, label: str = "Progress", ) -> None: self.file_obj = file_obj self.total = total self.label = label self.transferred = 0 self.started_at = time.monotonic() self.last_report_at = self.started_at - 1 self.finished = False self._report(force=True) def __getattr__(self, name): return getattr(self.file_obj, name) def __len__(self) -> int: return self.total or 0 def read(self, size: int = -1): chunk = self.file_obj.read(size) if chunk: self.transferred += len(chunk) self._report() else: self._report(force=True, complete=True) return chunk def finish(self) -> None: self._report(force=True, complete=True) @staticmethod def _format_bytes(value: float) -> str: units = ("B", "KiB", "MiB", "GiB") for unit in units: if value < 1024 or unit == units[-1]: return f"{value:.1f} {unit}" value /= 1024 return f"{value:.1f} GiB" def _report(self, *, force: bool = False, complete: bool = False) -> None: if self.finished: return now = time.monotonic() if not force and now - self.last_report_at < 1: return self.last_report_at = now elapsed = max(now - self.started_at, 0.001) transferred = self._format_bytes(self.transferred) rate = self._format_bytes(self.transferred / elapsed) if self.total is None: message = f"{self.label}: {transferred} at {rate}/s" else: percent = 100 * self.transferred / self.total if self.total else 100 total = self._format_bytes(self.total) message = f"{self.label}: {transferred} / {total} ({percent:.1f}%) at {rate}/s" LOGGER.info(message) if complete: self.finished = True class WebDAVClient: def __init__(self, base_url: str, remote_path: str) -> None: parsed = urllib.parse.urlsplit(base_url) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise SyncError("invalid WebDAV URL") if parsed.query or parsed.fragment: raise SyncError("WebDAV URL must not contain a query or fragment") username = urllib.parse.unquote(parsed.username or "") password = urllib.parse.unquote(parsed.password or "") if not username: username = os.environ.get("ALFRED_WEBDAV_USER", "") if not password: password = os.environ.get("ALFRED_WEBDAV_PASSWORD", "") # Strip credentials from request URLs and diagnostic output. netloc = parsed.netloc.rsplit("@", 1)[-1] base_path = parsed.path.rstrip("/") self.base_url = urllib.parse.urlunsplit( (parsed.scheme, netloc, f"{base_path}/", "", "") ) self.remote_parts = self._parse_remote_path(remote_path) self.auth_header = None password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm() if username and password: encoded_credentials = base64.b64encode( f"{username}:{password}".encode("utf-8") ).decode("ascii") self.auth_header = f"Basic {encoded_credentials}" origin = urllib.parse.urlunsplit( (parsed.scheme, netloc, "/", "", "") ) password_manager.add_password(None, origin, username, password) self.opener = urllib.request.build_opener( urllib.request.HTTPBasicAuthHandler(password_manager) ) @staticmethod def _parse_remote_path(remote_path: str) -> tuple[str, ...]: parts = tuple(remote_path.strip("/").split("/")) if not parts or any(not part or part in (".", "..") for part in parts): raise SyncError(f"invalid WebDAV remote path: {remote_path!r}") return parts def _url_for(self, parts: tuple[str, ...]) -> str: encoded = "/".join(urllib.parse.quote(part, safe="") for part in parts) return f"{self.base_url.rstrip('/')}/{encoded}" @property def archive_url(self) -> str: return self._url_for(self.remote_parts + (ARCHIVE_NAME,)) @property def display_location(self) -> str: return self.archive_url def _request( self, method: str, url: str, *, data: object | None = None, headers: dict[str, str] | None = None, ) -> urllib.response.addinfourl: request_headers = dict(headers or {}) if self.auth_header: request_headers.setdefault("Authorization", self.auth_header) request = urllib.request.Request( url, data=data, headers=request_headers, method=method ) try: return self.opener.open(request, timeout=WEBDAV_TIMEOUT) except urllib.error.HTTPError as exc: detail = f"HTTP {exc.code} {exc.reason}" raise SyncError( f"WebDAV {method} failed for remote archive: {detail}" ) from exc except urllib.error.URLError as exc: raise SyncError(f"WebDAV {method} failed: {exc.reason}") from exc def ensure_remote_directory(self) -> None: for index in range(1, len(self.remote_parts) + 1): # WebDAV collections are directory-like resources and this server # redirects collection URLs without their trailing slash. url = f"{self._url_for(self.remote_parts[:index])}/" headers = {"Authorization": self.auth_header} if self.auth_header else {} request = urllib.request.Request(url, headers=headers, method="MKCOL") try: response = self.opener.open(request, timeout=WEBDAV_TIMEOUT) except urllib.error.HTTPError as exc: # WebDAV uses 405 when MKCOL is sent for an existing collection. if exc.code != 405: detail = f"HTTP {exc.code} {exc.reason}" raise SyncError( f"WebDAV MKCOL failed for remote directory: {detail}" ) from exc exc.close() except urllib.error.URLError as exc: raise SyncError(f"WebDAV MKCOL failed: {exc.reason}") from exc else: response.close() def upload(self, archive_path: Path) -> None: self.ensure_remote_directory() try: total = archive_path.stat().st_size source = archive_path.open("rb") except OSError as exc: raise SyncError(f"could not open archive {archive_path}: {exc}") from exc progress = ProgressIO(source, total=total, label="Uploading") try: with source: with self._request( "PUT", self.archive_url, data=progress, headers={ "Content-Type": "application/zip", "Content-Length": str(total), }, ) as response: response.read() finally: progress.finish() def download(self, archive_path: Path) -> None: with self._request("GET", self.archive_url) as response: try: with archive_path.open("wb") as output: shutil.copyfileobj(response, output) output.flush() os.fsync(output.fileno()) except OSError as exc: raise SyncError( f"could not write archive {archive_path}: {exc}" ) from exc def configured_paths() -> tuple[Path, WebDAVClient]: local_dir = Path( os.environ.get("ALFRED_LOCAL_DIR", "~/Documents/alfred") ).expanduser() webdav = WebDAVClient( os.environ.get("ALFRED_WEBDAV_URL", DEFAULT_WEBDAV_URL), os.environ.get("ALFRED_REMOTE_PATH", DEFAULT_REMOTE_PATH), ) return local_dir, webdav def marker(kind: str) -> bytes: contents = { "format": FORMAT_VERSION, "package": PACKAGE_NAME, "kind": kind, "created_utc": datetime.now(timezone.utc).isoformat(), "source_host": socket.gethostname(), } return (json.dumps(contents, sort_keys=True) + "\n").encode("utf-8") def create_archive(source_dir: Path, archive_path: Path, kind: str) -> None: if not source_dir.is_dir(): raise SyncError(f"local preferences directory not found: {source_dir}") try: with zipfile.ZipFile( archive_path, "w", compression=zipfile.ZIP_DEFLATED ) as archive: archive.write(source_dir, PACKAGE_NAME) for path in sorted(source_dir.rglob("*"), key=lambda item: item.as_posix()): if path.is_symlink(): raise SyncError(f"symlinks are not supported in preferences: {path}") relative = path.relative_to(source_dir) archive.write( path, PurePosixPath(PACKAGE_NAME, *relative.parts).as_posix(), ) archive.writestr(MARKER_NAME, marker(kind)) except OSError as exc: raise SyncError(f"could not create archive {archive_path}: {exc}") from exc def validate_archive(archive_path: Path) -> None: try: with zipfile.ZipFile(archive_path) as archive: broken_file = archive.testzip() if broken_file is not None: raise SyncError(f"backup archive is corrupt: {broken_file}") names = archive.namelist() if names.count(MARKER_NAME) != 1: raise SyncError(f"backup archive must contain one {MARKER_NAME} marker") try: metadata = json.loads(archive.read(MARKER_NAME).decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as exc: raise SyncError("backup marker is invalid") from exc if metadata.get("format") != FORMAT_VERSION: raise SyncError("unsupported Alfred backup format") if metadata.get("package") != PACKAGE_NAME: raise SyncError("backup marker is for a different package") if metadata.get("kind") != "backup": raise SyncError("archive is not a primary Alfred backup") seen: set[str] = set() has_package = False for name in names: if name in seen: raise SyncError(f"duplicate file in backup archive: {name}") seen.add(name) if "\\" in name: raise SyncError(f"unsafe path in backup archive: {name}") path = PurePosixPath(name) if path.is_absolute() or ".." in path.parts: raise SyncError(f"unsafe path in backup archive: {name}") if name == MARKER_NAME: continue if name == PACKAGE_NAME or name.startswith(f"{PACKAGE_NAME}/"): has_package = True continue raise SyncError(f"unexpected file in backup archive: {name}") if not has_package: raise SyncError(f"backup archive does not contain {PACKAGE_NAME}") except zipfile.BadZipFile as exc: raise SyncError(f"backup archive is not a valid ZIP: {archive_path}") from exc def extract_archive(archive_path: Path, destination: Path) -> Path: package_destination = destination / PACKAGE_NAME destination.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(archive_path) as archive: for info in archive.infolist(): if info.filename == MARKER_NAME: continue relative = PurePosixPath(info.filename) target = destination.joinpath(*relative.parts) if info.is_dir() or info.filename.endswith("/"): target.mkdir(parents=True, exist_ok=True) continue mode = (info.external_attr >> 16) & 0xFFFF if stat.S_ISLNK(mode): raise SyncError(f"symlinks are not supported in backup archives: {info.filename}") target.parent.mkdir(parents=True, exist_ok=True) with archive.open(info) as source, target.open("wb") as output: shutil.copyfileobj(source, output) permissions = mode & 0o777 if permissions: target.chmod(permissions) if not package_destination.is_dir(): raise SyncError(f"backup archive does not contain {PACKAGE_NAME}") return package_destination def unique_backup_path(local_dir: Path) -> Path: timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") candidate = local_dir / f"{PACKAGE_NAME}.before-restore-{timestamp}.zip" if not candidate.exists(): return candidate return local_dir / f"{PACKAGE_NAME}.before-restore-{timestamp}-{os.getpid()}.zip" def backup_current_preferences(local_dir: Path, temporary_archive: Path) -> Path | None: package = local_dir / PACKAGE_NAME if not package.exists(): return None if not package.is_dir(): raise SyncError(f"local preferences path is not a directory: {package}") backup_path = unique_backup_path(local_dir) create_archive(package, temporary_archive, "pre-restore") os.replace(temporary_archive, backup_path) return backup_path def backup(local_dir: Path, webdav: WebDAVClient) -> None: local_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".alfred-backup-", dir=local_dir) as temp: archive = Path(temp) / ARCHIVE_NAME create_archive(local_dir / PACKAGE_NAME, archive, "backup") webdav.upload(archive) LOGGER.info("Backed up %s to %s", PACKAGE_NAME, webdav.display_location) def restore(local_dir: Path, webdav: WebDAVClient) -> None: local_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".alfred-restore-", dir=local_dir) as temp: temp_dir = Path(temp) archive = temp_dir / ARCHIVE_NAME webdav.download(archive) validate_archive(archive) restored_package = extract_archive(archive, temp_dir) previous_backup = backup_current_preferences( local_dir, temp_dir / "current-pre-restore.zip" ) if previous_backup is not None: LOGGER.info("Backed up current preferences to %s", previous_backup) old_container = Path( tempfile.mkdtemp(prefix=".alfred-old-", dir=local_dir) ) old_package = old_container / PACKAGE_NAME current_package = local_dir / PACKAGE_NAME try: if current_package.exists(): os.replace(current_package, old_package) os.replace(restored_package, current_package) except OSError as exc: if old_package.exists() and not current_package.exists(): os.replace(old_package, current_package) raise SyncError(f"could not install restored preferences: {exc}") from exc else: shutil.rmtree(old_container) LOGGER.info("Restored %s from %s", PACKAGE_NAME, webdav.display_location) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Back up or restore Alfred preferences using a marked ZIP archive." ) parser.add_argument( "mode", nargs="?", default="backup", choices=("backup", "push", "restore", "pull", "apply"), help="backup (default) or restore; push/pull/apply are aliases", ) return parser.parse_args() def main() -> int: args = parse_args() logging.basicConfig(level=logging.INFO, format="%(message)s") try: local_dir, webdav = configured_paths() if args.mode in ("backup", "push"): backup(local_dir, webdav) else: restore(local_dir, webdav) except SyncError as exc: LOGGER.error("sync-alfred: %s", exc) return 1 except OSError as exc: LOGGER.error("sync-alfred: filesystem error: %s", exc) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())