librus-python-api 1.0.0__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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_bootstrap.py +124 -0
- librus_python_api/_notification_codec.py +396 -0
- librus_python_api/_storage.py +403 -0
- librus_python_api/_windows_filesystem.py +390 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +242 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +145 -0
- librus_python_api/notification_persistence.py +1243 -0
- librus_python_api/notification_workflow.py +337 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +419 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0.dist-info/METADATA +262 -0
- librus_python_api-1.0.0.dist-info/RECORD +48 -0
- librus_python_api-1.0.0.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Pure, bounded, independently authored JSON and semantic HTML parsers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from lxml import etree, html
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from librus_python_api.config import PAGE_NOTICES, PROFILE_LABELS
|
|
12
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
13
|
+
from librus_python_api.models import (
|
|
14
|
+
Availability,
|
|
15
|
+
LuckyNumber,
|
|
16
|
+
Person,
|
|
17
|
+
ProfileFields,
|
|
18
|
+
_EnvelopeWire,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decode_json(body: bytes) -> Any:
|
|
23
|
+
# Bound nesting before the decoder allocates recursive containers. Strings
|
|
24
|
+
# and escaped quotes do not count as structure. Bytes were bounded by caller.
|
|
25
|
+
depth = 0
|
|
26
|
+
quoted = escaped = False
|
|
27
|
+
for value in body:
|
|
28
|
+
if quoted:
|
|
29
|
+
if escaped:
|
|
30
|
+
escaped = False
|
|
31
|
+
elif value == 92:
|
|
32
|
+
escaped = True
|
|
33
|
+
elif value == 34:
|
|
34
|
+
quoted = False
|
|
35
|
+
elif value == 34:
|
|
36
|
+
quoted = True
|
|
37
|
+
elif value in (91, 123):
|
|
38
|
+
depth += 1
|
|
39
|
+
if depth > 32:
|
|
40
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
41
|
+
elif value in (93, 125):
|
|
42
|
+
depth -= 1
|
|
43
|
+
failed = False
|
|
44
|
+
result: Any = None
|
|
45
|
+
try:
|
|
46
|
+
result = json.loads(
|
|
47
|
+
body.decode("utf-8"),
|
|
48
|
+
object_pairs_hook=_unique_object,
|
|
49
|
+
parse_constant=_invalid_constant,
|
|
50
|
+
parse_float=_finite_float,
|
|
51
|
+
)
|
|
52
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
53
|
+
failed = True
|
|
54
|
+
if failed:
|
|
55
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _invalid_constant(value: str) -> Any:
|
|
60
|
+
raise ValueError("Non-standard JSON number")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _finite_float(value: str) -> float:
|
|
64
|
+
number = float(value)
|
|
65
|
+
if not math.isfinite(number):
|
|
66
|
+
raise ValueError("Non-finite JSON number")
|
|
67
|
+
return number
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
71
|
+
result: dict[str, Any] = {}
|
|
72
|
+
for key, value in pairs:
|
|
73
|
+
if key in result:
|
|
74
|
+
raise ValueError("Duplicate JSON key")
|
|
75
|
+
result[key] = value
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_identity(body: bytes) -> tuple[Person, Person]:
|
|
80
|
+
data = decode_json(body)
|
|
81
|
+
failed = False
|
|
82
|
+
envelope: _EnvelopeWire | None = None
|
|
83
|
+
try:
|
|
84
|
+
envelope = _EnvelopeWire.model_validate(data)
|
|
85
|
+
except ValidationError:
|
|
86
|
+
failed = True
|
|
87
|
+
if failed:
|
|
88
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
89
|
+
assert envelope is not None
|
|
90
|
+
account, user = envelope.Me.Account, envelope.Me.User
|
|
91
|
+
# Gateway variants omit User.Id but supply its explicit Account.UserId
|
|
92
|
+
# reference. Account.Id remains the login owner, never the represented user.
|
|
93
|
+
student_id = user.Id if user.Id is not None else account.UserId
|
|
94
|
+
if student_id is None or (
|
|
95
|
+
user.Id is not None and account.UserId is not None and user.Id != account.UserId
|
|
96
|
+
):
|
|
97
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
98
|
+
return (
|
|
99
|
+
Person(account.Id, account.FirstName, account.LastName),
|
|
100
|
+
Person(student_id, user.FirstName, user.LastName),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_login(body: bytes) -> str:
|
|
105
|
+
data = decode_json(body)
|
|
106
|
+
if not isinstance(data, dict):
|
|
107
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
108
|
+
if any(data.get(key) for key in ("captcha", "twoFactorRequired", "requiresAction")):
|
|
109
|
+
raise LibrusError(ErrorKind.ACCOUNT_ACTION_REQUIRED)
|
|
110
|
+
if data.get("status") == "error":
|
|
111
|
+
raise LibrusError(ErrorKind.CREDENTIALS_REJECTED)
|
|
112
|
+
location = data.get("goTo")
|
|
113
|
+
if data.get("status") != "ok" or not isinstance(location, str) or not location:
|
|
114
|
+
raise LibrusError(ErrorKind.ACCOUNT_ACTION_REQUIRED)
|
|
115
|
+
if len(location) > 4096:
|
|
116
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
117
|
+
return location
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_html_document(
|
|
121
|
+
body: bytes, *, repeatable_id: str | None = None
|
|
122
|
+
) -> html.HtmlElement:
|
|
123
|
+
failed = False
|
|
124
|
+
document: html.HtmlElement | None = None
|
|
125
|
+
parser = html.HTMLParser(no_network=True, recover=True, huge_tree=False)
|
|
126
|
+
try:
|
|
127
|
+
text = body.decode("utf-8")
|
|
128
|
+
document = html.document_fromstring(
|
|
129
|
+
text,
|
|
130
|
+
parser=parser,
|
|
131
|
+
)
|
|
132
|
+
except (UnicodeError, etree.LxmlError, ValueError):
|
|
133
|
+
failed = True
|
|
134
|
+
# Browser HTML commonly contains stray closing tags. Accept only this
|
|
135
|
+
# observed repair category; semantic tables, spans, IDs, and bounds still
|
|
136
|
+
# validate independently. Do not blanket-ignore arbitrary parser errors.
|
|
137
|
+
if failed or any(
|
|
138
|
+
error.type_name != "ERR_TAG_NAME_MISMATCH"
|
|
139
|
+
and not (
|
|
140
|
+
repeatable_id is not None
|
|
141
|
+
and error.type_name == "DTD_ID_REDEFINED"
|
|
142
|
+
and error.message == f"ID {repeatable_id} already defined"
|
|
143
|
+
)
|
|
144
|
+
for error in parser.error_log
|
|
145
|
+
):
|
|
146
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
147
|
+
assert document is not None
|
|
148
|
+
count = 0
|
|
149
|
+
pending = [(document, 0)]
|
|
150
|
+
while pending:
|
|
151
|
+
node, depth = pending.pop()
|
|
152
|
+
count += 1
|
|
153
|
+
if count > 8192 or depth > 32:
|
|
154
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
155
|
+
pending.extend((child, depth + 1) for child in node)
|
|
156
|
+
return document
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def page_notices(document: html.HtmlElement) -> tuple[str, ...]:
|
|
160
|
+
"""Texts of page-level information boxes, which replace requested content."""
|
|
161
|
+
nodes = document.xpath(
|
|
162
|
+
'//div[contains(concat(" ",normalize-space(@class)," ")," warning-content ")]'
|
|
163
|
+
)
|
|
164
|
+
return tuple(" ".join(node.text_content().split()) for node in nodes)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_page(body: bytes, *, repeatable_id: str | None = None) -> html.HtmlElement:
|
|
168
|
+
"""Parse a whole Synergia page and raise for a recognized notice.
|
|
169
|
+
|
|
170
|
+
Notices such as a view disabled by the school administrator are typed
|
|
171
|
+
outcomes, never parse failures or empty collections.
|
|
172
|
+
"""
|
|
173
|
+
document = parse_html_document(body, repeatable_id=repeatable_id)
|
|
174
|
+
for notice in page_notices(document):
|
|
175
|
+
kind = PAGE_NOTICES.get(notice)
|
|
176
|
+
if kind is not None:
|
|
177
|
+
raise LibrusError(kind)
|
|
178
|
+
return document
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_profile(body: bytes) -> ProfileFields:
|
|
182
|
+
document = parse_page(body)
|
|
183
|
+
required = {"name", "class_name", "register_number", "tutor", "school"}
|
|
184
|
+
candidates: list[dict[str, str]] = []
|
|
185
|
+
for table in document.iter("table"):
|
|
186
|
+
fields: dict[str, str] = {}
|
|
187
|
+
for row in table.iter("tr"):
|
|
188
|
+
# Do not inherit rows of a nested table into its parent.
|
|
189
|
+
if next(row.iterancestors("table"), None) is not table:
|
|
190
|
+
continue
|
|
191
|
+
cells = [child for child in row if child.tag in ("td", "th")]
|
|
192
|
+
if len(cells) != 2:
|
|
193
|
+
continue
|
|
194
|
+
label = " ".join(cells[0].text_content().split()).rstrip(":")
|
|
195
|
+
key = PROFILE_LABELS.get(label)
|
|
196
|
+
if key is None:
|
|
197
|
+
continue
|
|
198
|
+
if key in fields or any(
|
|
199
|
+
c.get("rowspan", "1") != "1" or c.get("colspan", "1") != "1"
|
|
200
|
+
for c in cells
|
|
201
|
+
):
|
|
202
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
203
|
+
value = " ".join(cells[1].text_content().split())
|
|
204
|
+
if not value or len(value) > 1024:
|
|
205
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
206
|
+
fields[key] = value
|
|
207
|
+
if fields.keys() == required:
|
|
208
|
+
candidates.append(fields)
|
|
209
|
+
if len(candidates) != 1:
|
|
210
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
211
|
+
if not re.fullmatch(r"[0-9]{1,4}", candidates[0]["register_number"]):
|
|
212
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
213
|
+
markers = document.xpath('//*[@id="luckyNumber"]')
|
|
214
|
+
if not markers:
|
|
215
|
+
lucky = LuckyNumber(Availability.UNAVAILABLE)
|
|
216
|
+
elif len(markers) == 1:
|
|
217
|
+
value = " ".join(markers[0].text_content().split())
|
|
218
|
+
if not re.fullmatch(r"[0-9]{1,4}", value):
|
|
219
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
220
|
+
# No evidenced civil date in this marker. Never fabricate today's date.
|
|
221
|
+
lucky = LuckyNumber(Availability.AVAILABLE, int(value))
|
|
222
|
+
else:
|
|
223
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
224
|
+
fields = candidates[0]
|
|
225
|
+
return ProfileFields(
|
|
226
|
+
fields["name"],
|
|
227
|
+
fields["class_name"],
|
|
228
|
+
int(fields["register_number"]),
|
|
229
|
+
fields["tutor"],
|
|
230
|
+
fields["school"],
|
|
231
|
+
lucky,
|
|
232
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""A bounded parser executor whose canceled work is joined before release."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
from librus_python_api.budget import RequestBudget
|
|
8
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
9
|
+
from librus_python_api.lifecycle import join_owned
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ParserPool:
|
|
13
|
+
def __init__(self, max_bytes: int) -> None:
|
|
14
|
+
self._max_bytes = max_bytes
|
|
15
|
+
self._slots = asyncio.Semaphore(2)
|
|
16
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
17
|
+
|
|
18
|
+
async def run[T](
|
|
19
|
+
self,
|
|
20
|
+
parser: Callable[[bytes], T],
|
|
21
|
+
body: bytes,
|
|
22
|
+
budget: RequestBudget,
|
|
23
|
+
) -> T:
|
|
24
|
+
if len(body) > self._max_bytes:
|
|
25
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
26
|
+
# Service admission bounds waiting coroutines; acquire before submit so
|
|
27
|
+
# the executor never develops an unbounded hidden queue.
|
|
28
|
+
async with self._slots:
|
|
29
|
+
budget.remaining_seconds()
|
|
30
|
+
if self._executor is None:
|
|
31
|
+
self._executor = ThreadPoolExecutor(
|
|
32
|
+
max_workers=2, thread_name_prefix="librus-parse"
|
|
33
|
+
)
|
|
34
|
+
future = asyncio.get_running_loop().run_in_executor(
|
|
35
|
+
self._executor, parser, body
|
|
36
|
+
)
|
|
37
|
+
try:
|
|
38
|
+
result = await asyncio.shield(future)
|
|
39
|
+
except asyncio.CancelledError:
|
|
40
|
+
# Threads cannot be preempted. Pure parsing cannot mutate session
|
|
41
|
+
# state; release the slot only after actual bounded completion.
|
|
42
|
+
await join_owned(future)
|
|
43
|
+
raise
|
|
44
|
+
budget.remaining_seconds()
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
if self._executor is not None:
|
|
49
|
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Explicit optional durable sending, independent of MCP and core transport."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Self, cast
|
|
14
|
+
|
|
15
|
+
from pydantic import Field, model_validator
|
|
16
|
+
|
|
17
|
+
from librus_python_api._notification_codec import canonical_notification_id
|
|
18
|
+
from librus_python_api._storage import _SQLiteStore, _StorageLimits
|
|
19
|
+
from librus_python_api.budget import RequestBudget
|
|
20
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
21
|
+
from librus_python_api.models import AccountContext, SendResult, SendStatus
|
|
22
|
+
from librus_python_api.notification_models import (
|
|
23
|
+
NotificationArchive,
|
|
24
|
+
NotificationBaselineMapping,
|
|
25
|
+
NotificationBatch,
|
|
26
|
+
NotificationBootstrap,
|
|
27
|
+
NotificationBootstrapResult,
|
|
28
|
+
NotificationItem,
|
|
29
|
+
NotificationPendingDelivery,
|
|
30
|
+
NotificationProvenance,
|
|
31
|
+
NotificationRawCheckpointStatus,
|
|
32
|
+
NotificationRecoveryStatus,
|
|
33
|
+
NotificationSeen,
|
|
34
|
+
NotificationState,
|
|
35
|
+
)
|
|
36
|
+
from librus_python_api.notification_persistence import (
|
|
37
|
+
NotificationLimits,
|
|
38
|
+
NotificationStore,
|
|
39
|
+
)
|
|
40
|
+
from librus_python_api.notification_workflow import NotificationWorkflow
|
|
41
|
+
from librus_python_api.sending import SendAttempt
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"NotificationArchive",
|
|
45
|
+
"NotificationBaselineMapping",
|
|
46
|
+
"NotificationBatch",
|
|
47
|
+
"NotificationBootstrap",
|
|
48
|
+
"NotificationBootstrapResult",
|
|
49
|
+
"NotificationItem",
|
|
50
|
+
"NotificationPendingDelivery",
|
|
51
|
+
"NotificationProvenance",
|
|
52
|
+
"NotificationRawCheckpointStatus",
|
|
53
|
+
"NotificationRecoveryStatus",
|
|
54
|
+
"NotificationSeen",
|
|
55
|
+
"NotificationState",
|
|
56
|
+
"NotificationLimits",
|
|
57
|
+
"NotificationStore",
|
|
58
|
+
"NotificationWorkflow",
|
|
59
|
+
"canonical_notification_id",
|
|
60
|
+
"DurableSendOutcome",
|
|
61
|
+
"DurableSendPhase",
|
|
62
|
+
"DurableSendRecord",
|
|
63
|
+
"PersistenceLimits",
|
|
64
|
+
"PersistenceStore",
|
|
65
|
+
"SendConfirmation",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
_STATUS_VALUES = frozenset(
|
|
69
|
+
{"pending", "claimed", "invalidated", *(s.value for s in SendStatus)}
|
|
70
|
+
)
|
|
71
|
+
_HEX = re.compile(r"[0-9a-f]{64}")
|
|
72
|
+
_TOKEN = re.compile(r"[A-Za-z0-9_-]{20,128}")
|
|
73
|
+
_SendRow = tuple[str, str, str, int, int, str]
|
|
74
|
+
_SEND_SCHEMA = """CREATE TABLE send_attempts (
|
|
75
|
+
token_hash TEXT PRIMARY KEY CHECK(length(token_hash)=64),
|
|
76
|
+
context TEXT NOT NULL CHECK(length(context)=64),
|
|
77
|
+
payload_digest TEXT NOT NULL CHECK(length(payload_digest)=64),
|
|
78
|
+
created_at INTEGER NOT NULL CHECK(created_at>=0),
|
|
79
|
+
expires_at INTEGER NOT NULL CHECK(
|
|
80
|
+
expires_at>created_at AND expires_at<=created_at+300),
|
|
81
|
+
status TEXT NOT NULL CHECK(status IN (
|
|
82
|
+
'pending','claimed','invalidated','not_dispatched',
|
|
83
|
+
'unknown','accepted','rejected'))
|
|
84
|
+
)"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PersistenceLimits(_StorageLimits):
|
|
88
|
+
send_records: int = Field(default=256, ge=1, le=4096)
|
|
89
|
+
pending_confirmations: int = Field(default=32, ge=1, le=256)
|
|
90
|
+
confirmation_ttl_seconds: int = Field(default=300, ge=1, le=300)
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def pending_bound(self) -> Self:
|
|
94
|
+
if self.pending_confirmations > self.send_records:
|
|
95
|
+
raise ValueError("Pending confirmations exceed history capacity")
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class SendConfirmation:
|
|
101
|
+
token: str = field(repr=False)
|
|
102
|
+
expires_at: datetime
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DurableSendPhase(StrEnum):
|
|
106
|
+
PENDING = "pending"
|
|
107
|
+
CLAIMED = "claimed"
|
|
108
|
+
INVALIDATED = "invalidated"
|
|
109
|
+
NOT_DISPATCHED = "not_dispatched"
|
|
110
|
+
UNKNOWN = "unknown"
|
|
111
|
+
ACCEPTED = "accepted"
|
|
112
|
+
REJECTED = "rejected"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class DurableSendOutcome:
|
|
117
|
+
status: SendStatus | None
|
|
118
|
+
phase: DurableSendPhase
|
|
119
|
+
requires_reconciliation: bool
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class DurableSendRecord:
|
|
124
|
+
identifier: str = field(repr=False)
|
|
125
|
+
payload_digest: str = field(repr=False)
|
|
126
|
+
created_at: datetime
|
|
127
|
+
expires_at: datetime
|
|
128
|
+
outcome: DurableSendOutcome
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _valid_digest(value: str) -> str:
|
|
132
|
+
if type(value) is not str or _HEX.fullmatch(value) is None:
|
|
133
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _token_hash(token: str) -> str:
|
|
138
|
+
if type(token) is not str or _TOKEN.fullmatch(token) is None:
|
|
139
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
140
|
+
return hashlib.sha256(token.encode("ascii")).hexdigest()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _now() -> int:
|
|
144
|
+
return time.time_ns() // 1_000_000_000
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _binding(attempt: SendAttempt) -> tuple[str, str]:
|
|
148
|
+
if not isinstance(attempt, SendAttempt) or attempt.used:
|
|
149
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
150
|
+
context = _valid_digest(attempt.account_context.identifier)
|
|
151
|
+
encoded = json.dumps(
|
|
152
|
+
[1, context, attempt.outcome.backend.value, asdict(attempt.submission)],
|
|
153
|
+
ensure_ascii=False,
|
|
154
|
+
sort_keys=True,
|
|
155
|
+
separators=(",", ":"),
|
|
156
|
+
).encode("utf-8")
|
|
157
|
+
return context, hashlib.sha256(encoded).hexdigest()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class PersistenceStore(_SQLiteStore):
|
|
161
|
+
"""Explicit private durable send claims; core clients require no storage.
|
|
162
|
+
|
|
163
|
+
The selected directory's parent must exist. Store only digests and outcomes,
|
|
164
|
+
never send bodies, token plaintext or HTTP cookies. Existing files are
|
|
165
|
+
validated, not reset or automatically migrated. A durable claim is uncertain
|
|
166
|
+
after process loss, even if no HTTP request actually reached the upstream.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
_schema = (_SEND_SCHEMA,)
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self, directory: Path, *, limits: PersistenceLimits | None = None
|
|
173
|
+
) -> None:
|
|
174
|
+
self._limits = limits if limits is not None else PersistenceLimits()
|
|
175
|
+
if not isinstance(self._limits, PersistenceLimits):
|
|
176
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
177
|
+
super().__init__(directory, limits=self._limits)
|
|
178
|
+
|
|
179
|
+
def _validate_contents(self, connection: sqlite3.Connection) -> None:
|
|
180
|
+
self._rows(connection)
|
|
181
|
+
|
|
182
|
+
def _rows(self, connection: sqlite3.Connection) -> list[_SendRow]:
|
|
183
|
+
rows = cast(
|
|
184
|
+
list[_SendRow],
|
|
185
|
+
connection.execute(
|
|
186
|
+
"SELECT token_hash, context, payload_digest, created_at, "
|
|
187
|
+
"expires_at, status FROM send_attempts LIMIT ?",
|
|
188
|
+
(self._limits.send_records + 1,),
|
|
189
|
+
).fetchall(),
|
|
190
|
+
)
|
|
191
|
+
if len(rows) > self._limits.send_records:
|
|
192
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
193
|
+
for row in rows:
|
|
194
|
+
if (
|
|
195
|
+
any(
|
|
196
|
+
type(value) is not str or _HEX.fullmatch(value) is None
|
|
197
|
+
for value in row[:3]
|
|
198
|
+
)
|
|
199
|
+
or type(row[3]) is not int
|
|
200
|
+
or type(row[4]) is not int
|
|
201
|
+
or not 0 <= row[3] < row[4] <= row[3] + 300
|
|
202
|
+
or row[4] > 253402300799
|
|
203
|
+
or type(row[5]) is not str
|
|
204
|
+
or row[5] not in _STATUS_VALUES
|
|
205
|
+
):
|
|
206
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
207
|
+
return rows
|
|
208
|
+
|
|
209
|
+
async def preview_send(self, attempt: SendAttempt) -> SendConfirmation:
|
|
210
|
+
context, digest = _binding(attempt)
|
|
211
|
+
context = self.context_identifier(context)
|
|
212
|
+
return await self._io(lambda: self._issue(context, digest))
|
|
213
|
+
|
|
214
|
+
def _issue(self, context: str, digest: str) -> SendConfirmation:
|
|
215
|
+
now = _now()
|
|
216
|
+
with self._connection() as connection:
|
|
217
|
+
self._rows(connection)
|
|
218
|
+
connection.execute(
|
|
219
|
+
"DELETE FROM send_attempts WHERE status='pending' AND expires_at<=?",
|
|
220
|
+
(now,),
|
|
221
|
+
)
|
|
222
|
+
rows = self._rows(connection)
|
|
223
|
+
if self._duplicate(rows, context, digest):
|
|
224
|
+
raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
|
|
225
|
+
if (
|
|
226
|
+
len(rows) >= self._limits.send_records
|
|
227
|
+
or sum(row[5] == "pending" for row in rows)
|
|
228
|
+
>= self._limits.pending_confirmations
|
|
229
|
+
):
|
|
230
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
231
|
+
token = secrets.token_urlsafe(32)
|
|
232
|
+
deadline = now + self._limits.confirmation_ttl_seconds
|
|
233
|
+
connection.execute(
|
|
234
|
+
"INSERT INTO send_attempts VALUES (?,?,?,?,?,?)",
|
|
235
|
+
(_token_hash(token), context, digest, now, deadline, "pending"),
|
|
236
|
+
)
|
|
237
|
+
return SendConfirmation(token, datetime.fromtimestamp(deadline, UTC))
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _duplicate(rows: list[_SendRow], context: str, digest: str) -> bool:
|
|
241
|
+
return any(
|
|
242
|
+
row[1] == context
|
|
243
|
+
and row[2] == digest
|
|
244
|
+
and row[5] in {"claimed", "unknown", "accepted"}
|
|
245
|
+
for row in rows
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _claim(self, token_hash: str, context: str, digest: str) -> None:
|
|
249
|
+
with self._connection() as connection:
|
|
250
|
+
rows = self._rows(connection)
|
|
251
|
+
row = next((row for row in rows if row[0] == token_hash), None)
|
|
252
|
+
if row is None or row[5] != "pending":
|
|
253
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
254
|
+
invalid = (
|
|
255
|
+
row[1] != context
|
|
256
|
+
or row[2] != digest
|
|
257
|
+
or not row[3] <= _now() < row[4]
|
|
258
|
+
or self._duplicate(rows, context, digest)
|
|
259
|
+
)
|
|
260
|
+
connection.execute(
|
|
261
|
+
"UPDATE send_attempts SET status=? WHERE token_hash=?",
|
|
262
|
+
("invalidated" if invalid else "claimed", token_hash),
|
|
263
|
+
)
|
|
264
|
+
if invalid:
|
|
265
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
266
|
+
|
|
267
|
+
async def execute_send(
|
|
268
|
+
self, token: str, attempt: SendAttempt, *, budget: RequestBudget | None = None
|
|
269
|
+
) -> SendResult:
|
|
270
|
+
token_hash = _token_hash(token)
|
|
271
|
+
context, digest = _binding(attempt)
|
|
272
|
+
context = self.context_identifier(context)
|
|
273
|
+
return await self._owned(
|
|
274
|
+
lambda: self._execute_send(token_hash, context, digest, attempt, budget)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def _execute_send(
|
|
278
|
+
self,
|
|
279
|
+
token_hash: str,
|
|
280
|
+
context: str,
|
|
281
|
+
digest: str,
|
|
282
|
+
attempt: SendAttempt,
|
|
283
|
+
budget: RequestBudget | None,
|
|
284
|
+
) -> SendResult:
|
|
285
|
+
await self._io(lambda: self._claim(token_hash, context, digest))
|
|
286
|
+
try:
|
|
287
|
+
return await attempt.execute(budget=budget)
|
|
288
|
+
finally:
|
|
289
|
+
try:
|
|
290
|
+
await self._io(
|
|
291
|
+
lambda: self._finish(
|
|
292
|
+
token_hash, context, digest, attempt.outcome.status
|
|
293
|
+
),
|
|
294
|
+
finishing=True,
|
|
295
|
+
)
|
|
296
|
+
except LibrusError as error:
|
|
297
|
+
# LIMIT means "stopped before upstream work"; after a claim the
|
|
298
|
+
# send may have been dispatched and the claim stays uncertain.
|
|
299
|
+
if error.kind is not ErrorKind.LIMIT:
|
|
300
|
+
raise
|
|
301
|
+
raise LibrusError(ErrorKind.STORAGE) from None
|
|
302
|
+
|
|
303
|
+
def _finish(
|
|
304
|
+
self, token_hash: str, context: str, digest: str, status: SendStatus
|
|
305
|
+
) -> None:
|
|
306
|
+
with self._connection() as connection:
|
|
307
|
+
self._rows(connection)
|
|
308
|
+
changed = connection.execute(
|
|
309
|
+
"UPDATE send_attempts SET status=? WHERE token_hash=? "
|
|
310
|
+
"AND context=? AND payload_digest=? AND status='claimed'",
|
|
311
|
+
(status.value, token_hash, context, digest),
|
|
312
|
+
).rowcount
|
|
313
|
+
if changed != 1:
|
|
314
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
315
|
+
|
|
316
|
+
async def send_outcome(
|
|
317
|
+
self, token: str, *, context: AccountContext
|
|
318
|
+
) -> DurableSendOutcome:
|
|
319
|
+
token_hash = _token_hash(token)
|
|
320
|
+
if not isinstance(context, AccountContext):
|
|
321
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
322
|
+
identifier = self.context_identifier(_valid_digest(context.identifier))
|
|
323
|
+
return await self._io(lambda: self._outcome(token_hash, identifier))
|
|
324
|
+
|
|
325
|
+
def _outcome(self, token_hash: str, context: str) -> DurableSendOutcome:
|
|
326
|
+
with self._connection() as connection:
|
|
327
|
+
row = next(
|
|
328
|
+
(row for row in self._rows(connection) if row[0] == token_hash), None
|
|
329
|
+
)
|
|
330
|
+
if row is None or row[1] != context:
|
|
331
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
332
|
+
return self._snapshot(row[5])
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _snapshot(value: str) -> DurableSendOutcome:
|
|
336
|
+
phase = DurableSendPhase(value)
|
|
337
|
+
status = (
|
|
338
|
+
SendStatus.UNKNOWN
|
|
339
|
+
if phase is DurableSendPhase.CLAIMED
|
|
340
|
+
else SendStatus(phase)
|
|
341
|
+
if phase in {s.value for s in SendStatus}
|
|
342
|
+
else None
|
|
343
|
+
)
|
|
344
|
+
return DurableSendOutcome(status, phase, phase in {"claimed", "unknown"})
|
|
345
|
+
|
|
346
|
+
async def send_history(
|
|
347
|
+
self, *, context: AccountContext
|
|
348
|
+
) -> tuple[DurableSendRecord, ...]:
|
|
349
|
+
"""Bounded context-bound recovery, even if plaintext tokens were lost."""
|
|
350
|
+
if not isinstance(context, AccountContext):
|
|
351
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
352
|
+
identifier = self.context_identifier(_valid_digest(context.identifier))
|
|
353
|
+
return await self._io(lambda: self._history(identifier))
|
|
354
|
+
|
|
355
|
+
def _history(self, context: str) -> tuple[DurableSendRecord, ...]:
|
|
356
|
+
with self._connection() as connection:
|
|
357
|
+
rows = sorted(
|
|
358
|
+
(row for row in self._rows(connection) if row[1] == context),
|
|
359
|
+
key=lambda row: (row[3], row[0]),
|
|
360
|
+
)
|
|
361
|
+
return tuple(
|
|
362
|
+
DurableSendRecord(
|
|
363
|
+
row[0],
|
|
364
|
+
row[2],
|
|
365
|
+
datetime.fromtimestamp(row[3], UTC),
|
|
366
|
+
datetime.fromtimestamp(row[4], UTC),
|
|
367
|
+
self._snapshot(row[5]),
|
|
368
|
+
)
|
|
369
|
+
for row in rows
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
async def prune_send_history(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
context: AccountContext,
|
|
376
|
+
identifiers: tuple[str, ...],
|
|
377
|
+
allow_accepted: bool = False,
|
|
378
|
+
) -> int:
|
|
379
|
+
"""Explicit atomic retention. Uncertain/claimed/live pending sends stay.
|
|
380
|
+
|
|
381
|
+
Removing ACCEPTED records requires explicit duplicate-risk acceptance:
|
|
382
|
+
identical payloads can subsequently receive a new confirmation.
|
|
383
|
+
"""
|
|
384
|
+
if (
|
|
385
|
+
not isinstance(context, AccountContext)
|
|
386
|
+
or type(identifiers) is not tuple
|
|
387
|
+
or not 1 <= len(identifiers) <= self._limits.send_records
|
|
388
|
+
or type(allow_accepted) is not bool
|
|
389
|
+
):
|
|
390
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
391
|
+
for identifier in identifiers:
|
|
392
|
+
_valid_digest(identifier)
|
|
393
|
+
if len(set(identifiers)) != len(identifiers):
|
|
394
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
395
|
+
key = self.context_identifier(context.identifier)
|
|
396
|
+
return await self._io(
|
|
397
|
+
lambda: self._prune_sends(key, identifiers, allow_accepted)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def _prune_sends(
|
|
401
|
+
self, context: str, identifiers: tuple[str, ...], allow_accepted: bool
|
|
402
|
+
) -> int:
|
|
403
|
+
with self._connection() as connection:
|
|
404
|
+
rows = {row[0]: row for row in self._rows(connection) if row[1] == context}
|
|
405
|
+
allowed = {"invalidated", "not_dispatched", "rejected"}
|
|
406
|
+
if allow_accepted:
|
|
407
|
+
allowed.add("accepted")
|
|
408
|
+
now = _now()
|
|
409
|
+
for identifier in identifiers:
|
|
410
|
+
row = rows.get(identifier)
|
|
411
|
+
if row is None or not (
|
|
412
|
+
row[5] in allowed or (row[5] == "pending" and row[4] <= now)
|
|
413
|
+
):
|
|
414
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
415
|
+
connection.executemany(
|
|
416
|
+
"DELETE FROM send_attempts WHERE token_hash=? AND context=?",
|
|
417
|
+
[(identifier, context) for identifier in identifiers],
|
|
418
|
+
)
|
|
419
|
+
return len(identifiers)
|
|
File without changes
|