356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""Fail-closed access to the authoritative bare Git repository."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
_ALLOWED_COMMANDS = frozenset(
|
|
{
|
|
"cat-file",
|
|
"config",
|
|
"diff",
|
|
"for-each-ref",
|
|
"ls-remote",
|
|
"merge-base",
|
|
"push",
|
|
"remote",
|
|
"rev-parse",
|
|
"show-ref",
|
|
"tag",
|
|
}
|
|
)
|
|
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
_MAX_OUTPUT = 1_048_576
|
|
_TIMEOUT = 8.0
|
|
|
|
|
|
class AuthoritativeGitError(RuntimeError):
|
|
"""A fail-closed authoritative repository or ref query error."""
|
|
|
|
|
|
class AuthoritativeGitNotFound(AuthoritativeGitError):
|
|
"""A validated revision does not exist in the authoritative repository."""
|
|
|
|
|
|
class AuthoritativeGitOutputTooLarge(AuthoritativeGitError):
|
|
"""A controlled Git query exceeded the response-size boundary."""
|
|
|
|
|
|
def _redact(text: str) -> str:
|
|
value = str(text or "")
|
|
value = re.sub(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+", r"\1REDACTED", value)
|
|
value = re.sub(r"(?i)((?:access_)?token\s*[:=]\s*)\S+", r"\1REDACTED", value)
|
|
value = re.sub(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@", r"\1REDACTED@", value)
|
|
return value
|
|
|
|
|
|
def _configured_path(git_dir: str | Path | None = None) -> Path:
|
|
raw = str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
|
|
if not raw:
|
|
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
path = Path(raw)
|
|
if not path.is_absolute() or ".." in path.parts:
|
|
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
try:
|
|
resolved = path.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE") from exc
|
|
if not resolved.is_dir():
|
|
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
return resolved
|
|
|
|
|
|
def _run(
|
|
git_dir: Path,
|
|
args: list[str],
|
|
*,
|
|
check: bool,
|
|
timeout: float = _TIMEOUT,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
if not args or args[0] not in _ALLOWED_COMMANDS:
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "--git-dir", str(git_dir), *args],
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
|
if len(result.stdout) + len(result.stderr) > _MAX_OUTPUT:
|
|
raise AuthoritativeGitOutputTooLarge("GIT_OUTPUT_LIMIT_EXCEEDED")
|
|
if check and result.returncode != 0:
|
|
detail = _redact(result.stderr.strip() or result.stdout.strip())
|
|
raise AuthoritativeGitError(
|
|
"GIT_REF_QUERY_FAILED" + (f": {detail[:160]}" if detail else "")
|
|
)
|
|
return result
|
|
|
|
|
|
def _repository_identity_from_remote(url: str) -> str:
|
|
value = str(url or "").strip().split("?", 1)[0].rstrip("/")
|
|
if not value:
|
|
return ""
|
|
if "://" in value:
|
|
value = urllib.parse.urlparse(value).path
|
|
elif ":" in value:
|
|
value = value.rsplit(":", 1)[1]
|
|
value = value.rsplit("@", 1)[-1].strip("/")
|
|
value = re.sub(r"\.git$", "", value, flags=re.IGNORECASE)
|
|
parts = [part for part in value.split("/") if part]
|
|
if len(parts) >= 2:
|
|
value = "/".join(parts[-2:])
|
|
return value.strip("/").lower()
|
|
|
|
|
|
def verify_repository(
|
|
git_dir: str | Path | None = None,
|
|
repository_identity: str | None = None,
|
|
) -> tuple[Path, str]:
|
|
path = _configured_path(git_dir)
|
|
expected = str(
|
|
repository_identity
|
|
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
|
).strip().strip("/")
|
|
if not expected or expected.count("/") != 1:
|
|
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
|
|
bare = _run(path, ["rev-parse", "--is-bare-repository"], check=True)
|
|
if bare.stdout.strip().lower() != "true":
|
|
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
remote = _run(path, ["config", "--get", "remote.gitea.url"], check=False)
|
|
actual = _repository_identity_from_remote(remote.stdout)
|
|
if actual != expected.lower():
|
|
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
|
|
return path, expected
|
|
|
|
|
|
def run_authoritative_git(
|
|
args: list[str],
|
|
*,
|
|
check: bool = True,
|
|
git_dir: str | Path | None = None,
|
|
timeout: float = _TIMEOUT,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
path, _ = verify_repository(git_dir=git_dir)
|
|
return _run(path, list(args), check=check, timeout=timeout)
|
|
|
|
|
|
def authoritative_head_oid(
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> str:
|
|
result = run_authoritative_git(["rev-parse", "--verify", "refs/heads/main"], git_dir=git_dir)
|
|
oid = result.stdout.strip().lower()
|
|
if not _OID_RE.fullmatch(oid):
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
return oid
|
|
|
|
|
|
def authoritative_tags(
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> list[dict[str, str | None]]:
|
|
result = run_authoritative_git(
|
|
[
|
|
"for-each-ref",
|
|
"refs/tags",
|
|
"--format=%(refname:strip=2)\t%(objectname)\t%(objecttype)",
|
|
],
|
|
git_dir=git_dir,
|
|
)
|
|
tags: list[dict[str, str | None]] = []
|
|
for line in result.stdout.splitlines():
|
|
name, oid, object_type = (line.split("\t", 2) + ["", ""])[:3]
|
|
if not name or not _OID_RE.fullmatch(oid.lower()):
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
peeled_result = run_authoritative_git(
|
|
["rev-parse", "--verify", f"refs/tags/{name}^{{commit}}"],
|
|
git_dir=git_dir,
|
|
)
|
|
peeled = peeled_result.stdout.strip().lower()
|
|
if not _OID_RE.fullmatch(peeled):
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
tags.append(
|
|
{
|
|
"name": name,
|
|
"tag_oid": oid.lower(),
|
|
"object_type": object_type,
|
|
"peeled_target_oid": peeled,
|
|
}
|
|
)
|
|
return tags
|
|
|
|
|
|
def authoritative_branches(
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> list[dict[str, str]]:
|
|
result = run_authoritative_git(
|
|
[
|
|
"for-each-ref",
|
|
"refs/heads",
|
|
"--format=%(refname:strip=2)\t%(objectname)",
|
|
],
|
|
git_dir=git_dir,
|
|
)
|
|
branches: list[dict[str, str]] = []
|
|
for line in result.stdout.splitlines():
|
|
name, oid = (line.split("\t", 1) + [""])[:2]
|
|
if not name or not _OID_RE.fullmatch(oid.lower()):
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
branches.append({"name": name, "oid": oid.lower()})
|
|
return branches
|
|
|
|
|
|
def authoritative_commit(
|
|
oid: str,
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> str:
|
|
value = str(oid or "").strip().lower()
|
|
if not _OID_RE.fullmatch(value):
|
|
raise ValueError("commit OID must be a full 40-character value")
|
|
result = run_authoritative_git(
|
|
["rev-parse", "--verify", f"{value}^{{commit}}"],
|
|
git_dir=git_dir,
|
|
)
|
|
resolved = result.stdout.strip().lower()
|
|
if resolved != value:
|
|
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
return resolved
|
|
|
|
|
|
def authoritative_lineage(
|
|
ancestor: str,
|
|
descendant: str,
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> None:
|
|
authoritative_commit(ancestor, git_dir=git_dir)
|
|
authoritative_commit(descendant, git_dir=git_dir)
|
|
result = run_authoritative_git(
|
|
["merge-base", "--is-ancestor", ancestor, descendant],
|
|
check=False,
|
|
git_dir=git_dir,
|
|
)
|
|
if result.returncode != 0:
|
|
raise AuthoritativeGitError("evidence lineage mismatch")
|
|
|
|
|
|
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
|
|
|
|
|
def _safe_ref_name(value: str) -> bool:
|
|
return bool(
|
|
_SAFE_REF_RE.fullmatch(value)
|
|
and ".." not in value
|
|
and "//" not in value
|
|
and "@{" not in value
|
|
and not value.endswith(("/", ".", ".lock"))
|
|
and not any(part in {"", ".", ".."} for part in value.split("/"))
|
|
)
|
|
|
|
|
|
def validate_git_revision(
|
|
value: str,
|
|
field: str,
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> str:
|
|
revision = str(value or "").strip()
|
|
if not revision or revision != str(value or "") or revision.startswith("-"):
|
|
raise ValueError(f"invalid {field} revision")
|
|
if _OID_RE.fullmatch(revision.lower()):
|
|
try:
|
|
return authoritative_commit(revision.lower(), git_dir=git_dir)
|
|
except AuthoritativeGitError as exc:
|
|
raise AuthoritativeGitNotFound(f"{field} revision not found") from exc
|
|
if not _safe_ref_name(revision) or any(token in revision for token in ("^", "~", ":", "\\")):
|
|
raise ValueError(f"invalid {field} revision")
|
|
if revision.startswith("refs/"):
|
|
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
|
raise ValueError(f"invalid {field} revision")
|
|
candidates = [revision]
|
|
else:
|
|
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
|
matches: list[str] = []
|
|
for candidate in candidates:
|
|
result = run_authoritative_git(
|
|
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
|
check=False,
|
|
git_dir=git_dir,
|
|
)
|
|
oid = result.stdout.strip().lower()
|
|
if result.returncode == 0 and _OID_RE.fullmatch(oid):
|
|
matches.append(oid)
|
|
if not matches:
|
|
raise AuthoritativeGitNotFound(f"{field} revision not found")
|
|
if len(set(matches)) != 1 or len(matches) != 1:
|
|
raise ValueError(f"ambiguous {field} revision")
|
|
return matches[0]
|
|
|
|
|
|
def authoritative_diff(
|
|
from_revision: str,
|
|
to_revision: str,
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
) -> dict[str, object]:
|
|
path, _ = verify_repository(git_dir=git_dir)
|
|
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
|
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
|
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
|
result = run_authoritative_git(args, git_dir=path)
|
|
return {
|
|
"code": 0,
|
|
"stdout": result.stdout,
|
|
"stderr": "",
|
|
"cmd": ["git", f"--git-dir={path}", *args],
|
|
"from_oid": from_oid,
|
|
"to_oid": to_oid,
|
|
}
|
|
|
|
|
|
def repository_snapshot(
|
|
*,
|
|
git_dir: str | Path | None = None,
|
|
repository_identity: str | None = None,
|
|
) -> dict[str, object]:
|
|
configured = bool(
|
|
str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
|
|
)
|
|
try:
|
|
path, identity = verify_repository(
|
|
git_dir=git_dir,
|
|
repository_identity=repository_identity,
|
|
)
|
|
head = authoritative_head_oid(git_dir=path)
|
|
return {
|
|
"repository_available": True,
|
|
"authoritative_git_dir_configured": configured,
|
|
"authoritative_git_dir_readable": True,
|
|
"authoritative_git_dir": str(path),
|
|
"authoritative_head_oid": head,
|
|
"repository_identity": identity,
|
|
"query_error": None,
|
|
}
|
|
except (AuthoritativeGitError, ValueError) as exc:
|
|
return {
|
|
"repository_available": False,
|
|
"authoritative_git_dir_configured": configured,
|
|
"authoritative_git_dir_readable": False,
|
|
"authoritative_git_dir": None,
|
|
"authoritative_head_oid": None,
|
|
"repository_identity": str(
|
|
repository_identity
|
|
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
|
).strip() or None,
|
|
"query_error": _redact(str(exc)),
|
|
}
|