from __future__ import annotations import re from pathlib import PurePosixPath from uuid import uuid4 _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.@ -]+") class UnsafeFilePathError(ValueError): pass def normalize_logical_path(path: str | None, *, fallback_filename: str | None = None) -> str: """Return a safe tenant-relative logical path using POSIX separators. The logical path is metadata, not a filesystem path. It never starts with a slash and cannot contain path traversal components. It is used for browsing, wildcard matching and attachment rules. """ raw = (path or "").replace("\\", "/").strip() if not raw and fallback_filename: raw = fallback_filename if not raw: raise UnsafeFilePathError("File path is empty") if raw.startswith("/"): raw = raw.lstrip("/") parts: list[str] = [] for part in raw.split("/"): clean = part.strip() if not clean or clean == ".": continue if clean == "..": raise UnsafeFilePathError("Path traversal is not allowed") parts.append(clean) if not parts: raise UnsafeFilePathError("File path is empty") return "/".join(parts) def normalize_folder(path: str | None) -> str: raw = (path or "").replace("\\", "/").strip().strip("/") if not raw: return "" normalized = normalize_logical_path(raw) return "" if normalized == "." else normalized def filename_from_path(path: str) -> str: name = PurePosixPath(path).name if not name or name in {".", ".."}: raise UnsafeFilePathError("Invalid filename") return name def join_folder_filename(folder: str | None, filename: str) -> str: safe_name = sanitize_filename(filename) safe_folder = normalize_folder(folder) return f"{safe_folder}/{safe_name}" if safe_folder else safe_name def sanitize_filename(filename: str | None) -> str: raw = (filename or "file").replace("\\", "/").split("/")[-1].strip() raw = raw.strip(".") or "file" safe = _SAFE_NAME_RE.sub("_", raw) safe = re.sub(r"\s+", " ", safe).strip() return safe or f"file-{uuid4().hex}" def safe_storage_component(value: str | None, fallback: str = "file") -> str: safe = sanitize_filename(value or fallback) return safe.replace(" ", "_")[:180]