"""Small filesystem helpers shared by core modules.""" from __future__ import annotations import json import os import re import tempfile from pathlib import Path, PurePosixPath from typing import Any def atomic_write_bytes(path: Path, data: bytes, mode: int | None = None) -> None: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") try: with os.fdopen(fd, "wb") as fh: fh.write(data) fh.flush() os.fsync(fh.fileno()) if mode is not None: os.chmod(tmp, mode) os.replace(tmp, path) except BaseException: try: os.unlink(tmp) except OSError: pass raise def atomic_write_text(path: Path, text: str, mode: int | None = None) -> None: atomic_write_bytes(path, text.encode("utf-8"), mode) def atomic_write_json(path: Path, obj: Any, mode: int | None = None) -> None: atomic_write_text(path, json.dumps(obj, indent=2, sort_keys=True) + "\n", mode) def read_json(path: Path, default: Any) -> Any: try: with open(path, "r", encoding="utf-8") as fh: return json.load(fh) except (FileNotFoundError, json.JSONDecodeError): return default def slugify(text: str, fallback: str = "mod") -> str: slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") return slug[:48] or fallback def safe_relpath(rel: str) -> str | None: """Normalise an archive/dest path. Returns None if it is absolute or escapes.""" rel = rel.replace("\\", "/") if not rel or "\0" in rel or rel.startswith("/") or re.match(r"^[A-Za-z]:", rel): return None parts = [p for p in PurePosixPath(rel).parts if p not in ("", ".")] if not parts or any(p == ".." for p in parts): return None return "/".join(parts) def is_within(child: Path, parent: Path) -> bool: try: Path(os.path.realpath(child)).relative_to(os.path.realpath(parent)) return True except ValueError: return False