librus-python-api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_bootstrap.py +124 -0
- librus_python_api/_notification_codec.py +396 -0
- librus_python_api/_storage.py +403 -0
- librus_python_api/_windows_filesystem.py +390 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +242 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +145 -0
- librus_python_api/notification_persistence.py +1243 -0
- librus_python_api/notification_workflow.py +337 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +419 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0.dist-info/METADATA +262 -0
- librus_python_api-1.0.0.dist-info/RECORD +48 -0
- librus_python_api-1.0.0.dist-info/WHEEL +4 -0
- librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|