import hashlib import json import os from pathlib import PurePosixPath import subprocess import tempfile class WorkspaceError(Exception): pass def run(args, cwd, env=None, check=True): result = subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True, timeout=180) if check and result.returncode: raise WorkspaceError(f"{args[0]} failed ({result.returncode}): {result.stderr.strip() or result.stdout.strip()}") return result def relative_path(value, allow_dot=False): if not isinstance(value, str) or not value or "\\" in value or any(ord(c) < 32 for c in value): raise WorkspaceError("invalid relative path") path = PurePosixPath(value) if path.is_absolute() or ".." in path.parts or (value == "." and not allow_dot): raise WorkspaceError(f"unsafe relative path: {value}") return path def safe_path(root, value, allow_dot=False): relative_path(value, allow_dot) path = root / value # Refuse symlink path components even when a link currently points inside root. # This avoids creating a second apparent owner and later retargeting escapes. current = root for part in PurePosixPath(value).parts: current /= part if current.is_symlink(): raise WorkspaceError(f"symlink path is not owned by this workspace: {value}") if not path.resolve().is_relative_to(root.resolve()): raise WorkspaceError(f"path escapes workspace: {value}") return path def read_json(path): if path.is_symlink(): raise WorkspaceError(f"refusing symlink metadata: {path}") try: return json.loads(path.read_text()) except (OSError, ValueError) as error: raise WorkspaceError(f"cannot read {path.name}: {error}") from error def atomic_write(path, content): if path.is_symlink(): raise WorkspaceError(f"refusing symlink output: {path}") path.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + "-", dir=path.parent) try: with os.fdopen(descriptor, "w") as stream: stream.write(content) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) finally: if os.path.exists(temporary): os.unlink(temporary) def write_json(path, value): atomic_write(path, json.dumps(value, indent=2, sort_keys=True) + "\n") def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest()