wiki_fetch: кодирование адреса и отдельное исключение ForeignHost (I-2, T5.2, #1)

Адрес вложения из HTML бывает с пробелами и кириллицей: путь и запрос теперь кодируются перед запросом, готовые %XX
не кодируются повторно, одиночный % становится %25. Отказ по хосту — ForeignHost («чужой хост» или «некорректный
адрес»), а не голый ValueError: UnicodeEncodeError и прочие ValueError больше не маскируются под «чужой хост».
Добавлены тесты на похожий хост, userinfo, другой порт и схему.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 05:48:14 +04:00
parent a51c509a87
commit 4a410d4b08
3 changed files with 87 additions and 7 deletions

View File

@ -238,6 +238,20 @@ class EvaError(Exception):
"""JSON-RPC Eva вернула `error`: текст «<метод>: <error>».""" """JSON-RPC Eva вернула `error`: текст «<метод>: <error>»."""
class ForeignHost(Exception):
"""Адрес вложения не на хосте Eva (или не разбирается): токен туда не отправляется, запроса нет."""
_URL_SAFE = "/%:@!$&'()*+,;=-._~"
_STRAY_PERCENT = re.compile(r"%(?![0-9A-Fa-f]{2})")
def _quote_url_part(part: str, extra_safe: str = "") -> str:
"""Процентная кодировка пути/запроса для HTTP: пробелы и не-ASCII кодируются, готовые %XX остаются как есть,
одиночный `%` без двух шестнадцатеричных цифр становится %25 (I-2)."""
return urllib.parse.quote(_STRAY_PERCENT.sub("%25", part), safe=_URL_SAFE + extra_safe)
class EvaClient(Http): class EvaClient(Http):
"""Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова.""" """Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова."""
PAGE_SIZE = 200 PAGE_SIZE = 200
@ -325,10 +339,18 @@ class EvaClient(Http):
offset += self.PAGE_SIZE offset += self.PAGE_SIZE
def download(self, url: str, limit: int) -> bytes: def download(self, url: str, limit: int) -> bytes:
"""Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста.""" """Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста
if _origin(url) != _origin(self.base) or not url.startswith(self.base): (иначе ForeignHost); путь и запрос перед отправкой кодируются (адрес из HTML бывает с пробелами и кириллицей)."""
raise ValueError("чужой хост") try:
return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) same = _origin(url) == _origin(self.base)
except ValueError: # `http://[::1`, порт не числом
raise ForeignHost("некорректный адрес") from None
rest = url[len(self.base):] if url.startswith(self.base) else None
if not same or rest is None or rest[:1] not in ("", "/", "?"):
raise ForeignHost("чужой хост")
path, _, query = rest.split("#", 1)[0].partition("?")
path = _quote_url_part(path) + ("?" + _quote_url_part(query, "?") if query else "")
return self._retrying(lambda: self.fetch_bytes(path, limit))
class _ParseError(Exception): class _ParseError(Exception):
@ -870,8 +892,8 @@ class Executor:
return self._out("failed", error="wiki_fetch: не задан url") return self._out("failed", error="wiki_fetch: не задан url")
try: try:
data = self.eva.download(url, MAX_ATTACHMENT) data = self.eva.download(url, MAX_ATTACHMENT)
except ValueError: except ForeignHost as e:
return self._out("failed", error="чужой хост") return self._out("failed", error=str(e))
except TooBig: except TooBig:
return self._out("failed", error="файл больше 10 МБ — откройте в Eva") return self._out("failed", error="файл больше 10 МБ — откройте в Eva")
except HttpError as e: except HttpError as e:

View File

@ -101,10 +101,25 @@ class EvaClientTest(unittest.TestCase):
self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret") self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret")
def test_download_foreign_host(self): def test_download_foreign_host(self):
with self.assertRaises(ValueError): with self.assertRaises(agent.ForeignHost):
self.cli.download("http://example.invalid/files/a.bin", 10) self.cli.download("http://example.invalid/files/a.bin", 10)
self.assertEqual(self.eva.requests, []) self.assertEqual(self.eva.requests, [])
def test_download_lookalikes(self):
"""T5.2: похожий хост, userinfo, другой порт и схема — ForeignHost до запроса (адрес Eva — https://eva.test)."""
cli = agent.EvaClient(c.eva_cfg("https://eva.test"))
cli.fetch_bytes = lambda *a, **kw: self.fail("запроса быть не должно")
for url in ("https://eva.test.evil/files/a.bin", "https://eva.test@evil/files/a.bin",
"https://eva.test:8443/files/a.bin", "http://eva.test/files/a.bin",
"https://eva.testx/files/a.bin", "https://user@eva.test/files/a.bin"):
with self.subTest(url=url):
with self.assertRaises(agent.ForeignHost) as cm:
cli.download(url, 10)
self.assertEqual(str(cm.exception), "чужой хост")
with self.assertRaises(agent.ForeignHost) as cm:
cli.download("https://eva.test:abc/files/a.bin", 10)
self.assertEqual(str(cm.exception), "некорректный адрес")
def test_download_limit(self): def test_download_limit(self):
limit = 10 * 1024 * 1024 limit = 10 * 1024 * 1024
self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False) self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False)

View File

@ -44,6 +44,49 @@ class WikiFetchTest(unittest.TestCase):
self.assertEqual(self.eva.requests, []) self.assertEqual(self.eva.requests, [])
self.assertEqual(self.hub.uploaded, []) self.assertEqual(self.hub.uploaded, [])
def test_fetch_foreign_lookalikes(self):
"""T5.2: похожий хост, userinfo, другой порт и схема — «чужой хост», запроса к Eva нет."""
port = self.eva.base_url.rsplit(":", 1)[1] # http://127.0.0.1:<port>
for url in ("http://127.0.0.1.evil:%s/files/a.bin" % port,
"http://127.0.0.1:%s@evil.test/files/a.bin" % port,
"http://127.0.0.1:%d/files/a.bin" % (int(port) + 1),
"https://127.0.0.1:%s/files/a.bin" % port):
with self.subTest(url=url):
out = self.fetch(url)
self.assertEqual((out["status"], out["error"]), ("failed", "чужой хост"))
self.assertEqual(self.eva.requests, [])
self.assertEqual(self.hub.uploaded, [])
def test_fetch_malformed_url(self):
out = self.fetch("http://[::1/files/a.bin")
self.assertEqual(out["status"], "failed")
self.assertIn("некорректный адрес", out["error"])
self.assertEqual(self.eva.requests, [])
def test_fetch_non_ascii_and_spaces(self):
"""I-2: адрес из HTML с пробелами и кириллицей кодируется перед запросом; %XX не кодируется повторно."""
enc = "/files/obj/x/%D0%9F%D0%9C%D0%9F%20%D1%84%D0%B0%D0%B9%D0%BB.png"
self.eva.add_file(enc, b"png")
for path in ("/files/obj/x/ПМП файл.png", enc, "/files/obj/x/ПМП%20файл.png"):
with self.subTest(path=path):
out = self.fetch(self.file_url(path))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 3)
self.assertEqual([r["path"] for r in self.eva.requests], [enc] * 3)
def test_fetch_stray_percent(self):
self.eva.add_file("/files/100%25.txt", b"ok")
out = self.fetch(self.file_url("/files/100%.txt"))
self.assertEqual(out["status"], "done", out)
def test_value_error_inside_is_not_foreign_host(self):
"""I-2: ValueError внутри скачивания (например, UnicodeEncodeError) не выдаётся за «чужой хост»."""
def boom(*a, **kw):
raise UnicodeEncodeError("ascii", "я", 0, 1, "ordinal not in range")
self.ex.eva.fetch_bytes = boom
with self.assertRaises(UnicodeEncodeError):
self.fetch(self.file_url())
def test_fetch_too_big_header(self): def test_fetch_too_big_header(self):
self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт
out = self.fetch(self.file_url()) out = self.fetch(self.file_url())