Files
rune/tests/test_gitea_backup.py
T
Nick ca4f7593d7
CI Pipeline / best-practices (push) Successful in 8s
Auto-version and Release / release (push) Failing after 14s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / lint (push) Failing after 34s
CI Pipeline / canary (push) Successful in 37s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / unit-tests (push) Successful in 1m4s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 48s
feat(corrections): #314 defect fix, credential rotation, release guard, task lifecycle, route scoping, PL1 records + metrics
CORRECTION 1: #314 reopened — recall_with_associations mode field defect
CONFIRMED in current source and FIXED (mode: association_merged).

CORRECTION 3: Gitea credential removed from remote URL (credential helper).
TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires
Gitea web UI access). Remote URL now shows username only, no secret.

CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence
computed warranted bump from commits-since-tag without checking if master
version already satisfies the bump. FIX: already-converged guard in
version_convergence.py (master version > last tag → no commit). Repo-aware
(args.repo passed to read_version/latest_tag).

CORRECTION 6: lifecycle terminalization — execution exception now
guarantees task terminalizes as failed with evidence (no immortal RUNNING).

CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction
(NOT shared operator state). Read-only leading-verb override. Isolation
tests prove no cross-contamination between interleaved tasks.

PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure
class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics,
route-instruction learning loop (PE-1789102658-0269 SEED).

1253 tests green; ratchet 352/80 (shrink); henxels clean.
2026-09-11 15:36:08 +01:00

904 lines
41 KiB
Python

"""Failure-sensitive offline tests for Gitea backup and restore tooling."""
from __future__ import annotations
import hashlib
import io
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import textwrap
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
BACKUP = ROOT / "scripts" / "gitea-backup.sh"
RESTORE = ROOT / "scripts" / "gitea-restore-verify.sh"
REALISTIC_APP_INI = ROOT / "tests" / "fixtures" / "gitea" / "app.ini"
def write_executable(path: Path, body: str) -> None:
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + body)
path.chmod(0o755)
def make_archive(
directory: Path,
*,
missing: str | None = None,
database_type: str = "sqlite3",
traversal: bool = False,
database_sql: str | None = None,
app_ini: str | None = None,
alternate_backed: bool = False,
http_alternate_backed: bool = False,
environment_alternate_backed: bool = False,
repositories: tuple[tuple[str, str], ...] = (("alice", "demo"),),
omitted_repositories: tuple[tuple[str, str], ...] = (),
extra_repositories: tuple[tuple[str, str], ...] = (),
non_bare_repositories: tuple[tuple[str, str], ...] = (),
) -> Path:
directory.mkdir(parents=True, exist_ok=True)
source = directory / "source"
source.mkdir()
(source / "data").mkdir()
(source / "data" / "restore-data-marker.txt").write_text("archived application data\n")
(source / "data" / "conf").mkdir()
(source / "data" / "conf" / "app.ini").write_text("ARCHIVED_CONFIG_MUST_NOT_WIN\n")
(source / "data" / "gitea.db").write_text("ARCHIVED_DATABASE_MUST_NOT_WIN\n")
(source / "repos").mkdir()
(source / "app.ini").write_text(
app_ini
if app_ini is not None
else (
textwrap.dedent(
f"""
[database]
DB_TYPE = {database_type}
PATH = /data/gitea/gitea.db
"""
).strip()
+ "\n"
)
)
(source / "gitea-db.sql").write_text(
database_sql
or (
"CREATE TABLE user (id INTEGER PRIMARY KEY, lower_name TEXT);\n"
"CREATE TABLE repository (id INTEGER PRIMARY KEY, owner_id INTEGER, lower_name TEXT);\n"
+ "".join(
f"INSERT INTO user VALUES ({index}, '{owner}');\n"
f"INSERT INTO repository VALUES ({index}, {index}, '{name}');\n"
for index, (owner, name) in enumerate(repositories, 1)
)
)
)
for index, (owner, name) in enumerate(repositories + extra_repositories, 1):
if (owner, name) in omitted_repositories:
continue
repo = source / "repos" / owner / f"{name}.git"
repo.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "init", "--bare", "--initial-branch=main", repo],
check=True,
capture_output=True,
)
work = directory / f"work-{index}"
subprocess.run(["git", "clone", repo, work], check=True, capture_output=True)
subprocess.run(
["git", "-C", work, "config", "user.email", "test@example.invalid"],
check=True,
)
subprocess.run(["git", "-C", work, "config", "user.name", "Test"], check=True)
(work / "README.md").write_text(f"restore fixture {owner}/{name}\n")
subprocess.run(["git", "-C", work, "add", "README.md"], check=True)
subprocess.run(
["git", "-C", work, "commit", "-m", "fixture"],
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", work, "push", "origin", "main"],
check=True,
capture_output=True,
)
shutil.rmtree(work)
if (owner, name) in non_bare_repositories:
subprocess.run(
["git", "--git-dir", repo, "config", "core.bare", "false"],
check=True,
)
repo = source / "repos" / "alice" / "demo.git"
if alternate_backed or http_alternate_backed:
external = directory / "external-demo.git"
repo.rename(external)
subprocess.run(
["git", "clone", "--bare", "--shared", external, repo],
check=True,
capture_output=True,
)
assert (repo / "objects" / "info" / "alternates").is_file()
if http_alternate_backed:
(repo / "objects" / "info" / "alternates").rename(repo / "objects" / "info" / "http-alternates")
elif environment_alternate_backed:
external_objects = directory / "external-objects"
(repo / "objects").rename(external_objects)
(repo / "objects" / "info").mkdir(parents=True)
(repo / "objects" / "pack").mkdir()
archive = directory / "gitea-dump-20260826T040000Z.tar.gz"
members = ["app.ini", "data", "repos", "gitea-db.sql"]
with tarfile.open(archive, "w:gz") as tar:
for member in members:
if member != missing:
tar.add(source / member, arcname=member)
if traversal:
info = tarfile.TarInfo("../escaped")
payload = b"unsafe"
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
archive.with_suffix(archive.suffix + ".sha256").write_text(f"{digest} {archive.name}\n")
return archive
class RestoreVerifierTests(unittest.TestCase):
def run_restore(
self,
archive: Path,
tmp: Path,
extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
bin_dir = tmp / "bin"
bin_dir.mkdir(exist_ok=True)
podman_log = tmp / "podman.log"
write_executable(
bin_dir / "podman",
'printf "%s\\n" "$*" >> "$PODMAN_LOG"\n'
'if [[ "$1 $2" == "image exists" ]]; then\n'
' exit "${PODMAN_IMAGE_EXISTS_RC:-0}"\n'
"fi\n"
'if [[ "$1" == run ]]; then\n'
' [[ " $* " == *" --network none "* ]]\n'
' [[ " $* " == *" --read-only "* ]]\n'
' volume=""; while (($#)); do [[ "$1" == -v ]] && { volume="${2%%:*}"; break; }; shift; done\n'
' [[ -f "$volume/gitea/restore-data-marker.txt" ]]\n'
' [[ "$(cat "$volume/gitea/restore-data-marker.txt")" == "archived application data" ]]\n'
' grep -q "APP_NAME = Rune restore verification" "$volume/gitea/conf/app.ini"\n'
' [[ "$(head -c 15 "$volume/gitea/gitea.db")" == "SQLite format 3" ]]\n'
' printf "test-container\\n"\n'
'elif [[ "$1" == exec && "${PODMAN_CONTAINER_EXITED:-0}" == 1 ]]; then\n'
' printf "oauth2.JWT_SECRET=super-secret in /tmp/private/app.ini\\n" >&2\n'
" exit 125\n"
'elif [[ "$1" == inspect && "${PODMAN_CONTAINER_EXITED:-0}" == 1 ]]; then\n'
' printf "container vanished; config=/tmp/private/app.ini\\n" >&2\n'
" exit 125\n"
"fi\n",
)
write_executable(bin_dir / "sleep", "exit 0\n")
env = os.environ.copy()
env.update(
{
"PATH": f"{bin_dir}:{env['PATH']}",
"PODMAN_LOG": str(podman_log),
"TMPDIR": str(tmp),
"RUNE_GITEA_RESTORE_VERIFY": "1",
}
)
env.update(extra_env or {})
return subprocess.run([RESTORE, archive], text=True, capture_output=True, env=env)
def test_rejects_each_missing_mandatory_member_independently(self) -> None:
for missing in ("app.ini", "data", "repos", "gitea-db.sql"):
with self.subTest(missing=missing), tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, missing=missing)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(missing, result.stderr)
def test_rejects_archive_path_traversal(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, traversal=True)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0)
self.assertIn("unsafe archive path", result.stderr)
self.assertFalse((tmp.parent / "escaped").exists())
def test_rejects_checksum_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
archive.with_suffix(archive.suffix + ".sha256").write_text(f"{'0' * 64} {archive.name}\n")
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0)
self.assertIn("checksum", result.stderr.lower())
def test_accepts_legacy_absolute_posix_sidecar_for_supplied_archive_basename(
self,
) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
archive.with_suffix(archive.suffix + ".sha256").write_text(
f"{digest} /definitely/not/opened/{archive.name}\n"
)
result = self.run_restore(archive, tmp)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_accepts_current_basename_only_sidecar(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
result = self.run_restore(archive, tmp)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_accepts_real_gitea_ini_with_top_level_keys_before_sections(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, app_ini=REALISTIC_APP_INI.read_text())
result = self.run_restore(archive, tmp)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_rejects_unsafe_or_ambiguous_checksum_sidecar_paths(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
sidecar = archive.with_suffix(archive.suffix + ".sha256")
invalid_paths = {
"relative traversal": f"../{archive.name}",
"relative subdirectory": f"backups/{archive.name}",
"mismatched absolute basename": "/legacy/backups/not-the-supplied-archive.tar.gz",
"backslash path": rf"C:\legacy\backups\{archive.name}",
"mixed separator path": rf"/legacy\backups/{archive.name}",
}
for label, recorded_path in invalid_paths.items():
with self.subTest(label=label):
sidecar.write_text(f"{digest} {recorded_path}\n")
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("checksum", result.stderr.lower())
def test_rejects_extra_lines_and_malformed_checksum_digest(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
sidecar = archive.with_suffix(archive.suffix + ".sha256")
invalid_sidecars = {
"trailing blank line": f"{digest} {archive.name}\n\n",
"second record": f"{digest} {archive.name}\n{digest} {archive.name}\n",
"short digest": f"{digest[:-1]} {archive.name}\n",
"non-hex digest": f"{'g' * 64} {archive.name}\n",
}
for label, contents in invalid_sidecars.items():
with self.subTest(label=label):
sidecar.write_text(contents)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("checksum", result.stderr.lower())
def test_fails_closed_for_non_sqlite_and_cleans_workspace(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, database_type="postgres")
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0)
self.assertIn("unsupported database DB_TYPE", result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertEqual(list(tmp.glob("gitea-restore-verify.*")), [])
def test_rejects_malformed_app_ini_with_an_accurate_sanitized_error(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, app_ini="[database\nDB_TYPE = sqlite3\n")
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0)
self.assertIn("malformed app.ini", result.stderr)
self.assertNotIn("non-SQLite", result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertNotIn(str(tmp), result.stderr)
def test_rejects_duplicate_or_ambiguous_database_definitions(self) -> None:
invalid_configs = {
"duplicate section": "[database]\nDB_TYPE = sqlite3\n[database]\nPATH = x\n",
"case-ambiguous sections": "[database]\nDB_TYPE = sqlite3\n[DATABASE]\nPATH = x\n",
"duplicate DB_TYPE": "[database]\nDB_TYPE = sqlite3\nDB_TYPE = sqlite3\n",
"inherited DB_TYPE": "[DEFAULT]\nDB_TYPE = sqlite3\n[database]\nPATH = x\n",
}
for label, app_ini in invalid_configs.items():
with self.subTest(label=label), tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, app_ini=app_ini)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("ambiguous database configuration", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_rejects_missing_or_interpolated_database_type(self) -> None:
invalid_configs = {
"missing": ("[database]\nPATH = gitea.db\n", "missing database DB_TYPE"),
"percent interpolation": (
"DB_ENGINE = sqlite3\n[database]\nDB_TYPE = %(DB_ENGINE)s\n",
"unsupported database DB_TYPE",
),
"dollar interpolation": (
"DB_ENGINE = sqlite3\n[database]\nDB_TYPE = ${DB_ENGINE}\n",
"unsupported database DB_TYPE",
),
}
for label, (app_ini, expected_error) in invalid_configs.items():
with self.subTest(label=label), tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, app_ini=app_ini)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(expected_error, result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_sqlite_import_cannot_attach_or_create_an_outside_database(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
outside = tmp / "outside.db"
sql = (
"CREATE TABLE repository (id INTEGER PRIMARY KEY, name TEXT);\n"
f"ATTACH DATABASE '{outside}' AS escaped;\n"
"CREATE TABLE escaped.stolen (value TEXT);\n"
)
archive = make_archive(tmp / "fixture", database_sql=sql)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("not authorized", result.stderr.lower())
self.assertFalse(outside.exists())
self.assertEqual(list(tmp.glob("gitea-restore-verify.*")), [])
def test_validates_sqlite_git_default_branch_and_networkless_boot(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
result = self.run_restore(archive, tmp)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
log = (tmp / "podman.log").read_text()
self.assertIn("--network none", log)
self.assertIn("--read-only", log)
self.assertIn("--pull=never", log)
self.assertIn("--userns=keep-id:uid=1000,gid=1000", log)
self.assertIn(
"docker.io/gitea/gitea@sha256:414ba5b2b1163480e9ed4213a989cd798579cfa88582a2359303273009b2b852",
log,
)
self.assertEqual(list(tmp.glob("gitea-restore-verify.*")), [])
def test_container_exit_stops_health_probe_with_one_sanitized_failure(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
result = self.run_restore(archive, tmp, {"PODMAN_CONTAINER_EXITED": "1"})
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stderr.count("Restore verification failed:"), 1)
self.assertIn("container exited before becoming healthy", result.stderr)
self.assertNotIn("oauth2.JWT_SECRET", result.stderr)
self.assertNotIn("super-secret", result.stderr)
self.assertNotIn("app.ini", result.stderr)
self.assertNotIn(str(tmp), result.stderr)
log = (tmp / "podman.log").read_text().splitlines()
self.assertEqual(sum(line.startswith("exec ") for line in log), 1, log)
self.assertEqual(sum(line.startswith("inspect ") for line in log), 1, log)
self.assertTrue(any(line == "rm -f test-container" for line in log), log)
self.assertEqual(list(tmp.glob("gitea-restore-verify.*")), [])
def test_rejects_missing_database_declared_repository_even_when_another_repo_is_valid(
self,
) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(
tmp,
repositories=(("alice", "demo"), ("bob", "second")),
omitted_repositories=(("bob", "second"),),
)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("database-declared repository is missing", result.stderr)
self.assertNotIn("run ", (tmp / "podman.log").read_text())
def test_accepts_all_database_declared_repositories_and_restores_application_data(
self,
) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(
tmp,
repositories=(("alice", "demo"), ("bob", "second")),
)
result = self.run_restore(archive, tmp)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("2 Git repositories", result.stdout)
def test_rejects_absent_or_unsafe_repository_schema(self) -> None:
invalid_sql = {
"missing owner table": (
"CREATE TABLE repository (id INTEGER PRIMARY KEY, owner_id INTEGER, lower_name TEXT);\n"
"INSERT INTO repository VALUES (1, 1, 'demo');\n"
),
"unsafe owner component": (
"CREATE TABLE user (id INTEGER PRIMARY KEY, lower_name TEXT);\n"
"CREATE TABLE repository (id INTEGER PRIMARY KEY, owner_id INTEGER, lower_name TEXT);\n"
"INSERT INTO user VALUES (1, '../escaped');\n"
"INSERT INTO repository VALUES (1, 1, 'demo');\n"
),
}
for label, sql in invalid_sql.items():
with self.subTest(label=label), tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, database_sql=sql)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("run ", (tmp / "podman.log").read_text())
def test_missing_local_restore_image_fails_before_container_run(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp)
result = self.run_restore(archive, tmp, {"PODMAN_IMAGE_EXISTS_RC": "1"})
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("local image", result.stderr.lower())
log = (tmp / "podman.log").read_text().splitlines()
self.assertTrue(any(line.startswith("image exists ") for line in log), log)
self.assertFalse(any(line.startswith("run ") for line in log), log)
def test_rejects_repository_alternates_while_external_objects_exist(self) -> None:
for alternate_kind in ("alternates", "http-alternates"):
with (
self.subTest(alternate_kind=alternate_kind),
tempfile.TemporaryDirectory() as raw,
):
tmp = Path(raw)
archive = make_archive(
tmp,
alternate_backed=alternate_kind == "alternates",
http_alternate_backed=alternate_kind == "http-alternates",
)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("repository alternates are forbidden", result.stderr.lower())
self.assertNotIn("run ", (tmp / "podman.log").read_text())
def test_rejects_non_bare_database_declared_and_extra_repositories(self) -> None:
cases = {
"database-declared": {
"non_bare_repositories": (("alice", "demo"),),
},
"legitimate extra": {
"extra_repositories": (("bob", "extra"),),
"non_bare_repositories": (("bob", "extra"),),
},
}
for label, archive_options in cases.items():
with self.subTest(label=label), tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
archive = make_archive(tmp, **archive_options)
result = self.run_restore(archive, tmp)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(
result.stderr,
"Restore verification failed: repository is not bare\n",
)
self.assertNotIn("run ", (tmp / "podman.log").read_text())
def test_git_validation_ignores_inherited_external_object_store(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
fixture = tmp / "fixture"
archive = make_archive(fixture, environment_alternate_backed=True)
result = self.run_restore(
archive,
tmp,
{"GIT_ALTERNATE_OBJECT_DIRECTORIES": str(fixture / "external-objects")},
)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("run ", (tmp / "podman.log").read_text())
class BackupWorkflowTests(unittest.TestCase):
def test_checkout_is_pinned_to_audited_v422_commit(self) -> None:
workflow = (ROOT / ".gitea" / "workflows" / "auto-backup.yml").read_text()
self.assertIn(
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
workflow,
)
self.assertRegex(workflow, r"(?i)#.*checkout.*v4\.2\.2")
class BackupScriptTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.tmp = Path(self.temp.name)
self.mount = self.tmp / "foundry"
self.mount.mkdir()
self.backups = self.mount / "backups"
self.work = self.tmp / "work"
self.bin = self.tmp / "bin"
self.bin.mkdir()
self.state = self.tmp / "service-state"
self.state.write_text("inactive\n")
self.log = self.tmp / "commands.log"
self.fixture = make_archive(self.tmp / "fixture")
write_executable(
self.bin / "systemctl",
'cmd="$1"\n'
'if [[ "$cmd" == is-active ]]; then [[ "$(cat "$STATE")" == active ]]; exit; fi\n'
'printf "%s\\n" "$cmd" >> "$COMMAND_LOG"\n'
'case "$cmd" in stop) printf "inactive\\n" > "$STATE";; start) printf "active\\n" > "$STATE";; esac\n',
)
write_executable(self.bin / "curl", "exit 0\n")
write_executable(self.bin / "mountpoint", '[[ "${MOUNTPOINT_MOUNTED:-1}" == 1 ]]\n')
write_executable(
self.bin / "findmnt",
'target="${@: -1}"\n'
'if [[ "$target" == / ]]; then printf "8:1\\n";\n'
'elif [[ "${MOUNT_ROOT_BACKED:-0}" == 1 ]]; then printf "8:1\\n";\n'
'else printf "8:2\\n"; fi\n',
)
write_executable(
self.bin / "gitea",
'printf "dump\\n" >> "$COMMAND_LOG"\n'
'out=""; while (($#)); do [[ "$1" == --file ]] && { out="$2"; break; }; shift; done\n'
'cp "$FIXTURE_ARCHIVE" "$out"\n',
)
def tearDown(self) -> None:
self.temp.cleanup()
def env(self) -> dict[str, str]:
env = os.environ.copy()
env.update(
{
"BACKUP_DIR": str(self.backups),
"BACKUP_MOUNT": str(self.mount),
"WORK_ROOT": str(self.work),
"LOCK_FILE": str(self.tmp / "backup.lock"),
"BACKUP_OWNER": "",
"GITEA_RUN_USER": "",
"GITEA_BIN": str(self.bin / "gitea"),
"SYSTEMCTL_BIN": str(self.bin / "systemctl"),
"CURL_BIN": str(self.bin / "curl"),
"MOUNTPOINT_BIN": str(self.bin / "mountpoint"),
"FINDMNT_BIN": str(self.bin / "findmnt"),
"STATE": str(self.state),
"COMMAND_LOG": str(self.log),
"FIXTURE_ARCHIVE": str(self.fixture),
"RETENTION_DAYS": "30",
}
)
return env
def run_backup(self, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run([BACKUP], text=True, capture_output=True, env=env or self.env())
def test_preserves_inactive_service_state(self) -> None:
result = self.run_backup()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.state.read_text().strip(), "inactive")
commands = self.log.read_text()
self.assertNotIn("start", commands)
self.assertNotIn("stop", commands)
def test_absent_backup_mount_fails_before_creating_backup_directory_or_dumping(
self,
) -> None:
env = self.env()
env["MOUNTPOINT_MOUNTED"] = "0"
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(self.backups.exists())
self.assertFalse(self.log.exists())
def test_root_backed_fake_mount_fails_before_creating_backup_directory_or_dumping(
self,
) -> None:
env = self.env()
env["MOUNT_ROOT_BACKED"] = "1"
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(self.backups.exists())
self.assertFalse(self.log.exists())
def test_valid_distinct_mount_allows_backup(self) -> None:
result = self.run_backup()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue(any(self.backups.glob("gitea-dump-*.tar.gz")))
def test_backup_directory_must_be_lexically_contained_by_mount(self) -> None:
env = self.env()
env["BACKUP_DIR"] = str(self.tmp / "outside")
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse((self.tmp / "outside").exists())
self.assertFalse(self.log.exists())
def test_backup_directory_must_not_resolve_through_symlink_outside_mount(
self,
) -> None:
outside = self.tmp / "outside"
outside.mkdir()
(self.mount / "escaped").symlink_to(outside, target_is_directory=True)
env = self.env()
env["BACKUP_DIR"] = str(self.mount / "escaped" / "backups")
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse((outside / "backups").exists())
self.assertFalse(self.log.exists())
def test_restores_active_service_after_failed_dump(self) -> None:
self.state.write_text("active\n")
write_executable(self.bin / "gitea", "exit 23\n")
result = self.run_backup()
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.state.read_text().strip(), "active")
self.assertEqual(self.log.read_text().splitlines(), ["stop", "start"])
def test_overlap_lock_prevents_second_dump(self) -> None:
lock = self.tmp / "backup.lock"
holder = subprocess.Popen(["flock", str(lock), "sleep", "5"])
try:
time.sleep(0.1)
result = self.run_backup()
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.log.exists())
finally:
holder.terminate()
holder.wait()
def test_failed_checksum_publishes_neither_archive_nor_sidecar(self) -> None:
write_executable(self.bin / "sha256sum", "exit 31\n")
env = self.env()
env["SHA256SUM_BIN"] = str(self.bin / "sha256sum")
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(list(self.backups.glob("gitea-dump-*")), [])
def test_each_failed_final_link_removes_the_partial_publication(self) -> None:
real_ln = shutil.which("ln")
self.assertIsNotNone(real_ln)
write_executable(
self.bin / "ln",
'count=$(( $(cat "$LN_COUNT" 2>/dev/null || printf 0) + 1 ))\n'
'printf "%s\\n" "$count" > "$LN_COUNT"\n'
'[[ "$count" != "$FAIL_AT_LINK" ]] || exit 44\n'
'exec "$REAL_LN" "$@"\n',
)
for failed_link in (1, 2, 3):
with self.subTest(failed_link=failed_link):
shutil.rmtree(self.backups, ignore_errors=True)
count_file = self.tmp / f"ln-failure-count-{failed_link}"
env = self.env()
env.update(
{
"PATH": f"{self.bin}:{env['PATH']}",
"REAL_LN": real_ln or "",
"LN_COUNT": str(count_file),
"FAIL_AT_LINK": str(failed_link),
}
)
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(list(self.backups.iterdir()), [])
def test_sigterm_after_each_link_removes_only_this_runs_hard_links(self) -> None:
real_ln = shutil.which("ln")
self.assertIsNotNone(real_ln)
write_executable(
self.bin / "ln",
'count=$(( $(cat "$LN_COUNT" 2>/dev/null || printf 0) + 1 ))\n'
'printf "%s\\n" "$count" > "$LN_COUNT"\n'
'"$REAL_LN" "$@"\n'
'if [[ "$count" == "$SIGNAL_AT_LINK" ]]; then kill -TERM "$PPID"; fi\n',
)
for signal_at_link in (1, 2, 3):
with self.subTest(signal_at_link=signal_at_link):
shutil.rmtree(self.backups, ignore_errors=True)
env = self.env()
env.update(
{
"PATH": f"{self.bin}:{env['PATH']}",
"REAL_LN": real_ln or "",
"LN_COUNT": str(self.tmp / f"ln-signal-count-{signal_at_link}"),
"SIGNAL_AT_LINK": str(signal_at_link),
}
)
result = self.run_backup(env)
self.assertEqual(result.returncode, 143, result.stdout + result.stderr)
self.assertEqual(list(self.backups.iterdir()), [])
def test_signal_cleanup_preserves_destination_replaced_after_link(self) -> None:
real_ln = shutil.which("ln")
self.assertIsNotNone(real_ln)
replacement = self.tmp / "replacement-path"
write_executable(
self.bin / "ln",
'count=$(( $(cat "$LN_COUNT" 2>/dev/null || printf 0) + 1 ))\n'
'printf "%s\\n" "$count" > "$LN_COUNT"\n'
'"$REAL_LN" "$@"\n'
'if [[ "$count" == 2 ]]; then\n'
' target="${@: -1}"\n'
' rm -f -- "$target"\n'
' printf "replacement\\n" > "$target"\n'
' printf "%s\\n" "$target" > "$REPLACEMENT_PATH"\n'
' kill -TERM "$PPID"\n'
"fi\n",
)
env = self.env()
env.update(
{
"PATH": f"{self.bin}:{env['PATH']}",
"REAL_LN": real_ln or "",
"LN_COUNT": str(self.tmp / "ln-replacement-count"),
"REPLACEMENT_PATH": str(replacement),
}
)
result = self.run_backup(env)
self.assertEqual(result.returncode, 143, result.stdout + result.stderr)
raced = Path(replacement.read_text().strip())
self.assertEqual(raced.read_text(), "replacement\n")
self.assertEqual(list(self.backups.iterdir()), [raced])
def test_collision_failure_preserves_preexisting_publication_artifacts(
self,
) -> None:
real_date = shutil.which("date")
self.assertIsNotNone(real_date)
stamp = "20010203T040506Z"
write_executable(
self.bin / "date",
f'if [[ "$*" == "-u +%Y%m%dT%H%M%SZ" ]]; then\n printf "%s\\n" "{stamp}"\nelse\n "$REAL_DATE" "$@"\nfi\n',
)
self.backups.mkdir()
existing = self.backups / f"gitea-dump-{stamp}.tar.gz.metadata"
existing.write_text("must survive\n")
env = self.env()
env.update({"PATH": f"{self.bin}:{env['PATH']}", "REAL_DATE": real_date or ""})
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Refusing to overwrite", result.stderr)
self.assertEqual(existing.read_text(), "must survive\n")
self.assertEqual(list(self.backups.iterdir()), [existing])
def test_each_publication_race_preserves_raced_final_and_rolls_back_own_finals(
self,
) -> None:
real_date = shutil.which("date")
real_ln = shutil.which("ln")
self.assertIsNotNone(real_date)
self.assertIsNotNone(real_ln)
stamp = "20010203T040506Z"
archive = f"gitea-dump-{stamp}.tar.gz"
write_executable(
self.bin / "date",
f'if [[ "$*" == "-u +%Y%m%dT%H%M%SZ" ]]; then\n printf "%s\\n" "{stamp}"\nelse\n "$REAL_DATE" "$@"\nfi\n',
)
write_executable(
self.bin / "ln",
'count=$(( $(cat "$LN_COUNT" 2>/dev/null || printf 0) + 1 ))\n'
'printf "%s\\n" "$count" > "$LN_COUNT"\n'
'target="${@: -1}"\n'
'if [[ "$count" == "$RACE_AT_LINK" ]]; then\n'
' printf "raced sentinel\\n" > "$target"\n'
"fi\n"
'exec "$REAL_LN" "$@"\n',
)
raced_names = (f"{archive}.metadata", archive, f"{archive}.sha256")
for race_at_link, raced_name in enumerate(raced_names, start=1):
with self.subTest(race_at_link=race_at_link):
shutil.rmtree(self.backups, ignore_errors=True)
count_file = self.tmp / f"ln-count-{race_at_link}"
env = self.env()
env.update(
{
"PATH": f"{self.bin}:{env['PATH']}",
"REAL_DATE": real_date or "",
"REAL_LN": real_ln or "",
"LN_COUNT": str(count_file),
"RACE_AT_LINK": str(race_at_link),
}
)
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
raced = self.backups / raced_name
self.assertEqual(raced.read_text(), "raced sentinel\n")
self.assertEqual(list(self.backups.iterdir()), [raced])
def test_requires_every_archive_member_not_just_one_match(self) -> None:
fixture_dir = self.tmp / "incomplete"
fixture_dir.mkdir()
fixture = make_archive(fixture_dir, missing="repos")
env = self.env()
env["FIXTURE_ARCHIVE"] = str(fixture)
result = self.run_backup(env)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(list(self.backups.glob("gitea-dump-*")), [])
def test_expired_metadata_removes_the_complete_publication_as_a_unit(self) -> None:
self.backups.mkdir()
old = self.backups / "gitea-dump-20000101T000000Z.tar.gz"
old.write_bytes(b"old")
old.with_suffix(old.suffix + ".sha256").write_text("old\n")
old.with_suffix(old.suffix + ".metadata").write_text("old\n")
ancient = time.time() - 40 * 86400
os.utime(old.with_suffix(old.suffix + ".metadata"), (ancient, ancient))
result = self.run_backup()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
archives = list(self.backups.glob("gitea-dump-*.tar.gz"))
self.assertEqual(len(archives), 1)
archive = archives[0]
self.assertTrue(archive.with_suffix(archive.suffix + ".sha256").is_file())
metadata = archive.with_suffix(archive.suffix + ".metadata")
self.assertTrue(metadata.is_file())
self.assertIn("created_utc=", metadata.read_text())
self.assertFalse(old.exists())
self.assertFalse(old.with_suffix(old.suffix + ".sha256").exists())
self.assertFalse(old.with_suffix(old.suffix + ".metadata").exists())
def test_unexpired_legacy_archive_and_checksum_pair_is_preserved(self) -> None:
self.backups.mkdir()
legacy = self.backups / "gitea-dump-20000101T000000Z.tar.gz"
legacy.write_bytes(b"legacy")
checksum = legacy.with_suffix(legacy.suffix + ".sha256")
digest = hashlib.sha256(legacy.read_bytes()).hexdigest()
checksum.write_text(f"{digest} {legacy}\n")
result = self.run_backup()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(legacy.read_bytes(), b"legacy")
self.assertEqual(checksum.read_text(), f"{digest} {legacy}\n")
self.assertFalse(legacy.with_suffix(legacy.suffix + ".metadata").exists())
def test_expired_legacy_pair_is_deleted_pairwise(self) -> None:
self.backups.mkdir()
legacy = self.backups / "gitea-dump-20000101T000000Z.tar.gz"
legacy.write_bytes(b"legacy")
checksum = legacy.with_suffix(legacy.suffix + ".sha256")
checksum.write_text(f"{hashlib.sha256(legacy.read_bytes()).hexdigest()} {legacy}\n")
ancient = time.time() - 40 * 86400
os.utime(checksum, (ancient, ancient))
result = self.run_backup()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(legacy.exists())
self.assertFalse(checksum.exists())
def test_incomplete_legacy_and_metadata_sets_are_cleaned(self) -> None:
self.backups.mkdir()
lone_archive = self.backups / "gitea-dump-20000101T000000Z.tar.gz"
lone_archive.write_bytes(b"incomplete")
metadata_pair = self.backups / "gitea-dump-20010101T000000Z.tar.gz"
metadata_pair.with_suffix(metadata_pair.suffix + ".sha256").write_text("incomplete\n")
metadata_pair.with_suffix(metadata_pair.suffix + ".metadata").write_text("incomplete\n")
result = self.run_backup()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(lone_archive.exists())
self.assertFalse(metadata_pair.with_suffix(metadata_pair.suffix + ".sha256").exists())
self.assertFalse(metadata_pair.with_suffix(metadata_pair.suffix + ".metadata").exists())
if __name__ == "__main__":
unittest.main()