OpenTranscode/opentranscode/temp_manager.py

116 lines
4.3 KiB
Python

"""Temp directory management for intermediate encode files.
Owns the shared app cache dir, per-worker temp subdirs (v3-09 race
fix), private-dir mkdir (v3-09 umask defeat), and the per-source-path
hash naming helper. Pure stdlib; no internal package dependencies.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 — package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
import hashlib
import os
import tempfile
from pathlib import Path
# ──────────────────────────────────────────────
# TEMP DIRECTORY MANAGEMENT
# ──────────────────────────────────────────────
_APP_CACHE_DIR: Path | None = None
def _get_app_temp_dir() -> Path:
"""Return the shared temp directory for all intermediate files.
Priority:
1. ``~/.cache/OpenTranscode/tmp/`` (XDG-compliant, persistent across reboots)
2. ``/tmp/OpenTranscode/`` (fallback if home cache is unwritable)
The directory is created on first call. All temp intermediates
(pre-scaled MKVs, av1an work dirs) go here so the user's video
folders stay clean.
v3 (OTC-013, SEI CERT FIO09-C): the directory is created with
``mode=0o700`` so that other users on the system cannot create
symlinks inside it (which the cleanup sweep would then follow and
delete arbitrary files). The mode is verified after creation in
case the directory already existed with looser permissions.
"""
global _APP_CACHE_DIR
if _APP_CACHE_DIR is not None:
return _APP_CACHE_DIR
# Try XDG cache dir first
xdg_cache = os.environ.get("XDG_CACHE_HOME", "")
if xdg_cache:
candidate = Path(xdg_cache) / "OpenTranscode" / "tmp"
else:
candidate = Path.home() / ".cache" / "OpenTranscode" / "tmp"
if _mkdir_private(candidate):
_APP_CACHE_DIR = candidate
return _APP_CACHE_DIR
# Fallback: /tmp/OpenTranscode
fallback = Path("/tmp/OpenTranscode")
if _mkdir_private(fallback):
_APP_CACHE_DIR = fallback
return _APP_CACHE_DIR
# Last resort: system temp
_APP_CACHE_DIR = Path(tempfile.gettempdir()) / "OpenTranscode"
_mkdir_private(_APP_CACHE_DIR)
return _APP_CACHE_DIR
def _mkdir_private(path: Path) -> bool:
"""Create *path* (and parents) with mode 0o700.
Returns True on success, False on OSError/PermissionError.
SEI CERT FIO09-C: if the directory already existed with looser
permissions (e.g. created by a previous version of this app, or by
another user before us), we attempt to tighten the mode with
os.chmod(). The chmod may fail silently if we don't own the dir —
that's an accepted risk, logged but not fatal.
"""
try:
path.mkdir(parents=True, exist_ok=True, mode=0o700)
# mkdir(mode=) is masked by umask; explicitly chmod to be sure
os.chmod(path, 0o700)
return True
except (OSError, PermissionError):
return False
def _worker_temp_dir(worker_pid: int) -> Path:
"""Return a per-worker temp subdir named by PID.
v3: each EncoderWorker gets its own subdir under the shared app temp
dir, so the final cleanup sweep can safely nuke only this worker's
intermediates without affecting a concurrent worker. The subdir is
also created with mode=0o700 (FIO09-C).
"""
base = _get_app_temp_dir()
sub = base / f"worker-{worker_pid}"
_mkdir_private(sub)
return sub
def _temp_path_for(file_path: Path, suffix: str = ".scaled_tmp.mkv",
worker_dir: Path | None = None) -> Path:
"""Build a unique temp path for *file_path* inside the app temp dir.
Uses a short hash of the original absolute path to avoid collisions
when files in different subdirs share the same stem.
v3: if *worker_dir* is provided (per-worker subdir), the temp file
lands there instead of the shared parent. This isolates concurrent
workers' intermediates from each other.
"""
tmp_dir = worker_dir if worker_dir is not None else _get_app_temp_dir()
# Hash the absolute source path for uniqueness
path_hash = hashlib.sha256(str(file_path.resolve()).encode()).hexdigest()[:12]
return tmp_dir / f"{file_path.stem}.{path_hash}{suffix}"