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/__init__.py +11 -0
- sharefetch/__main__.py +8 -0
- sharefetch/auth.py +343 -0
- sharefetch/cli.py +782 -0
- sharefetch/config.py +374 -0
- sharefetch/engine.py +1055 -0
- sharefetch/errors.py +57 -0
- sharefetch/events.py +129 -0
- sharefetch/graph.py +474 -0
- sharefetch/gui.py +1170 -0
- sharefetch/hashes.py +123 -0
- sharefetch/listing.py +493 -0
- sharefetch/manifest.py +198 -0
- sharefetch/pacing.py +211 -0
- sharefetch/paths.py +245 -0
- sharefetch/py.typed +0 -0
- sharefetch/resolve.py +660 -0
- sharefetch/state.py +707 -0
- sharefetch/tokencache.py +214 -0
- sharefetch/transfer.py +815 -0
- sharefetch/urls.py +195 -0
- sharefetch/verify.py +378 -0
- sharefetch-1.0.0.dist-info/METADATA +193 -0
- sharefetch-1.0.0.dist-info/RECORD +28 -0
- sharefetch-1.0.0.dist-info/WHEEL +5 -0
- sharefetch-1.0.0.dist-info/entry_points.txt +5 -0
- sharefetch-1.0.0.dist-info/licenses/LICENSE +21 -0
- sharefetch-1.0.0.dist-info/top_level.txt +1 -0
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
|