sdlc-hub/src/jira_hub/files.py
Andrey Epifancev 19bba60cac feat(files): хранилище файлов вложений files/<sha256> с уборкой (#43)
put — атомарно (mkstemp, fsync, os.replace), одна копия на содержимое, mtime при повторе, предел 10 485 760 байт, пустой — отказ; path/exists/size/read только для sha из 64 hex; sweep — старше 30 дней без ensure_attachment в queued/claimed (решения 10–13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:38:31 +04:00

113 lines
4.7 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 put(root, data: bytes) -> tuple[str, int]:
"""Сохранить содержимое, вернуть (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):
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