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:
parent
a51c509a87
commit
4a410d4b08
34
src/agent.py
34
src/agent.py
@ -238,6 +238,20 @@ class EvaError(Exception):
|
||||
"""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):
|
||||
"""Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова."""
|
||||
PAGE_SIZE = 200
|
||||
@ -325,10 +339,18 @@ class EvaClient(Http):
|
||||
offset += self.PAGE_SIZE
|
||||
|
||||
def download(self, url: str, limit: int) -> bytes:
|
||||
"""Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста."""
|
||||
if _origin(url) != _origin(self.base) or not url.startswith(self.base):
|
||||
raise ValueError("чужой хост")
|
||||
return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit))
|
||||
"""Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста
|
||||
(иначе ForeignHost); путь и запрос перед отправкой кодируются (адрес из HTML бывает с пробелами и кириллицей)."""
|
||||
try:
|
||||
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):
|
||||
@ -870,8 +892,8 @@ class Executor:
|
||||
return self._out("failed", error="wiki_fetch: не задан url")
|
||||
try:
|
||||
data = self.eva.download(url, MAX_ATTACHMENT)
|
||||
except ValueError:
|
||||
return self._out("failed", error="чужой хост")
|
||||
except ForeignHost as e:
|
||||
return self._out("failed", error=str(e))
|
||||
except TooBig:
|
||||
return self._out("failed", error="файл больше 10 МБ — откройте в Eva")
|
||||
except HttpError as e:
|
||||
|
||||
@ -101,10 +101,25 @@ class EvaClientTest(unittest.TestCase):
|
||||
self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret")
|
||||
|
||||
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.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):
|
||||
limit = 10 * 1024 * 1024
|
||||
self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False)
|
||||
|
||||
@ -44,6 +44,49 @@ class WikiFetchTest(unittest.TestCase):
|
||||
self.assertEqual(self.eva.requests, [])
|
||||
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):
|
||||
self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт
|
||||
out = self.fetch(self.file_url())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user