Цифровые id и версия агента — только ASCII 0-9, без 500 на «²» (#129, #86 F.1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:27:47 +04:00
parent abbcf0cc1f
commit 4a6855d70b
12 changed files with 60 additions and 8 deletions

View File

@ -30,6 +30,12 @@ def int_group(m, i=1) -> int:
return int(m.group(i))
def is_digits(value) -> bool:
"""Непустая строка только из ASCII-цифр 0-9. str.isdigit() принимает «²» и арабо-индийские цифры, на которых int()
падает (#86 F.1); идентификаторы из цифр проверять только этим."""
return isinstance(value, str) and value.isascii() and value.isdigit()
def int_param(q, name, default) -> int:
try:
return int(q.get(name, default))

View File

@ -4,6 +4,7 @@
import re
from ...core import files
from ...core.http import is_digits
# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки;
# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя
@ -40,7 +41,7 @@ def ref_names(text: str | None) -> list[str]:
def _newest_key(item: dict):
aid = str(item["attachment_id"])
return item.get("created_at") or "", int(aid) if aid.isdigit() else -1, aid
return item.get("created_at") or "", int(aid) if is_digits(aid) else -1, aid
def resolve(names: list[str], items: list[dict]) -> list[str]:

View File

@ -1,6 +1,7 @@
"""Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки."""
from ...core import db, queue
from ...core.errors import CommandError, HttpMapped
from ...core.http import is_digits
from . import attachments, commands
FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment
@ -31,7 +32,7 @@ def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> di
if info["state"] in ("ready", "fetching"):
return info
v = queue.agent_version(conn, "jira")
if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем
if v is not None and is_digits(str(v)) and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем
raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}")
cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}])
hooks.check_setup(conn, cmds, files_dir)

View File

@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
from . import projects
from ...core import db, settings
from ...core.http import is_digits
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
@ -14,7 +15,7 @@ def jira_utc(value) -> str | None:
if not isinstance(value, str) or not value:
return None
s = value[:-1] + "+00:00" if value.endswith("Z") else value
if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM
if len(s) > 5 and s[-5] in "+-" and is_digits(s[-4:]): # ±HHMM → ±HH:MM
s = s[:-2] + ":" + s[-2:]
try:
d = datetime.fromisoformat(s)

View File

@ -3,6 +3,7 @@ import json
import logging
from ...core import files, projects, queue, settings
from ...core.http import is_digits
from ...core.errors import CommandError
from ...core.modules import CommandHooks
from . import attachments, commands, history, mirror, refresh
@ -40,7 +41,7 @@ def check_setup(conn, cmds, files_dir) -> None:
# кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
if c["type"] == "fetch_attachment":
if not p["attachment_id"].isdigit():
if not is_digits(p["attachment_id"]):
raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue")
row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?",
(p["attachment_id"], p["key"])).fetchone()

View File

@ -15,6 +15,7 @@ from mcp.server.fastmcp import FastMCP
from ...core import mcp as core
from ...core.client import HubError
from ...core.files import MAX_SIZE
from ...core.http import is_digits
from .attachments import fmt_size
from .commands import describe, is_temp
@ -46,7 +47,7 @@ def fmt_day(value) -> str:
text = value.strip()
if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00"
if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit():
if len(text) > 5 and text[-5] in "+-" and is_digits(text[-4:]):
text = text[:-2] + ":" + text[-2:]
try:
at = datetime.fromisoformat(text)
@ -218,7 +219,7 @@ DATE_LABELS = {"duedate": "срок", "planned_start": "плановое нач
def _day(value, text) -> str:
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and is_digits(value[:4]):
return f"{value[8:10]}.{value[5:7]}"
return core.value(text)
@ -346,7 +347,7 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
d = await asyncio.to_thread(client.get, f"/issues/{key}")
atts = d.get("attachments") or []
ref = attachment.strip().lstrip("#")
found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else []
found = [a for a in atts if str(a["attachment_id"]) == ref] if is_digits(ref) else []
prefix = ""
if not found:
want = unicodedata.normalize("NFC", attachment.strip())

View File

@ -5,6 +5,7 @@ import math
from . import attachments, history, projects
from ...core import db
from ...core.http import is_digits
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
@ -43,7 +44,7 @@ def check_attachments(issues) -> None:
where = f"{key}: attachments[{n}]"
if not isinstance(a, dict):
raise ValueError(f"{where} — объект вложения")
if not isinstance(a.get("id"), str) or not a["id"].isdigit():
if not is_digits(a.get("id")):
raise ValueError(f"{where}.id — строка из цифр")
if not isinstance(a.get("filename"), str) or not a["filename"]:
raise ValueError(f"{where}.filename — непустая строка")

View File

@ -1188,6 +1188,26 @@ def test_batches_post_fetch_too_large_413(hub):
assert hub["client"].get("/batches")["batches"] == []
@pytest.mark.parametrize("bad", ["²", "١٢", 7])
def test_batches_post_fetch_attachment_id_not_ascii_digits_422(hub, bad):
"""#86 F.1: attachment_id из не-ASCII цифр или числом — 422, не 500; пакет не создан."""
_seed_attachment(hub)
with pytest.raises(HubError) as e:
hub["client"].post("/batches", {"title": "x", "commands": [
{"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": bad}}]})
assert e.value.status == 422
assert hub["client"].get("/batches")["batches"] == []
@pytest.mark.parametrize("bad_id", ["²", 7])
def test_agent_state_attachment_id_ascii_only_422(hub, bad_id):
"""#86 F.1: id вложения в состоянии агента — только строка из ASCII-цифр (ascii digit)."""
with pytest.raises(HubError) as e:
hub["agent"].post("/agent/state", {"issues": [{"key": "Y-1", "summary": "Игрек", "labels": [],
"attachments": [{"id": bad_id, "filename": "a"}]}], "links": []})
assert e.value.status == 422 and e.value.error == "bad_state"
def test_command_retry_fetch_too_large_413(hub):
"""M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large."""
_seed_attachment(hub)

View File

@ -93,3 +93,9 @@ def test_state_failed_and_cancelled(conn, tmp_path):
def test_get_missing(conn, tmp_path):
assert attachments.get(conn, tmp_path, "999") is None
def test_newest_key_non_ascii_digit_goes_last_ascii():
"""#86 F.1: id «²» не бросает в int() и уходит в хвост, как нецифровой (ascii digit)."""
assert attachments._newest_key({"attachment_id": "²", "created_at": "t"}) == ("t", -1, "²")
assert attachments._newest_key({"attachment_id": "12", "created_at": "t"}) == ("t", 12, "12")

View File

@ -378,3 +378,10 @@ def test_normalize_summary_description_goal_untouched():
out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task",
"summary": " Т ", "description": " d\n"}}])
assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n"
def test_is_digits_ascii_only():
from sdlc_hub.core.http import is_digits
assert is_digits("123") is True
for bad in ("", "²", "١٢", "12a", 7, None):
assert is_digits(bad) is False

View File

@ -369,6 +369,7 @@ def test_attachment_moves_to_other_key(conn):
@pytest.mark.parametrize("bad, field", [
("x", "attachments"), (["x"], "attachments[0]"),
([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"),
([{"id": "²", "filename": "a"}], "attachments[0].id"), ([{"id": "١٢", "filename": "a"}], "attachments[0].id"),
([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"),
([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),

View File

@ -1287,6 +1287,12 @@ def test_request_fetch_agent_too_old(fconn, tmp_path):
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
def test_request_fetch_agent_version_not_ascii_digits_ascii(fconn, tmp_path):
"""#86 F.1: версия агента «²» — неизвестная, не блокирует и не бросает ValueError (ascii digit)."""
fconn.execute("UPDATE agent_status SET agent_version = '²' WHERE agent = 'jira'")
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
def test_request_fetch_after_failed(fconn, tmp_path):
root = tmp_path / "files"
first = jira_fetch.request_fetch(fconn, root, "31337", "x")