Files
airdef/tools/airdef_assets/pipeline.py
T

147 lines
5.2 KiB
Python

"""Manifest and transactional install helpers for generated AirDef assets."""
from __future__ import annotations
import hashlib
import os
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
def sha256(path: str | os.PathLike[str]) -> str:
digest = hashlib.sha256()
with open(path, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_manifest(path: str | os.PathLike[str]) -> dict[str, str]:
"""Read sha256sum-format entries and reject malformed or unsafe paths."""
manifest = Path(path)
if not manifest.exists():
return {}
entries: dict[str, str] = {}
for line_no, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), 1):
try:
digest, rel = line.split(" ", 1)
except ValueError as exc:
raise ValueError(f"{manifest}:{line_no}: invalid manifest row") from exc
parts = Path(rel).parts
if (len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest)
or not rel or Path(rel).is_absolute() or ".." in parts or rel in entries):
raise ValueError(f"{manifest}:{line_no}: invalid manifest entry {rel!r}")
entries[rel] = digest
return entries
@dataclass
class Plan:
create: list[str]
update: list[str]
same: list[str]
conflict: list[str]
remove: list[str]
keep: list[str]
def make_plan(root: str | os.PathLike[str], old: dict[str, str], new: dict[str, str]) -> Plan:
root = Path(root)
create: list[str] = []
update: list[str] = []
same: list[str] = []
conflict: list[str] = []
remove: list[str] = []
keep: list[str] = []
for rel, digest in sorted(new.items()):
target = root / rel
if not target.exists():
create.append(rel)
elif not target.is_file():
conflict.append(rel)
else:
current = sha256(target)
if current == digest:
same.append(rel)
elif old.get(rel) == current:
update.append(rel)
else:
conflict.append(rel)
for rel, digest in sorted(old.items()):
target = root / rel
if rel in new or not target.is_file():
continue
(remove if sha256(target) == digest else keep).append(rel)
return Plan(create, update, same, conflict, remove, keep)
def manifest_text(entries: dict[str, str]) -> str:
return "".join(f"{entries[rel]} {rel}\n" for rel in sorted(entries))
def overlay_sources(stage: str | os.PathLike[str], source_root: str | os.PathLike[str]) -> None:
"""Merge authored runtime-relative files into staging without changing source files."""
stage, source_root = Path(stage), Path(source_root)
asset_root = source_root / "assets"
if not asset_root.is_dir():
return
for source in sorted(p for p in asset_root.rglob("*") if p.is_file()):
rel = source.relative_to(source_root)
target = stage / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
def install(
root: str | os.PathLike[str], stage: str | os.PathLike[str],
manifest: str | os.PathLike[str], plan: Plan, entries: dict[str, str],
*, replace: Callable[[str, str], None] = os.replace,
) -> None:
"""Install a validated staged tree; roll back all touched paths on exceptions."""
root, stage, manifest = Path(root), Path(stage), Path(manifest)
touched = plan.create + plan.update + plan.remove
before = {rel: (root / rel).read_bytes() if (root / rel).is_file() else None for rel in touched}
had_manifest = manifest.exists()
old_manifest = manifest.read_bytes() if had_manifest else None
try:
for rel in plan.create + plan.update:
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
os.close(fd)
try:
shutil.copyfile(stage / rel, temp_name)
replace(temp_name, str(target))
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
for rel in plan.remove:
(root / rel).unlink()
manifest.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{manifest.name}.", dir=manifest.parent)
os.close(fd)
try:
with open(temp_name, "w", encoding="utf-8", newline="\n") as stream:
stream.write(manifest_text(entries))
replace(temp_name, str(manifest))
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
except Exception:
for rel, data in before.items():
target = root / rel
if data is None:
if target.exists():
target.unlink()
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
if had_manifest:
manifest.write_bytes(old_manifest or b"")
elif manifest.exists():
manifest.unlink()
raise