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.
Files changed (48) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_bootstrap.py +124 -0
  3. librus_python_api/_notification_codec.py +396 -0
  4. librus_python_api/_storage.py +403 -0
  5. librus_python_api/_windows_filesystem.py +390 -0
  6. librus_python_api/announcements.py +159 -0
  7. librus_python_api/attachment_routes.py +114 -0
  8. librus_python_api/attachments.py +297 -0
  9. librus_python_api/attendance.py +182 -0
  10. librus_python_api/attendance_frequency.py +112 -0
  11. librus_python_api/budget.py +79 -0
  12. librus_python_api/checkpoint.py +61 -0
  13. librus_python_api/completed_lessons.py +216 -0
  14. librus_python_api/config.py +1410 -0
  15. librus_python_api/detail_fields.py +50 -0
  16. librus_python_api/diagnostics.py +25 -0
  17. librus_python_api/exceptions.py +172 -0
  18. librus_python_api/files.py +242 -0
  19. librus_python_api/grade_parsers.py +169 -0
  20. librus_python_api/grade_records.py +454 -0
  21. librus_python_api/homework_range.py +41 -0
  22. librus_python_api/lifecycle.py +24 -0
  23. librus_python_api/markup.py +147 -0
  24. librus_python_api/message_content.py +230 -0
  25. librus_python_api/messages.py +288 -0
  26. librus_python_api/models.py +1160 -0
  27. librus_python_api/modern_body.py +75 -0
  28. librus_python_api/modern_mailbox.py +459 -0
  29. librus_python_api/modern_messages.py +276 -0
  30. librus_python_api/notification_models.py +145 -0
  31. librus_python_api/notification_persistence.py +1243 -0
  32. librus_python_api/notification_workflow.py +337 -0
  33. librus_python_api/notifications.py +216 -0
  34. librus_python_api/parsers.py +232 -0
  35. librus_python_api/parsing.py +49 -0
  36. librus_python_api/persistence.py +419 -0
  37. librus_python_api/py.typed +0 -0
  38. librus_python_api/recipients.py +271 -0
  39. librus_python_api/scheduler.py +287 -0
  40. librus_python_api/school_reads.py +400 -0
  41. librus_python_api/sending.py +125 -0
  42. librus_python_api/service.py +2285 -0
  43. librus_python_api/timetable.py +261 -0
  44. librus_python_api/transport.py +956 -0
  45. librus_python_api-1.0.0.dist-info/METADATA +262 -0
  46. librus_python_api-1.0.0.dist-info/RECORD +48 -0
  47. librus_python_api-1.0.0.dist-info/WHEEL +4 -0
  48. librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,390 @@
1
+ """Private local-NTFS guards using documented Win32 APIs, loaded on Windows only."""
2
+
3
+ import ntpath
4
+ import os
5
+ import sys
6
+ from collections.abc import Callable
7
+ from functools import partial
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import pywintypes
12
+ import win32api
13
+ import win32con
14
+ import win32file
15
+ import win32security
16
+ import winerror
17
+
18
+ from librus_python_api.exceptions import ErrorKind, LibrusError
19
+
20
+ _TRUSTED_SYSTEM_SIDS = {"S-1-5-18", "S-1-5-32-544"}
21
+ _MAX_PATH_COMPONENTS = 128
22
+ _MAX_ACES = 16
23
+
24
+
25
+ def _call[T](operation: Callable[[], T]) -> T:
26
+ try:
27
+ return operation()
28
+ except pywintypes.error:
29
+ raise OSError("Windows filesystem operation failed") from None
30
+
31
+
32
+ def _principal() -> tuple[str, str]:
33
+ token = win32security.OpenProcessToken(
34
+ win32api.GetCurrentProcess(), win32con.TOKEN_QUERY
35
+ )
36
+ try:
37
+ user = win32security.GetTokenInformation(token, win32security.TokenUser)[0]
38
+ owner = win32security.GetTokenInformation(token, win32security.TokenOwner)
39
+ return win32security.ConvertSidToStringSid(
40
+ user
41
+ ), win32security.ConvertSidToStringSid(owner)
42
+ finally:
43
+ win32api.CloseHandle(token)
44
+
45
+
46
+ def _descriptor(native: int) -> int:
47
+ if sys.platform == "win32":
48
+ import msvcrt
49
+
50
+ return msvcrt.open_osfhandle(native, os.O_RDWR | os.O_BINARY | os.O_NOINHERIT)
51
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
52
+
53
+
54
+ def _handle(descriptor: int) -> int:
55
+ if sys.platform == "win32":
56
+ import msvcrt
57
+
58
+ return msvcrt.get_osfhandle(descriptor)
59
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
60
+
61
+
62
+ def _discard_exclusive(native: int) -> None:
63
+ # Only called after CREATE_NEW succeeded. Delete by owned handle, never by
64
+ # a guessed random path which could refer to a preexisting collision.
65
+ _call(
66
+ lambda: win32file.SetFileInformationByHandle(
67
+ native, win32file.FileDispositionInfo, True
68
+ )
69
+ )
70
+
71
+
72
+ def _attributes(user: str) -> Any:
73
+ descriptor = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor(
74
+ f"O:{user}D:P(A;OICI;FA;;;{user})(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)",
75
+ win32security.SDDL_REVISION_1,
76
+ )
77
+ attributes = pywintypes.SECURITY_ATTRIBUTES()
78
+ attributes.SECURITY_DESCRIPTOR = descriptor
79
+ attributes.bInheritHandle = False
80
+ return attributes
81
+
82
+
83
+ def _private(
84
+ handle: Any, user: str, default_owner: str, *, directory: bool = False
85
+ ) -> None:
86
+ descriptor = win32security.GetSecurityInfo(
87
+ handle,
88
+ win32security.SE_FILE_OBJECT,
89
+ win32security.OWNER_SECURITY_INFORMATION
90
+ | win32security.DACL_SECURITY_INFORMATION,
91
+ )
92
+ owner = win32security.ConvertSidToStringSid(descriptor.GetSecurityDescriptorOwner())
93
+ if owner not in {user, default_owner}:
94
+ raise LibrusError(ErrorKind.STORAGE)
95
+ acl = descriptor.GetSecurityDescriptorDacl()
96
+ if acl is None or not 1 <= acl.GetAceCount() <= _MAX_ACES:
97
+ raise LibrusError(ErrorKind.STORAGE)
98
+ trusted = _TRUSTED_SYSTEM_SIDS | {user}
99
+ inheritable_user = False
100
+ for index in range(acl.GetAceCount()):
101
+ ace = acl.GetAce(index)
102
+ if ace[0][0] != win32security.ACCESS_ALLOWED_ACE_TYPE or len(ace) != 3:
103
+ raise LibrusError(ErrorKind.STORAGE)
104
+ if win32security.ConvertSidToStringSid(ace[2]) not in trusted:
105
+ raise LibrusError(ErrorKind.STORAGE)
106
+ if (
107
+ win32security.ConvertSidToStringSid(ace[2]) == user
108
+ and ace[0][1] & 3 == 3 # OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
109
+ and not ace[0][1] & 4 # NO_PROPAGATE_INHERIT_ACE
110
+ and ace[1] & 0x1F01FF == 0x1F01FF # FILE_ALL_ACCESS
111
+ ):
112
+ inheritable_user = True
113
+ if directory and (
114
+ not descriptor.GetSecurityDescriptorControl()[0] & 0x1000 # SE_DACL_PROTECTED
115
+ or not inheritable_user
116
+ ):
117
+ # SQLite creates its own journal files. A private directory without an
118
+ # inheritable ACL can give those files the process's broader default DACL.
119
+ raise LibrusError(ErrorKind.STORAGE)
120
+
121
+
122
+ def _path_boundary(path: Path) -> None:
123
+ if (
124
+ not path.is_absolute()
125
+ or len(path.parts) > _MAX_PATH_COMPONENTS
126
+ or len(str(path)) > 4096
127
+ ):
128
+ raise LibrusError(ErrorKind.INVALID_INPUT)
129
+ if (
130
+ len(path.drive) != 2
131
+ or path.drive[1] != ":"
132
+ or not path.drive[0].isascii()
133
+ or not path.drive[0].isalpha()
134
+ ):
135
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
136
+ if any(
137
+ part in {".", ".."} or ":" in part or part.endswith((" ", "."))
138
+ for part in path.parts[1:]
139
+ ):
140
+ raise LibrusError(ErrorKind.INVALID_INPUT)
141
+ if ntpath.isreserved(str(path)):
142
+ raise LibrusError(ErrorKind.INVALID_INPUT)
143
+ root = path.anchor
144
+ if win32file.GetDriveType(root) != win32con.DRIVE_FIXED:
145
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
146
+ volume = win32api.GetVolumeInformation(root)
147
+ if volume[4].upper() != "NTFS" or not volume[3] & win32con.FILE_PERSISTENT_ACLS:
148
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
149
+
150
+
151
+ def _pin(path: Path, *, private: bool) -> Any:
152
+ handle = win32file.CreateFile(
153
+ str(path),
154
+ (win32con.READ_CONTROL if private else 0) | 0x80,
155
+ win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
156
+ None,
157
+ win32con.OPEN_EXISTING,
158
+ win32con.FILE_FLAG_BACKUP_SEMANTICS | win32file.FILE_FLAG_OPEN_REPARSE_POINT,
159
+ None,
160
+ )
161
+ try:
162
+ information = win32file.GetFileInformationByHandle(int(handle))
163
+ if (
164
+ not information[0] & win32con.FILE_ATTRIBUTE_DIRECTORY
165
+ or information[0] & win32con.FILE_ATTRIBUTE_REPARSE_POINT
166
+ ):
167
+ raise LibrusError(ErrorKind.STORAGE)
168
+ return handle
169
+ except BaseException:
170
+ handle.Close()
171
+ raise
172
+
173
+
174
+ def _regular_file(handle: Any, user: str, default_owner: str) -> None:
175
+ information = _call(lambda: win32file.GetFileInformationByHandle(int(handle)))
176
+ if (
177
+ information[0]
178
+ & (win32con.FILE_ATTRIBUTE_DIRECTORY | win32con.FILE_ATTRIBUTE_REPARSE_POINT)
179
+ or information[7] != 1
180
+ or _call(lambda: win32file.GetFileType(int(handle))) != win32con.FILE_TYPE_DISK
181
+ ):
182
+ raise LibrusError(ErrorKind.STORAGE)
183
+ _call(lambda: _private(handle, user, default_owner))
184
+
185
+
186
+ class WindowsDirectory:
187
+ """Pin a private directory against rename/delete while owning its child handles."""
188
+
189
+ def __init__(self, path: Path, *, create: bool) -> None:
190
+ self.path = path
191
+ self._handle: Any = None
192
+ self._parents: list[Any] = []
193
+ self._user, self._owner = _call(_principal)
194
+ _call(lambda: _path_boundary(path))
195
+ try:
196
+ # Pin top-down before opening any child by path. Checking ancestors
197
+ # without retaining their handles leaves an ancestor-rename race.
198
+ for parent in reversed(path.parents):
199
+ self._parents.append(_call(partial(_pin, parent, private=False)))
200
+ if create:
201
+ try:
202
+ win32file.CreateDirectory(str(path), _attributes(self._user))
203
+ except pywintypes.error as error:
204
+ if error.winerror != winerror.ERROR_ALREADY_EXISTS:
205
+ raise OSError("Windows directory creation failed") from None
206
+ self._handle = _call(lambda: _pin(path, private=True))
207
+ self.validate()
208
+ except BaseException:
209
+ self.close()
210
+ raise
211
+
212
+ def validate(self) -> None:
213
+ assert self._handle is not None
214
+ information = _call(lambda: win32file.GetFileInformationByHandle(self._handle))
215
+ if (
216
+ not information[0] & win32con.FILE_ATTRIBUTE_DIRECTORY
217
+ or information[0] & win32con.FILE_ATTRIBUTE_REPARSE_POINT
218
+ ):
219
+ raise LibrusError(ErrorKind.STORAGE)
220
+ actual = _call(lambda: win32file.GetFinalPathNameByHandle(self._handle, 0))
221
+ if os.path.normcase(actual.removeprefix("\\\\?\\")) != os.path.normcase(
222
+ str(self.path)
223
+ ):
224
+ raise LibrusError(ErrorKind.STORAGE)
225
+ _call(lambda: _private(self._handle, self._user, self._owner, directory=True))
226
+
227
+ def close(self) -> None:
228
+ handles = [*reversed(self._parents)]
229
+ if self._handle is not None:
230
+ handles.insert(0, self._handle)
231
+ self._parents.clear()
232
+ self._handle = None
233
+ failed = False
234
+ for handle in handles:
235
+ try:
236
+ _call(handle.Close)
237
+ except OSError:
238
+ failed = True
239
+ if failed:
240
+ raise OSError("Windows directory handle cleanup failed")
241
+
242
+ def open_file(
243
+ self, name: str, *, create: bool = False, exclusive: bool = False
244
+ ) -> int:
245
+ if (
246
+ type(name) is not str
247
+ or not name
248
+ or Path(name).name != name
249
+ or any(c in name for c in "\\/:")
250
+ ):
251
+ raise LibrusError(ErrorKind.INVALID_INPUT)
252
+ self.validate()
253
+ disposition = (
254
+ win32con.CREATE_NEW
255
+ if exclusive
256
+ else win32con.OPEN_ALWAYS
257
+ if create
258
+ else win32con.OPEN_EXISTING
259
+ )
260
+ handle = _call(
261
+ lambda: win32file.CreateFile(
262
+ str(self.path / name),
263
+ win32con.GENERIC_READ
264
+ | win32con.GENERIC_WRITE
265
+ | win32con.READ_CONTROL
266
+ | (win32con.DELETE if exclusive else 0),
267
+ win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
268
+ _attributes(self._user) if create or exclusive else None,
269
+ disposition,
270
+ win32con.FILE_ATTRIBUTE_NORMAL | win32file.FILE_FLAG_OPEN_REPARSE_POINT,
271
+ None,
272
+ )
273
+ )
274
+ detached = False
275
+ try:
276
+ _regular_file(handle, self._user, self._owner)
277
+ native = int(handle.Detach())
278
+ detached = True
279
+ try:
280
+ return _descriptor(native)
281
+ except BaseException:
282
+ try:
283
+ if exclusive:
284
+ _discard_exclusive(native)
285
+ finally:
286
+ win32api.CloseHandle(native)
287
+ raise
288
+ except BaseException:
289
+ if exclusive and not detached:
290
+ _discard_exclusive(int(handle))
291
+ raise
292
+ finally:
293
+ if not detached:
294
+ handle.Close()
295
+
296
+ def check_file(
297
+ self, name: str, maximum: int, *, create: bool = False
298
+ ) -> tuple[int, int]:
299
+ descriptor = self.open_file(name, create=create)
300
+ try:
301
+ information = _call(
302
+ lambda: win32file.GetFileInformationByHandle(_handle(descriptor))
303
+ )
304
+ if ((information[5] << 32) | information[6]) > maximum:
305
+ raise LibrusError(ErrorKind.STORAGE)
306
+ return information[4], (information[8] << 32) | information[9]
307
+ finally:
308
+ os.close(descriptor)
309
+
310
+ def check_sidecar(self, name: str, maximum: int) -> None:
311
+ # A private pinned parent excludes other OS principals from replacement.
312
+ # Same-user writers remain outside the threat boundary on both platforms.
313
+ try:
314
+ (self.path / name).lstat()
315
+ except FileNotFoundError:
316
+ return
317
+ self.check_file(name, maximum)
318
+
319
+
320
+ def acquire_lock(directory: WindowsDirectory, name: str, held: list[int]) -> None:
321
+ descriptor = directory.open_file(name, create=True)
322
+ try:
323
+ if os.fstat(descriptor).st_size != 0:
324
+ raise LibrusError(ErrorKind.STORAGE)
325
+ try:
326
+ win32file.LockFileEx(
327
+ _handle(descriptor),
328
+ win32con.LOCKFILE_EXCLUSIVE_LOCK | win32con.LOCKFILE_FAIL_IMMEDIATELY,
329
+ 1,
330
+ 0,
331
+ pywintypes.OVERLAPPED(),
332
+ )
333
+ except pywintypes.error as error:
334
+ if error.winerror == winerror.ERROR_LOCK_VIOLATION:
335
+ raise LibrusError(ErrorKind.LIMIT) from None
336
+ raise OSError("Windows context lock failed") from None
337
+ held.append(descriptor)
338
+ descriptor = -1
339
+ finally:
340
+ if descriptor >= 0:
341
+ os.close(descriptor)
342
+
343
+
344
+ def release_lock(descriptor: int) -> None:
345
+ try:
346
+ _call(
347
+ lambda: win32file.UnlockFileEx(
348
+ _handle(descriptor), 1, 0, pywintypes.OVERLAPPED()
349
+ )
350
+ )
351
+ finally:
352
+ os.close(descriptor)
353
+
354
+
355
+ def publish_file(
356
+ directory: WindowsDirectory, descriptor: int, name: str, attempts: int
357
+ ) -> str:
358
+ """Rename the complete owned file by handle; never replace a destination."""
359
+ directory.validate()
360
+ os.fsync(descriptor)
361
+ path = Path(name)
362
+ for index in range(attempts):
363
+ candidate = name if index == 0 else f"{path.stem} ({index}){path.suffix}"
364
+ try:
365
+ win32file.SetFileInformationByHandle(
366
+ _handle(descriptor),
367
+ win32file.FileRenameInfo,
368
+ {
369
+ "ReplaceIfExists": False,
370
+ "RootDirectory": None,
371
+ "FileName": str(directory.path / candidate),
372
+ },
373
+ )
374
+ except pywintypes.error as error:
375
+ if error.winerror in (
376
+ winerror.ERROR_ALREADY_EXISTS,
377
+ winerror.ERROR_FILE_EXISTS,
378
+ ):
379
+ continue
380
+ # NTFS can report ACCESS_DENIED for an existing directory or link.
381
+ # This check only decides whether to try another name after a failed
382
+ # no-replace commit; it never authorizes replacement.
383
+ if error.winerror == winerror.ERROR_ACCESS_DENIED and os.path.lexists(
384
+ directory.path / candidate
385
+ ):
386
+ continue
387
+ raise OSError("Windows publication failed") from None
388
+ os.fsync(descriptor)
389
+ return candidate
390
+ raise LibrusError(ErrorKind.LIMIT)
@@ -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