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/engine.py
ADDED
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
"""Orchestration: one URL, one destination, one verified tree and one manifest.
|
|
2
|
+
|
|
3
|
+
This module joins every other module in the package and is the only thing a
|
|
4
|
+
front end calls. The seam is one function and one protocol: :func:`run` takes a
|
|
5
|
+
:class:`~sharefetch.config.Settings` and an
|
|
6
|
+
:class:`~sharefetch.events.EventSink`, emits the run's events and returns a
|
|
7
|
+
:class:`~sharefetch.events.RunSummary`. Nothing here writes to a
|
|
8
|
+
console and nothing here holds a reference to Tk.
|
|
9
|
+
|
|
10
|
+
The run has eight stages and each one emits its own event.
|
|
11
|
+
|
|
12
|
+
1. Parse the URL, which is pure and needs no network.
|
|
13
|
+
2. Build the one :class:`~sharefetch.pacing.Pacer`, the one
|
|
14
|
+
:class:`~sharefetch.auth.TokenProvider` and the one
|
|
15
|
+
:class:`~sharefetch.graph.GraphClient` the whole run shares. A second pacer
|
|
16
|
+
would double the effective request rate at two connections, so exactly one
|
|
17
|
+
is constructed and it is handed to both the client and every transfer.
|
|
18
|
+
3. Resolve the reference to a drive and a folder.
|
|
19
|
+
4. Enumerate the tree, mapping each drive-relative path onto a local path. An
|
|
20
|
+
addressed folder that answers ``404`` here is re-resolved once, one
|
|
21
|
+
percent-decode pass short of the ceiling the URL consumed, under four
|
|
22
|
+
conditions.
|
|
23
|
+
5. Reconcile the enumeration against what is already on disk.
|
|
24
|
+
6. Check free space against what the queue still has to fetch.
|
|
25
|
+
7. Transfer, on a pool of one or two workers.
|
|
26
|
+
8. Verify the whole tree, apply the hash implementation safeguard, and write
|
|
27
|
+
the manifest.
|
|
28
|
+
|
|
29
|
+
``--dry-run`` stops after stage 5 and transfers nothing, so the operator sees
|
|
30
|
+
what would be fetched and the state file records it. ``--verify-only`` runs
|
|
31
|
+
stage 8's tree pass alone: it builds no token provider and no client, so the
|
|
32
|
+
run makes no network call at all and needs no sign-in.
|
|
33
|
+
|
|
34
|
+
The engine thread owns the queue and the pool. A worker owns one file and
|
|
35
|
+
reaches the state file only through :class:`~sharefetch.state.StateStore`,
|
|
36
|
+
which serialises its own writes.
|
|
37
|
+
``pause`` and ``stop`` are :class:`threading.Event` objects created here and
|
|
38
|
+
passed into every :func:`sharefetch.transfer.fetch` call, so a front end that
|
|
39
|
+
sets one reaches a transfer already in flight at its next chunk boundary.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import shutil
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
49
|
+
from collections import deque
|
|
50
|
+
from collections.abc import Callable
|
|
51
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from .auth import TokenProvider
|
|
56
|
+
from .config import (
|
|
57
|
+
CHECK_CONTENT_HASH,
|
|
58
|
+
CHECK_NOT_SUBSTITUTED,
|
|
59
|
+
FREE_SPACE_MARGIN,
|
|
60
|
+
REASON_HASH_MISMATCH,
|
|
61
|
+
REASON_PATH_TOO_LONG,
|
|
62
|
+
STATE_FILENAME,
|
|
63
|
+
STATUS_COMPLETE,
|
|
64
|
+
STATUS_FAILED,
|
|
65
|
+
STATUS_SKIPPED,
|
|
66
|
+
VERIFICATION_FAILED,
|
|
67
|
+
Settings,
|
|
68
|
+
)
|
|
69
|
+
from .errors import (
|
|
70
|
+
AuthError,
|
|
71
|
+
EnumerationError,
|
|
72
|
+
ManifestError,
|
|
73
|
+
SharefetchError,
|
|
74
|
+
ValidationError,
|
|
75
|
+
VerificationError,
|
|
76
|
+
)
|
|
77
|
+
from .events import (
|
|
78
|
+
EnumerationComplete,
|
|
79
|
+
EventSink,
|
|
80
|
+
FileFinished,
|
|
81
|
+
Reconciled,
|
|
82
|
+
Resolved,
|
|
83
|
+
RunFinished,
|
|
84
|
+
RunSummary,
|
|
85
|
+
)
|
|
86
|
+
from .graph import GraphClient
|
|
87
|
+
from .listing import RemoteItem, SkippedItem, walk
|
|
88
|
+
from .manifest import write_manifest
|
|
89
|
+
from .pacing import Pacer
|
|
90
|
+
from .paths import long_path, map_drive_path
|
|
91
|
+
from .resolve import DriveTarget, decode_pass_ceiling
|
|
92
|
+
from .resolve import resolve as resolve_reference
|
|
93
|
+
from .state import ItemState, StateStore
|
|
94
|
+
from .transfer import STATUS_PAUSED, fetch
|
|
95
|
+
from .urls import SitePathRef, SourceRef
|
|
96
|
+
from .urls import parse as parse_url
|
|
97
|
+
from .verify import VerificationResult, verify_file
|
|
98
|
+
|
|
99
|
+
logger = logging.getLogger(__name__)
|
|
100
|
+
|
|
101
|
+
# How often a paused engine wakes to test whether it has been resumed or
|
|
102
|
+
# stopped. The interval lives beside the only loop that reads it. It bounds the
|
|
103
|
+
# delay between a front end calling resume() and the queue moving again, and
|
|
104
|
+
# nothing else.
|
|
105
|
+
PAUSE_POLL_SECONDS = 0.1
|
|
106
|
+
|
|
107
|
+
BYTES_PER_MIB = 1024 * 1024
|
|
108
|
+
|
|
109
|
+
# A worker thread name prefix, so a stack dump taken from a hung run names the
|
|
110
|
+
# pool the frame belongs to rather than an anonymous "ThreadPoolExecutor-0_1".
|
|
111
|
+
WORKER_NAME_PREFIX = "sharefetch-transfer"
|
|
112
|
+
|
|
113
|
+
# The one HTTP status the decode-pass fallback answers. A 403 names a folder
|
|
114
|
+
# that exists and the account may not read, and
|
|
115
|
+
# re-resolving a different path on that evidence would report the wrong problem
|
|
116
|
+
# and spend a site probe and a drive list doing it.
|
|
117
|
+
NOT_FOUND = 404
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _exists(path: Path) -> bool:
|
|
121
|
+
"""Whether ``path`` names an existing file, read through the long-path form.
|
|
122
|
+
|
|
123
|
+
Every filesystem call in the package goes through ``paths.long_path``, so a
|
|
124
|
+
destination deeper than 260 characters behaves the same as a shallow one.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
return os.path.isfile(long_path(path)) # noqa: PTH113
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
logger.warning("could not test whether %s exists, treating it as absent: %s", path, exc)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _EnumerationCancelledError(Exception):
|
|
134
|
+
"""Raised inside the walk's progress callback to end enumeration early.
|
|
135
|
+
|
|
136
|
+
``listing.walk`` is a lazy generator and an exception raised inside
|
|
137
|
+
``on_progress`` propagates out of it, which is the only way to stop a walk
|
|
138
|
+
that is part way through paging a large folder. The exception never leaves
|
|
139
|
+
this module: :meth:`Engine._enumerate` catches it and the run ends without
|
|
140
|
+
touching the state file, so a stop during enumeration leaves the previous
|
|
141
|
+
run's records exactly as they were.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run(settings: Settings, sink: EventSink, **injected: Any) -> RunSummary:
|
|
146
|
+
"""Execute one run and return its summary.
|
|
147
|
+
|
|
148
|
+
The convenience both front ends call. ``injected`` is forwarded to
|
|
149
|
+
:class:`Engine`, which is how a test supplies a client, a resolver, a
|
|
150
|
+
walker, a clock or a sleep without a socket.
|
|
151
|
+
"""
|
|
152
|
+
return Engine(settings, sink, **injected).run()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Engine:
|
|
156
|
+
"""One run: resolve, enumerate, reconcile, transfer, verify, report.
|
|
157
|
+
|
|
158
|
+
An instance drives a single run and is not reused. :meth:`pause`,
|
|
159
|
+
:meth:`resume` and :meth:`stop` are the only methods safe to call from
|
|
160
|
+
another thread, which is what a graphical front end does from its main
|
|
161
|
+
thread while :meth:`run` executes on a worker.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
settings: Settings,
|
|
167
|
+
sink: EventSink,
|
|
168
|
+
*,
|
|
169
|
+
tokens: Any | None = None,
|
|
170
|
+
client: GraphClient | None = None,
|
|
171
|
+
resolver: Callable[..., DriveTarget] = resolve_reference,
|
|
172
|
+
walker: Callable[..., Any] = walk,
|
|
173
|
+
fetcher: Callable[..., str] = fetch,
|
|
174
|
+
verifier: Callable[..., VerificationResult] = verify_file,
|
|
175
|
+
manifest_writer: Callable[[StateStore, Path], Path] = write_manifest,
|
|
176
|
+
disk_usage: Callable[[str], Any] = shutil.disk_usage,
|
|
177
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Build the engine and its two control events.
|
|
180
|
+
|
|
181
|
+
Every collaborator after ``sink`` has the production default and exists
|
|
182
|
+
so a test can drive the orchestration without a socket, without a
|
|
183
|
+
sign-in and without elapsing real time. ``tokens`` and ``client`` are
|
|
184
|
+
built inside :meth:`run` when they are not supplied, because
|
|
185
|
+
``--verify-only`` must build neither.
|
|
186
|
+
"""
|
|
187
|
+
self._settings = settings
|
|
188
|
+
self._sink = sink
|
|
189
|
+
self._dest = Path(settings.dest)
|
|
190
|
+
self._resolver = resolver
|
|
191
|
+
self._walker = walker
|
|
192
|
+
self._fetch = fetcher
|
|
193
|
+
self._verify = verifier
|
|
194
|
+
self._write_manifest = manifest_writer
|
|
195
|
+
self._disk_usage = disk_usage
|
|
196
|
+
self._sleep = sleep
|
|
197
|
+
|
|
198
|
+
self._tokens = tokens
|
|
199
|
+
self._client = client
|
|
200
|
+
self._pacer: Pacer | None = None
|
|
201
|
+
self._store: StateStore | None = None
|
|
202
|
+
self._drive_id: str | None = None
|
|
203
|
+
self._remote: dict[str, RemoteItem] = {}
|
|
204
|
+
|
|
205
|
+
self._pause = threading.Event()
|
|
206
|
+
self._stop = threading.Event()
|
|
207
|
+
|
|
208
|
+
# Whether the walk produced any output before it failed, and whether
|
|
209
|
+
# the decode-pass fallback has been spent. Only
|
|
210
|
+
# the fallback reads either, and the second bounds it to one retry.
|
|
211
|
+
self._enumeration_produced = False
|
|
212
|
+
self._decode_fallback_spent = False
|
|
213
|
+
|
|
214
|
+
# Run-local tallies. The remaining counts are read off the state store
|
|
215
|
+
# at summary time, so they cannot disagree with the item statuses.
|
|
216
|
+
self._transferred = 0
|
|
217
|
+
self._bytes_transferred = 0
|
|
218
|
+
self._enumeration_skipped = 0
|
|
219
|
+
self._unmappable = 0
|
|
220
|
+
self._hash_comparisons = 0
|
|
221
|
+
self._hash_matches = 0
|
|
222
|
+
self._hash_only_failures = 0
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Control, called from another thread
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def pause(self) -> None:
|
|
229
|
+
"""Suspend the queue at the next chunk boundary and flush state."""
|
|
230
|
+
self._pause.set()
|
|
231
|
+
logger.info("pause requested")
|
|
232
|
+
|
|
233
|
+
def resume(self) -> None:
|
|
234
|
+
"""Release a pause. A paused file continues by range on its next pass."""
|
|
235
|
+
self._pause.clear()
|
|
236
|
+
logger.info("resume requested")
|
|
237
|
+
|
|
238
|
+
def stop(self) -> None:
|
|
239
|
+
"""Finish the files in flight, stop queueing new work and end the run.
|
|
240
|
+
|
|
241
|
+
A stop clears any pause, because a paused engine would otherwise sit in
|
|
242
|
+
its wait loop rather than draining and returning.
|
|
243
|
+
"""
|
|
244
|
+
self._stop.set()
|
|
245
|
+
self._pause.clear()
|
|
246
|
+
logger.info("stop requested")
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
# The run
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def run(self) -> RunSummary:
|
|
253
|
+
"""Execute the run and return its summary.
|
|
254
|
+
|
|
255
|
+
Raises every fatal error class unchanged:
|
|
256
|
+
``ValidationError`` for a bad URL, a bad destination or a free-space
|
|
257
|
+
shortfall, ``AuthError`` for a refused sign-in, ``ResolveError`` for a
|
|
258
|
+
site, library or folder that does not resolve, ``EnumerationError`` for
|
|
259
|
+
a listing that cannot be read, and ``StateError`` for a state file that
|
|
260
|
+
cannot be written. A per-item ``TransferError`` or ``VerificationError``
|
|
261
|
+
is recorded against its item by the transfer layer and the queue
|
|
262
|
+
continues, so neither ever reaches this method.
|
|
263
|
+
"""
|
|
264
|
+
self._make_destination()
|
|
265
|
+
if self._settings.verify_only:
|
|
266
|
+
return self._run_verify_only()
|
|
267
|
+
return self._run_transfer()
|
|
268
|
+
|
|
269
|
+
def _run_verify_only(self) -> RunSummary:
|
|
270
|
+
"""Run the verification tree pass alone, with no network and no token."""
|
|
271
|
+
logger.info("verify-only run over %s, no network call is made", self._dest)
|
|
272
|
+
store = StateStore.load(self._dest)
|
|
273
|
+
if store is None:
|
|
274
|
+
raise ValidationError(
|
|
275
|
+
f"--verify-only needs the state file {self._dest / STATE_FILENAME} to know each "
|
|
276
|
+
f"file's declared size and hash, and no readable state file is present; run "
|
|
277
|
+
f"without --verify-only first"
|
|
278
|
+
)
|
|
279
|
+
self._store = store
|
|
280
|
+
self._verify_tree()
|
|
281
|
+
self._apply_hash_safeguard()
|
|
282
|
+
store.flush(force=True)
|
|
283
|
+
return self._finish(self._write_the_manifest())
|
|
284
|
+
|
|
285
|
+
def _run_transfer(self) -> RunSummary:
|
|
286
|
+
"""Run the full sequence: resolve, enumerate, reconcile, transfer, verify."""
|
|
287
|
+
reference = parse_url(self._settings.url)
|
|
288
|
+
self._open_connection()
|
|
289
|
+
target = self._resolver(reference, self._client)
|
|
290
|
+
self._announce(target)
|
|
291
|
+
|
|
292
|
+
store = StateStore.load(self._dest) or StateStore(self._dest)
|
|
293
|
+
self._store = store
|
|
294
|
+
|
|
295
|
+
target, items = self._enumerate_addressed_folder(reference, target)
|
|
296
|
+
if items is None:
|
|
297
|
+
logger.info("the run was stopped during enumeration, the state file is untouched")
|
|
298
|
+
return self._stopped_during_enumeration_summary()
|
|
299
|
+
|
|
300
|
+
# Set from the target that was actually listed, which the decode-pass
|
|
301
|
+
# fallback can replace with a second resolution.
|
|
302
|
+
self._drive_id = target.drive_id
|
|
303
|
+
store.replace_enumeration(self._enumeration_header(target), items)
|
|
304
|
+
counts = store.reconcile(restart=self._settings.restart)
|
|
305
|
+
self._sink.emit(Reconciled(counts=dict(counts)))
|
|
306
|
+
|
|
307
|
+
if self._settings.dry_run:
|
|
308
|
+
logger.info("dry run: %d item(s) reconciled and no content transferred", len(items))
|
|
309
|
+
store.flush(force=True)
|
|
310
|
+
return self._finish(None)
|
|
311
|
+
|
|
312
|
+
self._check_free_space(store.remaining_bytes())
|
|
313
|
+
self._drain_queue()
|
|
314
|
+
if not self._stop.is_set():
|
|
315
|
+
self._verify_tree()
|
|
316
|
+
self._apply_hash_safeguard()
|
|
317
|
+
store.flush(force=True)
|
|
318
|
+
return self._finish(self._write_the_manifest())
|
|
319
|
+
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
# Setup
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def _make_destination(self) -> None:
|
|
325
|
+
"""Create the destination directory, which every later stage writes into."""
|
|
326
|
+
try:
|
|
327
|
+
os.makedirs(long_path(self._dest), exist_ok=True) # noqa: PTH103
|
|
328
|
+
except OSError as exc:
|
|
329
|
+
raise ValidationError(
|
|
330
|
+
f"the destination directory {self._dest} could not be created: {exc}"
|
|
331
|
+
) from exc
|
|
332
|
+
|
|
333
|
+
def _announce(self, target: DriveTarget) -> None:
|
|
334
|
+
"""Emit ``Resolved`` for the target the run is about to enumerate.
|
|
335
|
+
|
|
336
|
+
Called a second time where the decode-pass fallback re-resolved, so the
|
|
337
|
+
folder a front end shows is the one that was actually listed. Both
|
|
338
|
+
front ends replace what they hold: the command line writes a second log
|
|
339
|
+
line and the window resets its source label.
|
|
340
|
+
"""
|
|
341
|
+
self._sink.emit(
|
|
342
|
+
Resolved(
|
|
343
|
+
site_web_url=target.site_web_url,
|
|
344
|
+
drive_name=target.drive_name,
|
|
345
|
+
folder_path=target.folder_path,
|
|
346
|
+
drive_id=target.drive_id,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
logger.info(
|
|
350
|
+
"resolved %s to the %s library, folder %r",
|
|
351
|
+
self._settings.url,
|
|
352
|
+
target.drive_name,
|
|
353
|
+
target.folder_path,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _open_connection(self) -> None:
|
|
357
|
+
"""Build the one pacer, token provider and client the run shares.
|
|
358
|
+
|
|
359
|
+
Exactly one :class:`~sharefetch.pacing.Pacer` exists per run and it is
|
|
360
|
+
handed to both the client and every transfer. A second one would let
|
|
361
|
+
two workers each spend the configured inter-request delay independently
|
|
362
|
+
and double the rate the service sees.
|
|
363
|
+
"""
|
|
364
|
+
settings = self._settings
|
|
365
|
+
self._pacer = Pacer(settings.delay, settings.limit_rate_kbps)
|
|
366
|
+
if self._client is not None:
|
|
367
|
+
return
|
|
368
|
+
if self._tokens is None:
|
|
369
|
+
self._tokens = TokenProvider(
|
|
370
|
+
settings.client_id,
|
|
371
|
+
settings.tenant,
|
|
372
|
+
self._sink,
|
|
373
|
+
reauth=settings.reauth,
|
|
374
|
+
)
|
|
375
|
+
self._client = GraphClient(
|
|
376
|
+
self._tokens,
|
|
377
|
+
self._pacer,
|
|
378
|
+
settings.connections,
|
|
379
|
+
self._sink,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
# Enumeration
|
|
384
|
+
# ------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def _enumerate_addressed_folder(
|
|
387
|
+
self, reference: SourceRef, target: DriveTarget
|
|
388
|
+
) -> tuple[DriveTarget, dict[str, ItemState] | None]:
|
|
389
|
+
"""Enumerate the tree, retrying once one decode pass short where 4.5 allows.
|
|
390
|
+
|
|
391
|
+
Returns the target that was listed and its items, with ``None`` items
|
|
392
|
+
where a stop ended the walk.
|
|
393
|
+
|
|
394
|
+
Normalisation rule 5 decodes the ``id`` parameter until the value stops
|
|
395
|
+
changing, which is right for the doubly encoded value a Teams link
|
|
396
|
+
carries and wrong for a folder genuinely named ``A%20B``: that arrives
|
|
397
|
+
as ``A%2520B`` and stabilises at ``A B``. A literal percent sign and an
|
|
398
|
+
extra encoding pass are indistinguishable by inspection, so the service
|
|
399
|
+
is the only arbiter. :func:`sharefetch.resolve.resolve` derives the
|
|
400
|
+
folder path without asking whether the folder exists, so the mistake
|
|
401
|
+
first shows as a ``404`` when enumeration requests that folder's
|
|
402
|
+
children, and that is where it is answered.
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
return target, self._enumerate(target)
|
|
406
|
+
except EnumerationError as first:
|
|
407
|
+
decode_passes = self._decode_fallback_ceiling(reference, first)
|
|
408
|
+
if decode_passes is None:
|
|
409
|
+
raise
|
|
410
|
+
return self._retry_one_decode_pass_short(reference, target, first, decode_passes)
|
|
411
|
+
|
|
412
|
+
def _decode_fallback_ceiling(
|
|
413
|
+
self, reference: SourceRef, failure: EnumerationError
|
|
414
|
+
) -> int | None:
|
|
415
|
+
"""Return the decode-pass ceiling to retry at, or ``None`` for no retry.
|
|
416
|
+
|
|
417
|
+
The retry is permitted under four conditions and no others. Each is
|
|
418
|
+
tested here, and each logs at DEBUG where it is the one that declined,
|
|
419
|
+
so a run that did not retry says why.
|
|
420
|
+
|
|
421
|
+
The ceiling comes from :func:`sharefetch.resolve.decode_pass_ceiling`,
|
|
422
|
+
which reports the passes this URL's ``id`` parameter actually consumed.
|
|
423
|
+
Subtracting one from ``config.MAX_DECODE_ITERATIONS`` instead would
|
|
424
|
+
change nothing for a link that stabilised after three passes, and the
|
|
425
|
+
retry would silently repeat the first attempt.
|
|
426
|
+
"""
|
|
427
|
+
if self._decode_fallback_spent:
|
|
428
|
+
logger.debug("the decode-pass fallback has already been spent on this run")
|
|
429
|
+
return None
|
|
430
|
+
if not isinstance(reference, SitePathRef):
|
|
431
|
+
logger.debug(
|
|
432
|
+
"the source is a sharing link, whose path was never doubly decoded, so it "
|
|
433
|
+
"carries no decode pass to drop"
|
|
434
|
+
)
|
|
435
|
+
return None
|
|
436
|
+
if self._enumeration_produced:
|
|
437
|
+
logger.debug(
|
|
438
|
+
"the walk had already produced output when it failed, so the failure lies "
|
|
439
|
+
"below the addressed folder and re-resolving would answer the wrong fault"
|
|
440
|
+
)
|
|
441
|
+
return None
|
|
442
|
+
status = self._failure_status(failure)
|
|
443
|
+
if status != NOT_FOUND:
|
|
444
|
+
logger.debug(
|
|
445
|
+
"the enumeration failure carried status %s and not %d, so the addressed "
|
|
446
|
+
"folder is not missing and no retry is made",
|
|
447
|
+
status,
|
|
448
|
+
NOT_FOUND,
|
|
449
|
+
)
|
|
450
|
+
return None
|
|
451
|
+
ceiling = decode_pass_ceiling(reference)
|
|
452
|
+
if ceiling < 2:
|
|
453
|
+
logger.debug(
|
|
454
|
+
"the id parameter consumed %d decode pass(es), so no lower ceiling exists "
|
|
455
|
+
"to retry at",
|
|
456
|
+
ceiling,
|
|
457
|
+
)
|
|
458
|
+
return None
|
|
459
|
+
self._decode_fallback_spent = True
|
|
460
|
+
return ceiling - 1
|
|
461
|
+
|
|
462
|
+
def _retry_one_decode_pass_short(
|
|
463
|
+
self,
|
|
464
|
+
reference: SourceRef,
|
|
465
|
+
target: DriveTarget,
|
|
466
|
+
first: EnumerationError,
|
|
467
|
+
decode_passes: int,
|
|
468
|
+
) -> tuple[DriveTarget, dict[str, ItemState] | None]:
|
|
469
|
+
"""Re-resolve at ``decode_passes`` and enumerate the folder that names.
|
|
470
|
+
|
|
471
|
+
The one retry permitted. A second failure, whether the
|
|
472
|
+
retry could not be resolved at all or resolved to a folder that is also
|
|
473
|
+
missing, raises a message naming both folders tried and carrying both
|
|
474
|
+
refusals: an operator reading it has to see which two names the run
|
|
475
|
+
actually looked for. Stepping the ceiling down further is not done,
|
|
476
|
+
because each rung costs a site probe and a drive list and no evidence
|
|
477
|
+
points at a name two passes short.
|
|
478
|
+
"""
|
|
479
|
+
status = self._failure_status(first)
|
|
480
|
+
try:
|
|
481
|
+
retried = self._resolver(reference, self._client, decode_passes=decode_passes)
|
|
482
|
+
except AuthError:
|
|
483
|
+
raise
|
|
484
|
+
except SharefetchError as exc:
|
|
485
|
+
raise EnumerationError(
|
|
486
|
+
f"the addressed folder {target.folder_path!r} was not found, and the one retry "
|
|
487
|
+
f"at {decode_passes} decode pass(es) could not be resolved at all: {exc}. The "
|
|
488
|
+
f"first failure was: {first}",
|
|
489
|
+
status=status,
|
|
490
|
+
) from first
|
|
491
|
+
|
|
492
|
+
logger.info(
|
|
493
|
+
"the addressed folder %r was not found; the id parameter of the source URL "
|
|
494
|
+
"consumed %d decode pass(es), so the run retries once at %d, which addresses %r "
|
|
495
|
+
"instead",
|
|
496
|
+
target.folder_path,
|
|
497
|
+
decode_passes + 1,
|
|
498
|
+
decode_passes,
|
|
499
|
+
retried.folder_path,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# The first attempt produced nothing, or the retry would have been
|
|
503
|
+
# declined, so all three are already empty. Clearing them holds that by
|
|
504
|
+
# construction rather than by the caller's reasoning: _enumerate builds
|
|
505
|
+
# its own item map and adds to the remote map this instance shares.
|
|
506
|
+
self._remote.clear()
|
|
507
|
+
self._enumeration_skipped = 0
|
|
508
|
+
self._unmappable = 0
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
items = self._enumerate(retried)
|
|
512
|
+
except EnumerationError as second:
|
|
513
|
+
raise EnumerationError(
|
|
514
|
+
f"the addressed folder was not found at {target.folder_path!r}, and the one "
|
|
515
|
+
f"retry one decode pass short addressed {retried.folder_path!r}, which was not "
|
|
516
|
+
f"found either. The first failure was: {first}. The retry failed with: {second}",
|
|
517
|
+
status=status,
|
|
518
|
+
) from first
|
|
519
|
+
|
|
520
|
+
logger.info(
|
|
521
|
+
"%r resolved one decode pass short of the ceiling and the run continues against it",
|
|
522
|
+
retried.folder_path,
|
|
523
|
+
)
|
|
524
|
+
self._announce(retried)
|
|
525
|
+
return retried, items
|
|
526
|
+
|
|
527
|
+
def _failure_status(self, failure: EnumerationError) -> int | None:
|
|
528
|
+
"""Return the HTTP status behind an enumeration failure.
|
|
529
|
+
|
|
530
|
+
:func:`sharefetch.listing.walk` reports a refused addressed folder as an
|
|
531
|
+
``EnumerationError`` carrying the refusal's text and not its status, and
|
|
532
|
+
chains the original refusal as the cause, so the status is read off the
|
|
533
|
+
cause. The HTTP layer also raises ``EnumerationError`` directly for a
|
|
534
|
+
body it cannot read, and those carry the status themselves.
|
|
535
|
+
"""
|
|
536
|
+
status = getattr(failure, "status", None)
|
|
537
|
+
if status is not None:
|
|
538
|
+
return status
|
|
539
|
+
return getattr(failure.__cause__, "status", None)
|
|
540
|
+
|
|
541
|
+
def _enumerate(self, target: DriveTarget) -> dict[str, ItemState] | None:
|
|
542
|
+
"""Walk the tree and map each item onto a local path.
|
|
543
|
+
|
|
544
|
+
Returns the enumerated items keyed on the drive-relative path, or
|
|
545
|
+
``None`` where a stop ended the walk before it finished. A partial walk
|
|
546
|
+
is never installed, because
|
|
547
|
+
:meth:`~sharefetch.state.StateStore.replace_enumeration` drops every
|
|
548
|
+
path the walk did not return and a truncated walk would therefore
|
|
549
|
+
discard the records of files it simply never reached.
|
|
550
|
+
"""
|
|
551
|
+
settings = self._settings
|
|
552
|
+
items: dict[str, ItemState] = {}
|
|
553
|
+
taken: set[str] = set()
|
|
554
|
+
wanted = target.single_file_name
|
|
555
|
+
# Reset per attempt, because the decode-pass fallback reads it after a
|
|
556
|
+
# failure to tell the addressed folder apart from a folder deeper in.
|
|
557
|
+
self._enumeration_produced = False
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
for remote in self._walker(
|
|
561
|
+
target,
|
|
562
|
+
self._client,
|
|
563
|
+
include=settings.include,
|
|
564
|
+
exclude=settings.exclude,
|
|
565
|
+
max_depth=settings.max_depth,
|
|
566
|
+
on_progress=self._on_enumeration_progress,
|
|
567
|
+
):
|
|
568
|
+
self._enumeration_produced = True
|
|
569
|
+
if wanted is not None and remote.name != wanted:
|
|
570
|
+
logger.debug(
|
|
571
|
+
"%s is not the single file the sharing link addressed", remote.drive_path
|
|
572
|
+
)
|
|
573
|
+
continue
|
|
574
|
+
state = self._map_item(remote, taken)
|
|
575
|
+
if state is None:
|
|
576
|
+
continue
|
|
577
|
+
items[remote.drive_path] = state
|
|
578
|
+
self._remote[remote.drive_path] = remote
|
|
579
|
+
except _EnumerationCancelledError:
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
logger.info("enumeration produced %d transferable item(s)", len(items))
|
|
583
|
+
return items
|
|
584
|
+
|
|
585
|
+
def _map_item(self, remote: RemoteItem, taken: set[str]) -> ItemState | None:
|
|
586
|
+
"""Map one enumerated item onto a local path, or report it unmappable.
|
|
587
|
+
|
|
588
|
+
A path whose component survives sanitisation longer than Windows
|
|
589
|
+
accepts is recorded as failed with the reason ``path_too_long``. The
|
|
590
|
+
item has no local path, so it has nowhere in the state file to be
|
|
591
|
+
recorded: it is counted failed for the run and
|
|
592
|
+
reported on its own ``FileFinished`` event.
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
mapped = map_drive_path(remote.drive_path, taken)
|
|
596
|
+
except ValidationError as exc:
|
|
597
|
+
self._unmappable += 1
|
|
598
|
+
message = str(exc)
|
|
599
|
+
logger.error("%s cannot be given a local path: %s", remote.drive_path, message)
|
|
600
|
+
self._sink.emit(
|
|
601
|
+
FileFinished(
|
|
602
|
+
drive_path=remote.drive_path,
|
|
603
|
+
status=STATUS_FAILED,
|
|
604
|
+
verification=None,
|
|
605
|
+
error=f"{REASON_PATH_TOO_LONG}: {message}",
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
taken.add(str(mapped.local_path).lower())
|
|
611
|
+
return ItemState(
|
|
612
|
+
item_id=remote.item_id,
|
|
613
|
+
size=remote.size,
|
|
614
|
+
etag=remote.etag,
|
|
615
|
+
ctag=remote.ctag,
|
|
616
|
+
mime_type=remote.mime_type,
|
|
617
|
+
quick_xor_hash=remote.quick_xor_hash,
|
|
618
|
+
local_path=str(mapped.local_path),
|
|
619
|
+
sanitised=mapped.sanitised,
|
|
620
|
+
original_name=mapped.original_name,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def _on_enumeration_progress(self, event: Any) -> None:
|
|
624
|
+
"""Forward a walk event to the sink, and end the walk on a stop.
|
|
625
|
+
|
|
626
|
+
Raising here is the documented way to stop ``listing.walk`` part way
|
|
627
|
+
through, because it is a lazy generator driven by this engine's own
|
|
628
|
+
loop.
|
|
629
|
+
|
|
630
|
+
Any event at all means the addressed folder's first page was read, so
|
|
631
|
+
a later failure lies below it. The decode-pass fallback reads that.
|
|
632
|
+
"""
|
|
633
|
+
self._enumeration_produced = True
|
|
634
|
+
if self._stop.is_set():
|
|
635
|
+
raise _EnumerationCancelledError(
|
|
636
|
+
"the run was stopped while the tree was being enumerated"
|
|
637
|
+
)
|
|
638
|
+
if isinstance(event, SkippedItem):
|
|
639
|
+
self._enumeration_skipped += 1
|
|
640
|
+
logger.warning("%s was skipped during enumeration: %s", event.drive_path, event.reason)
|
|
641
|
+
self._sink.emit(
|
|
642
|
+
FileFinished(
|
|
643
|
+
drive_path=event.drive_path,
|
|
644
|
+
status=STATUS_SKIPPED,
|
|
645
|
+
verification=None,
|
|
646
|
+
error=event.reason,
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
return
|
|
650
|
+
if isinstance(event, EnumerationComplete):
|
|
651
|
+
logger.info(
|
|
652
|
+
"enumeration complete: %d file(s), %d byte(s), %d folder(s)",
|
|
653
|
+
event.files,
|
|
654
|
+
event.total_bytes,
|
|
655
|
+
event.folders,
|
|
656
|
+
)
|
|
657
|
+
self._sink.emit(event)
|
|
658
|
+
|
|
659
|
+
def _enumeration_header(self, target: DriveTarget) -> dict[str, Any]:
|
|
660
|
+
"""The header fields the store accepts from this run's enumeration."""
|
|
661
|
+
return {
|
|
662
|
+
"source_url": self._settings.url,
|
|
663
|
+
"drive_id": target.drive_id,
|
|
664
|
+
"drive_name": target.drive_name,
|
|
665
|
+
"root_folder_path": target.folder_path,
|
|
666
|
+
# The store fills this with its own wall clock, so the recorded
|
|
667
|
+
# instant is the one the enumeration actually finished at.
|
|
668
|
+
"enumerated_at": None,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# ------------------------------------------------------------------
|
|
672
|
+
# Free space
|
|
673
|
+
# ------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
def _check_free_space(self, remaining: int) -> None:
|
|
676
|
+
"""Compare free space against the queue plus its margin.
|
|
677
|
+
|
|
678
|
+
Raises ``ValidationError`` naming both figures in bytes and in MiB.
|
|
679
|
+
``--ignore-free-space`` skips the check. Checking before the first
|
|
680
|
+
transfer is preferred because exhaustion part way through produces a
|
|
681
|
+
truncated file that reconciliation then has to interpret.
|
|
682
|
+
"""
|
|
683
|
+
if self._settings.ignore_free_space:
|
|
684
|
+
logger.info("the free-space check is skipped because --ignore-free-space was given")
|
|
685
|
+
return
|
|
686
|
+
if remaining <= 0:
|
|
687
|
+
logger.info("nothing remains to transfer, so no free space is required")
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
required = int(remaining * (1 + FREE_SPACE_MARGIN))
|
|
691
|
+
try:
|
|
692
|
+
free = int(self._disk_usage(long_path(self._dest)).free)
|
|
693
|
+
except OSError as exc:
|
|
694
|
+
logger.warning(
|
|
695
|
+
"free space on the volume holding %s could not be read, the check is skipped: %s",
|
|
696
|
+
self._dest,
|
|
697
|
+
exc,
|
|
698
|
+
)
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
if free < required:
|
|
702
|
+
raise ValidationError(
|
|
703
|
+
f"the queue needs {required} bytes ({required / BYTES_PER_MIB:.1f} MiB), which is "
|
|
704
|
+
f"{remaining} bytes of content plus the {FREE_SPACE_MARGIN:.0%} margin, and the "
|
|
705
|
+
f"volume holding {self._dest} has {free} bytes ({free / BYTES_PER_MIB:.1f} MiB) "
|
|
706
|
+
f"free; pass --ignore-free-space to proceed anyway"
|
|
707
|
+
)
|
|
708
|
+
logger.info(
|
|
709
|
+
"free space check passed: %d byte(s) required, %d byte(s) available", required, free
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# ------------------------------------------------------------------
|
|
713
|
+
# The transfer queue
|
|
714
|
+
# ------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
def _drain_queue(self) -> None:
|
|
717
|
+
"""Transfer every queued item on a pool of ``settings.connections`` workers.
|
|
718
|
+
|
|
719
|
+
A worker returning ``"paused"`` puts its item back at the head of the
|
|
720
|
+
queue, so the file it left partly written is the first one resumed. A
|
|
721
|
+
stop leaves the remaining queue untouched and lets the files already in
|
|
722
|
+
flight run to the end of their bodies.
|
|
723
|
+
"""
|
|
724
|
+
store = self._require_store()
|
|
725
|
+
queue = deque(drive_path for drive_path, _ in store.pending())
|
|
726
|
+
if not queue:
|
|
727
|
+
logger.info("nothing is queued for transfer")
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
workers = self._settings.connections
|
|
731
|
+
logger.info("transferring %d item(s) on %d worker(s)", len(queue), workers)
|
|
732
|
+
in_flight: dict[Future[tuple[str, int]], str] = {}
|
|
733
|
+
try:
|
|
734
|
+
with ThreadPoolExecutor(
|
|
735
|
+
max_workers=workers, thread_name_prefix=WORKER_NAME_PREFIX
|
|
736
|
+
) as pool:
|
|
737
|
+
while queue or in_flight:
|
|
738
|
+
while (
|
|
739
|
+
queue
|
|
740
|
+
and len(in_flight) < workers
|
|
741
|
+
and not self._stop.is_set()
|
|
742
|
+
and not self._pause.is_set()
|
|
743
|
+
):
|
|
744
|
+
drive_path = queue.popleft()
|
|
745
|
+
in_flight[pool.submit(self._transfer_one, drive_path)] = drive_path
|
|
746
|
+
|
|
747
|
+
if not in_flight:
|
|
748
|
+
if self._stop.is_set():
|
|
749
|
+
logger.info(
|
|
750
|
+
"the run was stopped with %d item(s) still queued", len(queue)
|
|
751
|
+
)
|
|
752
|
+
break
|
|
753
|
+
if self._pause.is_set():
|
|
754
|
+
store.flush(force=True)
|
|
755
|
+
self._wait_while_paused()
|
|
756
|
+
continue
|
|
757
|
+
break
|
|
758
|
+
|
|
759
|
+
done, _ = wait(list(in_flight), return_when=FIRST_COMPLETED)
|
|
760
|
+
for future in done:
|
|
761
|
+
finished = in_flight.pop(future)
|
|
762
|
+
outcome, written = future.result()
|
|
763
|
+
if outcome == STATUS_PAUSED:
|
|
764
|
+
queue.appendleft(finished)
|
|
765
|
+
logger.info("%s was paused and returns to the queue", finished)
|
|
766
|
+
continue
|
|
767
|
+
self._bytes_transferred += written
|
|
768
|
+
if outcome == STATUS_COMPLETE:
|
|
769
|
+
self._transferred += 1
|
|
770
|
+
self._note_recorded_hash_outcome(finished)
|
|
771
|
+
finally:
|
|
772
|
+
store.flush(force=True)
|
|
773
|
+
|
|
774
|
+
def _wait_while_paused(self) -> None:
|
|
775
|
+
"""Block the engine thread until the pause is released or a stop arrives."""
|
|
776
|
+
logger.info("the queue is paused and state has been flushed")
|
|
777
|
+
while self._pause.is_set() and not self._stop.is_set():
|
|
778
|
+
self._sleep(PAUSE_POLL_SECONDS)
|
|
779
|
+
|
|
780
|
+
def _transfer_one(self, drive_path: str) -> tuple[str, int]:
|
|
781
|
+
"""Fetch one item and return its outcome and the bytes this run wrote.
|
|
782
|
+
|
|
783
|
+
Runs on a worker thread. ``transfer.fetch`` records the item's outcome
|
|
784
|
+
against the store before returning and never re-raises a per-item
|
|
785
|
+
``TransferError``, so the returned string is everything the queue needs.
|
|
786
|
+
An ``AuthError`` is terminal for the whole run and propagates.
|
|
787
|
+
"""
|
|
788
|
+
store = self._require_store()
|
|
789
|
+
record = store.all_items()[drive_path]
|
|
790
|
+
before = record.bytes_written
|
|
791
|
+
outcome = self._fetch(
|
|
792
|
+
self._remote[drive_path],
|
|
793
|
+
store.final_path(record),
|
|
794
|
+
self._client,
|
|
795
|
+
store,
|
|
796
|
+
self._pacer,
|
|
797
|
+
self._settings,
|
|
798
|
+
self._sink,
|
|
799
|
+
self._pause,
|
|
800
|
+
self._stop,
|
|
801
|
+
drive_id=self._drive_id,
|
|
802
|
+
sleep=self._sleep,
|
|
803
|
+
)
|
|
804
|
+
after = store.all_items()[drive_path].bytes_written
|
|
805
|
+
return outcome, max(after - before, 0)
|
|
806
|
+
|
|
807
|
+
# ------------------------------------------------------------------
|
|
808
|
+
# Verification over the whole tree
|
|
809
|
+
# ------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
def _verify_tree(self) -> None:
|
|
812
|
+
"""Re-run checks 1 to 4 over every file sitting at its final name.
|
|
813
|
+
|
|
814
|
+
A ``.part`` file is deliberately outside the pass: partial bytes are
|
|
815
|
+
not a file in the tree, and verifying one would
|
|
816
|
+
record a size mismatch against an item that is resuming correctly.
|
|
817
|
+
"""
|
|
818
|
+
store = self._require_store()
|
|
819
|
+
checked = 0
|
|
820
|
+
for drive_path, item in sorted(store.all_items().items()):
|
|
821
|
+
final = store.final_path(item)
|
|
822
|
+
if not _exists(final):
|
|
823
|
+
continue
|
|
824
|
+
result = self._verify_one(final, drive_path, item)
|
|
825
|
+
if result is None:
|
|
826
|
+
continue
|
|
827
|
+
checked += 1
|
|
828
|
+
failed = result.status == VERIFICATION_FAILED
|
|
829
|
+
store.record(
|
|
830
|
+
drive_path,
|
|
831
|
+
status=STATUS_FAILED if failed else STATUS_COMPLETE,
|
|
832
|
+
verification=result.to_dict(),
|
|
833
|
+
last_error=f"verification failed: {result.reason}" if failed else None,
|
|
834
|
+
)
|
|
835
|
+
self._note_hash_outcome(item.quick_xor_hash, result.checks, result.reason)
|
|
836
|
+
logger.info("the end-of-run pass verified %d file(s) under %s", checked, self._dest)
|
|
837
|
+
|
|
838
|
+
def _verify_one(
|
|
839
|
+
self, path: Path, drive_path: str, item: ItemState
|
|
840
|
+
) -> VerificationResult | None:
|
|
841
|
+
"""Verify one file in the tree, explaining a hash mismatch in full.
|
|
842
|
+
|
|
843
|
+
Checks stop at the first failure, so a file failing check 2 leaves
|
|
844
|
+
checks 3 and 4 unrun and the safeguard with nothing to read. The file is
|
|
845
|
+
verified a second time with no hash to compare and that run's check list
|
|
846
|
+
replaces the truncated one, while the reported reason stays
|
|
847
|
+
``hash_mismatch`` because check 2 fails first whatever the second run
|
|
848
|
+
finds. This mirrors what the transfer layer records for a file that
|
|
849
|
+
never reached its final name.
|
|
850
|
+
"""
|
|
851
|
+
name = drive_path.rsplit("/", 1)[-1]
|
|
852
|
+
try:
|
|
853
|
+
result = self._verify(
|
|
854
|
+
path,
|
|
855
|
+
declared_size=item.size,
|
|
856
|
+
expected_hash=item.quick_xor_hash,
|
|
857
|
+
mime_type=item.mime_type,
|
|
858
|
+
name=name,
|
|
859
|
+
chunk_size=self._settings.chunk_bytes,
|
|
860
|
+
)
|
|
861
|
+
except (VerificationError, OSError) as exc:
|
|
862
|
+
logger.error("%s could not be verified and keeps its recorded status: %s", path, exc)
|
|
863
|
+
return None
|
|
864
|
+
|
|
865
|
+
if result.reason != REASON_HASH_MISMATCH:
|
|
866
|
+
return result
|
|
867
|
+
|
|
868
|
+
try:
|
|
869
|
+
without_hash = self._verify(
|
|
870
|
+
path,
|
|
871
|
+
declared_size=item.size,
|
|
872
|
+
expected_hash=None,
|
|
873
|
+
mime_type=item.mime_type,
|
|
874
|
+
name=name,
|
|
875
|
+
chunk_size=self._settings.chunk_bytes,
|
|
876
|
+
)
|
|
877
|
+
except (VerificationError, OSError) as exc:
|
|
878
|
+
logger.warning("%s could not be re-checked without its hash: %s", drive_path, exc)
|
|
879
|
+
return result
|
|
880
|
+
|
|
881
|
+
logger.info(
|
|
882
|
+
"%s failed the hash comparison while passing %s",
|
|
883
|
+
drive_path,
|
|
884
|
+
", ".join(without_hash.checks) or "no other check",
|
|
885
|
+
)
|
|
886
|
+
return VerificationResult(
|
|
887
|
+
status=VERIFICATION_FAILED,
|
|
888
|
+
checks=without_hash.checks,
|
|
889
|
+
warnings=without_hash.warnings,
|
|
890
|
+
reason=REASON_HASH_MISMATCH,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
# ------------------------------------------------------------------
|
|
894
|
+
# The hash implementation safeguard
|
|
895
|
+
# ------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
def _note_recorded_hash_outcome(self, drive_path: str) -> None:
|
|
898
|
+
"""Read the verdict the transfer layer recorded and tally it."""
|
|
899
|
+
item = self._require_store().all_items().get(drive_path)
|
|
900
|
+
if item is None or not isinstance(item.verification, dict):
|
|
901
|
+
return
|
|
902
|
+
checks = item.verification.get("checks") or ()
|
|
903
|
+
reason = item.verification.get("reason")
|
|
904
|
+
self._note_hash_outcome(item.quick_xor_hash, tuple(checks), reason)
|
|
905
|
+
|
|
906
|
+
def _note_hash_outcome(
|
|
907
|
+
self, expected_hash: str | None, checks: tuple[str, ...], reason: str | None
|
|
908
|
+
) -> None:
|
|
909
|
+
"""Count one file towards the safeguard, where it had a hash to compare.
|
|
910
|
+
|
|
911
|
+
A file whose result lists check 2 among the checks that passed is a
|
|
912
|
+
match. A file whose result names ``hash_mismatch`` while listing check 3
|
|
913
|
+
is a file that failed the hash alone: checks run in order and stop at
|
|
914
|
+
the first failure, so a passing check 3 means check 1 passed or was
|
|
915
|
+
skipped, and the reason names check 2 rather than check 4.
|
|
916
|
+
"""
|
|
917
|
+
if not expected_hash:
|
|
918
|
+
return
|
|
919
|
+
if CHECK_CONTENT_HASH in checks:
|
|
920
|
+
self._hash_comparisons += 1
|
|
921
|
+
self._hash_matches += 1
|
|
922
|
+
return
|
|
923
|
+
if reason == REASON_HASH_MISMATCH:
|
|
924
|
+
self._hash_comparisons += 1
|
|
925
|
+
if CHECK_NOT_SUBSTITUTED in checks:
|
|
926
|
+
self._hash_only_failures += 1
|
|
927
|
+
|
|
928
|
+
def _apply_hash_safeguard(self) -> bool | None:
|
|
929
|
+
"""Decide the safeguard's verdict and write it to the state header.
|
|
930
|
+
|
|
931
|
+
One match is evidence the implementation agrees with the service, and
|
|
932
|
+
the header records ``True``. Every file that had a hash to compare
|
|
933
|
+
failing check 2 while passing the others points at the implementation
|
|
934
|
+
in this build rather than at the library contents, and the header
|
|
935
|
+
records ``False``. A run with no hash to compare, or one whose hash
|
|
936
|
+
failures were mixed with failures of other checks, reaches no verdict
|
|
937
|
+
and leaves the header as it stands.
|
|
938
|
+
"""
|
|
939
|
+
store = self._require_store()
|
|
940
|
+
if self._hash_comparisons == 0:
|
|
941
|
+
logger.info(
|
|
942
|
+
"no verified file carried a quickXorHash, so this run reaches no verdict on the "
|
|
943
|
+
"hash implementation"
|
|
944
|
+
)
|
|
945
|
+
return store.header().get("hash_algorithm_validated")
|
|
946
|
+
|
|
947
|
+
if self._hash_matches:
|
|
948
|
+
logger.info(
|
|
949
|
+
"%d of %d file(s) matched the quickXorHash the source published",
|
|
950
|
+
self._hash_matches,
|
|
951
|
+
self._hash_comparisons,
|
|
952
|
+
)
|
|
953
|
+
store.set_hash_algorithm_validated(True)
|
|
954
|
+
return True
|
|
955
|
+
|
|
956
|
+
if self._hash_only_failures == self._hash_comparisons:
|
|
957
|
+
logger.error(
|
|
958
|
+
"all %d file(s) that carried a quickXorHash failed the hash comparison while "
|
|
959
|
+
"passing every other check; this points at the quickXorHash implementation in "
|
|
960
|
+
"this build rather than at the contents of the library, and the files are left "
|
|
961
|
+
"on disk for inspection",
|
|
962
|
+
self._hash_comparisons,
|
|
963
|
+
)
|
|
964
|
+
store.set_hash_algorithm_validated(False)
|
|
965
|
+
return False
|
|
966
|
+
|
|
967
|
+
logger.warning(
|
|
968
|
+
"none of the %d file(s) carrying a quickXorHash matched it, and %d of them also "
|
|
969
|
+
"failed another check, so no verdict on the hash implementation is recorded",
|
|
970
|
+
self._hash_comparisons,
|
|
971
|
+
self._hash_comparisons - self._hash_only_failures,
|
|
972
|
+
)
|
|
973
|
+
return store.header().get("hash_algorithm_validated")
|
|
974
|
+
|
|
975
|
+
# ------------------------------------------------------------------
|
|
976
|
+
# Reporting
|
|
977
|
+
# ------------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
def _write_the_manifest(self) -> Path | None:
|
|
980
|
+
"""Write ``manifest.csv`` and return its path, or ``None`` on a failure.
|
|
981
|
+
|
|
982
|
+
A manifest that cannot be written is reported at ERROR and does not
|
|
983
|
+
abort the run. The tree and the state file are already on disk, and
|
|
984
|
+
a partly failing operation reports precisely what succeeded, and
|
|
985
|
+
discarding the summary of completed transfers
|
|
986
|
+
because the report file could not be written would do the opposite.
|
|
987
|
+
"""
|
|
988
|
+
try:
|
|
989
|
+
return self._write_manifest(self._require_store(), self._dest)
|
|
990
|
+
except ManifestError as exc:
|
|
991
|
+
logger.error("the manifest could not be written and the run continues: %s", exc)
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
def _finish(self, manifest_path: Path | None) -> RunSummary:
|
|
995
|
+
"""Build the summary from the state store, emit ``RunFinished`` and return it.
|
|
996
|
+
|
|
997
|
+
Every count but ``transferred`` and ``bytes_transferred`` is read off
|
|
998
|
+
the store at this moment, so no count can disagree with the item
|
|
999
|
+
statuses the state file and the manifest carry.
|
|
1000
|
+
"""
|
|
1001
|
+
store = self._require_store()
|
|
1002
|
+
items = store.all_items()
|
|
1003
|
+
complete = sum(1 for item in items.values() if item.status == STATUS_COMPLETE)
|
|
1004
|
+
failed = sum(1 for item in items.values() if item.status == STATUS_FAILED)
|
|
1005
|
+
skipped = sum(1 for item in items.values() if item.status == STATUS_SKIPPED)
|
|
1006
|
+
verified = sum(
|
|
1007
|
+
1
|
|
1008
|
+
for item in items.values()
|
|
1009
|
+
if isinstance(item.verification, dict)
|
|
1010
|
+
and item.verification.get("status") != VERIFICATION_FAILED
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
summary = RunSummary(
|
|
1014
|
+
transferred=self._transferred,
|
|
1015
|
+
skipped=skipped + self._enumeration_skipped,
|
|
1016
|
+
failed=failed + self._unmappable,
|
|
1017
|
+
already_present=max(complete - self._transferred, 0),
|
|
1018
|
+
verified=verified,
|
|
1019
|
+
bytes_transferred=self._bytes_transferred,
|
|
1020
|
+
manifest_path=manifest_path,
|
|
1021
|
+
hash_algorithm_validated=store.header().get("hash_algorithm_validated"),
|
|
1022
|
+
)
|
|
1023
|
+
logger.info(
|
|
1024
|
+
"run finished: %d transferred, %d already present, %d verified, %d skipped, %d failed",
|
|
1025
|
+
summary.transferred,
|
|
1026
|
+
summary.already_present,
|
|
1027
|
+
summary.verified,
|
|
1028
|
+
summary.skipped,
|
|
1029
|
+
summary.failed,
|
|
1030
|
+
)
|
|
1031
|
+
self._sink.emit(RunFinished(summary=summary, manifest_path=manifest_path))
|
|
1032
|
+
return summary
|
|
1033
|
+
|
|
1034
|
+
def _stopped_during_enumeration_summary(self) -> RunSummary:
|
|
1035
|
+
"""Report a run stopped before any enumeration was installed."""
|
|
1036
|
+
summary = RunSummary(
|
|
1037
|
+
transferred=0,
|
|
1038
|
+
skipped=self._enumeration_skipped,
|
|
1039
|
+
failed=self._unmappable,
|
|
1040
|
+
already_present=0,
|
|
1041
|
+
verified=0,
|
|
1042
|
+
bytes_transferred=0,
|
|
1043
|
+
manifest_path=None,
|
|
1044
|
+
hash_algorithm_validated=None,
|
|
1045
|
+
)
|
|
1046
|
+
self._sink.emit(RunFinished(summary=summary, manifest_path=None))
|
|
1047
|
+
return summary
|
|
1048
|
+
|
|
1049
|
+
def _require_store(self) -> StateStore:
|
|
1050
|
+
"""Return the store, which every stage after enumeration depends on."""
|
|
1051
|
+
if self._store is None:
|
|
1052
|
+
raise ValidationError(
|
|
1053
|
+
"the run reached a stage that needs the state store before one was opened"
|
|
1054
|
+
)
|
|
1055
|
+
return self._store
|