← Files ClaraARCHIVED FILE
scripts/case_exchange_safety.py
5.46 KB · Oct 2, 2026 · 00:29 UTC
"""Preflight untrusted case exchanges before any filesystem mutation.
Fixed path and size rules protect the user's filesystem; no semantic case
judgment is performed here.
"""
from __future__ import annotations
import json
import re
import stat
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
from zipfile import ZipFile
__all__ = ["load_exchange", "validate_destinations"]
MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024
MAX_MANIFEST_BYTES = 16 * 1024 * 1024
MAX_MEMBERS = 10_000
EXCHANGE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
def _relative(value: Any) -> PurePosixPath:
"""Require a portable, non-aliased relative archive path."""
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
raise ValueError("unsafe exchange path")
path = PurePosixPath(value)
if (
path.is_absolute()
or PureWindowsPath(value).drive
or any(part in {"", ".", ".."} for part in value.split("/"))
or any(part.endswith((".", " ")) or ":" in part for part in path.parts)
or any(PureWindowsPath(part).is_reserved() for part in path.parts)
):
raise ValueError(f"unsafe exchange path: {value}")
return path
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate exchange field: {key}")
result[key] = value
return result
def load_exchange(package_path: Path) -> dict[str, Any]:
"""Validate all archive names and declared destinations before reading files."""
if package_path.stat().st_size > MAX_ARCHIVE_BYTES:
raise ValueError("case exchange exceeds archive size limit")
with ZipFile(package_path) as archive:
members = archive.infolist()
if (
len(members) > MAX_MEMBERS
or sum(m.file_size for m in members) > MAX_ARCHIVE_BYTES
):
raise ValueError("case exchange exceeds expanded size/member limit")
names: set[str] = set()
for member in members:
path = _relative(
member.filename.rstrip("/") if member.is_dir() else member.filename
)
key = path.as_posix().casefold()
if key in names or stat.S_ISLNK(member.external_attr >> 16):
raise ValueError("duplicate or symbolic-link exchange member")
names.add(key)
try:
info = archive.getinfo("case_update.json")
except KeyError as exc:
raise ValueError("case update package is missing case_update.json") from exc
if info.file_size > MAX_MANIFEST_BYTES:
raise ValueError("case exchange manifest exceeds size limit")
payload = json.loads(archive.read(info), object_pairs_hook=_unique_object)
if not isinstance(payload, dict):
raise ValueError("case update package must contain a JSON object")
identifier = payload.get("exchange_id")
if (
not isinstance(identifier, str)
or not EXCHANGE_ID.fullmatch(identifier)
or PureWindowsPath(identifier).is_reserved()
):
raise ValueError("unsafe exchange_id: expected one portable identifier")
destinations: dict[str, str] = {}
for field in ("included_files", "included_lineage_files"):
records = payload.get(field, [])
if not isinstance(records, list):
raise ValueError(f"invalid exchange {field}")
for record in records:
if not isinstance(record, dict):
raise ValueError(f"invalid exchange {field} record")
source = _relative(record.get("archive_path"))
if source.as_posix() not in archive.namelist():
raise ValueError(f"missing exchange member: {source}")
target = (
_relative(record.get("relative_path"))
if field == "included_files"
else PurePosixPath("lineage/artifacts") / source.name
)
key = target.as_posix().casefold()
if key in destinations and destinations[key] != source.as_posix():
raise ValueError(f"colliding exchange destination: {target}")
destinations[key] = source.as_posix()
return payload
def validate_destinations(case_dir: Path, payload: dict[str, Any]) -> Path:
"""Reject symlink ancestors before snapshot, extraction or rollback."""
case_root = case_dir.resolve()
root = case_root / "exchange_imports" / payload["exchange_id"]
paths = [root]
for field in ("included_files", "included_lineage_files"):
for record in payload.get(field, []):
relative = (
_relative(record["relative_path"])
if field == "included_files"
else PurePosixPath("lineage/artifacts")
/ _relative(record["archive_path"]).name
)
paths.append(root.joinpath(*relative.parts))
if root.exists():
paths.extend(root.rglob("*"))
for path in paths:
for candidate in (path, *path.parents):
if candidate == case_root:
break
if candidate.is_symlink():
raise ValueError(f"symbolic-link exchange destination: {candidate}")
if not path.resolve().is_relative_to(root.resolve()):
raise ValueError("exchange destination escapes root")
return root
SHA-256: 8aaa910146d7accdcdb99ab793fb2c3e59585697d5d9284aab8946a2de7c35e3