Prepare governed S1 offsite upload
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
parent
2c92b97f37
commit
d0a5a27926
11 changed files with 844 additions and 11 deletions
|
|
@ -1,13 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
|
@ -26,6 +31,21 @@ from s1_backup import ( # noqa: E402
|
|||
)
|
||||
from s1_backup_contract import BackupContractError, load_spec, validate_spec # noqa: E402
|
||||
from s1_restore import RestoreError, _safe_member, extract_bundle, validate_bundle # noqa: E402
|
||||
from s1_offsite import ( # noqa: E402
|
||||
OffsiteError,
|
||||
contract_sha256,
|
||||
plan_upload,
|
||||
review_contract,
|
||||
upload,
|
||||
validate_prune_receipt,
|
||||
)
|
||||
|
||||
EXPECTED_OFFSITE_MEMBERS = {
|
||||
"manifest.json",
|
||||
"receipt.json",
|
||||
"os-config.tar.gz.age",
|
||||
"packages.txt.age",
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("age") and shutil.which("age-keygen"), "age tools required")
|
||||
|
|
@ -207,6 +227,7 @@ class S1BackupRecoveryTests(unittest.TestCase):
|
|||
def test_prune_requires_current_exact_approval(self) -> None:
|
||||
declaration = yaml.safe_load(self.spec.read_text())
|
||||
declaration["retention"]["keep_complete_bundles"] = 2
|
||||
declaration["offsite"]["required_before_prune"] = False
|
||||
self.spec.write_text(yaml.safe_dump(declaration, sort_keys=False))
|
||||
for index in range(3):
|
||||
self._create(timestamp=f"2026082{index + 1}T120000Z")
|
||||
|
|
@ -219,6 +240,17 @@ class S1BackupRecoveryTests(unittest.TestCase):
|
|||
self.assertEqual("pruned", result["status"])
|
||||
self.assertFalse((self.output / plan["candidates"][0]).exists())
|
||||
|
||||
def test_prune_blocks_without_offsite_evidence(self) -> None:
|
||||
declaration = yaml.safe_load(self.spec.read_text())
|
||||
declaration["retention"]["keep_complete_bundles"] = 2
|
||||
self.spec.write_text(yaml.safe_dump(declaration, sort_keys=False))
|
||||
for index in range(3):
|
||||
self._create(timestamp=f"2026082{index + 1}T120000Z")
|
||||
plan = plan_prune(self.spec, self.output)
|
||||
self.assertEqual("blocked-missing-offsite-evidence", plan["status"])
|
||||
self.assertIsNone(plan["approval"])
|
||||
self.assertEqual(["s1-backup-20260821T120000Z"], plan["missing_offsite_evidence"])
|
||||
|
||||
def test_status_rejects_tampered_passing_receipt(self) -> None:
|
||||
bundle = self._create()
|
||||
receipt_path = bundle / "receipt.json"
|
||||
|
|
@ -251,6 +283,187 @@ class S1BackupRecoveryTests(unittest.TestCase):
|
|||
bootstrap = (ROOT / "ansible/playbooks/bootstrap.yaml").read_text()
|
||||
self.assertNotIn("s1_backup", bootstrap)
|
||||
|
||||
def _offsite_contract(self, base_url: str, *, accepted: bool) -> Path:
|
||||
contract = yaml.safe_load((ROOT / "spec/s1-offsite.yaml").read_text())
|
||||
contract["provider"]["base_url"] = base_url
|
||||
contract_path = self.spec_dir / "s1-offsite.yaml"
|
||||
contract_path.write_text(yaml.safe_dump(contract, sort_keys=False))
|
||||
acceptance = {
|
||||
"schema_version": "1.0",
|
||||
"contract_sha256": contract_sha256(contract_path) if accepted else "pending",
|
||||
"status": "accepted" if accepted else "pending",
|
||||
"owner_repo": "railiance-platform",
|
||||
"decision_id": str(uuid.uuid4()) if accepted else None,
|
||||
"accepted_at": "2026-08-23T12:00:00Z" if accepted else None,
|
||||
}
|
||||
(self.spec_dir / "s1-offsite-owner-acceptance.yaml").write_text(
|
||||
yaml.safe_dump(acceptance, sort_keys=False)
|
||||
)
|
||||
return contract_path
|
||||
|
||||
def test_offsite_contract_is_pending_owner_review(self) -> None:
|
||||
review = review_contract(ROOT / "spec/s1-offsite.yaml")
|
||||
self.assertEqual("review-required", review["status"])
|
||||
self.assertTrue(review["approval"].startswith("APPROVE S1-OFFSITE-CONTRACT-"))
|
||||
|
||||
def test_deterministic_single_object_offsite_upload_and_collision(self) -> None:
|
||||
token = "fixture-upload-token"
|
||||
objects: dict[str, bytes] = {}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_PUT(self) -> None: # noqa: N802
|
||||
expected_auth = "Basic " + base64.b64encode(f"{token}:".encode()).decode()
|
||||
if self.headers.get("Authorization") != expected_auth:
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.headers.get("If-None-Match") != "*":
|
||||
self.send_response(428)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path in objects:
|
||||
self.send_response(412)
|
||||
self.end_headers()
|
||||
return
|
||||
length = int(self.headers["Content-Length"])
|
||||
objects[self.path] = self.rfile.read(length)
|
||||
self.send_response(201)
|
||||
self.send_header("ETag", '"fixture-etag"')
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
bundle = self._create()
|
||||
contract = self._offsite_contract(
|
||||
f"http://127.0.0.1:{server.server_port}/filesdrop", accepted=True
|
||||
)
|
||||
plan = plan_upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
fixture_http=True,
|
||||
)
|
||||
self.assertTrue(plan["executable"])
|
||||
second_plan = plan_upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
fixture_http=True,
|
||||
)
|
||||
self.assertEqual(plan["envelope_sha256"], second_plan["envelope_sha256"])
|
||||
receipt_dir = self.root / "offsite-receipts"
|
||||
with mock.patch.dict(os.environ, {"RAILIANCE_BACKUP_NC_TOKEN": token}):
|
||||
with self.assertRaisesRegex(OffsiteError, "exact envelope"):
|
||||
upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
approval="UPLOAD-S1-OFFSITE-WRONG",
|
||||
receipt_dir=receipt_dir,
|
||||
fixture_http=True,
|
||||
)
|
||||
self.assertEqual({}, objects)
|
||||
receipt_path = upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
approval=plan["approval"],
|
||||
receipt_dir=receipt_dir,
|
||||
fixture_http=True,
|
||||
)
|
||||
with self.assertRaisesRegex(OffsiteError, "collision"):
|
||||
upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
approval=plan["approval"],
|
||||
receipt_dir=receipt_dir,
|
||||
fixture_http=True,
|
||||
)
|
||||
receipt = validate_prune_receipt(bundle, self.spec, receipt_path)
|
||||
self.assertEqual(plan["remote_object_identity"], receipt["remote_object_identity"])
|
||||
self.assertNotIn(token, receipt_path.read_text())
|
||||
self.assertEqual(1, len(objects))
|
||||
envelope_path = self.root / "observed-envelope.tar"
|
||||
envelope_path.write_bytes(next(iter(objects.values())))
|
||||
with tarfile.open(envelope_path, "r:") as archive:
|
||||
self.assertEqual(EXPECTED_OFFSITE_MEMBERS, {item.name for item in archive})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_offsite_upload_refuses_pending_owner_and_wrong_approval(self) -> None:
|
||||
bundle = self._create()
|
||||
contract = self._offsite_contract("http://127.0.0.1:9/filesdrop", accepted=False)
|
||||
plan = plan_upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
fixture_http=True,
|
||||
)
|
||||
self.assertFalse(plan["executable"])
|
||||
self.assertIsNone(plan["approval"])
|
||||
with self.assertRaisesRegex(OffsiteError, "has not accepted"):
|
||||
upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
approval="UPLOAD-S1-OFFSITE-WRONG",
|
||||
receipt_dir=self.root / "receipts",
|
||||
fixture_http=True,
|
||||
)
|
||||
|
||||
def test_offsite_upload_never_follows_redirect(self) -> None:
|
||||
paths: list[str] = []
|
||||
|
||||
class RedirectHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_PUT(self) -> None: # noqa: N802
|
||||
paths.append(self.path)
|
||||
self.send_response(307)
|
||||
self.send_header("Location", "/credential-capture")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RedirectHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
bundle = self._create()
|
||||
contract = self._offsite_contract(
|
||||
f"http://127.0.0.1:{server.server_port}/filesdrop", accepted=True
|
||||
)
|
||||
plan = plan_upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
fixture_http=True,
|
||||
)
|
||||
with mock.patch.dict(os.environ, {"RAILIANCE_BACKUP_NC_TOKEN": "redirect-token"}):
|
||||
with self.assertRaisesRegex(OffsiteError, "HTTP 307"):
|
||||
upload(
|
||||
bundle=bundle,
|
||||
backup_spec=self.spec,
|
||||
contract_path=contract,
|
||||
approval=plan["approval"],
|
||||
receipt_dir=self.root / "redirect-receipts",
|
||||
fixture_http=True,
|
||||
)
|
||||
self.assertEqual(1, len(paths))
|
||||
self.assertNotEqual("/credential-capture", paths[0])
|
||||
self.assertFalse((self.root / "redirect-receipts").exists())
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue