diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index ced6e53..00ff319 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -111,9 +111,10 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a 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 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). +13. 15.10.2026 — повторная загрузка: целая копия (тот же размер и sha256 содержимого на диске) — только `os.utime`; + оборванная (другой размер) или испорченная (тот же размер, другой sha256 — M-2 финального ревью) — переписывается. + Обоснование: имя — sha256 содержимого; повторная загрузка из чата должна лечить испорченную копию. Цена: чтение + копии (до 10 МБ) при каждой повторной загрузке. 14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64. Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 — 400». Цена: строка с пробелами внутри тоже принимается. @@ -140,3 +141,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a и LF) — «команда N: filename без кавычек и управляющих символов (CR, LF и др.)» (рулинг R-2). Обоснование: такие имена ломают заголовок части `multipart/form-data` у агента; кодирование имени в агенте остаётся запасной защитой. Цена: файл с кавычкой в имени прикладывается под другим именем (`filename` в команде). +21. 16.10.2026 — текст отчёта агента при повреждённом в пути файле («результат неизвестен (обрыв связи с хабом)…») + не меняется (рулинг R-3): общий путь повтора `Runner.execute`. Цена: неточная формулировка в редком случае — в Jira + при этом ничего не записано. +22. 16.10.2026 — агент без записи `issue_types.<тип>` (в том числе `task` и `epic`) отвечает `failed` «нет настройки + issue_types.<тип>» без вызова Jira вместо `{"name": null}` (рулинг R-4, следует из спеки agent). Цена: нет — на + живом хабе настройки `task` и `epic` есть; отмечено в PR. +23. 16.10.2026 — план агента (5 задач) исполнен параллельно фоновым исполнителем на sonnet по executing-plans, план + хаба — в сессии; финальное ревью обеих веток — opus (рулинг R-5). Цена: два контекста вместо одного. diff --git a/src/jira_hub/files.py b/src/jira_hub/files.py index edbea42..abb81c7 100644 --- a/src/jira_hub/files.py +++ b/src/jira_hub/files.py @@ -57,9 +57,17 @@ def read(root, sha) -> bytes | None: 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, размер). Копия того же размера уже есть — только обновить mtime (уборка - считает возраст от последней загрузки); другого размера (оборванная запись) — переписать (решение 13). Запись + """Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить + mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется (решение 11).""" if not data: @@ -70,7 +78,7 @@ def put(root, data: bytes) -> tuple[str, int]: secure_dir(root) sha = hashlib.sha256(data).hexdigest() target = root / sha - if size(root, sha) == len(data): + if size(root, sha) == len(data) and _intact(target, sha): try: os.utime(target) return sha, len(data) diff --git a/tests/test_files.py b/tests/test_files.py index 1ebf899..470412d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -147,3 +147,11 @@ 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] + + +def test_put_rewrites_corrupted_copy_of_same_size(root): + """Финальное ревью M-2: испорченная копия того же размера лечится повторной загрузкой.""" + files.put(root, b"abc") + (root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое + assert files.put(root, b"abc") == (ABC, 3) + assert (root / ABC).read_bytes() == b"abc"