s-corekit 0.0.2__tar.gz → 0.0.4__tar.gz
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.
- {s_corekit-0.0.2 → s_corekit-0.0.4}/CHANGELOG.md +18 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/PKG-INFO +1 -1
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/__init__.py +3 -2
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/context.py +11 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/access.py +14 -0
- s_corekit-0.0.4/corekit/dto.py +149 -0
- s_corekit-0.0.4/corekit/filelock.py +165 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/pyproject.toml +1 -1
- {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_diagnosis_access.py +30 -0
- s_corekit-0.0.4/tests/test_filelock.py +87 -0
- s_corekit-0.0.4/tests/test_session_ref.py +65 -0
- s_corekit-0.0.2/corekit/dto.py +0 -83
- {s_corekit-0.0.2 → s_corekit-0.0.4}/.gitignore +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/LICENSE +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/README.md +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/__init__.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/egress.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/diagnosis/verdict.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/enums.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/errors.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/limits.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/corekit/redaction.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_context.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_errors.py +0 -0
- {s_corekit-0.0.2 → s_corekit-0.0.4}/tests/test_stdlib_only.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# CHANGELOG — s-corekit
|
|
2
2
|
|
|
3
|
+
## [0.0.3] — 2026-08-08
|
|
4
|
+
|
|
5
|
+
### Добавлено
|
|
6
|
+
|
|
7
|
+
- `SessionRef` получил две координаты: `instance` — конкретный вход
|
|
8
|
+
многоинстансного сервиса (портал Битрикса, национальный домен), `stage` —
|
|
9
|
+
стадия сессии (боевая или черновик записи трафика). Обе добавлены В КОНЕЦ и
|
|
10
|
+
с дефолтами, как когда-то `tenant`: позиционный
|
|
11
|
+
`SessionRef(profile, social, account_id)` из чужих навыков и уже записанные
|
|
12
|
+
адреса продолжают работать.
|
|
13
|
+
- `DEFAULT_STAGE` — стадия боевой сессии (`"live"`), дефолт адреса.
|
|
14
|
+
|
|
15
|
+
Зачем. Стадия до сих пор занимала поле `profile`, и два смысла в одном поле
|
|
16
|
+
означали адрес, по которому нельзя спросить «дай боевую сессию ЭТОГО
|
|
17
|
+
аккаунта», не зная заранее, как её назвал пишущий. Порталы одного продукта
|
|
18
|
+
разводились ВТОРЫМ именем сервиса — от этого у аккаунта появлялась вторая
|
|
19
|
+
сессия, о которой никто не знал.
|
|
20
|
+
|
|
3
21
|
## [0.0.2] — 2026-08-04
|
|
4
22
|
|
|
5
23
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: s-corekit
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: ОСНОВАНИЕ китов: ExecutionContext, базовые DTO (SessionRef/Creds/LimitSpec/QuotaInfo), единая иерархия ошибок и чистая классификация состояния доступа (AccessState/AccessVerdict). Только stdlib.
|
|
5
5
|
Author: Dmitry
|
|
6
6
|
License: MIT
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"""
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
|
-
from corekit.context import DEFAULT_TENANT, ExecutionContext
|
|
48
|
+
from corekit.context import DEFAULT_STAGE, DEFAULT_TENANT, ExecutionContext
|
|
49
49
|
from corekit.diagnosis import (
|
|
50
50
|
ACCESS_STATE_MESSAGES,
|
|
51
51
|
REASON_MESSAGES,
|
|
@@ -99,12 +99,13 @@ from corekit.redaction import (
|
|
|
99
99
|
truncate_body,
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
-
__version__ = "0.0.
|
|
102
|
+
__version__ = "0.0.4"
|
|
103
103
|
|
|
104
104
|
__all__ = [
|
|
105
105
|
"__version__",
|
|
106
106
|
# контекст исполнения
|
|
107
107
|
"ExecutionContext",
|
|
108
|
+
"DEFAULT_STAGE",
|
|
108
109
|
"DEFAULT_TENANT",
|
|
109
110
|
# базовые DTO
|
|
110
111
|
"SessionRef",
|
|
@@ -17,6 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
|
+
"DEFAULT_STAGE",
|
|
20
21
|
"DEFAULT_TENANT",
|
|
21
22
|
"ExecutionContext",
|
|
22
23
|
]
|
|
@@ -26,6 +27,16 @@ __all__ = [
|
|
|
26
27
|
#: байт-в-байт совпадают с тем, что было до введения мультитенантности.
|
|
27
28
|
DEFAULT_TENANT = "local"
|
|
28
29
|
|
|
30
|
+
#: Стадия БОЕВОЙ сессии — той, которой работают. Дефолт `SessionRef`: пока
|
|
31
|
+
#: стадию не назвали явно, адрес совпадает с досегодняшним, и все уже
|
|
32
|
+
#: записанные сессии читаются как боевые (чем они и были).
|
|
33
|
+
#:
|
|
34
|
+
#: Вторая стадия — черновик записи трафика (`capture`): вход туда может не
|
|
35
|
+
#: удаться (в окне осталась форма), и такой снимок не должен затирать рабочую
|
|
36
|
+
#: сессию. Стадий ровно две по замыслу: третья означала бы, что мы моделируем
|
|
37
|
+
#: здесь жизненный цикл, а не разводим «проверено» и «ещё нет».
|
|
38
|
+
DEFAULT_STAGE = "live"
|
|
39
|
+
|
|
29
40
|
|
|
30
41
|
#: Сентинел «аргумент не передан» для `ExecutionContext.__init__`: отличает
|
|
31
42
|
#: «поле оставили по умолчанию» от «поле передали, и значение совпало с дефолтом».
|
|
@@ -131,6 +131,14 @@ LOGIN_HOST_MARKERS: tuple[str, ...] = (
|
|
|
131
131
|
"/login?continue=",
|
|
132
132
|
"/oauth/authorize",
|
|
133
133
|
"/auth/login",
|
|
134
|
+
# Ниже — из браузерного кита, где годами жила ВТОРАЯ таблица тех же улик.
|
|
135
|
+
# Копии расходятся молча: одна узнавала увод на вход, другая нет, и вердикт
|
|
136
|
+
# зависел от того, каким путём пошёл вызывающий.
|
|
137
|
+
"/login",
|
|
138
|
+
"/signin",
|
|
139
|
+
"/auth/",
|
|
140
|
+
"passport.", # внешний провайдер входа (Яндекс и подобные)
|
|
141
|
+
"pincode", # экран второго фактора — вход ещё не завершён
|
|
134
142
|
)
|
|
135
143
|
|
|
136
144
|
#: Тело-улики «сервер не знает сессию». Именно ПРЕДЪЯВЛЕННЫЕ и отвергнутые куки,
|
|
@@ -169,6 +177,12 @@ TOKEN_EXPIRED_MARKERS: tuple[str, ...] = (
|
|
|
169
177
|
"unauthenticated",
|
|
170
178
|
"invalid authentication credentials",
|
|
171
179
|
"csrf token",
|
|
180
|
+
"csrf-token",
|
|
181
|
+
"invalid-csrf",
|
|
182
|
+
# Замаскированное протухание csrf: сервис отвечает «неверный параметр»,
|
|
183
|
+
# не называя причину. Улика попала сюда из браузерного кита — там она
|
|
184
|
+
# была снята живьём и служила ровно этому диагнозу.
|
|
185
|
+
"illegalparameterexception",
|
|
172
186
|
"xsrf",
|
|
173
187
|
"sapisidhash",
|
|
174
188
|
"invalid at token",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Базовые DTO-ЗНАЧЕНИЯ основания: координаты сессии, креды, тело RPC-запроса.
|
|
2
|
+
|
|
3
|
+
Критерий, по которому DTO попал сюда, ровно один: он НЕ ТЯНЕТ РЕАЛИЗАЦИЮ. Это
|
|
4
|
+
неизменяемые значения (`frozen=True, slots=True`) — их можно шарить между
|
|
5
|
+
потоками, класть в ключи, логировать (кроме `Creds`) и сериализовать
|
|
6
|
+
``dataclasses.asdict``. Всё, что имеет жизненный цикл (клиенты, сторы,
|
|
7
|
+
браузеры), остаётся слоями выше.
|
|
8
|
+
|
|
9
|
+
ТОЛЬКО stdlib. Перенесено из ``librarykit.contract`` БЕЗ
|
|
10
|
+
изменения формы: порядок полей и дефолты те же, поэтому позиционные вызовы и
|
|
11
|
+
уже записанные данные продолжают работать.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from corekit.context import DEFAULT_STAGE, DEFAULT_TENANT
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Creds",
|
|
23
|
+
"RequestBody",
|
|
24
|
+
"SessionRef",
|
|
25
|
+
"cookies_from_storage_state",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class SessionRef:
|
|
31
|
+
"""Координаты сессии в `SessionStore`: profile / social / account-id.
|
|
32
|
+
|
|
33
|
+
Тройка ``profile``/``social``/``account_id`` однозначно адресует каталог
|
|
34
|
+
сессии ``<profile>/<social>/<account_id>/`` под корнем sessions. Без секретов —
|
|
35
|
+
это только ключ/путь, поэтому безопасно логировать и индексировать.
|
|
36
|
+
|
|
37
|
+
``tenant`` — ЧЕТВЁРТАЯ (последняя, с дефолтом) координата: владелец сессии в
|
|
38
|
+
мультитенантном хосте. Поле добавлено В КОНЕЦ, поэтому позиционное
|
|
39
|
+
``SessionRef(profile, social, account_id)`` и сравнение старых объектов
|
|
40
|
+
продолжают работать.
|
|
41
|
+
|
|
42
|
+
Координата ПОДКЛЮЧЕНА: при ``tenant != DEFAULT_TENANT`` каталог сессии
|
|
43
|
+
получает ведущий сегмент — ``<tenant>/<profile>/<social>/<account_id>/``, а
|
|
44
|
+
первичный ключ индекса — ``(tenant, profile, social, account_id)``. При
|
|
45
|
+
дефолтном ``"local"`` раскладка и ключ байт-в-байт прежние (сегмент не
|
|
46
|
+
добавляется), поэтому существующие сессии читаются как раньше. Пересборка
|
|
47
|
+
индекса из каталога — `SessionStore.reindex` (понимает обе глубины).
|
|
48
|
+
|
|
49
|
+
``instance`` — КОНКРЕТНЫЙ вход сервиса, у которого их много: портал
|
|
50
|
+
Битрикса (``acme.bitrix24.ru``), национальный домен, отдельная установка.
|
|
51
|
+
Продукт при этом один, и разводить такие входы вторым ИМЕНЕМ сервиса
|
|
52
|
+
(``bitrix24-kz``) нельзя: имена размножаются, а у одного аккаунта появляется
|
|
53
|
+
вторая сессия, о которой никто не знает. Пусто — у сервиса один вход, и
|
|
54
|
+
адрес совпадает с прежним.
|
|
55
|
+
|
|
56
|
+
``stage`` — стадия сессии: боевая (:data:`~corekit.context.DEFAULT_STAGE`)
|
|
57
|
+
или черновик записи трафика (``capture``). Стадия обязана быть ОТДЕЛЬНОЙ
|
|
58
|
+
координатой, а не значением ``profile``: профиль отвечает на вопрос «чей
|
|
59
|
+
это вход», стадия — «проверен ли он». Совмещая два смысла в одном поле,
|
|
60
|
+
получаешь адрес, по которому нельзя спросить «дай боевую сессию ЭТОГО
|
|
61
|
+
аккаунта», не зная заранее, как её назвал пишущий.
|
|
62
|
+
|
|
63
|
+
Обе координаты добавлены В КОНЕЦ и с дефолтами — по той же причине, что и
|
|
64
|
+
``tenant``: позиционные вызовы, сравнение старых объектов и уже записанные
|
|
65
|
+
адреса продолжают работать.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
profile: str
|
|
69
|
+
social: str
|
|
70
|
+
account_id: str
|
|
71
|
+
tenant: str = DEFAULT_TENANT
|
|
72
|
+
instance: str = ""
|
|
73
|
+
stage: str = DEFAULT_STAGE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class RequestBody:
|
|
78
|
+
"""Тело RPC-запроса после `Codec.encode_request` (E1.T2): json XOR data.
|
|
79
|
+
|
|
80
|
+
Нейтральный носитель сериализованного payload между codec и транспортом:
|
|
81
|
+
`json` уходит в `Transport.request(json=...)` (httpx сам сериализует), а
|
|
82
|
+
`data` — сырое тело (bytes/str/form) для не-JSON envelope. Заполняется ровно
|
|
83
|
+
одно из двух; `headers` опц. добавляет codec-специфичные заголовки
|
|
84
|
+
(`Content-Type` и т.п.) к запросу.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
json: Any | None = None
|
|
88
|
+
data: Any | None = None
|
|
89
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Creds:
|
|
94
|
+
"""Секреты авторизации одной сессии (НЕ логировать, НЕ сериализовать в индекс).
|
|
95
|
+
|
|
96
|
+
Гибкий контейнер под разные `AuthMode`: токены (token-auth), refresh-токен
|
|
97
|
+
(oauth2), cookies (cookie-сессия), произвольные `extra` (например, подписи).
|
|
98
|
+
Все поля опциональны — заполняется то, что нужно конкретному режиму.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
token: str | None = None
|
|
102
|
+
refresh_token: str | None = None
|
|
103
|
+
cookies: Mapping[str, str] = field(default_factory=dict)
|
|
104
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cookies_from_storage_state(
|
|
108
|
+
state: object,
|
|
109
|
+
*,
|
|
110
|
+
domain_substr: str = "",
|
|
111
|
+
) -> dict[str, str]:
|
|
112
|
+
"""``storage_state`` → плоский ``{имя: значение}``, как хочет HTTP-клиент.
|
|
113
|
+
|
|
114
|
+
ОДИН РАЗБОР НА ВСЕ КИТЫ. Одно и то же преобразование жило копиями: одна
|
|
115
|
+
читала файл и умела фильтр по домену, другая работала со словарём и знала
|
|
116
|
+
про плоскую форму. Копии расходятся молча, а цена расхождения здесь
|
|
117
|
+
несимметрична — лишняя кука уезжает чужому сервису, недостающая ломает
|
|
118
|
+
сессию.
|
|
119
|
+
|
|
120
|
+
Принимает обе формы, потому что обе живут в системе:
|
|
121
|
+
|
|
122
|
+
* Playwright: ``{"cookies": [{"name", "value", "domain", …}]}``;
|
|
123
|
+
* плоскую: ``{имя: значение}`` (так их хранят креды).
|
|
124
|
+
|
|
125
|
+
``domain_substr`` отсекает куки сторонних доменов — их отправка боевому API
|
|
126
|
+
и есть тот запрос, который сервис вправе счесть подозрительным.
|
|
127
|
+
|
|
128
|
+
Здесь, в основании, потому что это чистая работа со структурой: ни сети, ни
|
|
129
|
+
диска, ни знания о сервисе. Кто и где хранит снимок — вопрос сессионного
|
|
130
|
+
слоя, а не этого разбора.
|
|
131
|
+
"""
|
|
132
|
+
if not isinstance(state, Mapping):
|
|
133
|
+
return {}
|
|
134
|
+
raw = state.get("cookies")
|
|
135
|
+
if isinstance(raw, list):
|
|
136
|
+
out: dict[str, str] = {}
|
|
137
|
+
for item in raw:
|
|
138
|
+
if not isinstance(item, Mapping):
|
|
139
|
+
continue
|
|
140
|
+
if domain_substr and domain_substr not in str(item.get("domain") or ""):
|
|
141
|
+
continue
|
|
142
|
+
name, value = item.get("name"), item.get("value")
|
|
143
|
+
if name is not None and value is not None:
|
|
144
|
+
out[str(name)] = str(value)
|
|
145
|
+
return out
|
|
146
|
+
# Плоская форма домена не несёт, поэтому фильтровать по нему нечем: молча
|
|
147
|
+
# вернуть пусто значило бы «сессии нет» там, где она есть.
|
|
148
|
+
flat = raw if isinstance(raw, Mapping) else state
|
|
149
|
+
return {str(k): str(v) for k, v in flat.items() if k != "origins"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Межпроцессный advisory-лок на файле: одна механика на все киты.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ЭТОТ МОДУЛЬ. Одна и та же вещь была написана трижды — в сессионном ките
|
|
4
|
+
(`SessionFileLock`), в пуле аккаунтов (`_AccountLock`) и там же ещё раз под
|
|
5
|
+
строковый ключ (`KeyedProcessLock`, чей докстринг прямо называет себя портом
|
|
6
|
+
второго). Три копии критической секции — это три разных ответа на вопрос «что
|
|
7
|
+
делать с осиротевшим локом»: процесс упал, файл остался, и дальше поведение
|
|
8
|
+
зависит от того, чей код первым дошёл до записи. Для сессии цена такой разницы —
|
|
9
|
+
затёртая чужая ротация кук, то есть потерянный вход.
|
|
10
|
+
|
|
11
|
+
ПОЧЕМУ В ОСНОВАНИИ. Здесь нет ни сети, ни домена, ни знания о сессиях: только
|
|
12
|
+
файл, часы и stdlib. Кит с нулевыми зависимостями — единственное место, куда
|
|
13
|
+
могут смотреть все три прежних владельца, не создавая обратных связей.
|
|
14
|
+
|
|
15
|
+
ЧЕГО ЗДЕСЬ НЕТ. Реентерабельности (её отсутствие намеренно: рекурсивный захват
|
|
16
|
+
скрывает ошибку владения) и очереди справедливости — ожидание опросом, потому
|
|
17
|
+
что критические секции здесь короткие, а зависимостей ради «правильной» очереди
|
|
18
|
+
кит заводить не станет.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
__all__ = ["FileLock", "LockTimeout"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LockTimeout(TimeoutError): # noqa: N818 — подтип TimeoutError, доменное имя
|
|
31
|
+
"""Лок не взят за отведённое время: его держит другой писатель."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileLock:
|
|
35
|
+
"""Эксклюзивный лок через атомарное создание файла (``O_CREAT|O_EXCL``).
|
|
36
|
+
|
|
37
|
+
Атомарность создания даёт и POSIX, и Windows — поэтому внешняя библиотека не
|
|
38
|
+
нужна. Лок держится на всё окно работы, чтобы конкурентные писатели (фоновое
|
|
39
|
+
продление, команда человека, авто-обновление) не затирали друг друга.
|
|
40
|
+
|
|
41
|
+
``stale_after`` ломает ОСИРОТЕВШИЙ лок: процесс упал, файл остался, и без
|
|
42
|
+
этого правила система встаёт намертво до ручной уборки. Значение по
|
|
43
|
+
умолчанию заведомо больше любой честной критической секции — лучше подождать
|
|
44
|
+
лишнее, чем забрать лок у живого писателя.
|
|
45
|
+
|
|
46
|
+
Не реентерабелен: повторный захват тем же владельцем — это почти всегда
|
|
47
|
+
потерянный ``release`` в чужой ветке, и молча его прощать значит однажды
|
|
48
|
+
отпустить лок дважды.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
path: str | os.PathLike[str],
|
|
54
|
+
*,
|
|
55
|
+
timeout: float = 30.0,
|
|
56
|
+
poll: float = 0.05,
|
|
57
|
+
stale_after: float | None = 900.0,
|
|
58
|
+
clock: object | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._path = Path(path)
|
|
61
|
+
self._timeout = timeout
|
|
62
|
+
self._poll = poll
|
|
63
|
+
self._stale_after = stale_after
|
|
64
|
+
#: Источник настенного времени. Инъекция нужна не ради красоты: тест на
|
|
65
|
+
#: перехват осиротевшего лока иначе идёт столько же, сколько порог, —
|
|
66
|
+
#: то есть его не пишут вовсе, и правило остаётся непроверенным.
|
|
67
|
+
self._clock = clock or time.time
|
|
68
|
+
self._fd: int | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def path(self) -> Path:
|
|
72
|
+
return self._path
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def locked(self) -> bool:
|
|
76
|
+
return self._fd is not None
|
|
77
|
+
|
|
78
|
+
def _try_acquire(self) -> bool:
|
|
79
|
+
try:
|
|
80
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
fd = os.open(self._path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
82
|
+
except FileExistsError:
|
|
83
|
+
return False
|
|
84
|
+
# Кто держит и с какого момента — это читают люди, разбирая зависший
|
|
85
|
+
# лок. Без записи остаётся пустой файл, по которому не сказать ничего.
|
|
86
|
+
os.write(fd, f"{os.getpid()}\n{time.time()}".encode())
|
|
87
|
+
self._fd = fd
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def _break_if_stale(self) -> None:
|
|
91
|
+
if self._stale_after is None:
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
age = self._clock() - self._path.stat().st_mtime
|
|
95
|
+
except OSError:
|
|
96
|
+
return
|
|
97
|
+
if age > self._stale_after:
|
|
98
|
+
with contextlib.suppress(OSError):
|
|
99
|
+
self._path.unlink()
|
|
100
|
+
|
|
101
|
+
def acquire(self, *, raising: bool = True) -> bool:
|
|
102
|
+
"""Взять лок (блокирующе, опросом). Возвращает, взят ли он.
|
|
103
|
+
|
|
104
|
+
ДВА ЧЕСТНЫХ ОТВЕТА НА ЗАНЯТЫЙ ЛОК, и выбор за вызывающим:
|
|
105
|
+
|
|
106
|
+
* ``raising=True`` — ``LockTimeout``. Так ведёт себя тот, для кого
|
|
107
|
+
войти в секцию обязательно: писатель сессии, затерев чужую ротацию,
|
|
108
|
+
отбирает у человека вход.
|
|
109
|
+
* ``raising=False`` — ``False`` без исключения. Так ведёт себя тот, кому
|
|
110
|
+
важнее не встать намертво: пул аккаунтов уже держит внутрипроцессный
|
|
111
|
+
лок, и одна возможная гонка между процессами для него дешевле вечной
|
|
112
|
+
блокировки.
|
|
113
|
+
|
|
114
|
+
Разные ответы — не небрежность, а разная цена ошибки; поэтому они
|
|
115
|
+
названы явно, а не разъехались двумя копиями кода.
|
|
116
|
+
"""
|
|
117
|
+
if self._fd is not None:
|
|
118
|
+
return True
|
|
119
|
+
deadline = time.monotonic() + self._timeout
|
|
120
|
+
while True:
|
|
121
|
+
if self._try_acquire():
|
|
122
|
+
return True
|
|
123
|
+
self._break_if_stale()
|
|
124
|
+
if time.monotonic() >= deadline:
|
|
125
|
+
if raising:
|
|
126
|
+
raise LockTimeout(
|
|
127
|
+
f"Лок {self._path} занят другим писателем: не взят за "
|
|
128
|
+
f"{self._timeout:.1f}s."
|
|
129
|
+
)
|
|
130
|
+
return False
|
|
131
|
+
time.sleep(self._poll)
|
|
132
|
+
|
|
133
|
+
def release(self) -> None:
|
|
134
|
+
"""Отпустить лок. Идемпотентно: отпустить дважды — не ошибка."""
|
|
135
|
+
if self._fd is not None:
|
|
136
|
+
with contextlib.suppress(OSError):
|
|
137
|
+
os.close(self._fd)
|
|
138
|
+
self._fd = None
|
|
139
|
+
with contextlib.suppress(OSError):
|
|
140
|
+
self._path.unlink()
|
|
141
|
+
|
|
142
|
+
async def acquire_async(self, *, raising: bool = True) -> bool:
|
|
143
|
+
"""Взять лок, не стопоря event loop: опрос уходит в поток."""
|
|
144
|
+
import asyncio
|
|
145
|
+
|
|
146
|
+
return await asyncio.to_thread(self.acquire, raising=raising)
|
|
147
|
+
|
|
148
|
+
async def release_async(self) -> None:
|
|
149
|
+
import asyncio
|
|
150
|
+
|
|
151
|
+
await asyncio.to_thread(self.release)
|
|
152
|
+
|
|
153
|
+
def __enter__(self) -> FileLock:
|
|
154
|
+
self.acquire()
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def __exit__(self, *exc: object) -> None:
|
|
158
|
+
self.release()
|
|
159
|
+
|
|
160
|
+
async def __aenter__(self) -> FileLock:
|
|
161
|
+
await self.acquire_async()
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
165
|
+
await self.release_async()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "s-corekit"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.4"
|
|
8
8
|
description = "ОСНОВАНИЕ китов: ExecutionContext, базовые DTO (SessionRef/Creds/LimitSpec/QuotaInfo), единая иерархия ошибок и чистая классификация состояния доступа (AccessState/AccessVerdict). Только stdlib."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -130,3 +130,33 @@ def test_verdict_serializes_flat_with_its_evidence():
|
|
|
130
130
|
assert payload["state"] == "session_dead"
|
|
131
131
|
assert payload["why"] == "почему" and payload["evidence"] == "улика"
|
|
132
132
|
assert payload["message"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =========================================================================== #
|
|
136
|
+
# Улики входа и протухания — ОДИН словарь на все киты #
|
|
137
|
+
# =========================================================================== #
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_login_markers_cover_what_the_browser_kit_knew_alone():
|
|
141
|
+
"""Улики увода на вход собраны в основании, а не в копии браузерного кита.
|
|
142
|
+
|
|
143
|
+
Копии расходятся молча: одна таблица узнавала внешний провайдер входа и
|
|
144
|
+
экран второго фактора, другая — хосты Google. Вердикт «сессия мертва»
|
|
145
|
+
зависел от того, каким путём пошёл вызывающий.
|
|
146
|
+
"""
|
|
147
|
+
from corekit.diagnosis.access import LOGIN_HOST_MARKERS
|
|
148
|
+
|
|
149
|
+
for marker in ("accounts.google.com", "/login", "/signin", "passport.", "pincode"):
|
|
150
|
+
assert marker in LOGIN_HOST_MARKERS, f"улика входа {marker!r} потеряна при сведении"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_token_markers_keep_the_masked_csrf_evidence():
|
|
154
|
+
"""Замаскированное протухание csrf: сервис отвечает «неверный параметр».
|
|
155
|
+
|
|
156
|
+
Улика снята живьём в браузерном ките; при сведении таблиц её легко было
|
|
157
|
+
потерять — она не похожа ни на одно слово про токен.
|
|
158
|
+
"""
|
|
159
|
+
from corekit.diagnosis.access import TOKEN_EXPIRED_MARKERS
|
|
160
|
+
|
|
161
|
+
for marker in ("csrf-token", "invalid-csrf", "illegalparameterexception"):
|
|
162
|
+
assert marker in TOKEN_EXPIRED_MARKERS, f"улика {marker!r} потеряна при сведении"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Файловый лок: одна механика на все киты.
|
|
2
|
+
|
|
3
|
+
Раньше та же вещь была написана трижды — в сессионном ките, в пуле аккаунтов и
|
|
4
|
+
там же ещё раз под строковый ключ. Три копии критической секции — это три разных
|
|
5
|
+
ответа на вопрос «что делать с осиротевшим локом». Для сессии цена расхождения:
|
|
6
|
+
затёртая чужая ротация кук, то есть потерянный вход.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from corekit.filelock import FileLock, LockTimeout
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_second_writer_does_not_get_the_lock(tmp_path):
|
|
16
|
+
"""Двое не входят в критическую секцию одновременно — иначе она бессмысленна."""
|
|
17
|
+
path = tmp_path / "session.lock"
|
|
18
|
+
with FileLock(path):
|
|
19
|
+
with pytest.raises(LockTimeout):
|
|
20
|
+
FileLock(path, timeout=0.1, poll=0.01, stale_after=None).acquire()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_lock_is_released_for_the_next_writer(tmp_path):
|
|
24
|
+
"""После выхода лок свободен: иначе первый же писатель вешает систему."""
|
|
25
|
+
path = tmp_path / "session.lock"
|
|
26
|
+
with FileLock(path):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
with FileLock(path, timeout=0.5):
|
|
30
|
+
assert path.exists(), "лок взят, но файла нет — второй писатель войдёт следом"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_orphaned_lock_is_broken(tmp_path):
|
|
34
|
+
"""Процесс упал, файл остался — без перехвата система встаёт до ручной уборки."""
|
|
35
|
+
path = tmp_path / "session.lock"
|
|
36
|
+
path.write_text("999999\n0", encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
# Возраст файла заведомо больше порога: перехват обязан сработать.
|
|
39
|
+
with FileLock(path, timeout=0.5, poll=0.01, stale_after=0.0) as lock:
|
|
40
|
+
assert lock.locked
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_living_writer_is_never_robbed(tmp_path):
|
|
44
|
+
"""Порог перехвата не должен забирать лок у живого: он держит секцию честно."""
|
|
45
|
+
path = tmp_path / "session.lock"
|
|
46
|
+
with FileLock(path):
|
|
47
|
+
with pytest.raises(LockTimeout):
|
|
48
|
+
FileLock(path, timeout=0.1, poll=0.01, stale_after=900.0).acquire()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_release_twice_is_not_an_error(tmp_path):
|
|
52
|
+
"""Отпустить дважды — обычный путь при ошибке в середине работы."""
|
|
53
|
+
lock = FileLock(tmp_path / "session.lock")
|
|
54
|
+
lock.acquire()
|
|
55
|
+
lock.release()
|
|
56
|
+
lock.release()
|
|
57
|
+
|
|
58
|
+
assert not lock.locked
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_non_raising_mode_answers_false_instead_of_blocking(tmp_path):
|
|
62
|
+
"""Пулу аккаунтов важнее не встать намертво, чем не разойтись с соседом.
|
|
63
|
+
|
|
64
|
+
Он уже держит внутрипроцессный лок, поэтому одна возможная гонка между
|
|
65
|
+
процессами для него дешевле вечной блокировки. Писателю сессии — наоборот:
|
|
66
|
+
затерев чужую ротацию, он отбирает у человека вход. Два ответа названы
|
|
67
|
+
явно, чтобы они снова не разъехались двумя копиями кода.
|
|
68
|
+
"""
|
|
69
|
+
path = tmp_path / "session.lock"
|
|
70
|
+
with FileLock(path):
|
|
71
|
+
assert FileLock(path, timeout=0.1, poll=0.01, stale_after=None).acquire(
|
|
72
|
+
raising=False
|
|
73
|
+
) is False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_clock_injection_keeps_the_stale_test_short(tmp_path):
|
|
77
|
+
"""Порог перехвата проверяется подменой часов, а не ожиданием в сутки.
|
|
78
|
+
|
|
79
|
+
Без инъекции такой тест идёт столько же, сколько порог, — то есть его не
|
|
80
|
+
пишут вовсе, и правило остаётся непроверенным.
|
|
81
|
+
"""
|
|
82
|
+
path = tmp_path / "session.lock"
|
|
83
|
+
path.write_text("1\n0", encoding="utf-8")
|
|
84
|
+
ahead = path.stat().st_mtime + 10_000
|
|
85
|
+
|
|
86
|
+
with FileLock(path, timeout=0.5, poll=0.01, stale_after=900.0, clock=lambda: ahead) as lock:
|
|
87
|
+
assert lock.locked, "осиротевший лок не перехвачен при сдвинутых часах"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Адрес сессии: две новые координаты не ломают ни одного старого вызова.
|
|
2
|
+
|
|
3
|
+
Почему это проверяется отдельно. `SessionRef` — общий словарь между китами,
|
|
4
|
+
навыками и хранилищем: его собирают в одном месте, а разбирают в четырёх. Любая
|
|
5
|
+
правка формы здесь дороже обычной: старый позиционный вызов, лежащий в чужом
|
|
6
|
+
навыке, обязан продолжить работать, а уже записанные адреса — читаться.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from corekit import DEFAULT_STAGE, DEFAULT_TENANT, SessionRef
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_old_positional_call_still_works() -> None:
|
|
18
|
+
"""Тройка без новых координат собирается как раньше — и это боевая сессия."""
|
|
19
|
+
ref = SessionRef("default", "grok", "me")
|
|
20
|
+
|
|
21
|
+
assert (ref.profile, ref.social, ref.account_id) == ("default", "grok", "me")
|
|
22
|
+
assert ref.tenant == DEFAULT_TENANT
|
|
23
|
+
assert ref.instance == ""
|
|
24
|
+
assert ref.stage == DEFAULT_STAGE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_stage_makes_two_different_addresses() -> None:
|
|
28
|
+
"""Черновик и боевая — РАЗНЫЕ адреса одного аккаунта, а не один.
|
|
29
|
+
|
|
30
|
+
Ровно это и защищает рабочую сессию от неудачного захода в capture.
|
|
31
|
+
"""
|
|
32
|
+
live = SessionRef("default", "grok", "me")
|
|
33
|
+
draft = SessionRef("default", "grok", "me", stage="capture")
|
|
34
|
+
|
|
35
|
+
assert live != draft
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_instance_separates_portals_of_one_service() -> None:
|
|
39
|
+
"""Два портала одного продукта — разные адреса при одном имени сервиса."""
|
|
40
|
+
acme = SessionRef("default", "bitrix24", "me", instance="acme.bitrix24.ru")
|
|
41
|
+
beta = SessionRef("default", "bitrix24", "me", instance="beta.bitrix24.kz")
|
|
42
|
+
|
|
43
|
+
assert acme != beta
|
|
44
|
+
assert acme.social == beta.social == "bitrix24"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_ref_stays_hashable_and_frozen() -> None:
|
|
48
|
+
"""Адрес остаётся значением: годится в ключ и не меняется на ходу."""
|
|
49
|
+
ref = SessionRef("default", "grok", "me", stage="capture")
|
|
50
|
+
|
|
51
|
+
assert {ref: 1}[SessionRef("default", "grok", "me", stage="capture")] == 1
|
|
52
|
+
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
53
|
+
ref.stage = "live" # type: ignore[misc]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_new_fields_are_last() -> None:
|
|
57
|
+
"""Новые координаты стоят В КОНЦЕ — иначе позиционные вызовы молча съедут.
|
|
58
|
+
|
|
59
|
+
Проверка не про эстетику: `SessionRef("default", "grok", "me")` живёт в
|
|
60
|
+
чужих навыках, и перестановка полей превратила бы имя сервиса в стадию,
|
|
61
|
+
не вызвав ни одной ошибки.
|
|
62
|
+
"""
|
|
63
|
+
names = [f.name for f in dataclasses.fields(SessionRef)]
|
|
64
|
+
|
|
65
|
+
assert names == ["profile", "social", "account_id", "tenant", "instance", "stage"]
|
s_corekit-0.0.2/corekit/dto.py
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Базовые DTO-ЗНАЧЕНИЯ основания: координаты сессии, креды, тело RPC-запроса.
|
|
2
|
-
|
|
3
|
-
Критерий, по которому DTO попал сюда, ровно один: он НЕ ТЯНЕТ РЕАЛИЗАЦИЮ. Это
|
|
4
|
-
неизменяемые значения (`frozen=True, slots=True`) — их можно шарить между
|
|
5
|
-
потоками, класть в ключи, логировать (кроме `Creds`) и сериализовать
|
|
6
|
-
``dataclasses.asdict``. Всё, что имеет жизненный цикл (клиенты, сторы,
|
|
7
|
-
браузеры), остаётся слоями выше.
|
|
8
|
-
|
|
9
|
-
ТОЛЬКО stdlib. Перенесено из ``librarykit.contract`` БЕЗ
|
|
10
|
-
изменения формы: порядок полей и дефолты те же, поэтому позиционные вызовы и
|
|
11
|
-
уже записанные данные продолжают работать.
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
from collections.abc import Mapping
|
|
16
|
-
from dataclasses import dataclass, field
|
|
17
|
-
from typing import Any
|
|
18
|
-
|
|
19
|
-
from corekit.context import DEFAULT_TENANT
|
|
20
|
-
|
|
21
|
-
__all__ = [
|
|
22
|
-
"Creds",
|
|
23
|
-
"RequestBody",
|
|
24
|
-
"SessionRef",
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclass(frozen=True, slots=True)
|
|
29
|
-
class SessionRef:
|
|
30
|
-
"""Координаты сессии в `SessionStore`: profile / social / account-id.
|
|
31
|
-
|
|
32
|
-
Тройка ``profile``/``social``/``account_id`` однозначно адресует каталог
|
|
33
|
-
сессии ``<profile>/<social>/<account_id>/`` под корнем sessions. Без секретов —
|
|
34
|
-
это только ключ/путь, поэтому безопасно логировать и индексировать.
|
|
35
|
-
|
|
36
|
-
``tenant`` — ЧЕТВЁРТАЯ (последняя, с дефолтом) координата: владелец сессии в
|
|
37
|
-
мультитенантном хосте. Поле добавлено В КОНЕЦ, поэтому позиционное
|
|
38
|
-
``SessionRef(profile, social, account_id)`` и сравнение старых объектов
|
|
39
|
-
продолжают работать.
|
|
40
|
-
|
|
41
|
-
Координата ПОДКЛЮЧЕНА: при ``tenant != DEFAULT_TENANT`` каталог сессии
|
|
42
|
-
получает ведущий сегмент — ``<tenant>/<profile>/<social>/<account_id>/``, а
|
|
43
|
-
первичный ключ индекса — ``(tenant, profile, social, account_id)``. При
|
|
44
|
-
дефолтном ``"local"`` раскладка и ключ байт-в-байт прежние (сегмент не
|
|
45
|
-
добавляется), поэтому существующие сессии читаются как раньше. Пересборка
|
|
46
|
-
индекса из каталога — `SessionStore.reindex` (понимает обе глубины).
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
profile: str
|
|
50
|
-
social: str
|
|
51
|
-
account_id: str
|
|
52
|
-
tenant: str = DEFAULT_TENANT
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@dataclass(frozen=True, slots=True)
|
|
56
|
-
class RequestBody:
|
|
57
|
-
"""Тело RPC-запроса после `Codec.encode_request` (E1.T2): json XOR data.
|
|
58
|
-
|
|
59
|
-
Нейтральный носитель сериализованного payload между codec и транспортом:
|
|
60
|
-
`json` уходит в `Transport.request(json=...)` (httpx сам сериализует), а
|
|
61
|
-
`data` — сырое тело (bytes/str/form) для не-JSON envelope. Заполняется ровно
|
|
62
|
-
одно из двух; `headers` опц. добавляет codec-специфичные заголовки
|
|
63
|
-
(`Content-Type` и т.п.) к запросу.
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
json: Any | None = None
|
|
67
|
-
data: Any | None = None
|
|
68
|
-
headers: Mapping[str, str] = field(default_factory=dict)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@dataclass(frozen=True, slots=True)
|
|
72
|
-
class Creds:
|
|
73
|
-
"""Секреты авторизации одной сессии (НЕ логировать, НЕ сериализовать в индекс).
|
|
74
|
-
|
|
75
|
-
Гибкий контейнер под разные `AuthMode`: токены (token-auth), refresh-токен
|
|
76
|
-
(oauth2), cookies (cookie-сессия), произвольные `extra` (например, подписи).
|
|
77
|
-
Все поля опциональны — заполняется то, что нужно конкретному режиму.
|
|
78
|
-
"""
|
|
79
|
-
|
|
80
|
-
token: str | None = None
|
|
81
|
-
refresh_token: str | None = None
|
|
82
|
-
cookies: Mapping[str, str] = field(default_factory=dict)
|
|
83
|
-
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|