Files
Unraid-AppOps-Gateway-Public/backend/tests/test_appops_self_upgrade_recovery_boundaries.py
T

722 lines
28 KiB
Python

from __future__ import annotations
import http.server
import importlib.util
import json
import os
import stat
import subprocess
import sys
import threading
import urllib.error
import urllib.request
import uuid
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
CONTROLLER = ROOT / "ops" / "gitea-deploy-broker"
@pytest.fixture
def controller(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(CONTROLLER))
spec = importlib.util.spec_from_file_location(
"appops_self_upgrade_recovery_boundaries_test",
CONTROLLER / "status_server.py",
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
monkeypatch.setattr(module, "WEBHOOK_INBOX_ROOT", tmp_path / "inbox")
module.WEBHOOK_INBOX_ROOT.mkdir(mode=0o700)
monkeypatch.setattr(module, "APPOPS_PROGRESS_ROOT", tmp_path / "progress")
monkeypatch.setattr(module, "SELF_UPGRADE_RECOVERY_ROOT", tmp_path / "recovery")
monkeypatch.setattr(
module,
"SELF_UPGRADE_RECOVERY_REQUEST_ROOT",
tmp_path / "recovery" / "requests",
)
return module
def _private_recovery_payload(module, recovery_id: str) -> dict[str, object]:
revision = "c" * 40
return {
"schema": module._SELF_UPGRADE_RECOVERY_SCHEMA,
"recoveryId": recovery_id,
"semanticSha256": module._webhook_semantic_digest(
9, "refs/heads/main", revision,
),
"repository": module.SELF_UPGRADE_REPOSITORY,
"revision": revision,
"originalRawSha256": "1" * 64,
"idempotencyKeySha256": "2" * 64,
"preflightSha256": "3" * 64,
"expectedCurrentRevision": "4" * 40,
"currentContainerId": "5" * 64,
"currentImageId": "sha256:" + "6" * 64,
"currentSourceTree": "7" * 40,
"runtimeFingerprint": "8" * 64,
"registryFingerprint": "9" * 64,
"sourceFingerprint": "a" * 64,
"stateSha256": "b" * 64,
"runtimeEnvironment": {"API_TOKEN": "recovery-secret-sentinel"},
"createdAt": 1,
}
def _recovery_delivery(module, *, secret: str) -> dict[str, object]:
return {
"schema": module._WEBHOOK_SELF_UPGRADE_RECOVERY_SCHEMA,
"semantic_sha256": "a" * 64,
"recovery_of_sha256": "b" * 64,
"recovery_id": str(uuid.UUID("12345678-1234-4234-8234-123456789abc")),
"idempotency_key_sha256": "c" * 64,
"preflight_sha256": "d" * 64,
"body_sha256": "e" * 64,
"delivery": "22345678-1234-4234-8234-123456789abc",
"gitea_repository_id": 9,
"ref": "refs/heads/main",
"repository": module.SELF_UPGRADE_REPOSITORY,
"revision": "f" * 40,
"source": "typed_self_upgrade_recovery",
"status": "awaiting_validation",
"validation": {
"last_result": "wait",
"required_contexts": ["Managed validation / full (push)"],
"observed": [],
"checked_at": 1,
"deadline_at": 2,
},
"audit": [
{"at": 1, "stage": "self_upgrade_recovery_requested"},
{"at": 2, "stage": "awaiting_validation"},
],
"received_at": 1,
"updated_at": 2,
# These fields must remain private even if a caller or record contains them.
"runtimeEnvironment": {"API_TOKEN": secret},
"privateCredential": secret,
}
def test_ordinary_deploy_strips_ambient_recovery_binding_and_rejects_bad_bindings(
controller, monkeypatch,
):
ambient_secret = "ambient-recovery-secret-sentinel"
monkeypatch.setenv("APPOPS_SELF_UPGRADE_RECOVERY_ID", ambient_secret)
monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", ambient_secret)
calls = []
def fake_run(command, **kwargs):
calls.append((command, kwargs))
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(controller.subprocess, "run", fake_run)
ordinary = controller._run_registered_deploy("jens/quiz-monitor", "c" * 40)
assert ordinary.returncode == 0
command, kwargs = calls.pop()
assert command[-3:] == ["deploy", "jens/quiz-monitor", "c" * 40]
assert "APPOPS_SELF_UPGRADE_RECOVERY_ID" not in kwargs["env"]
assert "APPOPS_REGISTERED_LEGACY_REQUEST_ID" not in kwargs["env"]
assert ambient_secret not in json.dumps(kwargs["env"])
recovery_id = str(uuid.UUID("12345678-1234-4234-8234-123456789abc"))
authorized = controller._run_registered_deploy(
controller.SELF_UPGRADE_REPOSITORY,
"c" * 40,
self_upgrade_recovery_id=recovery_id,
)
assert authorized.returncode == 0
_, kwargs = calls.pop()
assert kwargs["env"]["APPOPS_SELF_UPGRADE_RECOVERY_ID"] == recovery_id
assert "APPOPS_REGISTERED_LEGACY_REQUEST_ID" not in kwargs["env"]
invalid_bindings = [
(controller.SELF_UPGRADE_REPOSITORY, "not-a-uuid", None),
("jens/quiz-monitor", recovery_id, None),
(controller.SELF_UPGRADE_REPOSITORY, recovery_id, recovery_id),
]
for repository, bad_recovery_id, registered_id in invalid_bindings:
with pytest.raises(ValueError):
controller._run_registered_deploy(
repository,
"c" * 40,
self_upgrade_recovery_id=bad_recovery_id,
registered_legacy_request_id=registered_id,
)
assert calls == []
def test_preflight_projects_runtime_identity_without_environment_values(
controller, monkeypatch,
):
project = {
"branch": "main",
"gitea_repository_id": 9,
"containers": ["Unraid-AppOps-Gateway"],
"compose_project": "unraid-appops-gateway",
"compose_files": ["docker-compose.yml"],
"environment": {
"APPOPS_DATA_PATH": controller.SELF_UPGRADE_DATA_PATH,
"APPOPS_PUBLISH_HOST": controller.SELF_UPGRADE_PUBLISH_HOST,
},
"verify_oci_revision": True,
"validation": {
"validation_gate_version": 1,
"required": True,
"contexts": ["Managed validation / full (push)"],
"timeout_seconds": 1800,
"poll_interval_seconds": 5,
"max_poll_interval_seconds": 30,
},
}
private_environment = {"API_TOKEN": "preflight-secret-sentinel"}
monkeypatch.setattr(
controller,
"_load_projects",
lambda: {controller.SELF_UPGRADE_REPOSITORY: project},
)
monkeypatch.setattr(controller, "_self_upgrade_protected_head", lambda _revision: True)
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *_args: True)
monkeypatch.setattr(
controller, "_fetch_gitea_validation", lambda *_args: ("pass", []),
)
monkeypatch.setattr(
controller,
"_self_upgrade_runtime",
lambda: {
"revision": "4" * 40,
"imageId": "sha256:" + "5" * 64,
"containerId": "6" * 64,
"imageRef": controller.SELF_UPGRADE_PRODUCTION_IMAGE_REF,
"sourceTree": "7" * 40,
"runtimeFingerprint": "8" * 64,
"runtimeEnvironment": private_environment,
},
)
monkeypatch.setattr(
controller.shutil,
"disk_usage",
lambda _path: type("Usage", (), {"free": 50 * 1024**3})(),
)
monkeypatch.setattr(controller, "_self_upgrade_protected_head", lambda _revision: True)
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *_args: True)
monkeypatch.setattr(
controller, "_fetch_gitea_validation", lambda *_args: ("pass", []),
)
monkeypatch.setattr(
controller, "_validation_policy",
lambda _repository, _project: {
"contexts": ["Managed validation / full (push)"],
},
)
monkeypatch.setattr(controller, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True)
monkeypatch.setattr(controller.Handler, "log_message", lambda *args: None)
result = controller._self_upgrade_preflight("c" * 40)
serialized = json.dumps(result)
assert result["status"] == "preflight_ok"
assert "runtimeEnvironment" not in result
assert "API_TOKEN" not in serialized
assert "preflight-secret-sentinel" not in serialized
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), controller.Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
body = json.dumps({
"repository": controller.SELF_UPGRADE_REPOSITORY,
"revision": "c" * 40,
"reason": "preflight boundary test",
"expectedCurrentRevision": None,
}).encode("utf-8")
request = urllib.request.Request(
f"http://127.0.0.1:{server.server_port}{controller.SELF_UPGRADE_PREFLIGHT_PATH}",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response_body = response.read()
assert response.status == 200
response_payload = json.loads(response_body)
serialized_response = response_body.decode("utf-8")
assert response_payload["status"] == "preflight_ok"
assert "runtimeEnvironment" not in response_payload
assert "API_TOKEN" not in serialized_response
assert "preflight-secret-sentinel" not in serialized_response
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def test_public_recovery_status_projects_away_private_environment(controller, monkeypatch):
secret = "public-status-secret-sentinel"
record = _recovery_delivery(controller, secret=secret)
monkeypatch.setattr(controller, "_self_upgrade_delivery_record", lambda _request_id: record)
monkeypatch.setattr(controller, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True)
monkeypatch.setattr(controller, "_read_inbox_record", lambda _path: None)
monkeypatch.setattr(controller.Handler, "log_message", lambda *args: None)
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), controller.Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
request = urllib.request.Request(
f"http://127.0.0.1:{server.server_port}"
f"{controller.SELF_UPGRADE_STATUS_PREFIX}{'a' * 64}",
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
status = response.status
body = response.read()
except urllib.error.HTTPError as error:
status = error.code
body = error.read()
assert status == 200, body.decode("utf-8", errors="replace")
view = json.loads(body)
assert view["repository"] == controller.SELF_UPGRADE_REPOSITORY
assert view["revision"] == "f" * 40
assert view["attemptType"] == "self-upgrade-recovery"
assert view["recoveryOf"] == "b" * 64
assert not {
"recovery_id", "recovery_of_sha256", "idempotency_key_sha256",
"preflight_sha256", "body_sha256", "recovery_hmac",
} & view.keys()
assert "runtimeEnvironment" not in view
assert "privateCredential" not in view
assert "recovery_hmac" not in view
assert "idempotency_key_sha256" not in view
assert secret.encode() not in body
assert "API_TOKEN" not in body.decode("utf-8")
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def test_private_recovery_journal_is_0600_and_hmac_tampering_fails_closed(
controller, monkeypatch, tmp_path,
):
if os.name == "nt" or os.geteuid() != 0:
pytest.skip("The controller recovery store requires root-owned POSIX files")
recovery_root = tmp_path / "recovery-store"
recovery_root.mkdir(mode=0o700)
recovery_root.chmod(0o700)
monkeypatch.setattr(controller, "SELF_UPGRADE_RECOVERY_ROOT", recovery_root)
secret = b"fixed-self-upgrade-recovery-test-key-0123456789"
monkeypatch.setattr(controller, "_self_upgrade_secret", lambda: secret)
recovery_id = str(uuid.UUID("12345678-1234-4234-8234-123456789abc"))
payload = _private_recovery_payload(controller, recovery_id)
controller._self_upgrade_write_private_journal(recovery_id, payload, secret)
marker = recovery_root / f"{recovery_id}.json"
assert stat.S_IMODE(marker.stat().st_mode) == 0o600
assert controller._self_upgrade_recovery_journal(recovery_id) == payload
envelope = json.loads(marker.read_text(encoding="utf-8"))
envelope["payload"]["runtimeEnvironment"]["API_TOKEN"] = "changed-secret"
marker.write_text(json.dumps(envelope, sort_keys=True, separators=(",", ":")) + "\n")
marker.chmod(0o600)
assert controller._self_upgrade_recovery_journal(recovery_id) is None
envelope = controller._self_upgrade_recovery_envelope(payload, secret)
marker.write_text(json.dumps(envelope, sort_keys=True, separators=(",", ":")) + "\n")
marker.chmod(0o644)
assert controller._self_upgrade_recovery_journal(recovery_id) is None
def test_recovery_request_is_immutable_and_idempotent_for_one_key(
controller, monkeypatch, tmp_path,
):
if os.name == "nt" or os.geteuid() != 0:
pytest.skip("The controller recovery store requires root-owned POSIX files")
recovery_root = tmp_path / "recovery-store"
recovery_root.mkdir(mode=0o700)
recovery_root.chmod(0o700)
request_root = recovery_root / "requests"
monkeypatch.setattr(controller, "SELF_UPGRADE_RECOVERY_ROOT", recovery_root)
monkeypatch.setattr(controller, "SELF_UPGRADE_RECOVERY_REQUEST_ROOT", request_root)
secret = b"fixed-self-upgrade-recovery-test-key-0123456789"
monkeypatch.setattr(controller, "_self_upgrade_secret", lambda: secret)
revision = "c" * 40
semantic = controller._webhook_semantic_digest(9, "refs/heads/main", revision)
original_path = tmp_path / "original-failed-delivery.json"
original_raw = b'{"immutable":"original failed delivery"}\n'
original_path.write_bytes(original_raw)
original = {"revision": revision}
original_info = (original_path, original_raw, original)
monkeypatch.setattr(
controller,
"_self_upgrade_original_failed_delivery",
lambda *_args: original_info,
)
monkeypatch.setattr(
controller, "_self_upgrade_delivery_revision", lambda _semantic: revision,
)
expected_preflight = "3" * 64
expected_current_revision = controller.SELF_UPGRADE_OLD_REVISION
expected_container_id = "5" * 64
expected_image_id = "sha256:" + "6" * 64
expected_source_tree = "7" * 40
expected_runtime_fingerprint = "8" * 64
private_environment = {"API_TOKEN": "journal-secret-sentinel"}
fresh = {
"status": "preflight_ok",
"preflightSha256": expected_preflight,
"currentRevision": expected_current_revision,
"currentContainerId": expected_container_id,
"currentImageId": expected_image_id,
"currentSourceTree": expected_source_tree,
"currentRuntimeFingerprint": expected_runtime_fingerprint,
}
bindings = {
"currentRevision": expected_current_revision,
"currentContainerId": expected_container_id,
"currentImageId": expected_image_id,
"currentSourceTree": expected_source_tree,
"runtimeFingerprint": expected_runtime_fingerprint,
"registryFingerprint": "9" * 64,
"sourceFingerprint": "a" * 64,
"stateSha256": "b" * 64,
"runtimeEnvironment": private_environment,
}
monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda _revision: fresh)
monkeypatch.setattr(
controller, "_self_upgrade_recovery_current_bindings", lambda: bindings,
)
policy = {
"repository": controller.SELF_UPGRADE_REPOSITORY,
"validation_gate_version": 1,
"contexts": ["Managed validation / full (push)"],
"timeout_seconds": 1800,
"poll_interval_seconds": 5,
"max_poll_interval_seconds": 30,
}
project = {"slug": "unraid-appops-gateway"}
monkeypatch.setattr(
controller, "_load_projects",
lambda: {controller.SELF_UPGRADE_REPOSITORY: project},
)
monkeypatch.setattr(
controller,
"_validation_policy",
lambda _repository, _project: policy,
)
original_sha = controller.hashlib.sha256(original_raw).hexdigest()
idempotency_key_sha = "2" * 64
reason_sha = "4" * 64
first = controller._self_upgrade_recovery_request_status(
semantic,
original_sha,
idempotency_key_sha,
expected_preflight,
expected_current_revision,
reason_sha,
policy,
)
assert first == ("awaiting_validation", semantic)
assert original_path.read_bytes() == original_raw
def store_snapshot() -> dict[str, bytes]:
return {
path.relative_to(recovery_root).as_posix(): path.read_bytes()
for path in sorted(recovery_root.rglob("*.json"))
}
accepted_bytes = store_snapshot()
assert accepted_bytes
assert stat.S_IMODE(recovery_root.stat().st_mode) == 0o700
assert stat.S_IMODE(request_root.stat().st_mode) == 0o700
assert all(
stat.S_IMODE(path.stat().st_mode) == 0o600
for path in recovery_root.rglob("*.json")
)
# The secret is intentionally present only in the root-owned HMAC journal.
recovery_id = str(uuid.UUID(bytes=bytes.fromhex(idempotency_key_sha)[:16], version=4))
journal_bytes = accepted_bytes[f"{recovery_id}.json"]
assert b"journal-secret-sentinel" in journal_bytes
assert b"journal-secret-sentinel" not in accepted_bytes[
f"{semantic}.delivery.json"
]
assert b"journal-secret-sentinel" not in accepted_bytes[
f"requests/{idempotency_key_sha}.json"
]
same_key = controller._self_upgrade_recovery_request_status(
semantic,
original_sha,
idempotency_key_sha,
expected_preflight,
expected_current_revision,
reason_sha,
policy,
)
assert same_key == ("awaiting_validation", semantic)
assert store_snapshot() == accepted_bytes
assert original_path.read_bytes() == original_raw
different_key = controller._self_upgrade_recovery_request_status(
semantic,
original_sha,
"d" * 64,
expected_preflight,
expected_current_revision,
reason_sha,
policy,
)
assert different_key == ("conflict", semantic)
assert store_snapshot() == accepted_bytes
assert original_path.read_bytes() == original_raw
@pytest.mark.parametrize(
"malformed",
[b"{", b"[]", b'{"duplicate":1,"duplicate":2}'],
)
def test_legacy_json_parser_rejects_malformed_or_duplicate_records(
controller, monkeypatch, malformed,
):
monkeypatch.setattr(
controller,
"_self_upgrade_read_legacy_cache_file",
lambda *_args, **_kwargs: malformed,
)
with pytest.raises((OSError, ValueError)):
controller._self_upgrade_read_legacy_cache_json(
Path("/mnt/cache/appdata/gitea-deploy-runners/state/webhook-inbox")
/ ("a" * 64 + ".json"),
maximum_bytes=65536,
allowed_modes=frozenset({0o600}),
)
def test_original_failed_delivery_rejects_modified_legacy_record(controller, monkeypatch):
revision = "c" * 40
semantic = controller._webhook_semantic_digest(9, "refs/heads/main", revision)
policy = {
"repository": controller.SELF_UPGRADE_REPOSITORY,
"validation_gate_version": 1,
"contexts": ["Managed validation / full (push)"],
"timeout_seconds": 1800,
"poll_interval_seconds": 5,
"max_poll_interval_seconds": 30,
}
validation = controller._validation_snapshot(policy, 1)
validation["last_result"] = "pass"
validation["observed"] = [{
"context": "Managed validation / full (push)",
"status": "success",
}]
record = {
"schema": controller._WEBHOOK_SCHEMA,
"semantic_sha256": semantic,
"gitea_repository_id": 9,
"ref": "refs/heads/main",
"repository": controller.SELF_UPGRADE_REPOSITORY,
"revision": revision,
"status": "failed",
"reason": "deployment_failed",
"source": "gitea_webhook",
"body_sha256": "1" * 64,
"validation": validation,
"audit": [
{"stage": "validation_passed"},
{"stage": "deployment_started"},
{"stage": "deployment_failed", "reason": "deployment_failed"},
],
}
raw = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
monkeypatch.setattr(
controller,
"_self_upgrade_read_legacy_cache_json",
lambda *_args, **_kwargs: (raw, record),
)
assert controller._self_upgrade_original_failed_delivery(
semantic, revision, policy,
) == (controller.WEBHOOK_INBOX_ROOT / f"{semantic}.json", raw, record)
tampered = {**record, "revision": "d" * 40}
monkeypatch.setattr(
controller,
"_self_upgrade_read_legacy_cache_json",
lambda *_args, **_kwargs: (raw, tampered),
)
assert controller._self_upgrade_original_failed_delivery(
semantic, revision, policy,
) is None
@pytest.mark.parametrize(("status", "extra", "valid"), [
("deploying", {}, True),
("failed", {"reason": "deployment_failed"}, True),
("deployed", {"receipt_sha256": "f" * 64}, True),
("deployed", {"receipt_sha256": "invalid"}, False),
("deploying", {"reason": "deployment_failed"}, False),
("failed", {"reason": "private-secret-value"}, False),
("failed", {"receipt_sha256": "f" * 64}, False),
("failed", {"unknown": "value"}, False),
("failed", {"reason": ["deployment_failed"]}, False),
("awaiting_validation", {"reason": "gitea_validation_unavailable"}, True),
("failed", {"reason": "recovery_preimage_changed"}, True),
])
def test_signed_recovery_reader_accepts_only_typed_worker_outcome_fields(
controller, monkeypatch, tmp_path, status, extra, valid,
):
record = _recovery_delivery(controller, secret="sentinel")
record.pop("runtimeEnvironment")
record.pop("privateCredential")
record.update(status=status, **extra)
secret = b"fixture-recovery-signing-key"
journal = {
"recoveryId": record["recovery_id"], "revision": record["revision"],
"idempotencyKeySha256": record["idempotency_key_sha256"],
"preflightSha256": record["preflight_sha256"],
"semanticSha256": record["semantic_sha256"],
"originalRawSha256": record["recovery_of_sha256"],
}
payload = {
"operationId": record["semantic_sha256"], "revision": record["revision"],
"preflightSha256": record["preflight_sha256"],
"idempotencyKeySha256": record["idempotency_key_sha256"],
"recoveryId": record["recovery_id"],
"recoveryOfSha256": record["recovery_of_sha256"],
}
record["body_sha256"] = controller.hashlib.sha256(controller._self_upgrade_canonical_json({
"semanticSha256": record["semantic_sha256"], "recoveryId": record["recovery_id"],
"idempotencyKeySha256": record["idempotency_key_sha256"],
})).hexdigest()
record["recovery_hmac"] = controller._self_upgrade_public_recovery_signature(record, secret)
monkeypatch.setattr(controller, "_self_upgrade_secret", lambda: secret)
monkeypatch.setattr(controller, "_self_upgrade_recovery_journal", lambda _id: journal)
monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: {}})
monkeypatch.setattr(controller, "_validation_policy", lambda *_a: {})
monkeypatch.setattr(controller, "_validation_snapshot_matches", lambda *_a: True)
monkeypatch.setattr(controller, "_self_upgrade_request_root", lambda **_k: tmp_path)
monkeypatch.setattr(controller, "_self_upgrade_read_private_json", lambda _path: (b"", {}))
monkeypatch.setattr(controller, "_self_upgrade_recovery_request_payload", lambda _record: payload)
def verify():
return controller._self_upgrade_recovery_record_valid(
record, semantic_digest=record["semantic_sha256"],
original_raw_sha256=record["recovery_of_sha256"],
)
assert verify() is valid
record["updated_at"] += 1
assert verify() is False # Even permitted outcome fields require the original complete HMAC.
@pytest.mark.parametrize(("uid", "gid", "mode"), [(99, 100, 0o777), (0, 0, 0o755)])
@pytest.mark.parametrize("drift", ["none", "owner", "mode", "symlink", "foreign_cache", "inode"])
def test_legacy_cache_reader_and_state_cas_bind_both_exact_appdata_profiles(
controller, monkeypatch, tmp_path, uid, gid, mode, drift,
):
if os.name == "nt" or os.geteuid() != 0:
pytest.skip("Pinned dirfd ownership contract requires root POSIX")
root = tmp_path / "virtual-host"
root.mkdir(mode=0o755)
dirs = [
("mnt", 0, 0, 0o755), ("mnt/cache", 99, 100, 0o777),
("mnt/cache/appdata", uid, gid, mode),
("mnt/cache/appdata/gitea-deploy-runners", 0, 0, 0o755),
("mnt/cache/appdata/gitea-deploy-runners/state", 0, 0, 0o755),
]
for name, owner, group, permissions in dirs:
path = root / name
path.mkdir()
os.chown(path, owner, group)
path.chmod(permissions)
appdata = root / "mnt/cache/appdata"
state_root = appdata / "gitea-deploy-runners/state"
state_path = state_root / "unraid-appops-gateway.json"
before = controller._self_upgrade_canonical_json({
"schema_version": 3, "repository": controller.SELF_UPGRADE_REPOSITORY, "marker": "old",
}) + b"\n"
state_path.write_bytes(before)
state_path.chmod(0o600)
after = before.replace(b'"old"', b'"new"')
if drift == "owner":
os.chown(appdata, 1000, 1000)
elif drift == "mode":
appdata.chmod(0o775)
elif drift == "foreign_cache":
os.chown(root / "mnt/cache", 0, 0)
(root / "mnt/cache").chmod(0o755)
elif drift == "inode":
replacement = appdata.with_name("different-inode")
replacement.mkdir()
os.chown(replacement, uid, gid)
replacement.chmod(mode)
elif drift == "symlink":
renamed = appdata.with_name("appdata-elsewhere")
appdata.rename(renamed)
appdata.symlink_to(renamed, target_is_directory=True)
monkeypatch.setattr(controller, "STATE_ROOT", Path("/mnt/cache/appdata/gitea-deploy-runners/state"))
real_open = os.open
def virtual_root_open(path, flags, *args, **kwargs):
if drift == "inode" and str(path) == "appdata":
return real_open(replacement, flags)
return real_open(root if str(path) == "/" else path, flags, *args, **kwargs)
monkeypatch.setattr(controller.os, "open", virtual_root_open)
requested = controller.STATE_ROOT / state_path.name
if drift == "none":
assert controller._self_upgrade_read_legacy_cache_file(
requested, maximum_bytes=65536, allowed_modes=frozenset({0o600}),
) == before
assert not controller._self_upgrade_write_legacy_state_cas("0" * 64, after)
assert state_path.read_bytes() == before
assert controller._self_upgrade_write_legacy_state_cas(
controller.hashlib.sha256(before).hexdigest(), after,
)
assert state_path.read_bytes() == after
else:
with pytest.raises(OSError):
controller._self_upgrade_read_legacy_cache_file(
requested, maximum_bytes=65536, allowed_modes=frozenset({0o600}),
)
assert not controller._self_upgrade_write_legacy_state_cas(
controller.hashlib.sha256(before).hexdigest(), after,
)
@pytest.mark.parametrize(("profile", "valid"), [
((99, 100, 0o777), True), ((0, 0, 0o755), True),
((0, 0, 0o775), False), ((99, 100, 0o755), False),
])
def test_source_root_allows_only_legacy_or_root_owned_appdata_anchor(
controller, monkeypatch, profile, valid,
):
from types import SimpleNamespace
root = Path("/srv/example/appdata/unraid-appops-gateway/source")
profiles = {
Path("/mnt"): (0, 0, 0o755), Path("/mnt/user"): (99, 100, 0o777),
Path("/srv/example/appdata"): profile,
root.parent: (0, 0, 0o755), root: (100, 101, 0o755),
}
def lstat(path):
uid, gid, mode = profiles[path]
return SimpleNamespace(st_uid=uid, st_gid=gid, st_mode=stat.S_IFDIR | mode)
monkeypatch.setattr(Path, "lstat", lstat)
monkeypatch.setattr(Path, "is_symlink", lambda _path: False)
assert controller._self_upgrade_source_root_safe(root) is valid
monkeypatch.setattr(Path, "is_symlink", lambda path: path == root.parent)
assert controller._self_upgrade_source_root_safe(root) is False