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.
Files changed (46) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_codec.py +310 -0
  3. librus_python_api/_storage.py +367 -0
  4. librus_python_api/announcements.py +159 -0
  5. librus_python_api/attachment_routes.py +114 -0
  6. librus_python_api/attachments.py +297 -0
  7. librus_python_api/attendance.py +182 -0
  8. librus_python_api/attendance_frequency.py +112 -0
  9. librus_python_api/budget.py +79 -0
  10. librus_python_api/checkpoint.py +61 -0
  11. librus_python_api/completed_lessons.py +216 -0
  12. librus_python_api/config.py +1410 -0
  13. librus_python_api/detail_fields.py +50 -0
  14. librus_python_api/diagnostics.py +25 -0
  15. librus_python_api/exceptions.py +172 -0
  16. librus_python_api/files.py +156 -0
  17. librus_python_api/grade_parsers.py +169 -0
  18. librus_python_api/grade_records.py +454 -0
  19. librus_python_api/homework_range.py +41 -0
  20. librus_python_api/lifecycle.py +24 -0
  21. librus_python_api/markup.py +147 -0
  22. librus_python_api/message_content.py +230 -0
  23. librus_python_api/messages.py +288 -0
  24. librus_python_api/models.py +1160 -0
  25. librus_python_api/modern_body.py +75 -0
  26. librus_python_api/modern_mailbox.py +459 -0
  27. librus_python_api/modern_messages.py +276 -0
  28. librus_python_api/notification_models.py +67 -0
  29. librus_python_api/notification_persistence.py +1044 -0
  30. librus_python_api/notification_workflow.py +303 -0
  31. librus_python_api/notifications.py +216 -0
  32. librus_python_api/parsers.py +232 -0
  33. librus_python_api/parsing.py +49 -0
  34. librus_python_api/persistence.py +405 -0
  35. librus_python_api/py.typed +0 -0
  36. librus_python_api/recipients.py +271 -0
  37. librus_python_api/scheduler.py +287 -0
  38. librus_python_api/school_reads.py +400 -0
  39. librus_python_api/sending.py +125 -0
  40. librus_python_api/service.py +2285 -0
  41. librus_python_api/timetable.py +261 -0
  42. librus_python_api/transport.py +956 -0
  43. librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
  44. librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
  45. librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
  46. librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,367 @@
1
+ """Private bounded SQLite ownership shared by explicit optional stores."""
2
+
3
+ import asyncio
4
+ import contextvars
5
+ import hashlib
6
+ import hmac
7
+ import os
8
+ import secrets
9
+ import sqlite3
10
+ import stat
11
+ from collections.abc import Callable, Coroutine, Iterator
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import Any, Self
15
+
16
+ from pydantic import Field
17
+
18
+ from librus_python_api.config import _ValidatedConfig
19
+ from librus_python_api.exceptions import ErrorKind, LibrusError
20
+ from librus_python_api.lifecycle import join_owned
21
+
22
+
23
+ class _StorageLimits(_ValidatedConfig):
24
+ operations: int = Field(default=8, ge=1, le=64)
25
+ busy_timeout_seconds: float = Field(default=0.1, gt=0, le=5, allow_inf_nan=False)
26
+ # Final saves follow upstream side effects (a dispatched send or a consumed
27
+ # read-once page), so they wait longer for other contexts' write locks.
28
+ final_busy_timeout_seconds: float = Field(
29
+ default=5, gt=0, le=60, allow_inf_nan=False
30
+ )
31
+
32
+
33
+ # Worker threads inherit the calling task's context through asyncio.to_thread.
34
+ _busy_timeout_seconds: contextvars.ContextVar[float | None] = contextvars.ContextVar(
35
+ "_busy_timeout_seconds", default=None
36
+ )
37
+
38
+ _STORE_META_SCHEMA = """CREATE TABLE store_metadata (
39
+ name TEXT PRIMARY KEY CHECK(name='context_salt'),
40
+ value BLOB NOT NULL CHECK(length(value)=32)
41
+ )"""
42
+
43
+
44
+ class _SQLiteStore:
45
+ _filename = "state.sqlite3"
46
+ _database_bytes = 8 * 1024 * 1024
47
+ _schema: tuple[str, ...] = ()
48
+
49
+ def __init__(self, directory: Path, *, limits: _StorageLimits) -> None:
50
+ if not isinstance(directory, Path) or not directory.is_absolute():
51
+ raise LibrusError(ErrorKind.INVALID_INPUT)
52
+ self._directory = directory
53
+ self._path = directory / self._filename
54
+ self._storage_limits = limits
55
+ self._opened = self._opening = self._closing = self._closed = False
56
+ self._operations = 0
57
+ self._lock = asyncio.Lock()
58
+ self._tasks: set[asyncio.Task[Any]] = set()
59
+ self._workflows: set[asyncio.Task[Any]] = set()
60
+ self._close_task: asyncio.Task[None] | None = None
61
+ self._file_identity: tuple[int, int] | None = None
62
+ self._context_salt: bytes | None = None
63
+
64
+ def context_identifier(self, context: str) -> str:
65
+ """Store-local context pseudonym; requires an explicitly opened store."""
66
+ if not self._opened or self._closed:
67
+ raise LibrusError(ErrorKind.CLOSED)
68
+ return self._context_key(context)
69
+
70
+ def _context_key(self, context: str) -> str:
71
+ if type(context) is not str or len(context) != 64:
72
+ raise LibrusError(ErrorKind.INVALID_INPUT)
73
+ try:
74
+ decoded = bytes.fromhex(context)
75
+ except ValueError:
76
+ raise LibrusError(ErrorKind.INVALID_INPUT) from None
77
+ if len(decoded) != 32 or context != decoded.hex():
78
+ raise LibrusError(ErrorKind.INVALID_INPUT)
79
+ assert self._context_salt is not None
80
+ return hmac.new(self._context_salt, decoded, hashlib.sha256).hexdigest()
81
+
82
+ async def __aenter__(self) -> Self:
83
+ await self.open()
84
+ return self
85
+
86
+ async def __aexit__(self, *args: object) -> None:
87
+ await self.aclose()
88
+
89
+ async def open(self) -> None:
90
+ if os.name != "posix":
91
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
92
+ if self._opened or self._opening or self._closing or self._closed:
93
+ raise LibrusError(ErrorKind.INVALID_INPUT)
94
+ self._opening = True
95
+ try:
96
+ await self._io(self._initialise, opening=True)
97
+ self._opened = True
98
+ finally:
99
+ self._opening = False
100
+
101
+ async def aclose(self) -> None:
102
+ if (
103
+ asyncio.current_task() in self._tasks
104
+ or asyncio.current_task() in self._workflows
105
+ ):
106
+ raise LibrusError(ErrorKind.INVALID_INPUT)
107
+ if self._close_task is None:
108
+ self._closing = True
109
+ self._close_task = asyncio.create_task(self._close())
110
+ interrupted = await join_owned(self._close_task)
111
+ self._close_task.result()
112
+ if interrupted:
113
+ raise asyncio.CancelledError
114
+
115
+ async def _close(self) -> None:
116
+ # Join workflow-owned final saves before cancelling ordinary workers.
117
+ for task in tuple(self._workflows):
118
+ task.cancel()
119
+ for task in tuple(self._workflows):
120
+ await join_owned(task)
121
+ tasks = tuple(self._tasks)
122
+ for task in tasks:
123
+ task.cancel()
124
+ for task in tasks:
125
+ await join_owned(task)
126
+ self._closed = True
127
+ self._opened = False
128
+
129
+ async def _owned[T](self, operation: Callable[[], Coroutine[Any, Any, T]]) -> T:
130
+ if not self._opened or self._closing or self._closed:
131
+ raise LibrusError(ErrorKind.CLOSED)
132
+ if len(self._workflows) >= self._storage_limits.operations:
133
+ raise LibrusError(ErrorKind.LIMIT)
134
+ task = asyncio.create_task(operation())
135
+ self._workflows.add(task)
136
+ cancelled = False
137
+ try:
138
+ await asyncio.wait((task,))
139
+ return task.result()
140
+ except asyncio.CancelledError:
141
+ cancelled = True
142
+ task.cancel()
143
+ raise
144
+ finally:
145
+ interrupted = await join_owned(task)
146
+ self._workflows.discard(task)
147
+ if cancelled or interrupted:
148
+ raise asyncio.CancelledError
149
+
150
+ async def _io[T](
151
+ self,
152
+ operation: Callable[[], T],
153
+ *,
154
+ opening: bool = False,
155
+ finishing: bool = False,
156
+ ) -> T:
157
+ if (
158
+ self._closed
159
+ or (not self._opened and not opening)
160
+ or (self._closing and not finishing)
161
+ ):
162
+ raise LibrusError(ErrorKind.CLOSED)
163
+ if self._operations >= self._storage_limits.operations:
164
+ raise LibrusError(ErrorKind.LIMIT)
165
+ self._operations += 1
166
+ task = asyncio.create_task(
167
+ self._worker(
168
+ operation,
169
+ self._storage_limits.final_busy_timeout_seconds
170
+ if finishing
171
+ else self._storage_limits.busy_timeout_seconds,
172
+ )
173
+ )
174
+ self._tasks.add(task)
175
+ cancelled = False
176
+ try:
177
+ await asyncio.wait((task,))
178
+ return task.result()
179
+ except asyncio.CancelledError:
180
+ cancelled = True
181
+ # Final saves remain owned even while waiting for another worker.
182
+ if not finishing:
183
+ task.cancel()
184
+ raise
185
+ finally:
186
+ interrupted = await join_owned(task)
187
+ self._tasks.discard(task)
188
+ self._operations -= 1
189
+ if cancelled or interrupted:
190
+ raise asyncio.CancelledError
191
+
192
+ async def _worker[T](self, operation: Callable[[], T], busy_seconds: float) -> T:
193
+ async with self._lock:
194
+ _busy_timeout_seconds.set(busy_seconds)
195
+ task = asyncio.create_task(asyncio.to_thread(operation))
196
+ interrupted = await join_owned(task)
197
+ if interrupted:
198
+ raise asyncio.CancelledError
199
+ try:
200
+ return task.result()
201
+ except LibrusError:
202
+ raise
203
+ except Exception:
204
+ pass
205
+ raise LibrusError(ErrorKind.STORAGE)
206
+
207
+ def _check_directory(self) -> None:
208
+ # A private immediate parent is the trust boundary. Malicious same-user
209
+ # filesystem writers and unsuitable network filesystems are not isolated.
210
+ if self._directory.is_symlink():
211
+ raise LibrusError(ErrorKind.STORAGE)
212
+ self._directory.mkdir(mode=0o700, exist_ok=True)
213
+ status = self._directory.stat()
214
+ if not stat.S_ISDIR(status.st_mode):
215
+ raise LibrusError(ErrorKind.STORAGE)
216
+ if os.name == "posix" and (
217
+ status.st_uid != os.geteuid() or stat.S_IMODE(status.st_mode) & 0o077
218
+ ):
219
+ raise LibrusError(ErrorKind.STORAGE)
220
+
221
+ def _check_file(self, *, create: bool = False) -> None:
222
+ if self._path.is_symlink():
223
+ raise LibrusError(ErrorKind.STORAGE)
224
+ flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
225
+ if create:
226
+ flags |= os.O_CREAT
227
+ descriptor = os.open(self._path, flags, 0o600)
228
+ try:
229
+ status = os.fstat(descriptor)
230
+ if (
231
+ not stat.S_ISREG(status.st_mode)
232
+ or status.st_nlink != 1
233
+ or status.st_size > self._database_bytes
234
+ ):
235
+ raise LibrusError(ErrorKind.STORAGE)
236
+ if os.name == "posix" and (
237
+ status.st_uid != os.geteuid() or stat.S_IMODE(status.st_mode) & 0o077
238
+ ):
239
+ raise LibrusError(ErrorKind.STORAGE)
240
+ identity = (status.st_dev, status.st_ino)
241
+ if self._file_identity is not None and self._file_identity != identity:
242
+ raise LibrusError(ErrorKind.STORAGE)
243
+ self._file_identity = identity
244
+ finally:
245
+ os.close(descriptor)
246
+
247
+ def _check_sidecars(self) -> None:
248
+ for suffix in ("-journal", "-wal", "-shm"):
249
+ path = self._path.with_name(self._path.name + suffix)
250
+ try:
251
+ status = path.lstat()
252
+ except FileNotFoundError:
253
+ continue
254
+ if (
255
+ not stat.S_ISREG(status.st_mode)
256
+ or status.st_nlink != 1
257
+ or status.st_size > self._database_bytes + 1024 * 1024
258
+ or (
259
+ os.name == "posix"
260
+ and (
261
+ status.st_uid != os.geteuid()
262
+ or stat.S_IMODE(status.st_mode) & 0o077
263
+ )
264
+ )
265
+ ):
266
+ raise LibrusError(ErrorKind.STORAGE)
267
+
268
+ @contextmanager
269
+ def _connection(self, *, initialise: bool = False) -> Iterator[sqlite3.Connection]:
270
+ connection = None
271
+ kind: ErrorKind | None = None
272
+ try:
273
+ self._check_directory()
274
+ self._check_file(create=initialise)
275
+ self._check_sidecars()
276
+ busy_seconds = _busy_timeout_seconds.get()
277
+ connection = sqlite3.connect(
278
+ self._path,
279
+ timeout=self._storage_limits.busy_timeout_seconds
280
+ if busy_seconds is None
281
+ else busy_seconds,
282
+ )
283
+ connection.execute("PRAGMA trusted_schema=OFF")
284
+ connection.execute("PRAGMA foreign_keys=ON")
285
+ connection.execute("PRAGMA synchronous=FULL")
286
+ connection.execute(f"PRAGMA max_page_count={self._database_bytes // 4096}")
287
+ connection.execute("BEGIN IMMEDIATE")
288
+ if not initialise:
289
+ self._validate_schema(connection)
290
+ yield connection
291
+ connection.commit()
292
+ except LibrusError:
293
+ raise
294
+ except sqlite3.Error as error:
295
+ kind = (
296
+ ErrorKind.LIMIT
297
+ if getattr(error, "sqlite_errorcode", None)
298
+ in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED)
299
+ else ErrorKind.STORAGE
300
+ )
301
+ except OSError:
302
+ kind = ErrorKind.STORAGE
303
+ finally:
304
+ if connection is not None:
305
+ connection.close()
306
+ if kind is not None:
307
+ raise LibrusError(kind)
308
+
309
+ def _initialise(self) -> None:
310
+ with self._connection(initialise=True) as connection:
311
+ version = connection.execute("PRAGMA user_version").fetchone()[0]
312
+ if version == 0:
313
+ if connection.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone():
314
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
315
+ for sql in (_STORE_META_SCHEMA, *self._schema):
316
+ connection.execute(sql)
317
+ connection.execute(
318
+ "INSERT INTO store_metadata VALUES (?,?)",
319
+ ("context_salt", secrets.token_bytes(32)),
320
+ )
321
+ connection.execute("PRAGMA user_version=3")
322
+ self._validate_schema(connection)
323
+ self._validate_contents(connection)
324
+ if os.name == "posix":
325
+ descriptor = os.open(
326
+ self._directory,
327
+ os.O_RDONLY
328
+ | getattr(os, "O_DIRECTORY", 0)
329
+ | getattr(os, "O_NOFOLLOW", 0),
330
+ )
331
+ try:
332
+ os.fsync(descriptor)
333
+ finally:
334
+ os.close(descriptor)
335
+
336
+ def _validate_schema(self, connection: sqlite3.Connection) -> None:
337
+ expected = []
338
+ for sql in (_STORE_META_SCHEMA, *self._schema):
339
+ name = sql.split()[2]
340
+ expected.extend(
341
+ [("table", name, sql), ("index", f"sqlite_autoindex_{name}_1", None)]
342
+ )
343
+ actual = connection.execute(
344
+ "SELECT type, name, sql FROM sqlite_master ORDER BY name LIMIT 32"
345
+ ).fetchall()
346
+ if (
347
+ connection.execute("PRAGMA user_version").fetchone()[0] != 3
348
+ or connection.execute("PRAGMA page_size").fetchone()[0] != 4096
349
+ or connection.execute("PRAGMA journal_mode").fetchone()[0] != "delete"
350
+ or actual != sorted(expected, key=lambda row: row[1])
351
+ ):
352
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
353
+ rows = connection.execute(
354
+ "SELECT name,value FROM store_metadata LIMIT 2"
355
+ ).fetchall()
356
+ if (
357
+ len(rows) != 1
358
+ or rows[0][0] != "context_salt"
359
+ or type(rows[0][1]) is not bytes
360
+ or len(rows[0][1]) != 32
361
+ or (self._context_salt is not None and self._context_salt != rows[0][1])
362
+ ):
363
+ raise LibrusError(ErrorKind.STORAGE)
364
+ self._context_salt = rows[0][1]
365
+
366
+ def _validate_contents(self, connection: sqlite3.Connection) -> None:
367
+ raise NotImplementedError
@@ -0,0 +1,159 @@
1
+ """Bounded semantic school announcements, independently authored from requirements."""
2
+
3
+ import hashlib
4
+ import re
5
+ from datetime import date
6
+
7
+ from lxml import html
8
+
9
+ from librus_python_api import markup
10
+ from librus_python_api.config import (
11
+ ANNOUNCEMENT_EMPTY_CLASSES,
12
+ ANNOUNCEMENT_EMPTY_MARKERS,
13
+ ANNOUNCEMENT_LABELS,
14
+ ANNOUNCEMENT_MAX_CONTENT_LENGTH,
15
+ ANNOUNCEMENT_MAX_FIELD_LENGTH,
16
+ ANNOUNCEMENT_MAX_ITEMS,
17
+ ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH,
18
+ ANNOUNCEMENT_TABLE_CLASSES,
19
+ )
20
+ from librus_python_api.exceptions import ErrorKind, LibrusError
21
+ from librus_python_api.models import Announcement
22
+ from librus_python_api.parsers import parse_page
23
+
24
+
25
+ def _label(cell: html.HtmlElement) -> str | None:
26
+ return ANNOUNCEMENT_LABELS.get(
27
+ markup.text(cell, ANNOUNCEMENT_MAX_FIELD_LENGTH).rstrip(":").casefold()
28
+ )
29
+
30
+
31
+ def _empty(document: html.HtmlElement) -> bool:
32
+ markers: list[html.HtmlElement] = []
33
+ for node in document.iter("div"):
34
+ if ANNOUNCEMENT_EMPTY_CLASSES <= set(node.get("class", "").split()):
35
+ for child in node:
36
+ if child.tag == "div":
37
+ markers.extend(p for p in child if p.tag == "p")
38
+ if len(markers) > 1:
39
+ raise LibrusError(ErrorKind.PARSE)
40
+ return bool(markers) and (
41
+ markup.text(markers[0], ANNOUNCEMENT_MAX_FIELD_LENGTH).rstrip(".").casefold()
42
+ in ANNOUNCEMENT_EMPTY_MARKERS
43
+ )
44
+
45
+
46
+ def _tables(document: html.HtmlElement) -> list[html.HtmlElement]:
47
+ tables = []
48
+ for table in document.iter("table"):
49
+ classes = set(table.get("class", "").split())
50
+ labels = {
51
+ _label(cell)
52
+ for row in markup.rows(table)
53
+ for cell in markup.cells(row)
54
+ if cell.tag == "th"
55
+ } - {None}
56
+ known = ANNOUNCEMENT_TABLE_CLASSES <= classes
57
+ if not known and len(labels) >= 2:
58
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
59
+ if known:
60
+ if next(table.iterancestors("table"), None) is not None:
61
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
62
+ tables.append(table)
63
+ if len(tables) > ANNOUNCEMENT_MAX_ITEMS:
64
+ raise LibrusError(ErrorKind.LIMIT)
65
+ return tables
66
+
67
+
68
+ def _fields(table: html.HtmlElement) -> dict[str, str]:
69
+ if len(list(table.iter("table"))) != 1:
70
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
71
+ fields: dict[str, str] = {}
72
+ for row in markup.rows(table):
73
+ key: str | None
74
+ cells = markup.cells(row)
75
+ if next(row.iterancestors("thead"), None) is not None:
76
+ if len(cells) != 1 or cells[0].tag != "td" or "title" in fields:
77
+ raise LibrusError(ErrorKind.PARSE)
78
+ key, cell = "title", cells[0]
79
+ if cell.get("colspan", "1") not in {"1", "2"}:
80
+ raise LibrusError(ErrorKind.PARSE)
81
+ elif {"line0", "line1"}.intersection(row.get("class", "").split()):
82
+ if len(cells) != 2 or [cell.tag for cell in cells] != ["th", "td"]:
83
+ raise LibrusError(ErrorKind.PARSE)
84
+ key, cell = _label(cells[0]), cells[1]
85
+ if key is None:
86
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
87
+ if any(
88
+ c.get("colspan", "1") != "1" or c.get("rowspan", "1") != "1"
89
+ for c in cells
90
+ ):
91
+ raise LibrusError(ErrorKind.PARSE)
92
+ elif markup.text(row, ANNOUNCEMENT_MAX_FIELD_LENGTH):
93
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
94
+ else:
95
+ continue
96
+ if key in fields or cell.get("rowspan", "1") != "1":
97
+ raise LibrusError(ErrorKind.PARSE)
98
+ limit = (
99
+ ANNOUNCEMENT_MAX_CONTENT_LENGTH
100
+ if key == "content"
101
+ else ANNOUNCEMENT_MAX_FIELD_LENGTH
102
+ )
103
+ fields[key] = markup.text(cell, limit, multiline=key == "content")
104
+ if fields.keys() != {"title", "author", "date_text", "content"}:
105
+ raise LibrusError(ErrorKind.PARSE)
106
+ if any(not fields[key] for key in ("title", "author", "date_text")):
107
+ raise LibrusError(ErrorKind.PARSE)
108
+ return fields
109
+
110
+
111
+ def _day(value: str) -> date:
112
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
113
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
114
+ try:
115
+ return date.fromisoformat(value)
116
+ except ValueError:
117
+ pass
118
+ raise LibrusError(ErrorKind.PARSE)
119
+
120
+
121
+ def _reference(account: str, fields: dict[str, str]) -> str:
122
+ digest = hashlib.sha256(b"librus-announcements-v1\x00")
123
+ for value in (
124
+ account,
125
+ *(fields[key] for key in ("title", "author", "date_text", "content")),
126
+ ):
127
+ encoded = value.encode("utf-8")
128
+ digest.update(len(encoded).to_bytes(4, "big"))
129
+ digest.update(encoded)
130
+ return "content-sha256:" + digest.hexdigest()
131
+
132
+
133
+ def parse_announcements(body: bytes, account: str) -> tuple[Announcement, ...]:
134
+ if not isinstance(account, str) or not account or len(account) > 80:
135
+ raise LibrusError(ErrorKind.INVALID_INPUT)
136
+ document = parse_page(body)
137
+ tables, empty = _tables(document), _empty(document)
138
+ if empty and tables:
139
+ raise LibrusError(ErrorKind.PARSE)
140
+ if not tables and not empty:
141
+ raise LibrusError(ErrorKind.PARSE)
142
+ items = []
143
+ total = 0
144
+ for table in tables:
145
+ fields = _fields(table)
146
+ total += sum(len(value) for value in fields.values())
147
+ if total > ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH:
148
+ raise LibrusError(ErrorKind.LIMIT)
149
+ items.append(
150
+ Announcement(
151
+ reference=_reference(account, fields),
152
+ published_on=_day(fields["date_text"]),
153
+ title=fields["title"],
154
+ author=fields["author"],
155
+ date_text=fields["date_text"],
156
+ content=fields["content"],
157
+ )
158
+ )
159
+ return tuple(items)
@@ -0,0 +1,114 @@
1
+ """Fail-closed reference and signed-destination validation, never network I/O."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ from librus_python_api.config import (
7
+ ATTACHMENT_KEY_PATTERN,
8
+ ATTACHMENT_MAX_BYTES,
9
+ ATTACHMENT_MAX_LOCATION_LENGTH,
10
+ ATTACHMENT_REDIRECT_PATH,
11
+ ConnectionSettings,
12
+ )
13
+ from librus_python_api.exceptions import ErrorKind, LibrusError
14
+ from librus_python_api.message_content import validate_reference
15
+ from librus_python_api.models import (
16
+ MessageAttachmentReference,
17
+ ModernMessageAttachmentReference,
18
+ )
19
+ from librus_python_api.modern_mailbox import (
20
+ validate_reference as validate_modern_reference,
21
+ )
22
+ from librus_python_api.parsers import decode_json
23
+
24
+
25
+ def validate_attachment_reference(
26
+ reference: MessageAttachmentReference, account: str
27
+ ) -> None:
28
+ if not isinstance(reference, MessageAttachmentReference):
29
+ raise LibrusError(ErrorKind.INVALID_INPUT)
30
+ validate_reference(reference.message, account)
31
+ if (
32
+ type(reference.identifier) is not str
33
+ or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
34
+ ):
35
+ raise LibrusError(ErrorKind.INVALID_INPUT)
36
+
37
+
38
+ def validate_key(key: str) -> None:
39
+ if (
40
+ type(key) is not str
41
+ or ATTACHMENT_KEY_PATTERN.fullmatch(key) is None
42
+ or key in {".", ".."}
43
+ ):
44
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
45
+
46
+
47
+ def validate_modern_attachment_reference(
48
+ reference: ModernMessageAttachmentReference,
49
+ account: str,
50
+ ) -> None:
51
+ if not isinstance(reference, ModernMessageAttachmentReference):
52
+ raise LibrusError(ErrorKind.INVALID_INPUT)
53
+ validate_modern_reference(reference.message, account)
54
+ if (
55
+ type(reference.archived) is not bool
56
+ or type(reference.identifier) is not str
57
+ or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
58
+ ):
59
+ raise LibrusError(ErrorKind.INVALID_INPUT)
60
+
61
+
62
+ def modern_attachment_key(body: bytes, connection: ConnectionSettings) -> str:
63
+ data = decode_json(body)
64
+ if not isinstance(data, dict) or not isinstance(data.get("data"), dict):
65
+ raise LibrusError(ErrorKind.PARSE)
66
+ return signed_attachment_key(data["data"].get("downloadLink"), connection)
67
+
68
+
69
+ def validate_max_bytes(max_bytes: int) -> None:
70
+ if type(max_bytes) is not int or not 1 <= max_bytes <= ATTACHMENT_MAX_BYTES:
71
+ raise LibrusError(ErrorKind.INVALID_INPUT)
72
+
73
+
74
+ def signed_attachment_key(location: str, connection: ConnectionSettings) -> str:
75
+ """Only an exact absolute official destination or explicitly configured loopback."""
76
+ # Controls make parsing ambiguous; other encoding characters are judged only
77
+ # after origin/scheme/userinfo, so a foreign destination is always a denial.
78
+ if (
79
+ type(location) is not str
80
+ or not 1 <= len(location) <= ATTACHMENT_MAX_LOCATION_LENGTH
81
+ or any(ord(c) <= 32 or ord(c) >= 127 for c in location)
82
+ ):
83
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
84
+ invalid = False
85
+ key = ""
86
+ try:
87
+ target = urlsplit(location)
88
+ expected = urlsplit(connection.download_origin)
89
+ authorities = {expected.netloc}
90
+ if expected.hostname == "sandbox.librus.pl":
91
+ authorities = {"sandbox.librus.pl", "sandbox.librus.pl:443"}
92
+ prefix, _, suffix = ATTACHMENT_REDIRECT_PATH.partition("{key}")
93
+ if (
94
+ target.scheme != expected.scheme
95
+ or target.netloc not in authorities
96
+ or target.username is not None
97
+ or target.password is not None
98
+ ):
99
+ invalid = True
100
+ elif (
101
+ any(c in location for c in ("%", "\\", "?", "#"))
102
+ or not target.path.startswith(prefix)
103
+ or suffix
104
+ ):
105
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
106
+ else:
107
+ key = target.path[len(prefix) :]
108
+ except ValueError:
109
+ invalid = True
110
+ if invalid:
111
+ raise LibrusError(ErrorKind.ACCESS_DENIED)
112
+ if ATTACHMENT_KEY_PATTERN.fullmatch(key) is None or key in {".", ".."}:
113
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
114
+ return key