"""Хранилище файлов вложений: <каталог данных>/files/, одна копия на содержимое, без имени вложения — имя задаёт команда ensure_attachment (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения: каталог передаётся параметром (Hub.files_dir, решение 10).""" import hashlib import os import re import tempfile import time from pathlib import Path from .paths import secure_dir MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2) SHA_RE = re.compile(r"[0-9a-f]{64}") class EmptyFile(ValueError): """Пустое содержимое — API отвечает 422 empty_file (решение 3).""" class TooLarge(ValueError): """Больше MAX_SIZE — API отвечает 413 too_large.""" def is_sha(value) -> bool: """sha256 в хранилище — ровно 64 символа [0-9a-f]: верхний регистр, «..», «/» путь не строят (решение 12).""" return isinstance(value, str) and SHA_RE.fullmatch(value) is not None def path(root, sha) -> Path: if not is_sha(sha): raise ValueError(f"не sha256: {sha!r}") return Path(root) / sha def exists(root, sha) -> bool: return is_sha(sha) and path(root, sha).is_file() def size(root, sha) -> int | None: """Размер файла в байтах; None — файла нет или sha негодный.""" if not is_sha(sha): return None try: return path(root, sha).stat().st_size except FileNotFoundError: return None def read(root, sha) -> bytes | None: if not is_sha(sha): return None try: return path(root, sha).read_bytes() except FileNotFoundError: return None def _intact(target, sha) -> bool: """Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью).""" try: return hashlib.sha256(target.read_bytes()).hexdigest() == sha except FileNotFoundError: return False def put(root, data: bytes) -> tuple[str, int]: """Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется (решение 11).""" if not data: raise EmptyFile("пустой файл") if len(data) > MAX_SIZE: raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") root = Path(root) secure_dir(root) sha = hashlib.sha256(data).hexdigest() target = root / sha if size(root, sha) == len(data) and _intact(target, sha): try: os.utime(target) return sha, len(data) except FileNotFoundError: pass # убран между проверкой и utime — пишем заново fd, tmp = tempfile.mkstemp(dir=root, prefix=f".{sha}.", suffix=".tmp") try: with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) os.replace(tmp, target) except BaseException: Path(tmp).unlink(missing_ok=True) raise return sha, len(data) def sweep(conn, root, now: float | None = None) -> list[str]: """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят тем же путём. Каталога нет — пусто.""" root = Path(root) if not root.is_dir(): return [] cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 active = {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command " "WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")} removed = [] for p in sorted(root.iterdir()): if p.name in active or not p.is_file(): continue try: if p.stat().st_mtime < cutoff: p.unlink() removed.append(p.name) except FileNotFoundError: continue return removed