657d8f16c6
Co-Authored-By: Codex <noreply@openai.com>
147 lines
5.2 KiB
Python
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
|