From 19bba60cacc7f45c9828c98f5e83ac13e155d387 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:38:31 +0400 Subject: [PATCH] =?UTF-8?q?feat(files):=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D1=89=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20files/=20=D1=81=20=D1=83=D0=B1=D0=BE=D1=80=D0=BA=D0=BE=D0=B9=20(?= =?UTF-8?q?#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 3 +- .../changes/bug-and-attachments/design.md | 14 ++ src/jira_hub/files.py | 112 +++++++++++++ tests/test_files.py | 149 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/jira_hub/files.py create mode 100644 tests/test_files.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5dfab78..b61da5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,7 @@ | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | `paths` | +| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | @@ -29,7 +30,7 @@ перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`. -Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index a0baf5f..1161912 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -100,3 +100,17 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a 9. 15.10.2026 — `issue_type` вне списка — 422 «команда N: issue_type — task, epic или bug»; `init` добавляет `bug`, если ключ `issue_type_bug` или `issue_type_bug_id` есть в конфиге даже со значением `null` (тогда постановка `bug` — 422, как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет. +10. 15.10.2026 — функции `files` принимают каталог хранилища первым параметром (`put(root, data)`, `path(root, sha)`, + `exists`, `size`, `read`, `sweep(conn, root, now)`), каталог — `Hub.files_dir` (`<каталог базы>/files`), а не + `paths.home()`. Обоснование: тесты поднимают хаб во временном каталоге без `JIRA_HUB_HOME` (как `release_dir`). + Цена: лишний параметр в вызовах из `queue` и `api`. +11. 15.10.2026 — временный файл записи — `tempfile.mkstemp` в `files/` (`..<случайное>.tmp`, права 0600), а не + фиксированное `..tmp`. Обоснование: две одновременные загрузки одного содержимого (ThreadingHTTPServer) не + пишут в один временный файл и не ломают друг другу `os.replace`. Цена: брошенные после сбоя временные файлы + убирает уборка общим правилом 30 дней. +12. 15.10.2026 — `files.path/exists/size/read` принимают только sha из 64 символов `[0-9a-f]`, иначе «файла нет» + (`path` — `ValueError`). Обоснование: APFS не различает регистр — `BA78…` нашёл бы `ba78…`; `..` и `/` не строят + путь вне `files/`. Цена: нет. +13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная + запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого, + агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). diff --git a/src/jira_hub/files.py b/src/jira_hub/files.py new file mode 100644 index 0000000..edbea42 --- /dev/null +++ b/src/jira_hub/files.py @@ -0,0 +1,112 @@ +"""Хранилище файлов вложений: <каталог данных>/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 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 diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..1ebf899 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,149 @@ +"""Хранилище файлов вложений (bug-and-attachments, hub-api «Хранилище файлов вложений»).""" +import hashlib +import json +import os +import stat +import time + +import pytest + +from jira_hub import db, files, settings + +ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… +DAY = 86400 + + +@pytest.fixture +def root(tmp_path): + return tmp_path / "files" + + +@pytest.fixture +def conn(open_db): + """База в памяти с настройками по умолчанию; закрывается после теста.""" + c = open_db() + settings.ensure_defaults(c) + return c + + +def test_put_creates_closed_dir_and_file(root): + assert files.put(root, b"abc") == (ABC, 3) + assert ABC.startswith("ba7816bf") + assert (root / ABC).read_bytes() == b"abc" + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + assert [p.name for p in root.iterdir()] == [ABC] # временных файлов не осталось + + +def test_put_again_keeps_one_copy_and_touches_mtime(root): + files.put(root, b"abc") + old = time.time() - 40 * DAY + os.utime(root / ABC, (old, old)) + assert files.put(root, b"abc") == (ABC, 3) + assert [p.name for p in root.iterdir()] == [ABC] + assert (root / ABC).stat().st_mtime > time.time() - 60 # свежая постановка не теряет файл в уборке + + +def test_put_rewrites_truncated_copy(root): + files.put(root, b"abc") + (root / ABC).write_bytes(b"ab") # оборванная копия + files.put(root, b"abc") + assert (root / ABC).read_bytes() == b"abc" + + +def test_put_limits(root): + with pytest.raises(files.EmptyFile): + files.put(root, b"") + with pytest.raises(files.TooLarge): + files.put(root, b"\0" * (files.MAX_SIZE + 1)) + assert not root.exists() or list(root.iterdir()) == [] + sha, size = files.put(root, b"\0" * files.MAX_SIZE) # ровно 10 485 760 байт — принимается + assert files.MAX_SIZE == 10_485_760 and size == files.MAX_SIZE and files.size(root, sha) == files.MAX_SIZE + + +def test_put_failure_leaves_no_temp(root, monkeypatch): + def broken(*args): + raise OSError("диск полон") + monkeypatch.setattr(files.os, "replace", broken) + with pytest.raises(OSError, match="диск полон"): + files.put(root, b"abc") + assert list(root.iterdir()) == [] + + +def test_put_after_dir_removed(root): + files.put(root, b"abc") + (root / ABC).unlink() + root.rmdir() + assert files.put(root, b"abc") == (ABC, 3) and files.exists(root, ABC) + assert stat.S_IMODE(os.stat(root).st_mode) == 0o700 + + +def test_lookup(root): + files.put(root, b"abc") + assert files.exists(root, ABC) and files.size(root, ABC) == 3 and files.read(root, ABC) == b"abc" + assert files.path(root, ABC) == root / ABC + missing = "0" * 64 + assert not files.exists(root, missing) and files.size(root, missing) is None and files.read(root, missing) is None + + +@pytest.mark.parametrize("bad", [ABC.upper(), ABC[:63], ABC + "0", "..", "../hub.sqlite", ABC + "\n", "", None, 5]) +def test_bad_sha_never_builds_path(root, bad): + """APFS не различает регистр: ABC.upper() нашёл бы файл ABC, если бы путь строился без проверки.""" + files.put(root, b"abc") + assert files.is_sha(bad) is False + assert not files.exists(root, bad) and files.size(root, bad) is None and files.read(root, bad) is None + with pytest.raises(ValueError): + files.path(root, bad) + + +def _file(root, content, age_days): + sha, _ = files.put(root, content) + t = time.time() - age_days * DAY + os.utime(root / sha, (t, t)) + return sha + + +def _command(conn, sha, status): + if conn.execute("SELECT 1 FROM batch WHERE id = 1").fetchone() is None: + conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 'x', 'claude', ?)", (db.now(),)) + conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (1, 0, 'ensure_attachment', ?, ?)", + (json.dumps({"key": "A-1", "file": sha, "filename": "a.xlsx"}), status)) + + +def test_sweep_spec_scenario(root, conn): + """Спека hub-api «Уборка старых файлов»: A старше 30 дней без ссылок, B старше 30 дней в queued, C вчерашний.""" + a = _file(root, b"A", 31) + b = _file(root, b"B", 31) + c = _file(root, b"C", 1) + _command(conn, b, "queued") + assert files.sweep(conn, root) == [a] + assert sorted(p.name for p in root.iterdir()) == sorted([b, c]) + + +def test_sweep_keeps_claimed_and_shared_file(root, conn): + shared = _file(root, b"S", 31) + claimed = _file(root, b"K", 31) + gone = _file(root, b"G", 31) + _command(conn, shared, "done") + _command(conn, shared, "queued") # тот же файл под другим именем в другой команде + _command(conn, claimed, "claimed") + for status in ("done", "cancelled", "failed", "skipped", "confirmed_by_state"): + _command(conn, gone, status) + assert files.sweep(conn, root) == [gone] + assert files.exists(root, shared) and files.exists(root, claimed) + + +def test_sweep_removes_stale_temp_and_handles_missing_dir(root, conn, tmp_path): + assert files.sweep(conn, tmp_path / "нет") == [] + files.put(root, b"abc") + tmp = root / f".{ABC}.x1y2.tmp" # брошен сбоем посреди записи + tmp.write_bytes(b"ab") + t = time.time() - 31 * DAY + os.utime(tmp, (t, t)) + assert files.sweep(conn, root) == [tmp.name] + assert files.exists(root, ABC) + + +def test_sweep_uses_now(root, conn): + sha = _file(root, b"N", 0) + assert files.sweep(conn, root) == [] + assert files.sweep(conn, root, now=time.time() + 31 * DAY) == [sha]