funora 0.0.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_extract.py ADDED
@@ -0,0 +1,124 @@
1
+ """Чтение атрибута как наблюдаемого значения.
2
+
3
+ Файл появился по той же причине, что и `_host.py`: правило нашлось написанным в
4
+ трёх местах. Две копии были одинаковы дословно, третью я дописал сам - и она
5
+ разошлась с ними в первый же час, потому что различала на одно состояние больше.
6
+
7
+ Различает она вот что. Атрибута нет вовсе и атрибут есть, но пуст - разные вещи.
8
+ Первое означает «площадка не дала», второе - «площадка дала пустое», и обе
9
+ прежние копии сводили их в пустое значение. Для адреса разница видна глазом:
10
+ `<a href="">` перезагружает текущую страницу, а `<a>` без адреса ссылкой не
11
+ является вовсе.
12
+
13
+ Правило одно на весь пакет и живёт здесь.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from selectolax.parser import HTMLParser, Node
21
+
22
+ from ._observed import Observed, Presence
23
+ from .extraction import ATTRIBUTES, SELECTORS
24
+
25
+ __all__ = ["attribute", "text"]
26
+
27
+
28
+ def text(node: Node | None, field_name: str) -> Observed[str]:
29
+ """Читает подпись, различая отсутствие узла и пустой текст."""
30
+ if node is None:
31
+ return Observed.missing(f"selector_no_match:{field_name}")
32
+ value = " ".join((node.text() or "").split())
33
+ return Observed.present(value) if value else Observed.empty("")
34
+
35
+
36
+ def attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
37
+ """Извлекает значение атрибута как наблюдаемое значение.
38
+
39
+ Различаются три исхода, и все три вызывающему нужны. Селектор не нашёл узла:
40
+ искать атрибут не у чего. Узел есть, атрибута нет: площадка его не дала.
41
+ Атрибут есть и пуст: площадка дала пустое - факт о странице, а не о нашем
42
+ незнании.
43
+
44
+ Тип Observed обещает, что PRESENT - это непустое значение, и собирать его в
45
+ состоянии, которое он сам себе запрещает, значит отбирать у вызывающего
46
+ единственный способ отличить «адрес есть» от «атрибут пуст».
47
+
48
+ Args:
49
+ node (Node | None): Узел либо None, если селектор не нашёл ничего.
50
+ name (str): Имя атрибута.
51
+ field_name (str): Имя поля, попадающее в причину отсутствия.
52
+
53
+ Returns:
54
+ Observed[str]: Наблюдение. Причина отсутствия называет, какой из двух
55
+ случаев произошёл: `selector_no_match:` либо `attribute_absent:`.
56
+ """
57
+ if node is None:
58
+ return Observed.missing(f"selector_no_match:{field_name}")
59
+
60
+ attributes = node.attributes or {}
61
+ # Проверяется наличие ключа, а не значение по нему. Разборщик отдаёт None и
62
+ # для атрибута без значения, и для пустого, так что .get() свёл бы «атрибута
63
+ # нет» с «атрибут пуст» - ровно то различие, ради которого файл и написан.
64
+ if name not in attributes:
65
+ return Observed.missing(f"attribute_absent:{field_name}")
66
+
67
+ raw = attributes[name]
68
+ if raw is None:
69
+ # `data-href` без значения и `data-href=""` разборщик не различает, и
70
+ # различать их незачем: оба означают «атрибут есть, значения нет».
71
+ return Observed.empty("")
72
+
73
+ value = raw.strip()
74
+ return Observed.present(value) if value else Observed.empty("")
75
+
76
+
77
+ #: Как выглядит языковая метка по BCP 47.
78
+ #:
79
+ #: Проверка формы, а не перечня: перечень объявлен контрактом и означает другое -
80
+ #: для каких локалей у адаптера есть текстовые шаблоны. Здесь вопрос проще:
81
+ #: похоже ли прочитанное на метку языка вообще.
82
+ _LANGUAGE_TAG = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
83
+
84
+
85
+ def observe_locale(html: str) -> Observed[str]:
86
+ """Читает локаль интерфейса со страницы.
87
+
88
+ Локаль привязана к аккаунту, а не к адресу: запрос с префиксом /en/ отдаёт
89
+ ту же страницу на том же языке. Переключить её запросом нельзя, и
90
+ единственное, что остаётся, - узнать, какая она.
91
+
92
+ Прежде не узнавали вовсе. Страница на чужой локали разбиралась молча, и
93
+ вызывающий получал английские тексты, полагая их русскими: поля, которые
94
+ приходят текстом - описание заказа, подпись времени, имя собеседника, -
95
+ возвращаются на том языке, на котором их отдала площадка.
96
+
97
+ Прочитанное проверяется на форму. Атрибут, который есть и языковой меткой
98
+ не выглядит, даёт ненаблюдённое значение, а не локаль: сообщить «интерфейс
99
+ отдан на локали T2:a#1» хуже, чем сказать «локали не видно».
100
+
101
+ Случай не выдуманный. Структурный скелет заменяет значения атрибутов
102
+ подписями, и всякий снимок проекта несёт в lang подпись вместо метки. Разбор
103
+ такого снимка объявлял возможность protocol.locale неподдержанной и писал в
104
+ журнал предупреждение о локали, которой не бывает, - то есть собственные
105
+ фикстуры проекта заставляли его говорить неправду. Проверка формы закрывает
106
+ это для снимков любой версии, включая уже снятые.
107
+
108
+ Args:
109
+ html (str): Разметка страницы.
110
+
111
+ Returns:
112
+ Observed[str]: Локаль либо причина, по которой её не видно. Исходов
113
+ четыре: узла нет, атрибута нет, значение пусто, значение не метка.
114
+ """
115
+ node = HTMLParser(html).css_first(SELECTORS["session.locale"])
116
+ observed = attribute(node, ATTRIBUTES["session.locale.attribute"], "locale")
117
+ # Пустой атрибут проверке формы не подлежит: это факт о странице - площадка
118
+ # отдала пустую локаль, - а не мусор в значении. Сводить их значило бы
119
+ # отбирать у вызывающего единственный способ их различить.
120
+ if observed.presence is not Presence.PRESENT:
121
+ return observed
122
+ if _LANGUAGE_TAG.match(observed.value) is None:
123
+ return Observed.missing("locale_not_a_language_tag")
124
+ return observed
@@ -0,0 +1,112 @@
1
+ """Схема фильтров раздела по наблюдённым элементам .lot-fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+ from selectolax.parser import HTMLParser
9
+
10
+ from ._extract import attribute, text
11
+ from ._observed import Observed
12
+ from ._result import Completeness, Defect, Severity
13
+ from .errors import IncompleteResultError, UnsupportedCapabilityError
14
+ from .extraction import SELECTORS
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class FieldOption:
19
+ """Вариант выбора. Пустое value тоже является допустимым значением."""
20
+
21
+ value: Observed[str]
22
+ label_text: Observed[str]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class FieldDefinition:
27
+ """Поле фильтра; kind открыт для новых видов, неизвестный вид - unknown."""
28
+
29
+ field_id: Observed[str]
30
+ input_name: Observed[str]
31
+ label_text: Observed[str]
32
+ kind: str
33
+ options: tuple[FieldOption, ...] = ()
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class FieldSchema:
38
+ """Схема конкретного раздела с явным признаком полноты."""
39
+
40
+ section_id: str
41
+ observed_at: datetime
42
+ completeness: Completeness
43
+ reason: str
44
+ defects: tuple[Defect, ...] = ()
45
+ _fields: tuple[FieldDefinition, ...] = field(default=(), repr=False)
46
+
47
+ def fields(self, *, accept_incomplete: bool = False) -> tuple[FieldDefinition, ...]:
48
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
49
+ raise IncompleteResultError(f"схема полей неполна: {self.reason}")
50
+ return self._fields
51
+
52
+
53
+ def parse_field_schema(html: str, *, section_id: str, observed_at: datetime) -> FieldSchema:
54
+ """Читает choice и range; новый вид сохраняется как неполный результат."""
55
+ tree = HTMLParser(html)
56
+ container = tree.css_first(SELECTORS["catalog.field_schema.container"])
57
+ if container is None:
58
+ raise UnsupportedCapabilityError("на странице раздела нет наблюдённого блока полей")
59
+ fields: list[FieldDefinition] = []
60
+ defects: list[Defect] = []
61
+ seen: set[str] = set()
62
+ for index, node in enumerate(container.css(SELECTORS["catalog.field_schema.fields"])):
63
+ field_id = attribute(node, "data-id", "field_id")
64
+ input_name = attribute(
65
+ node.css_first(SELECTORS["catalog.field_schema.input"]), "name", "input_name"
66
+ )
67
+ choice = node.css_first(SELECTORS["catalog.field_schema.kinds.choice"])
68
+ range_box = node.css_first(SELECTORS["catalog.field_schema.kinds.range"])
69
+ kind = "choice" if choice is not None else "range" if range_box is not None else "unknown"
70
+ options = (
71
+ tuple(
72
+ FieldOption(attribute(button, "value", "value"), text(button, "option_label"))
73
+ for button in choice.css(SELECTORS["catalog.field_schema.kinds.choice.options"])
74
+ )
75
+ if choice is not None
76
+ else ()
77
+ )
78
+ problems: list[str] = []
79
+ if not field_id.or_none() or not input_name.or_none():
80
+ problems.append("field_identifier_missing")
81
+ name = input_name.or_none()
82
+ if name:
83
+ if name in seen:
84
+ problems.append("duplicate_input_name")
85
+ seen.add(name)
86
+ if kind == "unknown" or (choice is not None and range_box is not None):
87
+ problems.append("field_kind_unrecognized")
88
+ if choice is not None and (
89
+ not options
90
+ or any(not option.value.is_observed for option in options)
91
+ or len({option.value.or_none() for option in options}) != len(options)
92
+ ):
93
+ problems.append("choice_options_unusable")
94
+ for code in problems:
95
+ defects.append(Defect(severity=Severity.ROW, code=code, detail=code, row_index=index))
96
+ fields.append(
97
+ FieldDefinition(
98
+ field_id,
99
+ input_name,
100
+ text(node.css_first(SELECTORS["catalog.field_schema.label"]), "label"),
101
+ kind,
102
+ options,
103
+ )
104
+ )
105
+ return FieldSchema(
106
+ section_id,
107
+ observed_at,
108
+ Completeness.PARTIAL if defects else Completeness.COMPLETE,
109
+ "field_defects" if defects else "all_fields_parsed",
110
+ tuple(defects),
111
+ tuple(fields),
112
+ )
funora/_fileio.py ADDED
@@ -0,0 +1,58 @@
1
+ """Атомарная запись и межпроцессная блокировка локальных журналов."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+
13
+ @contextmanager
14
+ def file_lock(path: Path, *, blocking: bool = True) -> Iterator[None]:
15
+ """Блокирует отдельный постоянный файл на время чтения и изменения данных.
16
+
17
+ Файл блокировки не удаляется: удаление позволило бы двум процессам
18
+ блокировать разные inode по одному пути. ОС освобождает блокировку при
19
+ завершении процесса, в том числе аварийном.
20
+ """
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ with path.open("a+b") as handle:
23
+ if sys.platform == "win32":
24
+ import msvcrt
25
+
26
+ if os.fstat(handle.fileno()).st_size == 0:
27
+ handle.write(b"\0")
28
+ handle.flush()
29
+ handle.seek(0)
30
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
31
+ try:
32
+ yield
33
+ finally:
34
+ handle.seek(0)
35
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
36
+ else:
37
+ import fcntl
38
+
39
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
40
+ try:
41
+ yield
42
+ finally:
43
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
44
+
45
+
46
+ def atomic_write(path: Path, body: str) -> None:
47
+ """Публикует полностью записанный файл; при отказе сохраняет прежний."""
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
50
+ temporary = Path(name)
51
+ try:
52
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
53
+ handle.write(body)
54
+ handle.flush()
55
+ os.fsync(handle.fileno())
56
+ os.replace(temporary, path)
57
+ finally:
58
+ temporary.unlink(missing_ok=True)
funora/_gate.py ADDED
@@ -0,0 +1,69 @@
1
+ """Ворота возможности: пускать вызов или отказать.
2
+
3
+ Ворота задают два вопроса, а не один, и порядок между ними значения не имеет -
4
+ важно, что задаются оба. Первый: есть ли позитивное свидетельство того, что
5
+ возможности нет. Второй: не требует ли состояние явного включения, которого
6
+ вызывающий не давал.
7
+
8
+ Ошибиться здесь легко ровно одним способом: спросить «работает ли возможность»
9
+ вместо «можно ли звать её прямо сейчас». Это разные вопросы, и у состояния
10
+ experimental ответы на них противоположны. Так и произошло в первой версии
11
+ порождённого модуля возможностей.
12
+
13
+ Отдельно стоит объяснить, почему unknown пропускается. Состояние означает «ещё
14
+ не выяснено», а не «нет». Блокировать по нему значило бы запрещать работу из-за
15
+ собственной неуверенности, и SDK выглядел бы как не умеющий ничего. Позитивным
16
+ свидетельством отсутствия может быть только полученный ответ, а не отсутствие
17
+ ответа.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .capabilities import CAPABILITY_INITIAL, Capability, CapabilityState
23
+ from .errors import ExperimentalCapabilityError, UnsupportedCapabilityError
24
+
25
+ __all__ = ["check_capability"]
26
+
27
+
28
+ def check_capability(
29
+ capability: Capability,
30
+ *,
31
+ state: CapabilityState | None = None,
32
+ opted_in: bool = False,
33
+ ) -> CapabilityState:
34
+ """Проверяет, разрешён ли вызов возможности.
35
+
36
+ Args:
37
+ capability (Capability): Проверяемая возможность.
38
+ state (CapabilityState | None): Текущее состояние. По умолчанию берётся
39
+ начальное из спецификации.
40
+ opted_in (bool): Включил ли вызывающий возможность явно.
41
+
42
+ Returns:
43
+ CapabilityState: Состояние, с которым вызов пропущен.
44
+
45
+ Raises:
46
+ UnsupportedCapabilityError: Если есть позитивное свидетельство того, что
47
+ возможности нет.
48
+ ExperimentalCapabilityError: Если состояние требует явного включения, а
49
+ вызывающий его не дал. Контракт такой возможности может измениться,
50
+ и молча его принимать нельзя за того, кто про это не знает.
51
+ """
52
+ current = state if state is not None else CAPABILITY_INITIAL[capability]
53
+
54
+ # Решение берётся у порождённого allows_call, а не собирается здесь заново.
55
+ # Правило объявлено нормативным в spec/capabilities.yaml, и второй его
56
+ # экземпляр разошёлся бы с первым молча: состояния те же, поведение разное.
57
+ if current.allows_call(opted_in=opted_in):
58
+ return current
59
+
60
+ if current.opt_in_required:
61
+ raise ExperimentalCapabilityError(
62
+ f"возможность {capability.value} экспериментальна: контракт может "
63
+ "измениться. Включите её явно, если готовы к этому"
64
+ )
65
+
66
+ raise UnsupportedCapabilityError(
67
+ f"возможность {capability.value} отсутствует по наблюдению. "
68
+ "Это не догадка: состояние выставляется только по полученному ответу"
69
+ )
funora/_hops.py ADDED
@@ -0,0 +1,101 @@
1
+ """Решение о следующем переходе, отделённое от способа сходить в сеть.
2
+
3
+ Модуль появился ради асинхронного транспорта. Без него правило переходов
4
+ пришлось бы написать второй раз - а именно оно однажды уже стоило сессионного
5
+ ключа, и оно же в проекте уже жило трижды и по-разному (см. [_host.py]).
6
+ Копия правила безопасности расходится ровно так же, как любая другая, но цена
7
+ расхождения здесь - чужой доступ к аккаунту.
8
+
9
+ Функция чистая: на вход - то, что видно в ответе, на выход - решение. Ни
10
+ синхронный, ни асинхронный транспорт не знают, как оно принято, и оба обязаны
11
+ поступить одинаково.
12
+
13
+ Решений три, и они не сводятся к двум.
14
+
15
+ ``Stop`` - переходов больше нет либо исчерпан предел. Ответ, который на руках,
16
+ и есть конечный.
17
+
18
+ ``Follow`` - переход проверен, секрет можно отправить дальше.
19
+
20
+ ``Reject`` - переход уводит с ожидаемого хоста либо понижает схему. Запрос туда
21
+ не отправляется вовсе: проверка после отправки бесполезна, секрет уже ушёл бы.
22
+ Отвергнутый адрес всё равно объявляется конечным - это не мелочь, а разница
23
+ между диагнозом «нас пытались увести» и диагнозом «разметка изменилась».
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from urllib.parse import urljoin
30
+
31
+ from ._host import is_safe_hop
32
+
33
+ __all__ = ["Stop", "Follow", "Reject", "Hop", "next_hop"]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Stop:
38
+ """Переходов больше нет: ответ на руках конечный."""
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Follow:
43
+ """Переход проверен и разрешён.
44
+
45
+ Attributes:
46
+ url (str): Адрес, по которому можно отправить запрос вместе с секретом.
47
+ """
48
+
49
+ url: str
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Reject:
54
+ """Переход отвергнут: секрет туда не уходит.
55
+
56
+ Attributes:
57
+ url (str): Адрес, куда нас пытались увести. Объявляется конечным, чтобы
58
+ классификатор увидел чужой хост и поставил диагноз wrong_identity, а
59
+ не unknown по пустому телу.
60
+ """
61
+
62
+ url: str
63
+
64
+
65
+ #: Любое из трёх решений.
66
+ Hop = Stop | Follow | Reject
67
+
68
+
69
+ def next_hop(
70
+ *,
71
+ current: str,
72
+ is_redirect: bool,
73
+ location: str,
74
+ redirects: int,
75
+ max_redirects: int,
76
+ expected: str,
77
+ ) -> Hop:
78
+ """Решает, что делать с полученным ответом.
79
+
80
+ Args:
81
+ current (str): Адрес, на который был отправлен запрос.
82
+ is_redirect (bool): Является ли ответ перенаправлением.
83
+ location (str): Значение заголовка Location. Пустая строка, если его нет.
84
+ redirects (int): Сколько переходов уже выполнено.
85
+ max_redirects (int): Предел числа переходов.
86
+ expected (str): Ожидаемый хост площадки.
87
+
88
+ Returns:
89
+ Hop: Stop, Follow либо Reject.
90
+ """
91
+ if not is_redirect or redirects >= max_redirects:
92
+ return Stop()
93
+ if not location:
94
+ # Перенаправление без адреса перехода. Идти некуда, и придумывать адрес
95
+ # самим - последнее, чем стоит заниматься с секретом в руках.
96
+ return Stop()
97
+
98
+ target = urljoin(current, location)
99
+ if not is_safe_hop(current, target, expected):
100
+ return Reject(target)
101
+ return Follow(target)
funora/_host.py ADDED
@@ -0,0 +1,120 @@
1
+ """Правило «тот ли это хост».
2
+
3
+ Файл появился после разбора, который нашёл это правило написанным в проекте
4
+ трижды и по-разному: верно в классификаторе, подстрокой в разборе переписки и
5
+ отсутствующим в транспорте. Последнее стоило сессионного ключа: переход по
6
+ заголовку Location уводил запрос на чужой адрес вместе с секретом.
7
+
8
+ Сравнение подстрокой особенно коварно тем, что выглядит работающим. Адрес
9
+ ``https://funpay.com.evil.example/`` содержит ``funpay.com`` и проходит такую
10
+ проверку, а ведёт совсем не туда.
11
+
12
+ Правило одно на весь пакет и живёт здесь: разъехаться трём копиям было легко,
13
+ одной копии - не с чем.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Final
20
+ from urllib.parse import urlparse
21
+
22
+ __all__ = ["host_of", "same_host", "is_safe_hop"]
23
+
24
+
25
+ #: Имя хоста, каким его допускает DNS.
26
+ #:
27
+ #: Метки из букв, цифр и дефисов, разделённые точками. Всё остальное именем
28
+ #: хоста не является, и притворяться им не должно.
29
+ _HOSTNAME_RE: Final[re.Pattern[str]] = re.compile(
30
+ r"^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))*\.?$"
31
+ )
32
+
33
+
34
+ def host_of(url: str) -> str:
35
+ """Извлекает хост из адреса.
36
+
37
+ Возвращает пустую строку, если разобранное имя хоста именем хоста не
38
+ является. Проверка не косметическая, и вот почему.
39
+
40
+ Питон разбирает адрес по RFC 3986, браузер - по правилам WHATWG, и в одном
41
+ месте они расходятся: обратная косая черта. Для питона
42
+ ``https://evil.example\\.funpay.com/`` имеет хост
43
+ ``evil.example\\.funpay.com``, и правило «оканчивается на .funpay.com»
44
+ признаёт такой адрес своим. Браузер по тому же адресу идёт на
45
+ ``evil.example``, а всё остальное считает путём.
46
+
47
+ Расхождение стоит дорого дважды. Ссылка в переписке проходит как своя,
48
+ хотя ведёт к чужому. Путь такой ссылки сохраняется в снимке дословно, как
49
+ сохраняются пути площадки, - и написанное там имя уходит в открытый
50
+ репозиторий, вместо того чтобы стать ``{t}``.
51
+
52
+ Args:
53
+ url (str): Адрес.
54
+
55
+ Returns:
56
+ str: Хост без порта, в нижнем регистре. Пустая строка, если хоста нет
57
+ либо разобранное имя недопустимо в DNS.
58
+ """
59
+ host = (urlparse(url).hostname or "").lower()
60
+ if not host or not _HOSTNAME_RE.match(host):
61
+ return ""
62
+ return host
63
+
64
+
65
+ def same_host(url: str, expected: str) -> bool:
66
+ """Сообщает, принадлежит ли адрес ожидаемому хосту.
67
+
68
+ Поддомен ожидаемого хоста считается своим: у площадки есть поддомены, и
69
+ отвергать их значило бы отвергать её же страницы. Сравнение идёт по границе
70
+ точки, а не подстрокой: адрес вида ``funpay.com.evil.example`` содержит
71
+ ожидаемый хост как подстроку и ведёт не туда.
72
+
73
+ Args:
74
+ url (str): Проверяемый адрес.
75
+ expected (str): Ожидаемый хост. Порт, если он есть, отбрасывается.
76
+
77
+ Returns:
78
+ bool: True, если адрес принадлежит ожидаемому хосту либо его поддомену.
79
+ """
80
+ want = expected.split("@")[-1].split(":")[0].strip().lower().rstrip(".")
81
+ if not want or not _HOSTNAME_RE.match(want):
82
+ return False
83
+
84
+ # host_of отвергает имена, недопустимые в DNS, и пустая строка здесь
85
+ # означает «своим это считать нельзя». Направление отказа безопасное во всех
86
+ # трёх местах, где правило применяется: секрет не уходит, ссылка считается
87
+ # чужой, путь маскируется целиком.
88
+ actual = host_of(url).rstrip(".")
89
+ if not actual:
90
+ return False
91
+
92
+ return actual == want or actual.endswith("." + want)
93
+
94
+
95
+ def is_safe_hop(current: str, target: str, expected: str) -> bool:
96
+ """Решает, можно ли отправить секрет по следующему переходу.
97
+
98
+ Два условия, и оба обязательны.
99
+
100
+ Целевой адрес принадлежит ожидаемому хосту. Иначе секрет уходит туда, где
101
+ ему делать нечего, и одного заголовка Location достаточно для угона
102
+ аккаунта.
103
+
104
+ Схема не понижается. Переход с https на http отдаёт секрет открытым текстом
105
+ любому, кто видит трафик, и заметить это по поведению клиента невозможно.
106
+
107
+ Args:
108
+ current (str): Адрес, с которого выполняется переход.
109
+ target (str): Адрес перехода.
110
+ expected (str): Ожидаемый хост площадки.
111
+
112
+ Returns:
113
+ bool: True, если переход безопасен.
114
+ """
115
+ if not same_host(target, expected):
116
+ return False
117
+
118
+ was_secure = urlparse(current).scheme == "https"
119
+ goes_secure = urlparse(target).scheme == "https"
120
+ return goes_secure or not was_secure