sharefetch 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.
sharefetch/config.py ADDED
@@ -0,0 +1,374 @@
1
+ """Constants, bounds, the ``Settings`` dataclass and the boundary validators.
2
+
3
+ Every constant and every bound the package uses lives here, so the command-line
4
+ front end and the graphical front end validate an option identically and cannot
5
+ diverge. The module is pure: it opens no socket and reads no clock.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from .errors import ValidationError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ GRAPH_ROOT = "https://graph.microsoft.com/v1.0"
19
+ GRAPH_HOST = "graph.microsoft.com"
20
+ AUTHORITY_TEMPLATE = "https://login.microsoftonline.com/{tenant}"
21
+ DEFAULT_TENANT = "organizations"
22
+ DEFAULT_CLIENT_ID = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
23
+
24
+ # msal raises ValueError if offline_access, openid or profile appears here and
25
+ # adds all three itself. See application.py:854 and :867.
26
+ # Measured against a live tenant on 2026-09-22: the Office first-party client
27
+ # is preauthorized for Files.Read.All and not for Sites.Read.All, so naming
28
+ # the second fails with AADSTS65002 before tenant consent is even consulted.
29
+ # Site lookup by path answers under the preconsented set this client already
30
+ # holds, so the scope was never needed. An application registered in the
31
+ # tenant may grant more; pass it with --client-id.
32
+ MSAL_SCOPES: tuple[str, ...] = ("Files.Read.All",)
33
+
34
+ ITEM_SELECT = "id,name,size,file,folder,eTag,cTag,package,parentReference"
35
+ PAGE_SIZE = 200
36
+ SITE_PROBE_MAX_SEGMENTS = 4
37
+
38
+ DEFAULT_CONNECTIONS = 1
39
+ CONNECTIONS_BOUNDS = (1, 2)
40
+
41
+ DEFAULT_DELAY_SECONDS = 0.5
42
+ DELAY_BOUNDS = (0.0, 60.0)
43
+
44
+ DEFAULT_CHUNK_KIB = 256
45
+ CHUNK_KIB_BOUNDS = (64, 4096)
46
+
47
+ LIMIT_RATE_MIN_KBPS = 1
48
+
49
+ CONNECT_TIMEOUT_SECONDS = 30
50
+ READ_TIMEOUT_SECONDS = 60
51
+ REQUEST_TIMEOUT = (CONNECT_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS)
52
+
53
+ MAX_ATTEMPTS_PER_FILE = 5
54
+ BACKOFF_BASE_SECONDS = 1.0
55
+ BACKOFF_CAP_SECONDS = 60.0
56
+ RETRY_AFTER_CLAMP = (0.0, 300.0)
57
+
58
+ STATE_FLUSH_INTERVAL_SECONDS = 2.0
59
+ FREE_SPACE_MARGIN = 0.05
60
+ DEVICE_FLOW_POLL_TICK_SECONDS = 1.0
61
+ GUI_DRAIN_INTERVAL_MS = 100
62
+ GUI_LOG_MAX_LINES = 2000
63
+ GUI_STOP_JOIN_SECONDS = 30.0
64
+
65
+ STATE_FILENAME = ".sharefetch-state.json"
66
+ STATE_SCHEMA_VERSION = 1
67
+ MANIFEST_FILENAME = "manifest.csv"
68
+ PART_SUFFIX = ".part"
69
+ # Appended to a final name to form the temporary a write is built in before
70
+ # ``os.replace`` renames it onto that name. The state file and the manifest are
71
+ # both written this way, so the suffix is defined once here and imported by
72
+ # both.
73
+ TMP_SUFFIX = ".tmp"
74
+ TOKEN_CACHE_DIRNAME = "sharefetch"
75
+ TOKEN_CACHE_FILENAME = "token_cache.bin"
76
+ DPAPI_ENTROPY = "sharefetch-token-cache-v1"
77
+
78
+ MIN_DEPTH = 0
79
+
80
+
81
+ # Shared status and reason vocabulary. The
82
+ # state file, the manifest and every event carry these strings, so they are
83
+ # defined once here and never spelled out at a call site. A reason is recorded
84
+ # as a field, never recovered by matching the text of an exception message.
85
+ STATUS_PENDING = "pending"
86
+ STATUS_PARTIAL = "partial"
87
+ STATUS_COMPLETE = "complete"
88
+ STATUS_FAILED = "failed"
89
+ STATUS_SKIPPED = "skipped"
90
+ ITEM_STATUSES = frozenset(
91
+ {
92
+ STATUS_PENDING,
93
+ STATUS_PARTIAL,
94
+ STATUS_COMPLETE,
95
+ STATUS_FAILED,
96
+ STATUS_SKIPPED,
97
+ }
98
+ )
99
+
100
+ VERIFICATION_PASSED = "passed"
101
+ # Check 5 warns and never fails, so a file can pass while carrying a warning.
102
+ # The state file and the manifest both record this status, so it is shared
103
+ # here rather than defined in verify.py, which state.py must not import.
104
+ VERIFICATION_PASSED_WITH_WARNINGS = "passed_with_warnings"
105
+ VERIFICATION_FAILED = "failed"
106
+ VERIFICATION_STATUSES = frozenset(
107
+ {VERIFICATION_PASSED, VERIFICATION_PASSED_WITH_WARNINGS, VERIFICATION_FAILED}
108
+ )
109
+
110
+ CHECK_SIZE = "size"
111
+ CHECK_CONTENT_HASH = "content_hash"
112
+ CHECK_NOT_SUBSTITUTED = "not_substituted"
113
+ CHECK_CONTAINER_INTEGRITY = "container_integrity"
114
+ CHECK_EXTENSION_CONSISTENCY = "extension_consistency"
115
+ VERIFICATION_CHECKS = (
116
+ CHECK_SIZE,
117
+ CHECK_CONTENT_HASH,
118
+ CHECK_NOT_SUBSTITUTED,
119
+ CHECK_CONTAINER_INTEGRITY,
120
+ CHECK_EXTENSION_CONSISTENCY,
121
+ )
122
+
123
+ REASON_SIZE_MISMATCH = "size_mismatch"
124
+ REASON_HASH_MISMATCH = "hash_mismatch"
125
+ REASON_HTML_RESPONSE = "html_response"
126
+ REASON_CONTAINER_CORRUPT = "container_corrupt"
127
+ REASON_PATH_TOO_LONG = "path_too_long"
128
+ FAILURE_REASONS = frozenset(
129
+ {
130
+ REASON_SIZE_MISMATCH,
131
+ REASON_HASH_MISMATCH,
132
+ REASON_HTML_RESPONSE,
133
+ REASON_CONTAINER_CORRUPT,
134
+ REASON_PATH_TOO_LONG,
135
+ }
136
+ )
137
+
138
+ # Check 5 warns and never fails, so its reason is not a member of
139
+ # FAILURE_REASONS.
140
+ REASON_EXTENSION_MISMATCH = "extension_mismatch"
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class MagicRule:
145
+ prefix: bytes
146
+ family: str
147
+ integrity: str # "zip" | "ole" | "pdf" | "gzip" | "jpeg" | "header"
148
+
149
+
150
+ MAGIC_SIGNATURES: tuple[MagicRule, ...] = (
151
+ MagicRule(b"\x89PNG\r\n\x1a\n", "png", "header"),
152
+ MagicRule(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "ole", "ole"),
153
+ MagicRule(b"\xfd7zXZ\x00", "xz", "header"),
154
+ MagicRule(b"7z\xbc\xaf\x27\x1c", "7z", "header"),
155
+ MagicRule(b"Rar!\x1a\x07", "rar", "header"),
156
+ MagicRule(b"PK\x03\x04", "zip", "zip"),
157
+ MagicRule(b"PK\x05\x06", "zip", "zip"),
158
+ MagicRule(b"PK\x07\x08", "zip", "zip"),
159
+ MagicRule(b"%PDF", "pdf", "pdf"),
160
+ MagicRule(b"GIF8", "gif", "header"),
161
+ MagicRule(b"II*\x00", "tiff", "header"),
162
+ MagicRule(b"MM\x00*", "tiff", "header"),
163
+ MagicRule(b"\x7fELF", "elf", "header"),
164
+ MagicRule(b"\xff\xd8\xff", "jpeg", "jpeg"),
165
+ MagicRule(b"BZh", "bzip2", "header"),
166
+ MagicRule(b"\x1f\x8b", "gzip", "gzip"),
167
+ MagicRule(b"BM", "bmp", "header"),
168
+ MagicRule(b"MZ", "pe", "header"),
169
+ MagicRule(b"#!", "script", "header"),
170
+ )
171
+
172
+ # Advisory only. Check 5 warns on disagreement and never fails a file.
173
+ EXTENSION_FAMILIES: dict[str, str] = {
174
+ ".docx": "zip",
175
+ ".xlsx": "zip",
176
+ ".xlsm": "zip",
177
+ ".pptx": "zip",
178
+ ".odt": "zip",
179
+ ".ods": "zip",
180
+ ".odp": "zip",
181
+ ".zip": "zip",
182
+ ".jar": "zip",
183
+ ".doc": "ole",
184
+ ".xls": "ole",
185
+ ".ppt": "ole",
186
+ ".mpp": "ole",
187
+ ".msg": "ole",
188
+ ".pdf": "pdf",
189
+ ".gz": "gzip",
190
+ ".bz2": "bzip2",
191
+ ".xz": "xz",
192
+ ".7z": "7z",
193
+ ".rar": "rar",
194
+ ".png": "png",
195
+ ".jpg": "jpeg",
196
+ ".jpeg": "jpeg",
197
+ ".gif": "gif",
198
+ ".tif": "tiff",
199
+ ".tiff": "tiff",
200
+ ".bmp": "bmp",
201
+ ".exe": "pe",
202
+ ".dll": "pe",
203
+ }
204
+
205
+ HTML_MARKERS = (b"<!doctype html", b"<html", b"<head")
206
+ HTML_SNIFF_BYTES = 1024
207
+ HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
208
+ # Searched for the PDF %%EOF marker and for the JPEG FF D9 end-of-image marker,
209
+ # so the name carries no format.
210
+ TRAILER_WINDOW = 2048
211
+
212
+ DISCARDED_QUERY_PARAMS = frozenset(
213
+ {
214
+ "viewid",
215
+ "ovuser",
216
+ "teamscid",
217
+ "or",
218
+ "ct",
219
+ "clickparams",
220
+ "e",
221
+ "csf",
222
+ "web",
223
+ "cid",
224
+ "rootfolder",
225
+ "folderctid",
226
+ "view",
227
+ }
228
+ )
229
+ SHARE_PATH_PREFIXES = ("/:f:/", "/:u:/", "/:w:/", "/:x:/", "/:p:/", "/:b:/", "/:o:/")
230
+ MAX_DECODE_ITERATIONS = 5
231
+
232
+ WINDOWS_FORBIDDEN_CHARS = '<>:"|?*'
233
+ WINDOWS_RESERVED_NAMES = frozenset(
234
+ {"CON", "PRN", "AUX", "NUL"}
235
+ | {f"COM{i}" for i in range(1, 10)}
236
+ | {f"LPT{i}" for i in range(1, 10)}
237
+ )
238
+ MAX_PATH_SEGMENT = 255
239
+
240
+ # --tenant is validated as a GUID or a domain and --client-id as a GUID, and
241
+ # every boundary validation lives in this module.
242
+ TENANT_ALIASES = frozenset({"organizations", "common", "consumers"})
243
+ GUID_PATTERN = re.compile(r"\A[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\Z")
244
+ DOMAIN_PATTERN = re.compile(
245
+ r"\A[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+\Z"
246
+ )
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class Settings:
251
+ url: str
252
+ dest: Path
253
+ connections: int = DEFAULT_CONNECTIONS
254
+ delay: float = DEFAULT_DELAY_SECONDS
255
+ limit_rate_kbps: int | None = None
256
+ chunk_kib: int = DEFAULT_CHUNK_KIB
257
+ max_depth: int | None = None
258
+ include: tuple[str, ...] = ()
259
+ exclude: tuple[str, ...] = ()
260
+ restart: bool = False
261
+ dry_run: bool = False
262
+ verify_only: bool = False
263
+ reauth: bool = False
264
+ tenant: str = DEFAULT_TENANT
265
+ client_id: str = DEFAULT_CLIENT_ID
266
+ ignore_free_space: bool = False
267
+ log_file: Path | None = None
268
+ verbose: bool = False
269
+
270
+ @property
271
+ def chunk_bytes(self) -> int:
272
+ return self.chunk_kib * 1024
273
+
274
+
275
+ def check_bounds(name: str, value: float, bounds: tuple[float, float]) -> None:
276
+ low, high = bounds
277
+ if not low <= value <= high:
278
+ raise ValidationError(f"{name} must be between {low} and {high}, received {value}")
279
+
280
+
281
+ def check_connections(value: int) -> int:
282
+ low, high = CONNECTIONS_BOUNDS
283
+ if value < low or value > high:
284
+ raise ValidationError(f"connections must be {low} or {high}, received {value}")
285
+ return value
286
+
287
+
288
+ def check_delay(value: float) -> float:
289
+ check_bounds("delay", value, DELAY_BOUNDS)
290
+ return value
291
+
292
+
293
+ def check_chunk_kib(value: int) -> int:
294
+ check_bounds("chunk size in KiB", value, CHUNK_KIB_BOUNDS)
295
+ return value
296
+
297
+
298
+ def check_limit_rate(value: int | None) -> int | None:
299
+ """A rate ceiling is optional. ``None`` leaves the ceiling off."""
300
+ if value is None:
301
+ return None
302
+ if value < LIMIT_RATE_MIN_KBPS:
303
+ raise ValidationError(
304
+ f"rate limit must be an integer of at least {LIMIT_RATE_MIN_KBPS} KB/s, "
305
+ f"received {value}"
306
+ )
307
+ return value
308
+
309
+
310
+ def check_max_depth(value: int | None) -> int | None:
311
+ """A depth limit is optional. ``None`` leaves the walk unbounded."""
312
+ if value is None:
313
+ return None
314
+ if value < MIN_DEPTH:
315
+ raise ValidationError(
316
+ f"max depth must be an integer of at least {MIN_DEPTH}, received {value}"
317
+ )
318
+ return value
319
+
320
+
321
+ def check_dest(value: str | Path) -> Path:
322
+ """Return the destination as an absolute path, rejecting an unusable parent.
323
+
324
+ The destination directory itself need not exist, because the run creates it.
325
+ Its parent must exist and be writable, which is the earliest point at which
326
+ an unusable destination can be reported.
327
+ """
328
+ if value is None or not str(value).strip():
329
+ raise ValidationError("destination must be a directory path, received an empty value")
330
+ dest = Path(value).expanduser().resolve()
331
+ if dest.exists() and not dest.is_dir():
332
+ raise ValidationError(f"destination must be a directory, received {dest}, which is a file")
333
+ parent = dest.parent
334
+ if not parent.is_dir():
335
+ raise ValidationError(
336
+ f"destination parent directory must exist, received {dest}, "
337
+ f"whose parent {parent} does not exist"
338
+ )
339
+ if not os.access(parent, os.W_OK):
340
+ raise ValidationError(
341
+ f"destination parent directory must be writable, received {dest}, "
342
+ f"whose parent {parent} is not writable"
343
+ )
344
+ return dest
345
+
346
+
347
+ def check_tenant(value: str) -> str:
348
+ """Accept a GUID, a domain, or one of the msal tenant aliases."""
349
+ candidate = (value or "").strip()
350
+ if not candidate:
351
+ raise ValidationError(
352
+ "tenant must be a GUID, a domain or one of "
353
+ f"{sorted(TENANT_ALIASES)}, received an empty value"
354
+ )
355
+ if candidate.lower() in TENANT_ALIASES:
356
+ return candidate.lower()
357
+ if GUID_PATTERN.match(candidate) or DOMAIN_PATTERN.match(candidate):
358
+ return candidate
359
+ raise ValidationError(
360
+ f"tenant must be a GUID, a domain or one of {sorted(TENANT_ALIASES)}, "
361
+ f"received {candidate!r}"
362
+ )
363
+
364
+
365
+ def check_client_id(value: str) -> str:
366
+ """Accept a GUID in the canonical 8-4-4-4-12 form."""
367
+ candidate = (value or "").strip()
368
+ if not GUID_PATTERN.match(candidate):
369
+ raise ValidationError(
370
+ "client id must be a GUID in the form "
371
+ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, "
372
+ f"received {candidate!r}"
373
+ )
374
+ return candidate