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
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.
904 lines
41 KiB
Python
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()
|