Вики: безопасность и чистка (M-4, M-5, M-6, M-8, M-9, M-10, M-14, M-15 хаб)

- root_set кодирует метку; parse_root вынесен в wiki/roots.py; PATCH wiki_root "" -> 422
- суррогаты в пачке страниц заменяются; dir_name экранирует ведущую точку; error у вложений
- beautifulsoup4 и markdownify<2 объявлены; убраны неиспользуемые импорты и дубль _has_table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 04:01:54 +04:00
parent 82bbf0f5cd
commit c8af45ced8
23 changed files with 145 additions and 49 deletions

View File

@ -4,7 +4,8 @@ version = "0.1.0"
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
requires-python = ">=3.13"
dependencies = [
"markdownify>=1.2.3",
"beautifulsoup4>=4.12",
"markdownify>=1.2.3,<2",
"mcp>=1.2,<2",
]
@ -31,3 +32,7 @@ filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExcep
# Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS
# вешает хаб под параллельными запросами (O-14, #29); у miniconda — 3.51.1
python-preference = "only-managed"
[tool.ruff.lint.per-file-ignores]
# фикстура api импортируется из test_wiki_sync (идиома pytest), а не определяется заново
"tests/test_wiki_api.py" = ["F811"]

View File

@ -1 +1 @@
from .core.cli import main
from .core.cli import main # noqa: F401 точка входа jira-hub

View File

@ -403,7 +403,7 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object
if editable_only and not fld.editable:
raise ApiError(422, "bad_project", f"{key} после создания не меняется")
try:
value = fld.validate(value)
value = (fld.validate_edit if editable_only and fld.validate_edit else fld.validate)(value)
except ValueError as e:
raise ApiError(422, "bad_project", f"{key}: {e}")
out.setdefault(mod.name, (mod, {}))[1][key] = value

View File

@ -188,6 +188,11 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
conn.execute("INSERT OR IGNORE INTO agent_status(agent) VALUES (?)", (m.name,))
def has_table(conn: sqlite3.Connection, name: str) -> bool:
"""Есть ли в базе таблица (в том числе виртуальная) с таким именем."""
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None
_savepoints = itertools.count(1)

View File

@ -42,6 +42,7 @@ class ProjectField:
name: str # ключ в теле POST/PATCH и в GET /projects
validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение
editable: bool = False # можно ли менять после создания (PATCH)
validate_edit: Callable[[Any], Any] | None = None # проверка при PATCH вместо validate (строже, чем при создании)
@dataclass(frozen=True)

View File

@ -120,7 +120,3 @@ def for_agent(conn, module: str) -> dict:
def tokens(conn) -> dict:
"""{"client": токен, "agents": {модуль: токен}}."""
return get(conn, "tokens")
def agent_token(conn, module: str) -> str:
return tokens(conn)["agents"][module]

View File

@ -8,7 +8,7 @@ from urllib.parse import quote
from ...core import db, files, queue, settings
from ...core.errors import CommandError, HttpMapped
from ...core.http import ApiError, Raw, int_group, int_param, mapped, not_found, project_param
from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param
from . import attachments, fetch, history, mirror, refresh
from . import projects as jira_projects

View File

@ -72,8 +72,9 @@ def h_page(hub, conn, m, q, body):
if text.startswith("---\n"): # front matter копии клиенту не нужен
end = text.find("\n---\n", 4)
text = text[end + 5:].lstrip("\n") if end != -1 else text
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"]} for a in conn.execute(
"SELECT id, name, state, size FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))]
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"],
"error": a["error"]} for a in conn.execute(
"SELECT id, name, state, size, error FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))]
return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"],
"markdown": text, "attachments": atts}

View File

@ -1,6 +1,5 @@
"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»).
Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит."""
import json
from urllib.parse import quote
from ...core import db, files, queue

View File

@ -9,7 +9,7 @@ from mcp.server.fastmcp import FastMCP
from ...core import mcp as core
from ...core.client import HubError
from . import projects as wiki_projects
from . import roots as wiki_roots
POLL_SECONDS = 2
ATTACHMENT_WAIT = 60
@ -228,12 +228,12 @@ def root_set(root: str) -> str:
"""Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя
копия остаётся до её успеха."""
try:
code = wiki_projects.parse_root(root)
code = wiki_roots.parse_root(root)
except ValueError as e:
return f"корень не задан: {e}; пример: root_set DOC-123"
if code is None:
return "корень не задан: укажите код DOC-… или ссылку на документ Eva"
out = core.client().patch(f"/projects/{core.PROJECT}", {"wiki_root": code, "author": core.AUTHOR})
out = core.client().patch(f"/projects/{quote(core.PROJECT, safe='')}", {"wiki_root": code, "author": core.AUTHOR})
ids = out.get("batch_ids") or []
return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids
else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)")
@ -244,7 +244,7 @@ def project_create(label: str, name: str, wiki_root: str = "") -> str:
"""Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki."""
try:
code = wiki_projects.parse_root(wiki_root)
code = wiki_roots.parse_root(wiki_root)
except ValueError as e:
return f"проект не заведён: {e}"
return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki")

View File

@ -11,10 +11,10 @@ def root(conn) -> Path:
def dir_name(label: str) -> str:
"""Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а «.» и «..» закрыты, чтобы метка не выводила
"""Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а ведущая «.» заменена на %2E, чтобы метка не выводила
за каталог вики."""
name = label.replace("%", "%25").replace("/", "%2F").replace("\\", "%5C")
return {".": "%2E", "..": "%2E%2E"}.get(name, name)
return "%2E" + name[1:] if name.startswith(".") else name
def project_dir(conn, label: str) -> Path:

View File

@ -1,29 +1,16 @@
"""Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта."""
import glob
import logging
import re
import shutil
from ...core import db, queue
from ...core.modules import ProjectField
from . import commands, paths
from .roots import parse_root, parse_root_edit # noqa: F401 parse_root — прежнее имя для импортёров
log = logging.getLogger(__name__)
_CODE = re.compile(r"(?<![A-Za-z0-9])DOC-(\d+)", re.IGNORECASE)
def parse_root(value) -> str | None:
"""Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError."""
if value is None or value == "":
return None
m = _CODE.search(value) if isinstance(value, str) else None
if m is None:
raise ValueError("ожидается код DOC-… или ссылка на документ Eva")
return f"DOC-{m.group(1)}"
FIELDS = (ProjectField("wiki_root", parse_root, editable=True),)
FIELDS = (ProjectField("wiki_root", parse_root, editable=True, validate_edit=parse_root_edit),)
def view(conn, labels: list[str]) -> dict[str, dict]:
@ -60,13 +47,9 @@ def _cancel_queued(conn, label: str) -> None:
queue.cancel_batch(conn, batch_id)
def _has_table(conn, name: str) -> bool:
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None
def _drop_copy(conn, label: str) -> None:
"""Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии."""
if _has_table(conn, "wiki_page_fts"):
if db.has_table(conn, "wiki_page_fts"):
conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,))
conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,))
conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,))

View File

@ -0,0 +1,21 @@
"""Разбор корня вики: чистые функции без обращения к базе и очереди (их безопасно импортирует и MCP)."""
import re
_CODE = re.compile(r"(?<![A-Za-z0-9])DOC-(\d+)", re.IGNORECASE)
def parse_root(value) -> str | None:
"""Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError."""
if value is None or value == "":
return None
m = _CODE.search(value) if isinstance(value, str) else None
if m is None:
raise ValueError("ожидается код DOC-… или ссылка на документ Eva")
return f"DOC-{m.group(1)}"
def parse_root_edit(value) -> str | None:
"""Правка корня (PATCH): сбрасывает только null; пустая строка — ошибка, чтобы случайное "" не удаляло копию (M-6)."""
if value == "":
raise ValueError("пустая строка не сбрасывает корень: передайте null")
return parse_root(value)

View File

@ -3,6 +3,7 @@ import logging
import re
import sqlite3
from ...core import db
from . import paths
log = logging.getLogger(__name__)
@ -11,14 +12,10 @@ _WORD = re.compile(r"\w+")
_warned = False
def _has_table(conn) -> bool:
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() is not None
def ensure(conn) -> bool:
"""Создать индекс, если его нет. False — FTS5 недоступен (одно предупреждение в лог на процесс)."""
global _warned
if _has_table(conn):
if db.has_table(conn, "wiki_page_fts"):
return True
try:
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, "
@ -52,7 +49,7 @@ def query(conn, label: str, q: str, limit: int) -> list[dict]:
if match is None:
return []
pages = {r["code"]: r for r in conn.execute("SELECT code, title, path FROM wiki_page WHERE label = ?", (label,))}
if _has_table(conn):
if db.has_table(conn, "wiki_page_fts"):
try:
rows = conn.execute("SELECT code, title, snippet(wiki_page_fts, 3, '', '', '…', 16) AS snippet "
"FROM wiki_page_fts WHERE wiki_page_fts MATCH ? AND label = ? ORDER BY rank LIMIT ?",

View File

@ -23,10 +23,23 @@ def _text(page, key, code, required=True, nullable=False):
return v if v.strip() else None # пустое необязательное поле (parent_code "") — как отсутствующее
def _clean(v):
"""Строки с одиночным суррогатом (JSON \\ud83d без пары) не кодируются в UTF-8 и роняли запись стадии: заменяем
их на U+FFFD по всей странице (M-8 финального ревью)."""
if isinstance(v, str):
return v.encode("utf-8", "replace").decode("utf-8")
if isinstance(v, list):
return [_clean(x) for x in v]
if isinstance(v, dict):
return {k: _clean(x) for k, x in v.items()}
return v
def _check(i, page) -> dict:
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
if not isinstance(page, dict):
raise ApiError(422, "bad_pages", f"страница {i}: не объект")
page = _clean(page)
code = page.get("code")
if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]:
raise ApiError(422, "bad_pages", f"страница {i}: code")

View File

@ -11,7 +11,7 @@ from datetime import timedelta
import pytest
from jira_hub.core import api, db, files, projects, queue, release, settings
from jira_hub.core import api, db, files, queue, release, settings
from jira_hub.core.client import HubClient, HubError
from jira_hub.modules.jira import api as jira_api
from jira_hub.modules.jira.commands import CommandError

View File

@ -145,7 +145,7 @@ def test_batch_wait_timeout(monkeypatch):
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
fake = FakeClient(["running"])
monkeypatch.setattr(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
_fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked))
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
assert fake.calls == minutes

View File

@ -2,7 +2,7 @@ import hashlib
import pytest
from jira_hub.core import api, db, files, projects, queue, settings
from jira_hub.core import api, db, files, queue, settings
from jira_hub.modules.jira import attachments, commands, mirror, refresh
from jira_hub.modules.jira import fetch as jira_fetch
from jira_hub.modules.jira import projects as jira_projects

View File

@ -62,7 +62,7 @@ def test_status_tree_page(api):
p = api.client.get("/wiki/pages/DOC-3", project="AWPCA")
assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"]
assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---")
assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None}]
assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None}]
assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404
@ -216,3 +216,15 @@ def test_search_results(api):
first_sync(api)
r = api.client.get("/wiki/search", project="AWPCA", q="согласов")["results"]
assert [x["code"] for x in r] == ["DOC-3"] and set(r[0]) == {"code", "title", "path", "snippet"}
def test_page_attachment_failed_error_visible(api):
"""M-15: ответ страницы несёт error у вложения, текст ошибки виден без клика."""
aid = synced(api)
c = api.hub.conn()
try:
c.execute("UPDATE wiki_attachment SET state = 'failed', error = 'Eva: 404' WHERE id = ?", (aid,))
finally:
c.close()
att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0]
assert att["state"] == "failed" and att["error"] == "Eva: 404"

View File

@ -226,7 +226,7 @@ def test_sync_nowait_and_in_progress(hub):
def test_sync_wait(hub):
done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]}
fake = hub({("POST", "/wiki/sync"): {"batch_id": 5},
hub({("POST", "/wiki/sync"): {"batch_id": 5},
"/batches/5": [{"id": 5, "status": "running", "commands": []}, done],
"/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}})
out = asyncio.run(wmcp.sync(wait=True))
@ -304,3 +304,12 @@ def test_projects_table_and_create(hub):
created = wmcp.project_create("n", "Н", "DOC-5")
assert "jira-hub-wiki" in created
assert fake.calls[-1][2]["wiki_root"] == "DOC-5"
def test_root_set_quotes_label(hub):
"""M-4: метка со спецсимволами кодируется в адресе PATCH."""
label = "A#B?c%d/e"
fake = hub({("PATCH", "/projects/A%23B%3Fc%25d%2Fe"): {"project": {}, "batch_ids": [5]}}, project=label)
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
assert "#5" in wmcp.root_set("DOC-2")
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"

View File

@ -119,3 +119,33 @@ def test_claimed_sync_is_not_cancelled_by_new_root(hub_server):
conn.close()
c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("queued", "DOC-21")]
def test_patch_root_empty_string_rejected(hub_server):
"""M-6: PATCH {wiki_root: ""} — 422, копия цела; сбрасывает только null; POST с "" — проект без корня."""
c = hub_server["client"]
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
with pytest.raises(HubError) as e:
c.patch("/projects/AWPCA", {"wiki_root": ""})
assert e.value.status == 422 and "wiki_root" in e.value.detail
assert c.get("/projects")["projects"][0]["wiki_root"] == "DOC-1"
out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": ""})
assert out["project"]["wiki_root"] is None
assert c.patch("/projects/AWPCA", {"wiki_root": None})["project"]["wiki_root"] is None
def test_wiki_mcp_imports_no_db_or_queue():
"""M-5: mcp.py и roots.py вики не импортируют core.db и core.queue ни прямо, ни через wiki.projects."""
import ast
from pathlib import Path
base = Path(__file__).parent.parent / "src" / "jira_hub" / "modules" / "wiki"
for name in ("mcp.py", "roots.py"):
tree = ast.parse((base / name).read_text(encoding="utf-8"))
mods = []
for n in ast.walk(tree):
if isinstance(n, ast.ImportFrom):
mods += [f"{'.' * n.level}{n.module or ''}.{a.name}" for a in n.names]
elif isinstance(n, ast.Import):
mods += [a.name for a in n.names]
bad = [m for m in mods if m.endswith((".db", ".queue", ".projects")) or ".core.db" in m or ".core.queue" in m]
assert not bad, (name, bad)

View File

@ -606,3 +606,25 @@ def test_housekeeping_ensures_fts(open_db, tmp_path):
assert not conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone()
tick.housekeeping_tick(None, conn)
assert conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone()
def test_lone_surrogate_in_page_does_not_break_sync(api):
"""M-8: одиночный суррогат (JSON-эскейп \\ud83d без пары) в html/title не роняет запись стадии (раньше 500)."""
import urllib.request
bad = [page("DOC-1", "Корень \ud83d", html="<p>текст \ud83d конец</p>")]
cmd = api.claim()
body = json.dumps({"command_id": cmd["id"], "pages": bad}).encode("ascii") # ensure_ascii: эскейп остаётся
req = urllib.request.Request(api.base + "/agent/wiki/pages", data=body, method="POST",
headers={"Authorization": "Bearer " + api.wiki.token, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
assert r.status == 200
api.report(cmd, "done", {"root_title": "К", "codes": ["DOC-1"], "eva_calls": 1})
assert api.rows("SELECT title FROM wiki_page WHERE code = 'DOC-1'")[0]["title"] == "Корень ?" # кодек заменяет суррогат на «?»
def test_dir_name_escapes_leading_dot():
"""M-14: метка с ведущей точкой не даёт каталог вида .X.new.5 / .X.old, который уборка при старте сочла бы своим."""
from jira_hub.modules.wiki import paths
assert paths.dir_name(".X.new.5") == "%2EX.new.5" and paths.dir_name(".") == "%2E" and paths.dir_name("..") == "%2E."
assert not paths.dir_name(".X.old").startswith(".") and paths.dir_name("A.B") == "A.B"

4
uv.lock generated
View File

@ -282,6 +282,7 @@ name = "jira-hub"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "markdownify" },
{ name = "mcp" },
]
@ -293,7 +294,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "markdownify", specifier = ">=1.2.3" },
{ name = "beautifulsoup4", specifier = ">=4.12" },
{ name = "markdownify", specifier = ">=1.2.3,<2" },
{ name = "mcp", specifier = ">=1.2,<2" },
]