put сверяет sha256 копии на диске при совпадении размера и переписывает испорченную; design.md: решение 13 уточнено, рулинги R-3…R-5 — решения 21–23 (M-5 финального ревью). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
5.1 KiB
Python
121 lines
5.1 KiB
Python
"""Хранилище файлов вложений: <каталог данных>/files/<sha256>, одна копия на содержимое, без имени вложения — имя задаёт
|
||
команда 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
|