diff --git a/src/sdlc_hub/core/http.py b/src/sdlc_hub/core/http.py index 76c590f..4abe2ef 100644 --- a/src/sdlc_hub/core/http.py +++ b/src/sdlc_hub/core/http.py @@ -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)) diff --git a/src/sdlc_hub/modules/jira/attachments.py b/src/sdlc_hub/modules/jira/attachments.py index 633e8ee..743fe42 100644 --- a/src/sdlc_hub/modules/jira/attachments.py +++ b/src/sdlc_hub/modules/jira/attachments.py @@ -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]: diff --git a/src/sdlc_hub/modules/jira/fetch.py b/src/sdlc_hub/modules/jira/fetch.py index 79a4a1a..780b190 100644 --- a/src/sdlc_hub/modules/jira/fetch.py +++ b/src/sdlc_hub/modules/jira/fetch.py @@ -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) diff --git a/src/sdlc_hub/modules/jira/history.py b/src/sdlc_hub/modules/jira/history.py index 7721b01..256a62e 100644 --- a/src/sdlc_hub/modules/jira/history.py +++ b/src/sdlc_hub/modules/jira/history.py @@ -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) diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py index 17e2a9e..c9a1175 100644 --- a/src/sdlc_hub/modules/jira/hooks.py +++ b/src/sdlc_hub/modules/jira/hooks.py @@ -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() diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index 3120304..70efa36 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -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()) diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index 90da1a3..59f7b2a 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -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 — непустая строка") diff --git a/tests/test_api.py b/tests/test_api.py index a99f414..74cf6e5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 8018dea..d6db28b 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -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") diff --git a/tests/test_commands.py b/tests/test_commands.py index b9393e6..6eec392 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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 diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 78c5558..3bf15fd 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -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"), diff --git a/tests/test_queue.py b/tests/test_queue.py index 5f95a77..62bfaf2 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -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")