librus-python-api 1.0.0rc1__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_codec.py +310 -0
- librus_python_api/_storage.py +367 -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 +156 -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 +67 -0
- librus_python_api/notification_persistence.py +1044 -0
- librus_python_api/notification_workflow.py +303 -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 +405 -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.0rc1.dist-info/METADATA +254 -0
- librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
- librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
"""Explicit optional notification state, raw checkpoints and two-phase delivery."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import stat
|
|
9
|
+
from collections.abc import AsyncIterator, Callable, Coroutine
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from dataclasses import asdict, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from librus_python_api._notification_codec import (
|
|
18
|
+
ARCHIVE_BYTES,
|
|
19
|
+
HEX,
|
|
20
|
+
META_BYTES,
|
|
21
|
+
WIRE_BYTES,
|
|
22
|
+
canonical_notification_id,
|
|
23
|
+
dump,
|
|
24
|
+
empty_state,
|
|
25
|
+
encode_batch,
|
|
26
|
+
encode_envelope,
|
|
27
|
+
load,
|
|
28
|
+
restore_batch,
|
|
29
|
+
restore_envelope,
|
|
30
|
+
restore_state,
|
|
31
|
+
state_bytes,
|
|
32
|
+
)
|
|
33
|
+
from librus_python_api._storage import _SQLiteStore, _StorageLimits
|
|
34
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
35
|
+
from librus_python_api.models import (
|
|
36
|
+
AccountContext,
|
|
37
|
+
NotificationCategory,
|
|
38
|
+
ScheduleEventResponse,
|
|
39
|
+
)
|
|
40
|
+
from librus_python_api.notification_models import (
|
|
41
|
+
NotificationArchive,
|
|
42
|
+
NotificationBatch,
|
|
43
|
+
NotificationItem,
|
|
44
|
+
NotificationSeen,
|
|
45
|
+
NotificationState,
|
|
46
|
+
)
|
|
47
|
+
from librus_python_api.notifications import decode_payload, parse_schedule_events
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import fcntl
|
|
51
|
+
except ImportError:
|
|
52
|
+
fcntl = None # type: ignore[assignment]
|
|
53
|
+
|
|
54
|
+
_CONTEXT_SCHEMA = """CREATE TABLE notification_contexts (
|
|
55
|
+
context TEXT PRIMARY KEY CHECK(length(context)=64)
|
|
56
|
+
)"""
|
|
57
|
+
_STATE_SCHEMA = """CREATE TABLE notification_state (
|
|
58
|
+
context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
|
|
59
|
+
payload BLOB NOT NULL,
|
|
60
|
+
last_receipt TEXT CHECK(last_receipt IS NULL OR length(last_receipt)=64)
|
|
61
|
+
)"""
|
|
62
|
+
_RAW_SCHEMA = """CREATE TABLE notification_raw (
|
|
63
|
+
context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
|
|
64
|
+
identifier TEXT NOT NULL CHECK(length(identifier)=64),
|
|
65
|
+
metadata BLOB NOT NULL,
|
|
66
|
+
body BLOB NOT NULL,
|
|
67
|
+
cursor INTEGER NOT NULL CHECK(cursor>=0 AND cursor<=1024),
|
|
68
|
+
total INTEGER CHECK(total IS NULL OR (total>=cursor AND total<=1024))
|
|
69
|
+
)"""
|
|
70
|
+
_RESERVATION_SCHEMA = """CREATE TABLE notification_reservations (
|
|
71
|
+
context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
|
|
72
|
+
bytes INTEGER NOT NULL CHECK(bytes>0 AND bytes<=8388608)
|
|
73
|
+
)"""
|
|
74
|
+
_DELIVERY_SCHEMA = """CREATE TABLE notification_deliveries (
|
|
75
|
+
context TEXT PRIMARY KEY REFERENCES notification_contexts(context),
|
|
76
|
+
receipt TEXT NOT NULL CHECK(length(receipt)=64),
|
|
77
|
+
payload BLOB NOT NULL,
|
|
78
|
+
state_after BLOB NOT NULL,
|
|
79
|
+
raw_identifier TEXT CHECK(raw_identifier IS NULL OR length(raw_identifier)=64),
|
|
80
|
+
cursor_after INTEGER CHECK(cursor_after IS NULL OR (
|
|
81
|
+
cursor_after>=0 AND cursor_after<=1024))
|
|
82
|
+
)"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NotificationLimits(_StorageLimits):
|
|
86
|
+
contexts: int = Field(default=16, ge=1, le=64)
|
|
87
|
+
checkpoint_records: int = Field(default=32, ge=1, le=256)
|
|
88
|
+
checkpoint_bytes: int = Field(default=16 * 1024 * 1024, ge=1, le=32 * 1024 * 1024)
|
|
89
|
+
seen_ids_per_category: int = Field(default=4096, ge=1, le=4096)
|
|
90
|
+
state_bytes: int = Field(default=4 * 1024 * 1024, ge=1, le=4 * 1024 * 1024)
|
|
91
|
+
batch_items: int = Field(default=500, ge=1, le=4096)
|
|
92
|
+
batch_bytes: int = Field(default=1024 * 1024, ge=1, le=4 * 1024 * 1024)
|
|
93
|
+
replay_events: int = Field(default=500, ge=1, le=1024)
|
|
94
|
+
replay_bytes: int = Field(default=128 * 1024, ge=1, le=1024 * 1024)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _context_id(context: AccountContext) -> str:
|
|
98
|
+
if (
|
|
99
|
+
not isinstance(context, AccountContext)
|
|
100
|
+
or type(context.identifier) is not str
|
|
101
|
+
or HEX.fullmatch(context.identifier) is None
|
|
102
|
+
or type(context.alias) is not str
|
|
103
|
+
or not 1 <= len(context.alias) <= 80
|
|
104
|
+
):
|
|
105
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
106
|
+
return context.identifier
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _raw_id(context: str, metadata: bytes, body: bytes) -> str:
|
|
110
|
+
digest = hashlib.sha256(context.encode("ascii"))
|
|
111
|
+
digest.update(len(metadata).to_bytes(8, "big"))
|
|
112
|
+
digest.update(metadata)
|
|
113
|
+
digest.update(body)
|
|
114
|
+
return digest.hexdigest()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _seen_after(
|
|
118
|
+
state: NotificationState, items: tuple[NotificationItem, ...], limit: int
|
|
119
|
+
) -> NotificationState:
|
|
120
|
+
seen = {entry.category: list(entry.identifiers) for entry in state.seen}
|
|
121
|
+
for item in items:
|
|
122
|
+
identifiers = seen[item.category]
|
|
123
|
+
if item.identifier not in identifiers:
|
|
124
|
+
if len(identifiers) >= limit:
|
|
125
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
126
|
+
identifiers.append(item.identifier)
|
|
127
|
+
return NotificationState(
|
|
128
|
+
True,
|
|
129
|
+
tuple(
|
|
130
|
+
NotificationSeen(entry.category, tuple(seen[entry.category]))
|
|
131
|
+
for entry in state.seen
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NotificationStore(_SQLiteStore):
|
|
137
|
+
"""Private native recovery store. No MCP files, auto migration or live access.
|
|
138
|
+
|
|
139
|
+
Context locks require POSIX flock. Unsupported platforms fail explicitly.
|
|
140
|
+
Raw checkpoints contain private account/content metadata, never HTTP cookies.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
_filename = "notifications.sqlite3"
|
|
144
|
+
_database_bytes = 64 * 1024 * 1024
|
|
145
|
+
_schema = (
|
|
146
|
+
_CONTEXT_SCHEMA,
|
|
147
|
+
_STATE_SCHEMA,
|
|
148
|
+
_RAW_SCHEMA,
|
|
149
|
+
_RESERVATION_SCHEMA,
|
|
150
|
+
_DELIVERY_SCHEMA,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self, directory: Path, *, limits: NotificationLimits | None = None
|
|
155
|
+
) -> None:
|
|
156
|
+
self._notification_limits = (
|
|
157
|
+
limits if limits is not None else NotificationLimits()
|
|
158
|
+
)
|
|
159
|
+
if not isinstance(self.limits, NotificationLimits):
|
|
160
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
161
|
+
super().__init__(directory, limits=self.limits)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def limits(self) -> NotificationLimits:
|
|
165
|
+
return self._notification_limits
|
|
166
|
+
|
|
167
|
+
def _validate_contents(self, connection: sqlite3.Connection) -> None:
|
|
168
|
+
contexts = connection.execute(
|
|
169
|
+
"SELECT context FROM notification_contexts LIMIT ?",
|
|
170
|
+
(self.limits.contexts + 1,),
|
|
171
|
+
).fetchall()
|
|
172
|
+
if len(contexts) > self.limits.contexts:
|
|
173
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
174
|
+
if any(
|
|
175
|
+
type(row[0]) is not str or HEX.fullmatch(row[0]) is None for row in contexts
|
|
176
|
+
):
|
|
177
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
178
|
+
if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
|
179
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
180
|
+
records, used = connection.execute(
|
|
181
|
+
"SELECT count(*), coalesce(sum(length(metadata)+length(body)),0) "
|
|
182
|
+
"FROM notification_raw"
|
|
183
|
+
).fetchone()
|
|
184
|
+
reservations, reserved = connection.execute(
|
|
185
|
+
"SELECT count(*), coalesce(sum(bytes),0) FROM notification_reservations"
|
|
186
|
+
).fetchone()
|
|
187
|
+
if (
|
|
188
|
+
records + reservations > self.limits.checkpoint_records
|
|
189
|
+
or used + reserved > self.limits.checkpoint_bytes
|
|
190
|
+
):
|
|
191
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
192
|
+
state_size = connection.execute(
|
|
193
|
+
"SELECT coalesce(sum(length(payload)),0) FROM notification_state"
|
|
194
|
+
).fetchone()[0]
|
|
195
|
+
delivery_size = connection.execute(
|
|
196
|
+
"SELECT coalesce(sum(length(payload)+length(state_after)),0) "
|
|
197
|
+
"FROM notification_deliveries"
|
|
198
|
+
).fetchone()[0]
|
|
199
|
+
if state_size > self.limits.state_bytes or delivery_size > 16 * 1024 * 1024:
|
|
200
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
201
|
+
|
|
202
|
+
def _register(self, context: str) -> None:
|
|
203
|
+
context = self._context_key(context)
|
|
204
|
+
with self._connection() as connection:
|
|
205
|
+
self._validate_contents(connection)
|
|
206
|
+
if (
|
|
207
|
+
connection.execute(
|
|
208
|
+
"SELECT 1 FROM notification_contexts WHERE context=?", (context,)
|
|
209
|
+
).fetchone()
|
|
210
|
+
is None
|
|
211
|
+
):
|
|
212
|
+
count = connection.execute(
|
|
213
|
+
"SELECT count(*) FROM notification_contexts"
|
|
214
|
+
).fetchone()[0]
|
|
215
|
+
if count >= self.limits.contexts:
|
|
216
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
217
|
+
connection.execute(
|
|
218
|
+
"INSERT INTO notification_contexts VALUES (?)", (context,)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _lock_context(self, context: str, held: list[int]) -> None:
|
|
222
|
+
context = self._context_key(context)
|
|
223
|
+
if fcntl is None:
|
|
224
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
225
|
+
path = self._directory / f"notification-{context}.lock"
|
|
226
|
+
if path.is_symlink():
|
|
227
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
228
|
+
descriptor = os.open(
|
|
229
|
+
path, os.O_CREAT | os.O_RDWR | os.O_NONBLOCK | os.O_NOFOLLOW, 0o600
|
|
230
|
+
)
|
|
231
|
+
try:
|
|
232
|
+
status = os.fstat(descriptor)
|
|
233
|
+
if (
|
|
234
|
+
not stat.S_ISREG(status.st_mode)
|
|
235
|
+
or status.st_nlink != 1
|
|
236
|
+
or status.st_size != 0
|
|
237
|
+
or status.st_uid != os.geteuid()
|
|
238
|
+
or stat.S_IMODE(status.st_mode) & 0o077
|
|
239
|
+
):
|
|
240
|
+
raise LibrusError(ErrorKind.STORAGE)
|
|
241
|
+
try:
|
|
242
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
243
|
+
except BlockingIOError:
|
|
244
|
+
raise LibrusError(ErrorKind.LIMIT) from None
|
|
245
|
+
held.append(descriptor)
|
|
246
|
+
descriptor = -1
|
|
247
|
+
finally:
|
|
248
|
+
if descriptor >= 0:
|
|
249
|
+
os.close(descriptor)
|
|
250
|
+
|
|
251
|
+
@asynccontextmanager
|
|
252
|
+
async def _context(self, context: AccountContext) -> AsyncIterator[None]:
|
|
253
|
+
identifier = _context_id(context)
|
|
254
|
+
held: list[int] = []
|
|
255
|
+
try:
|
|
256
|
+
await self._io(lambda: self._register(identifier))
|
|
257
|
+
await self._io(lambda: self._lock_context(identifier, held))
|
|
258
|
+
yield
|
|
259
|
+
finally:
|
|
260
|
+
# A resource-release syscall, not a new queued storage operation.
|
|
261
|
+
# The acquisition worker is joined before this point, even on cancel.
|
|
262
|
+
if held:
|
|
263
|
+
os.close(held.pop())
|
|
264
|
+
|
|
265
|
+
async def _transaction[T](
|
|
266
|
+
self, context: AccountContext, operation: Callable[[], Coroutine[Any, Any, T]]
|
|
267
|
+
) -> T:
|
|
268
|
+
async def run() -> T:
|
|
269
|
+
async with self._context(context):
|
|
270
|
+
return await operation()
|
|
271
|
+
|
|
272
|
+
return await self._owned(run)
|
|
273
|
+
|
|
274
|
+
def _state(
|
|
275
|
+
self, connection: sqlite3.Connection, context: str
|
|
276
|
+
) -> tuple[NotificationState, str | None]:
|
|
277
|
+
row = connection.execute(
|
|
278
|
+
"SELECT payload,last_receipt FROM notification_state WHERE context=?",
|
|
279
|
+
(self._context_key(context),),
|
|
280
|
+
).fetchone()
|
|
281
|
+
if row is None:
|
|
282
|
+
return empty_state(), None
|
|
283
|
+
if type(row[0]) is not bytes or (
|
|
284
|
+
row[1] is not None
|
|
285
|
+
and (type(row[1]) is not str or HEX.fullmatch(row[1]) is None)
|
|
286
|
+
):
|
|
287
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
288
|
+
return restore_state(
|
|
289
|
+
row[0], self.limits.seen_ids_per_category, self.limits.state_bytes
|
|
290
|
+
), row[1]
|
|
291
|
+
|
|
292
|
+
async def state(self, *, context: AccountContext) -> NotificationState:
|
|
293
|
+
async def read() -> NotificationState:
|
|
294
|
+
return await self._io(lambda: self._read_state(context.identifier))
|
|
295
|
+
|
|
296
|
+
return await self._transaction(context, read)
|
|
297
|
+
|
|
298
|
+
def _read_state(self, context: str) -> NotificationState:
|
|
299
|
+
with self._connection() as connection:
|
|
300
|
+
self._validate_contents(connection)
|
|
301
|
+
return self._state(connection, context)[0]
|
|
302
|
+
|
|
303
|
+
async def prune_seen(
|
|
304
|
+
self,
|
|
305
|
+
*,
|
|
306
|
+
context: AccountContext,
|
|
307
|
+
category: NotificationCategory,
|
|
308
|
+
identifiers: tuple[str, ...],
|
|
309
|
+
) -> int:
|
|
310
|
+
"""Explicit retention; forgotten IDs may be notified again.
|
|
311
|
+
|
|
312
|
+
Staged delivery and uncertain reservations must be resolved first. Raw
|
|
313
|
+
checkpoints/progress are retained unchanged, including at seen saturation.
|
|
314
|
+
No age-based expiry, background pruning or first-run reset is performed.
|
|
315
|
+
"""
|
|
316
|
+
if (
|
|
317
|
+
not isinstance(category, NotificationCategory)
|
|
318
|
+
or type(identifiers) is not tuple
|
|
319
|
+
or not 1 <= len(identifiers) <= self.limits.seen_ids_per_category
|
|
320
|
+
or any(
|
|
321
|
+
type(value) is not str or HEX.fullmatch(value) is None
|
|
322
|
+
for value in identifiers
|
|
323
|
+
)
|
|
324
|
+
or len(set(identifiers)) != len(identifiers)
|
|
325
|
+
):
|
|
326
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
327
|
+
|
|
328
|
+
async def prune() -> int:
|
|
329
|
+
return await self._io(
|
|
330
|
+
lambda: self._prune_seen(context, category, identifiers)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return await self._transaction(context, prune)
|
|
334
|
+
|
|
335
|
+
def _prune_seen(
|
|
336
|
+
self,
|
|
337
|
+
context: AccountContext,
|
|
338
|
+
category: NotificationCategory,
|
|
339
|
+
identifiers: tuple[str, ...],
|
|
340
|
+
) -> int:
|
|
341
|
+
with self._connection() as connection:
|
|
342
|
+
self._validate_contents(connection)
|
|
343
|
+
key = self._context_key(context.identifier)
|
|
344
|
+
for table in (
|
|
345
|
+
"notification_deliveries",
|
|
346
|
+
"notification_reservations",
|
|
347
|
+
):
|
|
348
|
+
if connection.execute(
|
|
349
|
+
f"SELECT 1 FROM {table} WHERE context=?", (key,)
|
|
350
|
+
).fetchone():
|
|
351
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
352
|
+
state, last = self._state(connection, context.identifier)
|
|
353
|
+
removed = set(identifiers)
|
|
354
|
+
current = next(entry for entry in state.seen if entry.category is category)
|
|
355
|
+
if not removed.issubset(current.identifiers):
|
|
356
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
357
|
+
raw = self._raw(connection, context)
|
|
358
|
+
if category is NotificationCategory.AGENDA and raw is not None and raw[2]:
|
|
359
|
+
response = raw[1]
|
|
360
|
+
events = parse_schedule_events(
|
|
361
|
+
decode_payload(response.wire.body, response.wire, WIRE_BYTES)
|
|
362
|
+
)
|
|
363
|
+
protected = {
|
|
364
|
+
canonical_notification_id(NotificationCategory.AGENDA, value)
|
|
365
|
+
for value in events[: raw[2]]
|
|
366
|
+
}
|
|
367
|
+
if removed & protected:
|
|
368
|
+
# Prefix membership proves acknowledged cursor progress during
|
|
369
|
+
# archive import. Retain it until the raw receipt is drained.
|
|
370
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
371
|
+
after = replace(
|
|
372
|
+
state,
|
|
373
|
+
seen=tuple(
|
|
374
|
+
replace(
|
|
375
|
+
entry,
|
|
376
|
+
identifiers=tuple(
|
|
377
|
+
value for value in entry.identifiers if value not in removed
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
if entry.category is category
|
|
381
|
+
else entry
|
|
382
|
+
for entry in state.seen
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
connection.execute(
|
|
386
|
+
"UPDATE notification_state SET payload=? WHERE context=?",
|
|
387
|
+
(
|
|
388
|
+
state_bytes(
|
|
389
|
+
after,
|
|
390
|
+
self.limits.seen_ids_per_category,
|
|
391
|
+
self.limits.state_bytes,
|
|
392
|
+
),
|
|
393
|
+
key,
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
assert self._state(connection, context.identifier) == (after, last)
|
|
397
|
+
return len(identifiers)
|
|
398
|
+
|
|
399
|
+
def _raw(
|
|
400
|
+
self, connection: sqlite3.Connection, context: AccountContext
|
|
401
|
+
) -> tuple[str, ScheduleEventResponse, int, int | None] | None:
|
|
402
|
+
row = connection.execute(
|
|
403
|
+
"SELECT identifier,metadata,body,cursor,total FROM notification_raw "
|
|
404
|
+
"WHERE context=?",
|
|
405
|
+
(self._context_key(context.identifier),),
|
|
406
|
+
).fetchone()
|
|
407
|
+
if row is None:
|
|
408
|
+
return None
|
|
409
|
+
identifier, metadata, body, cursor, total = row
|
|
410
|
+
if (
|
|
411
|
+
type(metadata) is not bytes
|
|
412
|
+
or type(body) is not bytes
|
|
413
|
+
or type(cursor) is not int
|
|
414
|
+
or not 0 <= cursor <= 1024
|
|
415
|
+
or (
|
|
416
|
+
total is not None
|
|
417
|
+
and (type(total) is not int or not cursor <= total <= 1024)
|
|
418
|
+
)
|
|
419
|
+
or identifier
|
|
420
|
+
!= _raw_id(self._context_key(context.identifier), metadata, body)
|
|
421
|
+
):
|
|
422
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
423
|
+
return (
|
|
424
|
+
identifier,
|
|
425
|
+
restore_envelope(metadata, body, context.alias),
|
|
426
|
+
cursor,
|
|
427
|
+
total,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def _pending(
|
|
431
|
+
self, context: AccountContext
|
|
432
|
+
) -> tuple[
|
|
433
|
+
NotificationBatch | None,
|
|
434
|
+
tuple[str, ScheduleEventResponse, int, int | None] | None,
|
|
435
|
+
bool,
|
|
436
|
+
]:
|
|
437
|
+
with self._connection() as connection:
|
|
438
|
+
self._validate_contents(connection)
|
|
439
|
+
delivery = self._delivery(connection, context)
|
|
440
|
+
raw = self._raw(connection, context)
|
|
441
|
+
reservation = (
|
|
442
|
+
connection.execute(
|
|
443
|
+
"SELECT 1 FROM notification_reservations WHERE context=?",
|
|
444
|
+
(self._context_key(context.identifier),),
|
|
445
|
+
).fetchone()
|
|
446
|
+
is not None
|
|
447
|
+
)
|
|
448
|
+
if reservation and raw is not None:
|
|
449
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
450
|
+
return delivery[0] if delivery else None, raw, reservation
|
|
451
|
+
|
|
452
|
+
def _reserve(self, context: str, body_bytes: int) -> None:
|
|
453
|
+
context = self._context_key(context)
|
|
454
|
+
if type(body_bytes) is not int or not 1 <= body_bytes <= WIRE_BYTES:
|
|
455
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
456
|
+
with self._connection() as connection:
|
|
457
|
+
self._validate_contents(connection)
|
|
458
|
+
if (
|
|
459
|
+
connection.execute(
|
|
460
|
+
"SELECT 1 FROM notification_raw WHERE context=?", (context,)
|
|
461
|
+
).fetchone()
|
|
462
|
+
or connection.execute(
|
|
463
|
+
"SELECT 1 FROM notification_reservations WHERE context=?",
|
|
464
|
+
(context,),
|
|
465
|
+
).fetchone()
|
|
466
|
+
):
|
|
467
|
+
raise LibrusError(ErrorKind.CHECKPOINT)
|
|
468
|
+
connection.execute(
|
|
469
|
+
"INSERT INTO notification_reservations VALUES (?,?)",
|
|
470
|
+
(context, body_bytes + META_BYTES),
|
|
471
|
+
)
|
|
472
|
+
self._validate_contents(connection)
|
|
473
|
+
|
|
474
|
+
def _checkpoint(
|
|
475
|
+
self, context: AccountContext, response: ScheduleEventResponse
|
|
476
|
+
) -> None:
|
|
477
|
+
metadata, body = encode_envelope(response, context.alias)
|
|
478
|
+
key = self._context_key(context.identifier)
|
|
479
|
+
with self._connection() as connection:
|
|
480
|
+
self._validate_contents(connection)
|
|
481
|
+
row = connection.execute(
|
|
482
|
+
"SELECT bytes FROM notification_reservations WHERE context=?",
|
|
483
|
+
(key,),
|
|
484
|
+
).fetchone()
|
|
485
|
+
if row is None or len(metadata) + len(body) > row[0]:
|
|
486
|
+
raise LibrusError(ErrorKind.CHECKPOINT)
|
|
487
|
+
identifier = _raw_id(key, metadata, body)
|
|
488
|
+
connection.execute(
|
|
489
|
+
"INSERT INTO notification_raw VALUES (?,?,?,?,0,NULL)",
|
|
490
|
+
(key, identifier, metadata, body),
|
|
491
|
+
)
|
|
492
|
+
connection.execute(
|
|
493
|
+
"DELETE FROM notification_reservations WHERE context=?",
|
|
494
|
+
(key,),
|
|
495
|
+
)
|
|
496
|
+
self._validate_contents(connection)
|
|
497
|
+
|
|
498
|
+
def _stage(
|
|
499
|
+
self,
|
|
500
|
+
batch: NotificationBatch,
|
|
501
|
+
raw_identifier: str | None,
|
|
502
|
+
cursor_after: int | None,
|
|
503
|
+
total: int | None,
|
|
504
|
+
) -> None:
|
|
505
|
+
stored_context = replace(
|
|
506
|
+
batch.context, identifier=self._context_key(batch.context.identifier)
|
|
507
|
+
)
|
|
508
|
+
payload = encode_batch(
|
|
509
|
+
replace(batch, context=stored_context), self.limits.batch_bytes
|
|
510
|
+
)
|
|
511
|
+
if len(batch.items) > self.limits.batch_items:
|
|
512
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
513
|
+
with self._connection() as connection:
|
|
514
|
+
self._validate_contents(connection)
|
|
515
|
+
state, _ = self._state(connection, batch.context.identifier)
|
|
516
|
+
after = state_bytes(
|
|
517
|
+
_seen_after(state, batch.items, self.limits.seen_ids_per_category),
|
|
518
|
+
self.limits.seen_ids_per_category,
|
|
519
|
+
self.limits.state_bytes,
|
|
520
|
+
)
|
|
521
|
+
if raw_identifier is not None:
|
|
522
|
+
raw = self._raw(connection, batch.context)
|
|
523
|
+
if (
|
|
524
|
+
raw is None
|
|
525
|
+
or raw[0] != raw_identifier
|
|
526
|
+
or cursor_after is None
|
|
527
|
+
or total is None
|
|
528
|
+
or not raw[2] <= cursor_after <= total <= 1024
|
|
529
|
+
):
|
|
530
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
531
|
+
connection.execute(
|
|
532
|
+
"UPDATE notification_raw SET total=? WHERE context=?",
|
|
533
|
+
(total, stored_context.identifier),
|
|
534
|
+
)
|
|
535
|
+
connection.execute(
|
|
536
|
+
"INSERT INTO notification_deliveries VALUES (?,?,?,?,?,?)",
|
|
537
|
+
(
|
|
538
|
+
stored_context.identifier,
|
|
539
|
+
batch.receipt,
|
|
540
|
+
payload,
|
|
541
|
+
after,
|
|
542
|
+
raw_identifier,
|
|
543
|
+
cursor_after,
|
|
544
|
+
),
|
|
545
|
+
)
|
|
546
|
+
self._validate_contents(connection)
|
|
547
|
+
self._delivery(connection, batch.context)
|
|
548
|
+
|
|
549
|
+
def _delivery(
|
|
550
|
+
self, connection: sqlite3.Connection, context: AccountContext
|
|
551
|
+
) -> tuple[NotificationBatch, NotificationState, str | None, int | None] | None:
|
|
552
|
+
row = connection.execute(
|
|
553
|
+
"SELECT receipt,payload,state_after,raw_identifier,cursor_after "
|
|
554
|
+
"FROM notification_deliveries WHERE context=?",
|
|
555
|
+
(self._context_key(context.identifier),),
|
|
556
|
+
).fetchone()
|
|
557
|
+
if row is None:
|
|
558
|
+
return None
|
|
559
|
+
if type(row[1]) is not bytes or type(row[2]) is not bytes:
|
|
560
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
561
|
+
batch = restore_batch(row[1], self.limits.batch_bytes)
|
|
562
|
+
if batch.context != replace(
|
|
563
|
+
context, identifier=self._context_key(context.identifier)
|
|
564
|
+
):
|
|
565
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
566
|
+
batch = replace(batch, context=context)
|
|
567
|
+
after = restore_state(
|
|
568
|
+
row[2], self.limits.seen_ids_per_category, self.limits.state_bytes
|
|
569
|
+
)
|
|
570
|
+
before, _ = self._state(connection, context.identifier)
|
|
571
|
+
seen = {entry.category: set(entry.identifiers) for entry in before.seen}
|
|
572
|
+
keys = [(item.category, item.identifier) for item in batch.items]
|
|
573
|
+
if (
|
|
574
|
+
batch.context != context
|
|
575
|
+
or batch.receipt != row[0]
|
|
576
|
+
or batch.first_run == before.initialized
|
|
577
|
+
or len(batch.items) > self.limits.batch_items
|
|
578
|
+
or len(set(keys)) != len(keys)
|
|
579
|
+
or any(identifier in seen[category] for category, identifier in keys)
|
|
580
|
+
or after
|
|
581
|
+
!= _seen_after(before, batch.items, self.limits.seen_ids_per_category)
|
|
582
|
+
):
|
|
583
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
584
|
+
raw = self._raw(connection, context)
|
|
585
|
+
if row[3] is None:
|
|
586
|
+
if (
|
|
587
|
+
row[4] is not None
|
|
588
|
+
or batch.has_more_schedule
|
|
589
|
+
or NotificationCategory.AGENDA in batch.categories
|
|
590
|
+
):
|
|
591
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
592
|
+
elif (
|
|
593
|
+
raw is None
|
|
594
|
+
or raw[0] != row[3]
|
|
595
|
+
or type(row[4]) is not int
|
|
596
|
+
or raw[3] is None
|
|
597
|
+
or not raw[2] <= row[4] <= raw[3]
|
|
598
|
+
or batch.has_more_schedule != (row[4] < raw[3])
|
|
599
|
+
):
|
|
600
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
601
|
+
return batch, after, row[3], row[4]
|
|
602
|
+
|
|
603
|
+
async def acknowledge(self, receipt: str, *, context: AccountContext) -> None:
|
|
604
|
+
if type(receipt) is not str or HEX.fullmatch(receipt) is None:
|
|
605
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
606
|
+
|
|
607
|
+
async def commit() -> None:
|
|
608
|
+
await self._io(lambda: self._ack(context, receipt), finishing=True)
|
|
609
|
+
|
|
610
|
+
await self._transaction(context, commit)
|
|
611
|
+
|
|
612
|
+
def _ack(self, context: AccountContext, receipt: str) -> None:
|
|
613
|
+
with self._connection() as connection:
|
|
614
|
+
self._validate_contents(connection)
|
|
615
|
+
delivery = self._delivery(connection, context)
|
|
616
|
+
if self._state(connection, context.identifier)[1] == receipt:
|
|
617
|
+
# A late acknowledgement retry must leave a newer batch intact.
|
|
618
|
+
return
|
|
619
|
+
if delivery is None:
|
|
620
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
621
|
+
batch, after, raw_identifier, cursor_after = delivery
|
|
622
|
+
if batch.receipt != receipt:
|
|
623
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
624
|
+
payload = state_bytes(
|
|
625
|
+
after, self.limits.seen_ids_per_category, self.limits.state_bytes
|
|
626
|
+
)
|
|
627
|
+
connection.execute(
|
|
628
|
+
"INSERT OR REPLACE INTO notification_state VALUES (?,?,?)",
|
|
629
|
+
(self._context_key(context.identifier), payload, receipt),
|
|
630
|
+
)
|
|
631
|
+
if raw_identifier is not None:
|
|
632
|
+
raw = self._raw(connection, context)
|
|
633
|
+
assert raw is not None
|
|
634
|
+
if cursor_after == raw[3]:
|
|
635
|
+
connection.execute(
|
|
636
|
+
"DELETE FROM notification_raw WHERE context=?",
|
|
637
|
+
(self._context_key(context.identifier),),
|
|
638
|
+
)
|
|
639
|
+
else:
|
|
640
|
+
connection.execute(
|
|
641
|
+
"UPDATE notification_raw SET cursor=? WHERE context=?",
|
|
642
|
+
(cursor_after, self._context_key(context.identifier)),
|
|
643
|
+
)
|
|
644
|
+
connection.execute(
|
|
645
|
+
"DELETE FROM notification_deliveries WHERE context=?",
|
|
646
|
+
(self._context_key(context.identifier),),
|
|
647
|
+
)
|
|
648
|
+
self._validate_contents(connection)
|
|
649
|
+
|
|
650
|
+
async def resolve_uncertain_consume(
|
|
651
|
+
self, *, context: AccountContext, accept_possible_loss: bool = False
|
|
652
|
+
) -> None:
|
|
653
|
+
if type(accept_possible_loss) is not bool or not accept_possible_loss:
|
|
654
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
655
|
+
|
|
656
|
+
async def resolve() -> None:
|
|
657
|
+
await self._io(lambda: self._resolve(context))
|
|
658
|
+
|
|
659
|
+
await self._transaction(context, resolve)
|
|
660
|
+
|
|
661
|
+
def _resolve(self, context: AccountContext) -> None:
|
|
662
|
+
with self._connection() as connection:
|
|
663
|
+
self._validate_contents(connection)
|
|
664
|
+
if (
|
|
665
|
+
self._raw(connection, context) is not None
|
|
666
|
+
or self._delivery(connection, context) is not None
|
|
667
|
+
):
|
|
668
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
669
|
+
if (
|
|
670
|
+
connection.execute(
|
|
671
|
+
"DELETE FROM notification_reservations WHERE context=?",
|
|
672
|
+
(self._context_key(context.identifier),),
|
|
673
|
+
).rowcount
|
|
674
|
+
!= 1
|
|
675
|
+
):
|
|
676
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
677
|
+
|
|
678
|
+
async def export_archive(self, *, context: AccountContext) -> NotificationArchive:
|
|
679
|
+
async def export() -> NotificationArchive:
|
|
680
|
+
payload = await self._io(lambda: self._export(context))
|
|
681
|
+
return NotificationArchive(3, context, payload)
|
|
682
|
+
|
|
683
|
+
return await self._transaction(context, export)
|
|
684
|
+
|
|
685
|
+
def _export(self, context: AccountContext) -> bytes:
|
|
686
|
+
with self._connection() as connection:
|
|
687
|
+
self._validate_contents(connection)
|
|
688
|
+
state, last = self._state(connection, context.identifier)
|
|
689
|
+
raw = self._raw(connection, context)
|
|
690
|
+
delivery = self._delivery(connection, context)
|
|
691
|
+
reservation = connection.execute(
|
|
692
|
+
"SELECT bytes FROM notification_reservations WHERE context=?",
|
|
693
|
+
(self._context_key(context.identifier),),
|
|
694
|
+
).fetchone()
|
|
695
|
+
raw_record = None
|
|
696
|
+
if raw:
|
|
697
|
+
metadata, body = encode_envelope(raw[1], context.alias)
|
|
698
|
+
raw_record = {
|
|
699
|
+
"identifier": raw[0],
|
|
700
|
+
"metadata": load(metadata, META_BYTES),
|
|
701
|
+
"body": base64.b64encode(body).decode("ascii"),
|
|
702
|
+
"cursor": raw[2],
|
|
703
|
+
"total": raw[3],
|
|
704
|
+
}
|
|
705
|
+
delivery_record = None
|
|
706
|
+
if delivery:
|
|
707
|
+
delivery_record = {
|
|
708
|
+
"batch": load(
|
|
709
|
+
encode_batch(
|
|
710
|
+
replace(
|
|
711
|
+
delivery[0],
|
|
712
|
+
context=replace(
|
|
713
|
+
context,
|
|
714
|
+
identifier=self._context_key(context.identifier),
|
|
715
|
+
),
|
|
716
|
+
),
|
|
717
|
+
self.limits.batch_bytes,
|
|
718
|
+
),
|
|
719
|
+
self.limits.batch_bytes,
|
|
720
|
+
),
|
|
721
|
+
"state_after": asdict(delivery[1]),
|
|
722
|
+
"raw_identifier": delivery[2],
|
|
723
|
+
"cursor_after": delivery[3],
|
|
724
|
+
}
|
|
725
|
+
return dump(
|
|
726
|
+
{
|
|
727
|
+
"state": asdict(state),
|
|
728
|
+
"version": 3,
|
|
729
|
+
"context": asdict(
|
|
730
|
+
replace(
|
|
731
|
+
context, identifier=self._context_key(context.identifier)
|
|
732
|
+
)
|
|
733
|
+
),
|
|
734
|
+
"context_salt": self._context_salt.hex()
|
|
735
|
+
if self._context_salt
|
|
736
|
+
else None,
|
|
737
|
+
"last_receipt": last,
|
|
738
|
+
"raw": raw_record,
|
|
739
|
+
"delivery": delivery_record,
|
|
740
|
+
"reservation": reservation[0] if reservation else None,
|
|
741
|
+
},
|
|
742
|
+
ARCHIVE_BYTES,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
async def import_archive(self, archive: NotificationArchive) -> None:
|
|
746
|
+
if (
|
|
747
|
+
not isinstance(archive, NotificationArchive)
|
|
748
|
+
or type(archive.version) is not int
|
|
749
|
+
or archive.version != 3
|
|
750
|
+
or type(archive.payload) is not bytes
|
|
751
|
+
):
|
|
752
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
753
|
+
|
|
754
|
+
async def restore() -> None:
|
|
755
|
+
await self._io(lambda: self._import(archive))
|
|
756
|
+
|
|
757
|
+
await self._transaction(archive.context, restore)
|
|
758
|
+
|
|
759
|
+
def _rebind_archive(self, record: dict[str, Any], context: AccountContext) -> None:
|
|
760
|
+
"""Validate source namespace before rebinding to this empty target store."""
|
|
761
|
+
salt = record["context_salt"]
|
|
762
|
+
if type(salt) is not str or HEX.fullmatch(salt) is None:
|
|
763
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
764
|
+
source_key = hmac.new(
|
|
765
|
+
bytes.fromhex(salt), bytes.fromhex(context.identifier), hashlib.sha256
|
|
766
|
+
).hexdigest()
|
|
767
|
+
source_context = replace(context, identifier=source_key)
|
|
768
|
+
if record["context"] != asdict(source_context):
|
|
769
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
770
|
+
target_context = replace(
|
|
771
|
+
context, identifier=self._context_key(context.identifier)
|
|
772
|
+
)
|
|
773
|
+
record["context"] = asdict(target_context)
|
|
774
|
+
raw, delivery = record["raw"], record["delivery"]
|
|
775
|
+
source_raw: str | None = None
|
|
776
|
+
target_raw: str | None = None
|
|
777
|
+
if raw is not None:
|
|
778
|
+
if (
|
|
779
|
+
type(raw) is not dict
|
|
780
|
+
or set(raw) != {"identifier", "metadata", "body", "cursor", "total"}
|
|
781
|
+
or type(raw["body"]) is not str
|
|
782
|
+
):
|
|
783
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
784
|
+
try:
|
|
785
|
+
body = base64.b64decode(raw["body"], validate=True)
|
|
786
|
+
except (ValueError, UnicodeError):
|
|
787
|
+
raise LibrusError(ErrorKind.PARSE) from None
|
|
788
|
+
metadata = dump(raw["metadata"], META_BYTES)
|
|
789
|
+
source_raw = _raw_id(source_key, metadata, body)
|
|
790
|
+
if raw["identifier"] != source_raw:
|
|
791
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
792
|
+
target_raw = _raw_id(target_context.identifier, metadata, body)
|
|
793
|
+
raw["identifier"] = target_raw
|
|
794
|
+
if delivery is not None:
|
|
795
|
+
if type(delivery) is not dict or set(delivery) != {
|
|
796
|
+
"batch",
|
|
797
|
+
"state_after",
|
|
798
|
+
"raw_identifier",
|
|
799
|
+
"cursor_after",
|
|
800
|
+
}:
|
|
801
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
802
|
+
batch = restore_batch(
|
|
803
|
+
dump(delivery["batch"], self.limits.batch_bytes),
|
|
804
|
+
self.limits.batch_bytes,
|
|
805
|
+
)
|
|
806
|
+
if (
|
|
807
|
+
batch.context != source_context
|
|
808
|
+
or delivery["raw_identifier"] != source_raw
|
|
809
|
+
):
|
|
810
|
+
# Ordinary-only batches may coexist with pending raw recovery.
|
|
811
|
+
if (
|
|
812
|
+
batch.context != source_context
|
|
813
|
+
or delivery["raw_identifier"] is not None
|
|
814
|
+
):
|
|
815
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
816
|
+
if delivery["raw_identifier"] is not None:
|
|
817
|
+
delivery["raw_identifier"] = target_raw
|
|
818
|
+
delivery["batch"] = load(
|
|
819
|
+
encode_batch(
|
|
820
|
+
replace(batch, context=target_context), self.limits.batch_bytes
|
|
821
|
+
),
|
|
822
|
+
self.limits.batch_bytes,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
def _import(self, archive: NotificationArchive) -> None:
|
|
826
|
+
record = load(archive.payload, ARCHIVE_BYTES)
|
|
827
|
+
if type(record) is not dict or set(record) != {
|
|
828
|
+
"state",
|
|
829
|
+
"last_receipt",
|
|
830
|
+
"raw",
|
|
831
|
+
"delivery",
|
|
832
|
+
"reservation",
|
|
833
|
+
"version",
|
|
834
|
+
"context",
|
|
835
|
+
"context_salt",
|
|
836
|
+
}:
|
|
837
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
838
|
+
context = archive.context
|
|
839
|
+
if type(record["version"]) is not int or record["version"] != 3:
|
|
840
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
841
|
+
self._rebind_archive(record, context)
|
|
842
|
+
with self._connection() as connection:
|
|
843
|
+
self._validate_contents(connection)
|
|
844
|
+
# Import is empty-target only, never a silent history reset.
|
|
845
|
+
for table in (
|
|
846
|
+
"notification_state",
|
|
847
|
+
"notification_raw",
|
|
848
|
+
"notification_deliveries",
|
|
849
|
+
"notification_reservations",
|
|
850
|
+
):
|
|
851
|
+
if connection.execute(
|
|
852
|
+
f"SELECT 1 FROM {table} WHERE context=?",
|
|
853
|
+
(self._context_key(context.identifier),),
|
|
854
|
+
).fetchone():
|
|
855
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
856
|
+
state = restore_state(
|
|
857
|
+
dump(record["state"], self.limits.state_bytes),
|
|
858
|
+
self.limits.seen_ids_per_category,
|
|
859
|
+
self.limits.state_bytes,
|
|
860
|
+
)
|
|
861
|
+
last = record["last_receipt"]
|
|
862
|
+
if last is not None and (
|
|
863
|
+
type(last) is not str
|
|
864
|
+
or HEX.fullmatch(last) is None
|
|
865
|
+
or not state.initialized
|
|
866
|
+
):
|
|
867
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
868
|
+
if state.initialized or last is not None:
|
|
869
|
+
connection.execute(
|
|
870
|
+
"INSERT INTO notification_state VALUES (?,?,?)",
|
|
871
|
+
(
|
|
872
|
+
self._context_key(context.identifier),
|
|
873
|
+
state_bytes(
|
|
874
|
+
state,
|
|
875
|
+
self.limits.seen_ids_per_category,
|
|
876
|
+
self.limits.state_bytes,
|
|
877
|
+
),
|
|
878
|
+
last,
|
|
879
|
+
),
|
|
880
|
+
)
|
|
881
|
+
raw = record["raw"]
|
|
882
|
+
if raw is not None:
|
|
883
|
+
self._import_raw(connection, context, raw)
|
|
884
|
+
reservation = record["reservation"]
|
|
885
|
+
if reservation is not None:
|
|
886
|
+
if (
|
|
887
|
+
raw is not None
|
|
888
|
+
or type(reservation) is not int
|
|
889
|
+
or not 1 <= reservation <= WIRE_BYTES + META_BYTES
|
|
890
|
+
):
|
|
891
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
892
|
+
connection.execute(
|
|
893
|
+
"INSERT INTO notification_reservations VALUES (?,?)",
|
|
894
|
+
(self._context_key(context.identifier), reservation),
|
|
895
|
+
)
|
|
896
|
+
delivery = record["delivery"]
|
|
897
|
+
if delivery is not None:
|
|
898
|
+
if type(delivery) is not dict or set(delivery) != {
|
|
899
|
+
"batch",
|
|
900
|
+
"state_after",
|
|
901
|
+
"raw_identifier",
|
|
902
|
+
"cursor_after",
|
|
903
|
+
}:
|
|
904
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
905
|
+
batch = restore_batch(
|
|
906
|
+
dump(delivery["batch"], self.limits.batch_bytes),
|
|
907
|
+
self.limits.batch_bytes,
|
|
908
|
+
)
|
|
909
|
+
if batch.context != replace(
|
|
910
|
+
context, identifier=self._context_key(context.identifier)
|
|
911
|
+
):
|
|
912
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
913
|
+
public_batch = replace(batch, context=context)
|
|
914
|
+
after = restore_state(
|
|
915
|
+
dump(delivery["state_after"], self.limits.state_bytes),
|
|
916
|
+
self.limits.seen_ids_per_category,
|
|
917
|
+
self.limits.state_bytes,
|
|
918
|
+
)
|
|
919
|
+
connection.execute(
|
|
920
|
+
"INSERT INTO notification_deliveries VALUES (?,?,?,?,?,?)",
|
|
921
|
+
(
|
|
922
|
+
self._context_key(context.identifier),
|
|
923
|
+
batch.receipt,
|
|
924
|
+
encode_batch(batch, self.limits.batch_bytes),
|
|
925
|
+
state_bytes(
|
|
926
|
+
after,
|
|
927
|
+
self.limits.seen_ids_per_category,
|
|
928
|
+
self.limits.state_bytes,
|
|
929
|
+
),
|
|
930
|
+
delivery["raw_identifier"],
|
|
931
|
+
delivery["cursor_after"],
|
|
932
|
+
),
|
|
933
|
+
)
|
|
934
|
+
self._delivery(connection, context)
|
|
935
|
+
self._validate_import_delivery(connection, context, public_batch)
|
|
936
|
+
self._validate_contents(connection)
|
|
937
|
+
|
|
938
|
+
def _validate_import_delivery(
|
|
939
|
+
self,
|
|
940
|
+
connection: sqlite3.Connection,
|
|
941
|
+
context: AccountContext,
|
|
942
|
+
batch: NotificationBatch,
|
|
943
|
+
) -> None:
|
|
944
|
+
"""Prove imported cursor progress does not discard an undelivered event."""
|
|
945
|
+
raw = self._raw(connection, context)
|
|
946
|
+
if NotificationCategory.AGENDA not in batch.categories:
|
|
947
|
+
return
|
|
948
|
+
if raw is None:
|
|
949
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
950
|
+
delivery = self._delivery(connection, context)
|
|
951
|
+
assert delivery is not None and delivery[3] is not None
|
|
952
|
+
response = raw[1]
|
|
953
|
+
events = parse_schedule_events(
|
|
954
|
+
decode_payload(response.wire.body, response.wire, WIRE_BYTES)
|
|
955
|
+
)
|
|
956
|
+
before, _ = self._state(connection, context.identifier)
|
|
957
|
+
seen = {
|
|
958
|
+
identifier
|
|
959
|
+
for entry in before.seen
|
|
960
|
+
if entry.category is NotificationCategory.AGENDA
|
|
961
|
+
for identifier in entry.identifiers
|
|
962
|
+
}
|
|
963
|
+
expected = []
|
|
964
|
+
for value in events[raw[2] : delivery[3]]:
|
|
965
|
+
identifier = canonical_notification_id(NotificationCategory.AGENDA, value)
|
|
966
|
+
if identifier not in seen:
|
|
967
|
+
expected.append(
|
|
968
|
+
NotificationItem(
|
|
969
|
+
NotificationCategory.AGENDA,
|
|
970
|
+
identifier,
|
|
971
|
+
value,
|
|
972
|
+
response.identity,
|
|
973
|
+
response.observation,
|
|
974
|
+
)
|
|
975
|
+
)
|
|
976
|
+
seen.add(identifier)
|
|
977
|
+
actual = [
|
|
978
|
+
item for item in batch.items if item.category is NotificationCategory.AGENDA
|
|
979
|
+
]
|
|
980
|
+
if actual != expected:
|
|
981
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
982
|
+
|
|
983
|
+
def _import_raw(
|
|
984
|
+
self, connection: sqlite3.Connection, context: AccountContext, raw: Any
|
|
985
|
+
) -> None:
|
|
986
|
+
if (
|
|
987
|
+
type(raw) is not dict
|
|
988
|
+
or set(raw) != {"identifier", "metadata", "body", "cursor", "total"}
|
|
989
|
+
or type(raw["body"]) is not str
|
|
990
|
+
):
|
|
991
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
992
|
+
failed = False
|
|
993
|
+
body = b""
|
|
994
|
+
try:
|
|
995
|
+
body = base64.b64decode(raw["body"], validate=True)
|
|
996
|
+
except (ValueError, UnicodeError):
|
|
997
|
+
failed = True
|
|
998
|
+
if (
|
|
999
|
+
failed
|
|
1000
|
+
or type(raw["cursor"]) is not int
|
|
1001
|
+
or (raw["total"] is not None and type(raw["total"]) is not int)
|
|
1002
|
+
):
|
|
1003
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
1004
|
+
metadata = dump(raw["metadata"], META_BYTES)
|
|
1005
|
+
response = restore_envelope(metadata, body, context.alias)
|
|
1006
|
+
if raw["total"] is None and raw["cursor"] != 0:
|
|
1007
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
1008
|
+
if raw["total"] is not None:
|
|
1009
|
+
events = parse_schedule_events(
|
|
1010
|
+
decode_payload(body, response.wire, WIRE_BYTES)
|
|
1011
|
+
)
|
|
1012
|
+
if raw["total"] != len(events):
|
|
1013
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
1014
|
+
# Acknowledged progress marks every event before the cursor seen; an
|
|
1015
|
+
# unseen one there would be skipped without ever being delivered.
|
|
1016
|
+
state, _ = self._state(connection, context.identifier)
|
|
1017
|
+
seen = {
|
|
1018
|
+
identifier
|
|
1019
|
+
for entry in state.seen
|
|
1020
|
+
if entry.category is NotificationCategory.AGENDA
|
|
1021
|
+
for identifier in entry.identifiers
|
|
1022
|
+
}
|
|
1023
|
+
if any(
|
|
1024
|
+
canonical_notification_id(NotificationCategory.AGENDA, value)
|
|
1025
|
+
not in seen
|
|
1026
|
+
for value in events[: raw["cursor"]]
|
|
1027
|
+
):
|
|
1028
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
1029
|
+
if raw["identifier"] != _raw_id(
|
|
1030
|
+
self._context_key(context.identifier), metadata, body
|
|
1031
|
+
):
|
|
1032
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
1033
|
+
connection.execute(
|
|
1034
|
+
"INSERT INTO notification_raw VALUES (?,?,?,?,?,?)",
|
|
1035
|
+
(
|
|
1036
|
+
self._context_key(context.identifier),
|
|
1037
|
+
raw["identifier"],
|
|
1038
|
+
metadata,
|
|
1039
|
+
body,
|
|
1040
|
+
raw["cursor"],
|
|
1041
|
+
raw["total"],
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
self._raw(connection, context)
|