Files
ss-tools/scripts/dr_recovery_git.py

315 lines
13 KiB
Python

# #region Tooling.DrRecovery.Git [C:4] [TYPE Module] [SEMANTICS dr,recovery,git,closure]
# @BRIEF Verify actual bundle closure/commit/blob bytes in owned scratch, then independently inspect declared stand roots.
# @RELATION DEPENDS_ON -> [Tooling.DrRecovery.Git.Runner]
# @INVARIANT No transport, inherited Git config, authentication, replacement objects or another object source is used.
# @SIDE_EFFECT Writes only a caller-owned temporary bare repository; deletes only that temporary directory.
# @RATIONALE Importing the declared bundle into an empty ODB proves actual available objects, unlike ls-remote/header metadata.
# @REJECTED Satisfying bundle prerequisites from a source clone would hide an incomplete backup.
import configparser
from hashlib import sha256
import os
from pathlib import Path
import subprocess as subprocess
import tempfile
from dr_recovery_io import Refused, require, read, pinned, BUNDLE_LIMIT, BLOB_LIMIT
from dr_recovery_git_command import git
# #region Tooling.DrRecovery.Git.Refs [C:3] [TYPE Function] [SEMANTICS dr,recovery]
def refs(source):
values = {item.name: item.commit_sha for item in source.refs}
require(len(values) == len(source.refs), "DR_RECOVERY_CONTRACT_INVALID")
return values
# #endregion Tooling.DrRecovery.Git.Refs
# #region Tooling.DrRecovery.Git.ExactCommit [C:3] [TYPE Function]
# @POST The literal claim resolves to exactly itself and is a commit object; neither prefix resolution nor tag peeling supplies identity.
# @RATIONALE A tag or abbreviated SHA can resolve to valid publication bytes while naming a different object than the receipt claims.
# @REJECTED SHA^{commit} and cat-file commit alone peel annotated tags and do not establish literal commit identity.
def exact_commit(root, identity):
actual = (
git(
root,
["rev-parse", "--verify", "--end-of-options", identity],
"DR_RECOVERY_GIT_OBJECT_UNAVAILABLE",
)
.decode("ascii")
.strip()
)
require(actual == identity, "DR_RECOVERY_GIT_OBJECT_UNAVAILABLE")
kind = git(
root, ["cat-file", "-t", identity], "DR_RECOVERY_GIT_OBJECT_UNAVAILABLE"
).strip()
require(kind == b"commit", "DR_RECOVERY_GIT_OBJECT_UNAVAILABLE")
# #endregion Tooling.DrRecovery.Git.ExactCommit
# #region Tooling.DrRecovery.Git.RequiredRoots [C:3] [TYPE Function] [SEMANTICS dr,recovery]
def required_roots(root, source, operations):
# Current publication contract requires one explicit nonnull reviewed parent; no parent is synthesized.
roots = {ref.commit_sha for ref in source.refs}
roots.update(operation.commit_sha for operation in operations)
roots.update(operation.expected_branch_head for operation in operations)
for identity in roots:
exact_commit(root, identity)
return sorted(roots)
# #endregion Tooling.DrRecovery.Git.RequiredRoots
# #region Tooling.DrRecovery.Git.Inspect [C:3] [TYPE Function] [SEMANTICS dr,recovery]
def inspect(root, source, operations, render_bytes):
for name, commit in refs(source).items():
actual = (
git(root, ["rev-parse", "--verify", name], "DR_RECOVERY_GIT_REFS_MISMATCH")
.decode()
.strip()
)
require(actual == commit, "DR_RECOVERY_GIT_REFS_MISMATCH")
exact_commit(root, commit)
for operation in operations:
commit = operation.commit_sha
exact_commit(root, commit)
provenance(root, source, operation)
path = commit + ":" + operation.destination.path
size = git(root, ["cat-file", "-s", path], "DR_RECOVERY_GIT_OBJECT_UNAVAILABLE")
require(size.strip().isdigit() and int(size) <= BLOB_LIMIT, "DR_RECOVERY_LIMIT")
content = git(
root,
["cat-file", "blob", path],
"DR_RECOVERY_GIT_OBJECT_UNAVAILABLE",
BLOB_LIMIT,
)
require(
content == render_bytes[operation.id]
and sha256(content).hexdigest() == operation.render_sha256,
"DR_RECOVERY_GIT_RENDER_MISMATCH",
)
# #endregion Tooling.DrRecovery.Git.Inspect
# #region Tooling.DrRecovery.Git.Provenance [C:3] [TYPE Function] [SEMANTICS dr,recovery]
def provenance(root, source, operation):
# Reuse the publication owner's exact parent+marker rule over actual local commit bytes.
try:
from src.services.dashboard_testing.execution.publication_worker import (
_observe_own_commit,
)
except Exception:
raise Refused("DR_RECOVERY_GIT_OWNER_UNAVAILABLE") from None
raw = git(
root,
["cat-file", "commit", operation.commit_sha],
"DR_RECOVERY_GIT_OBJECT_UNAVAILABLE",
BLOB_LIMIT,
)
headers, message = raw.split(b"\n\n", 1)
parents = [
{"sha": line[7:].decode("ascii")}
for line in headers.splitlines()
if line.startswith(b"parent ")
]
payload = {
"sha": operation.commit_sha,
"parents": parents,
"commit": {"message": message.decode("utf-8")},
}
# #region Tooling.DrRecovery.Git.Provenance.Localresponse [C:3] [TYPE Class] [SEMANTICS dr,recovery]
class LocalResponse:
status_code = 200
# #region Tooling.DrRecovery.Git.Provenance.Localresponse.Json [C:3] [TYPE Function] [SEMANTICS dr,recovery]
def json(self):
return payload
# #endregion Tooling.DrRecovery.Git.Provenance.Localresponse.Json
# #endregion Tooling.DrRecovery.Git.Provenance.Localresponse
# #region Tooling.DrRecovery.Git.Provenance.Localreader [C:3] [TYPE Class] [SEMANTICS dr,recovery]
class LocalReader:
# #region Tooling.DrRecovery.Git.Provenance.Localreader.Get [C:3] [TYPE Function] [SEMANTICS dr,recovery]
def get(self, *args, **kwargs):
return LocalResponse()
# #endregion Tooling.DrRecovery.Git.Provenance.Localreader.Get
# #endregion Tooling.DrRecovery.Git.Provenance.Localreader
reader = LocalReader()
require(
_observe_own_commit(
reader, source.repository, operation.commit_sha, operation
).matches,
"DR_RECOVERY_GIT_PROVENANCE_MISMATCH",
)
# #endregion Tooling.DrRecovery.Git.Provenance
# #region Tooling.DrRecovery.Git.Bundle [C:4] [TYPE Function]
# @INVARIANT Install every declared root in owned scratch before full closure validation.
def bundle(root_fd, source, operations, render_bytes):
data = read(root_fd, source.bundle.path, BUNDLE_LIMIT, source.bundle)
boundary = data.find(b"\n\n", 0, 128 * 1024)
require(boundary >= 0, "DR_RECOVERY_GIT_BUNDLE_INVALID")
header = data[:boundary].splitlines()
require(
header[0] in (b"# v2 git bundle", b"# v3 git bundle"),
"DR_RECOVERY_GIT_BUNDLE_INVALID",
)
prerequisites = [
line[1:].split(b" ", 1)[0].decode("ascii")
for line in header
if line.startswith(b"-")
]
require(
set(prerequisites) == set(source.prerequisites)
and len(prerequisites) == len(source.prerequisites),
"DR_RECOVERY_GIT_PREREQUISITES_MISMATCH",
)
require(not prerequisites, "DR_RECOVERY_GIT_NOT_SELF_CONTAINED")
with tempfile.TemporaryDirectory(prefix="ss-dr-git-check-") as directory:
scratch = Path(directory)
archive = scratch / "catalog.bundle"
archive.write_bytes(data)
odb = scratch / "objects.git"
git(
odb,
["init", "--bare", "--object-format=" + source.object_format, str(odb)],
"DR_RECOVERY_GIT_UNAVAILABLE",
)
advertised = git(
odb,
["bundle", "list-heads", str(archive)],
"DR_RECOVERY_GIT_BUNDLE_INVALID",
)
actual = {}
for line in advertised.decode("ascii").splitlines():
commit, name = line.split(" ", 1)
require(name not in actual, "DR_RECOVERY_GIT_REFS_MISMATCH")
actual[name] = commit
require(actual == refs(source), "DR_RECOVERY_GIT_REFS_MISMATCH")
git(odb, ["bundle", "verify", str(archive)], "DR_RECOVERY_GIT_BUNDLE_INVALID")
git(odb, ["bundle", "unbundle", str(archive)], "DR_RECOVERY_GIT_BUNDLE_INVALID")
roots = required_roots(odb, source, operations)
# unbundle imports ODB objects but does not install refs; only this owned scratch is written.
for name, commit in actual.items():
git(odb, ["update-ref", name, commit], "DR_RECOVERY_GIT_BUNDLE_INVALID")
git(
odb,
["fsck", "--full", "--strict", "--no-reflogs", *roots],
"DR_RECOVERY_GIT_BUNDLE_INVALID",
)
inspect(odb, source, operations, render_bytes)
# #endregion Tooling.DrRecovery.Git.Bundle
# #region Tooling.DrRecovery.Git.Stand [C:3] [TYPE Function]
def stand(path, source, operations, render_bytes):
path = Path(path)
require(path.is_absolute() and not path.is_symlink(), "DR_RECOVERY_GIT_ROOT_UNSAFE")
with pinned(path) as descriptor:
stand_snapshot(descriptor, source, operations, render_bytes)
# #endregion Tooling.DrRecovery.Git.Stand
# #region Tooling.DrRecovery.Git.StandSnapshot [C:4] [TYPE Function]
# @INVARIANT Config and all ODB/ref files use the same pinned root; a root rename cannot select another repo.
def stand_snapshot(descriptor, source, operations, render_bytes):
raw = read(descriptor, "config", 64 * 1024)
config = configparser.ConfigParser()
try:
config.read_string(raw.decode())
allowed = {
"core": {"repositoryformatversion", "bare", "filemode", "logallrefupdates"},
"extensions": {"objectformat"},
}
require(
not config.defaults()
and all(
section in allowed and set(config[section]) <= allowed[section]
for section in config.sections()
),
"DR_RECOVERY_GIT_ROOT_UNSAFE",
)
require(config.get("core", "bare") == "true", "DR_RECOVERY_GIT_ROOT_UNSAFE")
if source.object_format == "sha1":
require(
config.get("core", "repositoryformatversion") == "0"
and not config.has_option("extensions", "objectformat"),
"DR_RECOVERY_GIT_ROOT_UNSAFE",
)
else:
require(
config.get("extensions", "objectformat") == "sha256"
and config.get("core", "repositoryformatversion") == "1",
"DR_RECOVERY_GIT_ROOT_UNSAFE",
)
except (configparser.Error, UnicodeError):
raise Refused("DR_RECOVERY_GIT_ROOT_UNSAFE") from None
# Git itself never opens the untrusted root or its config. Copy only pinned regular ODB/ref
# files into an owned bounded snapshot; a directory/pack swap cannot redirect the byte read.
with tempfile.TemporaryDirectory(prefix="ss-dr-stand-check-") as directory:
snapshot = Path(directory) / "stand.git"
git(
snapshot,
[
"init",
"--bare",
"--object-format=" + source.object_format,
str(snapshot),
],
"DR_RECOVERY_GIT_UNAVAILABLE",
)
total = count = 0
anchor = Path("/proc/self/fd") / str(descriptor)
for current, directories, files in os.walk(anchor, followlinks=False):
relative = Path(current).relative_to(anchor)
if relative == Path("."):
directories[:] = [
name for name in directories if name in ("objects", "refs")
]
require(
"shallow" not in files and "commondir" not in files,
"DR_RECOVERY_GIT_ROOT_UNSAFE",
)
files = [name for name in files if name in ("HEAD", "packed-refs")]
count += len(directories) + len(files)
require(count <= 200000, "DR_RECOVERY_LIMIT")
require(
not any(
(Path(current) / name).is_symlink()
for name in (*directories, *files)
),
"DR_RECOVERY_GIT_ROOT_UNSAFE",
)
for name in files:
entry = (relative / name).as_posix()
require(
entry != "objects/info/alternates"
and not name.endswith(".promisor"),
"DR_RECOVERY_GIT_ROOT_UNSAFE",
)
data = read(descriptor, entry, BUNDLE_LIMIT)
total += len(data)
require(total <= 128 * 1024 * 1024, "DR_RECOVERY_LIMIT")
destination = snapshot / entry
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
roots = required_roots(snapshot, source, operations)
git(
snapshot,
["fsck", "--full", "--strict", "--no-reflogs", *roots],
"DR_RECOVERY_GIT_OBJECT_UNAVAILABLE",
)
inspect(snapshot, source, operations, render_bytes)
# #endregion Tooling.DrRecovery.Git.StandSnapshot
# #endregion Tooling.DrRecovery.Git