643 lines
25 KiB
Python
643 lines
25 KiB
Python
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Optional
|
|
|
|
from bot.services.backup_archive import (
|
|
BACKUP_APP_ID,
|
|
BACKUP_FILENAME_PREFIX,
|
|
BACKUP_FORMAT_VERSION,
|
|
BACKUP_MANIFEST_NAME,
|
|
attach_archive_integrity,
|
|
backup_filename_timestamp,
|
|
build_file_records,
|
|
write_manifest,
|
|
write_zip_from_directory,
|
|
)
|
|
from bot.services.backup_worker import (
|
|
DEFAULT_COMPOSE_EXCLUDED_DIRS,
|
|
)
|
|
from config.settings import Settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
|
BACKUP_MAX_MEMBERS = 20_000
|
|
BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024
|
|
BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
|
|
BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024
|
|
BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
|
|
BACKUP_MAX_COMPRESSION_RATIO = 200
|
|
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
|
|
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
|
|
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
|
|
|
|
|
|
class BackupArchiveError(ValueError):
|
|
"""The selected archive cannot be used for restore."""
|
|
|
|
|
|
class BackupRestoreError(RuntimeError):
|
|
"""Restore command failed after archive validation."""
|
|
|
|
|
|
@dataclass
|
|
class BackupArchiveInfo:
|
|
name: str
|
|
path: Path
|
|
size_bytes: int
|
|
modified_at: datetime
|
|
created_at: Optional[str] = None
|
|
created_at_local: Optional[str] = None
|
|
has_database: bool = False
|
|
has_compose: bool = False
|
|
database_name: Optional[str] = None
|
|
compose_files_count: int = 0
|
|
warnings: list[str] = field(default_factory=list)
|
|
manifest: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"size_bytes": self.size_bytes,
|
|
"modified_at": self.modified_at.isoformat(),
|
|
"created_at": self.created_at,
|
|
"created_at_local": self.created_at_local,
|
|
"has_database": self.has_database,
|
|
"has_compose": self.has_compose,
|
|
"database_name": self.database_name,
|
|
"compose_files_count": self.compose_files_count,
|
|
"warnings": self.warnings,
|
|
"manifest": self.manifest,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class BackupRestoreResult:
|
|
archive_name: str
|
|
started_at: datetime
|
|
completed_at: datetime
|
|
database_restored: bool = False
|
|
compose_files_restored: int = 0
|
|
compose_target_dir: Optional[str] = None
|
|
compose_pre_restore_archive: Optional[str] = None
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"archive_name": self.archive_name,
|
|
"started_at": self.started_at.isoformat(),
|
|
"completed_at": self.completed_at.isoformat(),
|
|
"database_restored": self.database_restored,
|
|
"compose_files_restored": self.compose_files_restored,
|
|
"compose_target_dir": self.compose_target_dir,
|
|
"compose_pre_restore_archive": self.compose_pre_restore_archive,
|
|
"warnings": self.warnings,
|
|
}
|
|
|
|
|
|
class BackupRestoreService:
|
|
def __init__(self, settings: Settings):
|
|
self.settings = settings
|
|
|
|
def backup_dir(self) -> Path:
|
|
path = Path(self.settings.BACKUP_DIR).expanduser()
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
def list_archives(self) -> list[BackupArchiveInfo]:
|
|
backup_dir = self.backup_dir()
|
|
archives = []
|
|
for path in backup_dir.glob("*.zip"):
|
|
if not path.is_file():
|
|
continue
|
|
try:
|
|
archives.append(self.inspect_archive(path))
|
|
except BackupArchiveError as exc:
|
|
logger.warning("Skipping invalid backup archive %s: %s", path, exc)
|
|
return sorted(archives, key=lambda item: item.modified_at, reverse=True)
|
|
|
|
def archive_path_for_name(self, archive_name: str) -> Path:
|
|
raw_name = str(archive_name or "").strip()
|
|
safe_name = Path(raw_name).name
|
|
if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name):
|
|
raise BackupArchiveError("Invalid archive name")
|
|
|
|
backup_dir = self.backup_dir().resolve()
|
|
archive_path = (backup_dir / safe_name).resolve()
|
|
try:
|
|
archive_path.relative_to(backup_dir)
|
|
except ValueError as exc:
|
|
raise BackupArchiveError("Archive path escapes backup directory") from exc
|
|
if not archive_path.is_file():
|
|
raise BackupArchiveError("Archive does not exist")
|
|
return archive_path
|
|
|
|
def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo:
|
|
if not zipfile.is_zipfile(archive_path):
|
|
raise BackupArchiveError("Archive is not a valid ZIP file")
|
|
|
|
stat = archive_path.stat()
|
|
warnings: list[str] = []
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
self._validate_zip_members(archive.infolist())
|
|
manifest = self._read_manifest(archive)
|
|
has_database = self._find_database_dump_member(archive) is not None
|
|
compose_members = self._compose_file_members(archive)
|
|
|
|
manifest_warnings = manifest.get("warnings")
|
|
if isinstance(manifest_warnings, list):
|
|
warnings.extend(str(item) for item in manifest_warnings if item)
|
|
|
|
postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {}
|
|
compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {}
|
|
return BackupArchiveInfo(
|
|
name=archive_path.name,
|
|
path=archive_path,
|
|
size_bytes=int(stat.st_size),
|
|
modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
|
created_at=str(manifest.get("created_at") or "") or None,
|
|
created_at_local=str(manifest.get("created_at_local") or "") or None,
|
|
has_database=has_database,
|
|
has_compose=bool(compose_members),
|
|
database_name=str(postgres.get("database") or "") or None,
|
|
compose_files_count=int(compose.get("files_count") or len(compose_members)),
|
|
warnings=warnings,
|
|
manifest=manifest,
|
|
)
|
|
|
|
def import_uploaded_archive(
|
|
self,
|
|
temp_path: Path,
|
|
original_filename: str = "",
|
|
) -> BackupArchiveInfo:
|
|
self._validate_archive_for_restore(temp_path)
|
|
digest = self._file_digest(temp_path)
|
|
stamp = backup_filename_timestamp()
|
|
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
|
|
target_path = self._unique_archive_path(archive_name)
|
|
temp_path.replace(target_path)
|
|
return self.inspect_archive(target_path)
|
|
|
|
async def restore_archive(
|
|
self,
|
|
archive_name: str,
|
|
*,
|
|
restore_database: bool,
|
|
restore_compose: bool,
|
|
) -> BackupRestoreResult:
|
|
return await asyncio.to_thread(
|
|
self.restore_archive_sync,
|
|
archive_name,
|
|
restore_database=restore_database,
|
|
restore_compose=restore_compose,
|
|
)
|
|
|
|
def restore_archive_sync(
|
|
self,
|
|
archive_name: str,
|
|
*,
|
|
restore_database: bool,
|
|
restore_compose: bool,
|
|
) -> BackupRestoreResult:
|
|
if not restore_database and not restore_compose:
|
|
raise BackupArchiveError("Select at least one restore target")
|
|
|
|
archive_path = self.archive_path_for_name(archive_name)
|
|
self._validate_archive_for_restore(archive_path)
|
|
started_at = datetime.now(timezone.utc)
|
|
warnings: list[str] = []
|
|
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=f"restore-{archive_path.stem}-",
|
|
dir=self.backup_dir(),
|
|
) as tmp:
|
|
temp_dir = Path(tmp)
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
self._validate_zip_members(archive.infolist())
|
|
db_member = self._find_database_dump_member(archive) if restore_database else None
|
|
compose_members = self._compose_file_members(archive) if restore_compose else []
|
|
|
|
if restore_database and db_member is None:
|
|
raise BackupArchiveError("Archive does not contain a database dump")
|
|
if restore_compose and not compose_members:
|
|
raise BackupArchiveError("Archive does not contain compose files")
|
|
|
|
compose_target_dir: Optional[Path] = None
|
|
compose_pre_restore_archive: Optional[Path] = None
|
|
if restore_compose:
|
|
compose_target_dir = self._compose_restore_target_dir()
|
|
self._assert_compose_target_writable(compose_target_dir)
|
|
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
|
|
|
|
database_restored = False
|
|
if db_member is not None:
|
|
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
|
self._run_pg_restore(dump_path)
|
|
database_restored = True
|
|
|
|
compose_files_restored = 0
|
|
if compose_target_dir is not None:
|
|
compose_files_restored = self._restore_compose_members(
|
|
archive,
|
|
compose_members,
|
|
compose_target_dir,
|
|
)
|
|
|
|
return BackupRestoreResult(
|
|
archive_name=archive_path.name,
|
|
started_at=started_at,
|
|
completed_at=datetime.now(timezone.utc),
|
|
database_restored=database_restored,
|
|
compose_files_restored=compose_files_restored,
|
|
compose_target_dir=str(compose_target_dir) if compose_target_dir else None,
|
|
compose_pre_restore_archive=str(compose_pre_restore_archive)
|
|
if compose_pre_restore_archive
|
|
else None,
|
|
warnings=warnings,
|
|
)
|
|
|
|
def _run_pg_restore(self, dump_path: Path) -> None:
|
|
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
|
|
pg_restore_path = pg_restore_path or "pg_restore"
|
|
if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path:
|
|
raise BackupRestoreError(
|
|
"pg_restore executable was not found. Rebuild the backend image with "
|
|
"PostgreSQL client tools."
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
|
command = [
|
|
pg_restore_path,
|
|
"-h",
|
|
self.settings.POSTGRES_HOST,
|
|
"-p",
|
|
str(self.settings.POSTGRES_PORT),
|
|
"-U",
|
|
self.settings.POSTGRES_USER,
|
|
"-d",
|
|
self.settings.POSTGRES_DB,
|
|
"--clean",
|
|
"--if-exists",
|
|
"--no-owner",
|
|
"--no-privileges",
|
|
str(dump_path),
|
|
]
|
|
timeout = max(
|
|
30,
|
|
int(
|
|
getattr(
|
|
self.settings,
|
|
"BACKUP_PG_RESTORE_TIMEOUT_SECONDS",
|
|
self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS,
|
|
)
|
|
or 1800
|
|
),
|
|
)
|
|
result = subprocess.run(
|
|
command,
|
|
check=False,
|
|
capture_output=True,
|
|
env=env,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or result.stdout or "").strip()
|
|
raise BackupRestoreError(
|
|
f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}"
|
|
)
|
|
|
|
def _compose_restore_target_dir(self) -> Path:
|
|
target_raw = (
|
|
getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None)
|
|
or self.settings.BACKUP_COMPOSE_SOURCE_DIR
|
|
or ""
|
|
)
|
|
if not str(target_raw).strip():
|
|
raise BackupArchiveError("Compose restore directory is not configured")
|
|
return Path(str(target_raw)).expanduser()
|
|
|
|
def _assert_compose_target_writable(self, target_dir: Path) -> None:
|
|
if not target_dir.exists() or not target_dir.is_dir():
|
|
raise BackupArchiveError(
|
|
f"Compose restore directory is unavailable: {target_dir}. "
|
|
"Mount the compose folder into the backend container."
|
|
)
|
|
probe = target_dir / f".restore-write-test-{os.getpid()}"
|
|
try:
|
|
probe.write_text("", encoding="utf-8")
|
|
probe.unlink()
|
|
except OSError as exc:
|
|
raise BackupArchiveError(
|
|
f"Compose restore directory is not writable: {target_dir}"
|
|
) from exc
|
|
|
|
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
|
|
stamp = backup_filename_timestamp()
|
|
archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
|
|
excluded_dirs = self._compose_excluded_dirs()
|
|
files_count = 0
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=f"{archive_path.stem}-",
|
|
dir=self.backup_dir(),
|
|
) as tmp:
|
|
staging_dir = Path(tmp)
|
|
compose_dir = staging_dir / "compose"
|
|
for path in sorted(target_dir.rglob("*")):
|
|
relative = path.relative_to(target_dir)
|
|
if any(part in excluded_dirs for part in relative.parts):
|
|
continue
|
|
if path.is_dir() or path.is_symlink():
|
|
continue
|
|
destination = compose_dir / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, destination)
|
|
files_count += 1
|
|
if files_count <= 0:
|
|
return None
|
|
|
|
completed_at = datetime.now(timezone.utc)
|
|
manifest = {
|
|
"app": BACKUP_APP_ID,
|
|
"format_version": BACKUP_FORMAT_VERSION,
|
|
"type": "compose-pre-restore",
|
|
"created_at": completed_at.isoformat(),
|
|
"created_at_local": completed_at.astimezone().isoformat(),
|
|
"postgres": {
|
|
"database": self.settings.POSTGRES_DB,
|
|
"included": False,
|
|
},
|
|
"compose": {
|
|
"source_dir": str(target_dir),
|
|
"included": True,
|
|
"files_count": files_count,
|
|
},
|
|
"warnings": [],
|
|
}
|
|
attach_archive_integrity(
|
|
manifest,
|
|
file_records=build_file_records(staging_dir),
|
|
)
|
|
write_manifest(staging_dir, manifest)
|
|
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
|
try:
|
|
write_zip_from_directory(staging_dir, tmp_archive)
|
|
tmp_archive.replace(archive_path)
|
|
finally:
|
|
if tmp_archive.exists():
|
|
try:
|
|
tmp_archive.unlink()
|
|
except OSError:
|
|
logger.warning("Failed to remove temporary snapshot %s", tmp_archive)
|
|
return archive_path
|
|
|
|
def _restore_compose_members(
|
|
self,
|
|
archive: zipfile.ZipFile,
|
|
members: list[zipfile.ZipInfo],
|
|
target_dir: Path,
|
|
) -> int:
|
|
target_root = target_dir.resolve()
|
|
restored = 0
|
|
for member in members:
|
|
relative = PurePosixPath(member.filename).relative_to("compose")
|
|
destination = target_root.joinpath(*relative.parts).resolve()
|
|
try:
|
|
destination.relative_to(target_root)
|
|
except ValueError as exc:
|
|
raise BackupArchiveError(
|
|
f"Unsafe compose archive member: {member.filename}"
|
|
) from exc
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_destination = destination.with_name(
|
|
f".{destination.name}.restore-{os.getpid()}.tmp"
|
|
)
|
|
try:
|
|
with archive.open(member) as source, temp_destination.open("wb") as target:
|
|
shutil.copyfileobj(source, target)
|
|
temp_destination.replace(destination)
|
|
finally:
|
|
if temp_destination.exists():
|
|
try:
|
|
temp_destination.unlink()
|
|
except OSError:
|
|
logger.warning(
|
|
"Failed to remove temporary restore file %s",
|
|
temp_destination,
|
|
)
|
|
restored += 1
|
|
return restored
|
|
|
|
def _extract_database_dump(
|
|
self,
|
|
archive: zipfile.ZipFile,
|
|
member: zipfile.ZipInfo,
|
|
temp_dir: Path,
|
|
) -> Path:
|
|
dump_dir = temp_dir / "database"
|
|
dump_dir.mkdir(parents=True, exist_ok=True)
|
|
dump_path = dump_dir / Path(member.filename).name
|
|
with archive.open(member) as source, dump_path.open("wb") as target:
|
|
shutil.copyfileobj(source, target)
|
|
return dump_path
|
|
|
|
def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]:
|
|
candidates = [
|
|
item
|
|
for item in archive.infolist()
|
|
if not item.is_dir()
|
|
and item.filename.startswith("database/")
|
|
and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"}
|
|
]
|
|
return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None
|
|
|
|
def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
|
members = [
|
|
item
|
|
for item in archive.infolist()
|
|
if not item.is_dir() and item.filename.startswith("compose/")
|
|
]
|
|
self._validate_compose_members(members)
|
|
return members
|
|
|
|
def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None:
|
|
if len(members) > BACKUP_MAX_MEMBERS:
|
|
raise BackupArchiveError("Archive contains too many files")
|
|
|
|
seen: set[str] = set()
|
|
total_size = 0
|
|
for member in members:
|
|
filename = member.filename
|
|
if "\\" in filename or "\x00" in filename:
|
|
raise BackupArchiveError(f"Unsafe archive member path: {filename}")
|
|
path = PurePosixPath(member.filename)
|
|
if (
|
|
not path.parts
|
|
or path.is_absolute()
|
|
or ".." in path.parts
|
|
or any(part in {"", "."} for part in path.parts)
|
|
):
|
|
raise BackupArchiveError(f"Unsafe archive member path: {member.filename}")
|
|
if member.is_dir():
|
|
continue
|
|
if filename in seen:
|
|
raise BackupArchiveError(f"Duplicate archive member path: {filename}")
|
|
seen.add(filename)
|
|
if member.file_size > BACKUP_MAX_MEMBER_BYTES:
|
|
raise BackupArchiveError(f"Archive member is too large: {filename}")
|
|
total_size += int(member.file_size)
|
|
if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES:
|
|
raise BackupArchiveError("Archive uncompressed size is too large")
|
|
compressed = max(1, int(member.compress_size or 1))
|
|
ratio = int(member.file_size) / compressed
|
|
if (
|
|
member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES
|
|
and ratio > BACKUP_MAX_COMPRESSION_RATIO
|
|
):
|
|
raise BackupArchiveError(
|
|
f"Archive member compression ratio is too high: {filename}"
|
|
)
|
|
|
|
def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None:
|
|
total_size = 0
|
|
for member in members:
|
|
if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES:
|
|
raise BackupArchiveError(f"Compose archive member is too large: {member.filename}")
|
|
total_size += int(member.file_size)
|
|
if total_size > BACKUP_MAX_COMPOSE_BYTES:
|
|
raise BackupArchiveError("Compose archive contents are too large")
|
|
|
|
def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]:
|
|
if BACKUP_MANIFEST_NAME not in archive.namelist():
|
|
raise BackupArchiveError("Archive does not contain manifest.json")
|
|
try:
|
|
manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise BackupArchiveError("manifest.json is not valid JSON") from exc
|
|
if not isinstance(manifest, dict):
|
|
raise BackupArchiveError("manifest.json must contain an object")
|
|
if manifest.get("app") != BACKUP_APP_ID:
|
|
raise BackupArchiveError("Archive manifest belongs to another application")
|
|
try:
|
|
format_version = int(manifest.get("format_version") or 0)
|
|
except (TypeError, ValueError) as exc:
|
|
raise BackupArchiveError("Archive manifest format is not supported") from exc
|
|
if format_version != BACKUP_FORMAT_VERSION:
|
|
raise BackupArchiveError("Archive manifest format is not supported")
|
|
return manifest
|
|
|
|
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
|
if not zipfile.is_zipfile(archive_path):
|
|
raise BackupArchiveError("Archive is not a valid ZIP file")
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
self._validate_zip_members(archive.infolist())
|
|
manifest = self._read_manifest(archive)
|
|
self._validate_archive_integrity(archive, manifest)
|
|
|
|
def _validate_archive_integrity(
|
|
self,
|
|
archive: zipfile.ZipFile,
|
|
manifest: dict[str, Any],
|
|
) -> None:
|
|
archive_manifest = (
|
|
manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
|
|
)
|
|
file_records = archive_manifest.get("files")
|
|
if not isinstance(file_records, list):
|
|
raise BackupArchiveError("Archive manifest does not contain file checksums")
|
|
|
|
expected: dict[str, dict[str, Any]] = {}
|
|
for record in file_records:
|
|
if not isinstance(record, dict):
|
|
raise BackupArchiveError("Archive manifest contains invalid file record")
|
|
filename = str(record.get("path") or "")
|
|
if not filename:
|
|
raise BackupArchiveError("Archive manifest contains empty file path")
|
|
if filename in expected:
|
|
raise BackupArchiveError(
|
|
f"Archive manifest contains duplicate file path: {filename}"
|
|
)
|
|
expected[filename] = record
|
|
|
|
actual = {
|
|
item.filename
|
|
for item in archive.infolist()
|
|
if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME
|
|
}
|
|
if actual != set(expected):
|
|
raise BackupArchiveError("Archive contents do not match manifest")
|
|
|
|
for info in archive.infolist():
|
|
if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME:
|
|
continue
|
|
record = expected[info.filename]
|
|
try:
|
|
expected_size = int(record.get("size_bytes") or -1)
|
|
except (TypeError, ValueError) as exc:
|
|
raise BackupArchiveError(
|
|
f"Archive manifest size is invalid: {info.filename}"
|
|
) from exc
|
|
expected_hash = str(record.get("sha256") or "")
|
|
if expected_size != int(info.file_size):
|
|
raise BackupArchiveError(
|
|
f"Archive member size does not match manifest: {info.filename}"
|
|
)
|
|
if not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
|
|
raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}")
|
|
digest = hashlib.sha256()
|
|
with archive.open(info) as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
if not hmac.compare_digest(digest.hexdigest(), expected_hash):
|
|
raise BackupArchiveError(
|
|
f"Archive member checksum does not match manifest: {info.filename}"
|
|
)
|
|
|
|
def _compose_excluded_dirs(self) -> set[str]:
|
|
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
|
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
|
|
|
@staticmethod
|
|
def _split_csv(value: Optional[str]) -> list[str]:
|
|
if not value:
|
|
return []
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
@staticmethod
|
|
def _file_digest(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()[:16]
|
|
|
|
@staticmethod
|
|
def _safe_original_stem(filename: str) -> str:
|
|
stem = Path(str(filename or "backup")).stem
|
|
safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-")
|
|
return (safe or "backup")[:72]
|
|
|
|
def _unique_archive_path(self, archive_name: str) -> Path:
|
|
backup_dir = self.backup_dir()
|
|
stem = Path(archive_name).stem
|
|
suffix = Path(archive_name).suffix
|
|
candidate = backup_dir / archive_name
|
|
counter = 2
|
|
while candidate.exists():
|
|
candidate = backup_dir / f"{stem}-{counter}{suffix}"
|
|
counter += 1
|
|
return candidate
|