Files

70 lines
2.1 KiB
Python

"""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