2026-08-06 20:55:31 +08:00

139 lines
5.2 KiB
Python

"""Path and URL layout helpers for primary uploads and generated assets."""
import hashlib
import os
import stat
from pathlib import Path
from urllib.parse import quote
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
UPLOAD_CONVERSIONS_DIRNAME = ".upload-conversions"
UPLOAD_LOCKS_DIRNAME = ".locks"
UPLOAD_STAGE_LOCKS_DIRNAME = "stages"
class UnsafeConversionPathError(ValueError):
"""Raised when the generated-conversion namespace is unsafe."""
def conversion_dir_for_uploads(uploads_dir: Path) -> Path:
"""Return the system-owned conversion directory beside ``uploads_dir``."""
return uploads_dir.parent / UPLOAD_CONVERSIONS_DIRNAME
def _truncate_utf8(value: str, max_bytes: int) -> str:
encoded = value.encode("utf-8")
if len(encoded) <= max_bytes:
return value
return encoded[:max_bytes].decode("utf-8", errors="ignore")
def conversion_filename_for_upload(filename: str) -> str:
"""Return a deterministic generated-Markdown component within 255 bytes."""
desired = f"{filename}.md"
if len(desired.encode("utf-8")) <= 255:
return desired
digest = hashlib.sha256(filename.encode("utf-8")).hexdigest()
marker = f".{digest}.md"
prefix = _truncate_utf8(filename, 255 - len(marker.encode("utf-8")))
return f"{prefix}{marker}"
def conversion_path_for_upload(upload_path: Path) -> Path:
"""Return the generated Markdown path owned by one primary upload."""
return conversion_dir_for_uploads(upload_path.parent) / conversion_filename_for_upload(upload_path.name)
def validate_conversion_dir(uploads_dir: Path) -> Path | None:
"""Return an existing real conversion directory without following links."""
conversion_dir = conversion_dir_for_uploads(uploads_dir)
try:
conversion_stat = os.lstat(conversion_dir)
except FileNotFoundError:
return None
if stat.S_ISLNK(conversion_stat.st_mode) or not stat.S_ISDIR(conversion_stat.st_mode):
raise UnsafeConversionPathError("Unsafe upload conversion directory")
return conversion_dir
def ensure_conversion_dir(uploads_dir: Path) -> Path:
"""Create and validate the system-owned conversion directory."""
conversion_dir = conversion_dir_for_uploads(uploads_dir)
try:
conversion_dir.mkdir(mode=0o755)
except FileExistsError:
pass
validated = validate_conversion_dir(uploads_dir)
if validated is None:
raise UnsafeConversionPathError("Upload conversion directory disappeared")
return validated
def upload_lock_dir_for_uploads(uploads_dir: Path) -> Path:
"""Return the stable per-upload lock directory."""
return conversion_dir_for_uploads(uploads_dir) / UPLOAD_LOCKS_DIRNAME
def ensure_upload_lock_dir(uploads_dir: Path) -> Path:
"""Create and validate the system-owned upload lock directory."""
conversion_dir = ensure_conversion_dir(uploads_dir)
lock_dir = conversion_dir / UPLOAD_LOCKS_DIRNAME
try:
lock_dir.mkdir(mode=0o700)
except FileExistsError:
pass
try:
lock_stat = os.lstat(lock_dir)
except FileNotFoundError as exc:
raise UnsafeConversionPathError("Upload lock directory disappeared") from exc
if stat.S_ISLNK(lock_stat.st_mode) or not stat.S_ISDIR(lock_stat.st_mode):
raise UnsafeConversionPathError("Unsafe upload lock directory")
return lock_dir
def ensure_upload_stage_lock_dir(uploads_dir: Path) -> Path:
"""Create and validate the liveness-lock directory for upload stages."""
lock_dir = ensure_upload_lock_dir(uploads_dir)
stage_lock_dir = lock_dir / UPLOAD_STAGE_LOCKS_DIRNAME
try:
stage_lock_dir.mkdir(mode=0o700)
except FileExistsError:
pass
try:
stage_lock_stat = os.lstat(stage_lock_dir)
except FileNotFoundError as exc:
raise UnsafeConversionPathError("Upload stage lock directory disappeared") from exc
if stat.S_ISLNK(stage_lock_stat.st_mode) or not stat.S_ISDIR(stage_lock_stat.st_mode):
raise UnsafeConversionPathError("Unsafe upload stage lock directory")
return stage_lock_dir
def existing_conversion_path_for_upload(upload_path: Path) -> Path | None:
"""Return an existing safe generated file owned by ``upload_path``."""
if validate_conversion_dir(upload_path.parent) is None:
return None
candidate = conversion_path_for_upload(upload_path)
try:
candidate_stat = os.lstat(candidate)
except FileNotFoundError:
return None
if not stat.S_ISREG(candidate_stat.st_mode) or candidate_stat.st_nlink != 1:
raise UnsafeConversionPathError("Unsafe upload conversion file")
return candidate
def upload_virtual_path(filename: str) -> str:
"""Build the sandbox virtual path for a primary upload."""
return f"{VIRTUAL_PATH_PREFIX}/uploads/{filename}"
def conversion_virtual_path(filename: str) -> str:
"""Build the sandbox virtual path for an upload's generated Markdown."""
return f"{VIRTUAL_PATH_PREFIX}/{UPLOAD_CONVERSIONS_DIRNAME}/{conversion_filename_for_upload(filename)}"
def artifact_url_for_virtual_path(thread_id: str, virtual_path: str) -> str:
"""Build an artifact URL while preserving path separators."""
return f"/api/threads/{thread_id}/artifacts{quote(virtual_path, safe='/')}"