sdlc-hub/src/jira_hub/files.py
Andrey Epifancev 2e98ce6062 fix(files): повторная загрузка лечит испорченную копию того же размера (M-2, #43)
put сверяет sha256 копии на диске при совпадении размера и переписывает испорченную; design.md: решение 13 уточнено, рулинги R-3…R-5 — решения 21–23 (M-5 финального ревью).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:05:45 +04:00

121 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Хранилище файлов вложений: <каталог данных>/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