Leon4gr45's picture
Deploy complete BrainAPI source ea2acb63d782
89e57b7 verified
Raw History Blame Contribute Delete
5.69 kB
"""Bounded Git operations; token is only passed via ephemeral child environment."""
import os
import re
import subprocess
from pathlib import Path
class BackupError(RuntimeError):
"""Messages are fixed codes: never include subprocess output or credentials."""
class GitRepository:
def __init__(self, path, url, branch="main", token=None):
self.path = Path(path)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", branch) or ".." in branch:
raise BackupError("BACKUP_INVALID_BRANCH")
self.branch = branch
self.url = url
self.token = token
@classmethod
def configured(cls, path):
token = os.getenv("GITHUB_PAT")
if not token:
raise BackupError("BACKUP_GITHUB_PAT_REQUIRED")
repo = os.getenv("BRAIN_BACKUP_REPOSITORY", "JsonLord/nodepad-brain-backup")
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo):
raise BackupError("BACKUP_INVALID_REPOSITORY")
return cls(path, f"https://github.com/{repo}.git",
os.getenv("BRAIN_BACKUP_BRANCH", "main"), token)
def run(self, *args, check=True, timeout=90):
env = os.environ.copy()
env.update(GIT_TERMINAL_PROMPT="0", GIT_TRACE="0", GIT_CURL_VERBOSE="0",
GIT_TRACE_CURL="0", GIT_TRACE_PACKET="0")
if self.token:
# Executable contains no credential; neither URL nor Git config does.
helper = self.path.parent / "askpass.py"
helper.write_text("#!/usr/bin/env python3\nimport os,sys\nprint('x-access-token' if 'Username' in sys.argv[1] else os.environ['GITHUB_PAT'])\n")
helper.chmod(0o700)
env.update(GIT_ASKPASS=str(helper), GITHUB_PAT=self.token,
GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="credential.helper",
GIT_CONFIG_VALUE_0="")
try:
result = subprocess.run(["git", "-C", str(self.path), *args], env=env,
capture_output=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired):
raise BackupError("BACKUP_GIT_UNAVAILABLE") from None
if check and result.returncode:
# Live qualification must distinguish access, transport and local
# failures without emitting Git stderr (which may contain secrets).
operation = next((arg.upper().replace("-", "_") for arg in args
if arg in {"init", "ls-remote", "fetch", "checkout", "clean", "rev-parse", "add", "commit", "push"}), "OPERATION")
failure = self._failure_category(result.stderr)
raise BackupError(f"BACKUP_GIT_{operation}_{failure}")
return result
@staticmethod
def _failure_category(stderr):
message = stderr.lower()
categories = (
("AUTHENTICATION_FAILED", (b"authentication failed", b"invalid username or token", b"invalid username or password", b"error: 401")),
("REPOSITORY_ACCESS_DENIED", (b"repository not found", b"error: 403", b"access denied")),
("CREDENTIAL_HELPER_FAILED", (b"askpass", b"could not read username", b"could not read password")),
("TLS_FAILED", (b"ssl certificate", b"certificate verification", b"gnutls_handshake")),
("NETWORK_FAILED", (b"could not resolve", b"failed to connect", b"connection timed out", b"connection reset")),
("OWNERSHIP_FAILED", (b"dubious ownership",)),
("REMOTE_CONFLICT", (b"non-fast-forward", b"fetch first")),
("FILESYSTEM_FAILED", (b"permission denied", b"read-only file system", b"no space left")),
)
return next((category for category, markers in categories if any(marker in message for marker in markers)), "OPERATION_FAILED")
def fetch(self):
self.path.mkdir(parents=True, exist_ok=True, mode=0o700)
if not (self.path / ".git").exists():
self.run("init", "--quiet")
refs = self.run("ls-remote", "--heads", self.url).stdout.decode().splitlines()
head = next((line.split()[0] for line in refs if line.split()[1] == f"refs/heads/{self.branch}"), None)
if head is None:
# A populated repo with a missing configured branch is not first boot.
if refs:
raise BackupError("BACKUP_REMOTE_BRANCH_MISSING")
return None
self.run("fetch", "--quiet", "--no-tags", self.url, f"refs/heads/{self.branch}")
self.run("checkout", "--quiet", "--detach", "FETCH_HEAD")
self.run("clean", "-fdq")
return self.run("rev-parse", "HEAD").stdout.decode().strip()
def publish(self, source, base):
# Compare before copying and rely on non-force push's atomic ref check.
current = self.fetch()
if current != base:
raise BackupError("BACKUP_REMOTE_CONFLICT")
import shutil
for child in self.path.iterdir():
if child.name != ".git":
shutil.rmtree(child) if child.is_dir() else child.unlink()
for child in Path(source).iterdir():
shutil.copytree(child, self.path / child.name) if child.is_dir() else shutil.copy2(child, self.path / child.name)
self.run("add", "--all")
self.run("-c", "user.name=BrainAPI Backup", "-c", "user.email=backup@brainapi.local",
"commit", "--quiet", "-m", "Checkpoint registered Brain content")
# No force, rebase, or blind retry: divergent state needs operator review.
self.run("push", "--quiet", self.url, f"HEAD:refs/heads/{self.branch}")
return self.run("rev-parse", "HEAD").stdout.decode().strip()