74 lines
2.3 KiB
Python
74 lines
2.3 KiB
Python
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]
|