batchvault 0.1.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.
batchvault/api.py ADDED
@@ -0,0 +1,884 @@
1
+ """Public Python API. The command-line interface is a thin wrapper over this."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import logging
7
+ import os
8
+ import tempfile
9
+ import time
10
+ import unicodedata
11
+ from collections.abc import Callable, Iterable
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from pathlib import Path
14
+
15
+ from batchvault import signing
16
+ from batchvault.crypto import require_cryptography
17
+ from batchvault.engines.base import Engine
18
+ from batchvault.engines.registry import engine_for_volume, get_engine, option_warnings
19
+ from batchvault.errors import (
20
+ BatchVaultError,
21
+ EngineError,
22
+ InputError,
23
+ ManifestError,
24
+ OptionsError,
25
+ OutputExistsError,
26
+ SignatureError,
27
+ )
28
+ from batchvault.filters import PathFilter
29
+ from batchvault.inputs import gather_sources
30
+ from batchvault.levels import AUTO, LevelChoice, choose_level, parse_level
31
+ from batchvault.locate import Target, resolve_targets
32
+ from batchvault.manifest import (
33
+ MANIFEST_NAME,
34
+ Manifest,
35
+ ManifestEntry,
36
+ VolumeInfo,
37
+ hash_file,
38
+ )
39
+ from batchvault.models import (
40
+ FileEntry,
41
+ FileResult,
42
+ InspectEntry,
43
+ ProgressEvent,
44
+ RunResult,
45
+ Status,
46
+ )
47
+ from batchvault.names import unsafe_on_windows
48
+ from batchvault.options import PackOptions, format_size, parse_size
49
+ from batchvault.volumes import STAGING_PREFIX, cleanup_staging, existing_volumes
50
+
51
+ Progress = Callable[[ProgressEvent], None]
52
+ Confirm = Callable[[list[Path]], bool]
53
+
54
+ log = logging.getLogger(__name__)
55
+
56
+ RETRY_BASE_DELAY = 1.0
57
+ RETRY_MAX_DELAY = 30.0
58
+ ENCRYPTED_MESSAGE = "Archive is encrypted; a password is required."
59
+
60
+
61
+ def _log_result(action: str, outcome: FileResult) -> None:
62
+ level = logging.WARNING if outcome.status == Status.FAILED else logging.INFO
63
+ log.log(
64
+ level, "%s %s: %s %s", action, outcome.source, outcome.status, outcome.message
65
+ )
66
+
67
+
68
+ def _split_jobs(jobs: int | None, files: int) -> tuple[int, int]:
69
+ """Split one thread budget into (files in parallel, threads per file).
70
+
71
+ A budget of 0 means no worker threads: one file at a time, compressed in
72
+ the calling thread.
73
+ """
74
+ total = jobs or 0
75
+ if total < 0:
76
+ raise InputError("jobs cannot be negative")
77
+ if total == 0:
78
+ return 1, 0
79
+ workers = max(1, min(files, total))
80
+ return workers, max(1, total // workers)
81
+
82
+
83
+ def _notify(progress: Progress | None, event: ProgressEvent) -> None:
84
+ if progress:
85
+ progress(event)
86
+
87
+
88
+ def _relative(path: Path, base: Path) -> str:
89
+ try:
90
+ return Path(os.path.relpath(path, base)).as_posix()
91
+ except ValueError: # different drive on Windows
92
+ return str(path)
93
+
94
+
95
+ def _retry(call, retries: int, retry_on: tuple[type[Exception], ...]):
96
+ """Run ``call()``, retrying transient failures with exponential backoff."""
97
+ attempt = 0
98
+ while True:
99
+ try:
100
+ return call()
101
+ except FileNotFoundError:
102
+ raise
103
+ except retry_on as exc:
104
+ if attempt >= retries:
105
+ raise
106
+ delay = min(RETRY_BASE_DELAY * 2**attempt, RETRY_MAX_DELAY)
107
+ log.warning(
108
+ "Attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay
109
+ )
110
+ time.sleep(delay)
111
+ attempt += 1
112
+
113
+
114
+ def _format_name(backend: Engine, options: PackOptions) -> str:
115
+ return "7z" if backend.name == "7zip" else options.format
116
+
117
+
118
+ def _up_to_date(
119
+ entry: ManifestEntry,
120
+ stat: os.stat_result,
121
+ backend: Engine,
122
+ options: PackOptions,
123
+ manifest_dir: Path,
124
+ ) -> bool:
125
+ """True if an archive already matches the file and the requested settings."""
126
+ if not entry.mtime_ns or entry.mtime_ns != stat.st_mtime_ns:
127
+ return False
128
+ if entry.size != stat.st_size:
129
+ return False
130
+ if (
131
+ entry.engine != backend.name
132
+ or entry.format != _format_name(backend, options)
133
+ or entry.level != options.level
134
+ or entry.volume_size != options.volume_size
135
+ or entry.encrypted != bool(options.password)
136
+ ):
137
+ return False
138
+
139
+ for volume in entry.volumes:
140
+ path = manifest_dir / volume.file
141
+ if not path.is_file() or path.stat().st_size != volume.size:
142
+ return False
143
+ return True
144
+
145
+
146
+ def pack(
147
+ files: Iterable[str | Path] = (),
148
+ output: str | Path = "archives",
149
+ *,
150
+ list_file: str | Path | None = None,
151
+ root: str | Path | None = None,
152
+ engine: str = "auto",
153
+ seven_zip: str | Path | None = None,
154
+ format: str | None = None,
155
+ level: int | str = AUTO,
156
+ volume_size: str | int = "10m",
157
+ jobs: int = 0,
158
+ include: Iterable[str] = (),
159
+ exclude: Iterable[str] = (),
160
+ force: bool = False,
161
+ update: bool = False,
162
+ verify: bool = False,
163
+ manifest: str | Path | None = None,
164
+ allow_archives: bool = False,
165
+ strict: bool = False,
166
+ dry_run: bool = False,
167
+ password: str | None = None,
168
+ retries: int = 3,
169
+ sign_key: str | Path | None = None,
170
+ sign_key_password: str | None = None,
171
+ delete_source: bool = False,
172
+ confirm: Confirm | None = None,
173
+ progress: Progress | None = None,
174
+ ) -> RunResult:
175
+ """Archive files into split volumes and record them in a manifest.
176
+
177
+ Each file gets its own folder ``<output>/<file name>/`` holding its volumes.
178
+ Folders are kept: a file in a subfolder is stored under
179
+ ``<output>/<folder>/<file name>/`` and ``unpack`` puts it back there. The
180
+ folder is relative to the one ``list files`` recorded (so the listed folder's
181
+ name is kept), else to the deepest folder all the files share; ``root``
182
+ overrides this with a folder of your choice.
183
+
184
+ Existing archives are skipped unless ``force`` is set; with ``update`` they
185
+ are skipped only if the file and settings are unchanged, and re-archived
186
+ otherwise. ``jobs`` is the total thread budget; ``progress`` is called from
187
+ worker threads. With ``strict``, settings the engine cannot honour raise an
188
+ error instead of a warning. With ``dry_run`` nothing is written.
189
+
190
+ ``password`` encrypts the archives (the native engine needs
191
+ ``batchvault[crypto]``). ``retries`` retries a file that failed on an I/O or
192
+ engine error. ``sign_key`` signs the manifest with an Ed25519 private key.
193
+ ``delete_source`` deletes each original after a full restore-and-compare
194
+ proves the archive is good (and ``confirm``, if given, agrees).
195
+ """
196
+ if retries < 0:
197
+ raise OptionsError("retries cannot be negative")
198
+ level = parse_level(level)
199
+ auto_level = level == AUTO # each file gets the level that suits its type
200
+
201
+ root_path = Path(root).expanduser().resolve() if root is not None else None
202
+ sourced = gather_sources(
203
+ files,
204
+ list_file,
205
+ path_filter=PathFilter(include, exclude),
206
+ allow_archives=allow_archives,
207
+ root=root_path,
208
+ )
209
+ sources = [path for path, _ in sourced]
210
+ folders = dict(sourced)
211
+ backend = get_engine(engine, seven_zip)
212
+ warnings = option_warnings(backend, format, level)
213
+ if strict and warnings:
214
+ raise OptionsError(" ".join(warnings))
215
+ if password and backend.name == "native":
216
+ require_cryptography()
217
+
218
+ private_key = None
219
+ if sign_key and not dry_run:
220
+ private_key = signing.load_private_key(Path(sign_key), sign_key_password)
221
+
222
+ workers, per_file_jobs = _split_jobs(jobs, len(sources))
223
+ options = PackOptions(
224
+ level=5 if auto_level else level,
225
+ volume_size=parse_size(volume_size),
226
+ jobs=per_file_jobs,
227
+ force=force,
228
+ format=format or "xz",
229
+ password=password,
230
+ )
231
+
232
+ out_root = Path(output).expanduser().resolve()
233
+ manifest_path = (
234
+ Path(manifest).expanduser().resolve() if manifest else out_root / MANIFEST_NAME
235
+ )
236
+ # Load first so a damaged manifest stops the run before any work is done.
237
+ known = Manifest.load(manifest_path) if manifest_path.exists() else Manifest()
238
+ if dry_run:
239
+ return _plan_pack(
240
+ backend, sources, folders, out_root, manifest_path.parent, options,
241
+ warnings, known if update else None, auto_level,
242
+ ) # fmt: skip
243
+
244
+ cleanup_staging(out_root)
245
+ out_root.mkdir(parents=True, exist_ok=True)
246
+
247
+ log.info("Packing %d file(s) with %s into %s", len(sources), backend.name, out_root)
248
+ total = sum(source.stat().st_size for source in sources)
249
+ _notify(progress, ProgressEvent("plan", out_root, total, count=len(sources)))
250
+
251
+ def work(source: Path) -> tuple[FileResult, ManifestEntry | None]:
252
+ return _pack_one(
253
+ backend, source, folders[source], out_root, manifest_path.parent,
254
+ options, verify, progress, known if update else None, retries,
255
+ auto_level,
256
+ ) # fmt: skip
257
+
258
+ with ThreadPoolExecutor(max_workers=workers) as executor:
259
+ outcomes = list(executor.map(work, sources))
260
+
261
+ result = RunResult(
262
+ warnings=warnings, manifest=manifest_path, engine=backend.name
263
+ )
264
+ for file_result, entry in outcomes:
265
+ result.entries.append(file_result)
266
+ if entry:
267
+ known.entries[entry.path] = entry
268
+
269
+ if any(entry for _, entry in outcomes):
270
+ known.save(manifest_path)
271
+ _sign_or_clear(manifest_path, private_key, result)
272
+
273
+ if delete_source:
274
+ _delete_sources(backend, outcomes, password, confirm, result)
275
+ return result
276
+
277
+
278
+ def _sign_or_clear(manifest_path: Path, private_key, result: RunResult) -> None:
279
+ if private_key is not None:
280
+ signing.sign_manifest(manifest_path, private_key)
281
+ return
282
+
283
+ stale = signing.signature_path(manifest_path)
284
+ if stale.exists():
285
+ stale.unlink()
286
+ result.warnings.append(
287
+ "The manifest changed, so its old signature was removed. "
288
+ "Sign it again with a private key."
289
+ )
290
+
291
+
292
+ def _file_options(
293
+ options: PackOptions, source: Path, auto_level: bool
294
+ ) -> tuple[PackOptions, LevelChoice | None]:
295
+ """The options for one file; with ``--level auto`` the level suits its type."""
296
+ if not auto_level or options.format == "store": # store ignores the level
297
+ return options, None
298
+ choice = choose_level(source)
299
+ return dataclasses.replace(options, level=choice.level), choice
300
+
301
+
302
+ def _folder_path(base: Path, folder: str) -> Path:
303
+ """``base`` joined with a stored forward-slash folder (``base`` if empty)."""
304
+ return base.joinpath(*folder.split("/")) if folder else base
305
+
306
+
307
+ def _plan_pack(
308
+ backend: Engine,
309
+ sources: list[Path],
310
+ folders: dict[Path, str],
311
+ out_root: Path,
312
+ manifest_dir: Path,
313
+ options: PackOptions,
314
+ warnings: list[str],
315
+ known: Manifest | None,
316
+ auto_level: bool = False,
317
+ ) -> RunResult:
318
+ result = RunResult(warnings=warnings, engine=backend.name)
319
+ suffix = backend.capabilities.archive_suffix
320
+
321
+ for source in sources:
322
+ stat = source.stat()
323
+ folder = folders[source]
324
+ existing = existing_volumes(
325
+ _folder_path(out_root, folder) / source.name, f"{source.name}{suffix}"
326
+ )
327
+ key = f"{folder}/{source.name}" if folder else source.name
328
+ entry = known.entries.get(key) if known else None
329
+ file_options, choice = _file_options(options, source, auto_level)
330
+
331
+ if entry and _up_to_date(entry, stat, backend, file_options, manifest_dir):
332
+ outcome = FileResult(
333
+ source, Status.SKIPPED, "Would skip: up to date.", stat.st_size
334
+ )
335
+ elif existing and not (file_options.force or known is not None):
336
+ outcome = FileResult(
337
+ source,
338
+ Status.SKIPPED,
339
+ "Would skip: archive already exists.",
340
+ stat.st_size,
341
+ )
342
+ else:
343
+ message = "Would update" if existing else "Would archive"
344
+ if choice:
345
+ message += f" at level {choice.level} ({choice.kind})"
346
+ outcome = FileResult(source, Status.SUCCESS, message + ".", stat.st_size)
347
+ outcome.folder = folder
348
+ outcome.level = file_options.level
349
+ outcome.name = source.name
350
+ result.entries.append(outcome)
351
+ return result
352
+
353
+
354
+ def _pack_summary(
355
+ size: int, stored: int, volumes: int, verified: bool, choice: LevelChoice | None
356
+ ) -> str:
357
+ """e.g. ``3 volumes, 12.3 MiB -> 4.1 MiB (33%), level 6 (text), verified``."""
358
+ sizes = f"{format_size(size)} -> {format_size(stored)}"
359
+ if size:
360
+ sizes += f" ({stored / size:.0%})"
361
+ parts = [f"{volumes} volume{'' if volumes == 1 else 's'}", sizes]
362
+ if choice:
363
+ parts.append(f"level {choice.level} ({choice.kind})")
364
+ if verified:
365
+ parts.append("verified")
366
+ return ", ".join(parts)
367
+
368
+
369
+ def _pack_one(
370
+ backend: Engine,
371
+ source: Path,
372
+ folder: str,
373
+ out_root: Path,
374
+ manifest_dir: Path,
375
+ options: PackOptions,
376
+ verify: bool,
377
+ progress: Progress | None,
378
+ known: Manifest | None,
379
+ retries: int,
380
+ auto_level: bool = False,
381
+ ) -> tuple[FileResult, ManifestEntry | None]:
382
+ stat = source.stat()
383
+ size = stat.st_size
384
+ _notify(progress, ProgressEvent("start", source, size))
385
+ options, choice = _file_options(options, source, auto_level)
386
+
387
+ entry = None
388
+ try:
389
+ key = f"{folder}/{source.name}" if folder else source.name
390
+ previous = known.entries.get(key) if known is not None else None
391
+ if previous and _up_to_date(previous, stat, backend, options, manifest_dir):
392
+ outcome = FileResult(source, Status.SKIPPED, "Up to date.", size)
393
+ else:
394
+ # In update mode a changed file replaces its old archive.
395
+ file_options = options
396
+ if known is not None:
397
+ file_options = dataclasses.replace(options, force=True)
398
+
399
+ destination = _folder_path(out_root, folder) / source.name
400
+ volumes = _retry(
401
+ lambda: backend.pack(source, destination, file_options),
402
+ retries,
403
+ (OSError, EngineError),
404
+ )
405
+ entry = ManifestEntry(
406
+ name=source.name,
407
+ folder=folder,
408
+ size=size,
409
+ sha256=hash_file(source),
410
+ engine=backend.name,
411
+ format=_format_name(backend, options),
412
+ level=options.level,
413
+ volume_size=options.volume_size,
414
+ volumes=[
415
+ VolumeInfo(
416
+ _relative(volume, manifest_dir),
417
+ volume.stat().st_size,
418
+ hash_file(volume),
419
+ )
420
+ for volume in volumes
421
+ ],
422
+ encrypted=bool(options.password),
423
+ mtime_ns=stat.st_mtime_ns,
424
+ )
425
+ if verify:
426
+ backend.test(volumes[0], password=options.password)
427
+ stored = sum(volume.size for volume in entry.volumes)
428
+ outcome = FileResult(
429
+ source,
430
+ Status.SUCCESS,
431
+ _pack_summary(size, stored, len(volumes), verify, choice),
432
+ size,
433
+ volumes,
434
+ archive=volumes[0],
435
+ stored_size=stored,
436
+ level=options.level,
437
+ )
438
+ except OutputExistsError as exc:
439
+ outcome = FileResult(source, Status.SKIPPED, str(exc), size)
440
+ except (BatchVaultError, OSError) as exc:
441
+ outcome, entry = FileResult(source, Status.FAILED, str(exc), size), None
442
+
443
+ outcome.folder = folder
444
+ outcome.name = source.name
445
+ _log_result("pack", outcome)
446
+ _notify(progress, ProgressEvent("finish", source, size, outcome.status))
447
+ return outcome, entry
448
+
449
+
450
+ def _roundtrip_problem(
451
+ backend: Engine, outcome: FileResult, entry: ManifestEntry, password: str | None
452
+ ) -> str | None:
453
+ """Restore the archive and compare it with the original; None if identical."""
454
+ try:
455
+ parent = outcome.volumes[0].parent
456
+ with tempfile.TemporaryDirectory(dir=parent, prefix=STAGING_PREFIX) as tmp:
457
+ restored = backend.unpack(
458
+ outcome.volumes[0],
459
+ Path(tmp),
460
+ force=True,
461
+ password=password,
462
+ max_bytes=entry.size,
463
+ )
464
+ if [path.name for path in restored] != [entry.name]:
465
+ return "the archive does not contain the expected file"
466
+ if hash_file(restored[0]) != entry.sha256:
467
+ return "the restored file differs from the original"
468
+ if hash_file(outcome.source) != entry.sha256:
469
+ return "the original changed while it was being archived"
470
+ except (BatchVaultError, OSError) as exc:
471
+ return str(exc)
472
+ return None
473
+
474
+
475
+ def _delete_sources(
476
+ backend: Engine,
477
+ outcomes: list[tuple[FileResult, ManifestEntry | None]],
478
+ password: str | None,
479
+ confirm: Confirm | None,
480
+ result: RunResult,
481
+ ) -> None:
482
+ verified = []
483
+ for outcome, entry in outcomes:
484
+ if outcome.status != Status.SUCCESS or entry is None:
485
+ continue
486
+ problem = _roundtrip_problem(backend, outcome, entry, password)
487
+ if problem:
488
+ outcome.warnings.append(f"Original kept: {problem}.")
489
+ log.warning("Original kept for %s: %s", outcome.source, problem)
490
+ else:
491
+ verified.append(outcome)
492
+
493
+ if not verified:
494
+ return
495
+ if confirm is not None and not confirm([o.source for o in verified]):
496
+ result.warnings.append("Original files were kept (deletion not confirmed).")
497
+ return
498
+
499
+ for outcome in verified:
500
+ try:
501
+ outcome.source.unlink()
502
+ except OSError as exc:
503
+ outcome.warnings.append(f"Could not delete the original: {exc}")
504
+ else:
505
+ outcome.message += "; original deleted"
506
+ log.info("Deleted original %s", outcome.source)
507
+
508
+
509
+ def _resolve(source: str | Path, public_key: str | Path | None) -> list[Target]:
510
+ """Find the archives for ``source``.
511
+
512
+ With ``public_key``, each manifest's signature is checked on the very bytes
513
+ that are then parsed, so the file cannot be swapped in between.
514
+ """
515
+ if not public_key:
516
+ return resolve_targets(source)
517
+
518
+ key = Path(public_key)
519
+
520
+ def load_signed(path: Path) -> Manifest:
521
+ try:
522
+ raw = path.read_bytes()
523
+ except OSError as exc:
524
+ raise ManifestError(f"Cannot read manifest {path}: {exc}") from exc
525
+ signing.verify_manifest_bytes(raw, path, key)
526
+ return Manifest.from_bytes(raw, path)
527
+
528
+ targets = resolve_targets(source, load_signed)
529
+ if any(target.manifest is None for target in targets):
530
+ raise SignatureError("Signature check needs a manifest, but none was found.")
531
+ return targets
532
+
533
+
534
+ def _plan_event(where: Path, targets: list[Target]) -> ProgressEvent:
535
+ # Sizes are only known from a manifest; without one the UI counts files.
536
+ known = all(target.entry for target in targets)
537
+ total = sum(target.entry.size for target in targets if target.entry)
538
+ return ProgressEvent("plan", where, total if known else 0, count=len(targets))
539
+
540
+
541
+ def unpack(
542
+ source: str | Path,
543
+ output: str | Path = "restored",
544
+ *,
545
+ force: bool = False,
546
+ update: bool = False,
547
+ jobs: int = 0,
548
+ seven_zip: str | Path | None = None,
549
+ password: str | None = None,
550
+ public_key: str | Path | None = None,
551
+ retries: int = 3,
552
+ max_output_size: str | int | None = None,
553
+ progress: Progress | None = None,
554
+ ) -> RunResult:
555
+ """Restore files from a manifest, a folder, any volume, or a list of volumes.
556
+
557
+ With a manifest, volumes are checked first and the restored file is checked
558
+ against its recorded SHA-256 before it is moved into place. Existing files
559
+ are skipped unless ``force`` is set; with ``update`` they are skipped only
560
+ when they already match the archive, and replaced otherwise. ``public_key``
561
+ requires a valid manifest signature before anything is restored.
562
+
563
+ ``max_output_size`` refuses an archive that would expand beyond that many
564
+ bytes (a decompression bomb) before anything is written. With a manifest an
565
+ archive may never expand beyond the size recorded for its file.
566
+ """
567
+ if retries < 0:
568
+ raise OptionsError("retries cannot be negative")
569
+ max_bytes = parse_size(max_output_size) if max_output_size is not None else None
570
+
571
+ targets = _resolve(source, public_key)
572
+
573
+ out_root = Path(output).expanduser().resolve()
574
+ cleanup_staging(out_root)
575
+ workers, _ = _split_jobs(jobs, len(targets))
576
+
577
+ log.info("Unpacking %d archive(s) into %s", len(targets), out_root)
578
+ _notify(progress, _plan_event(out_root, targets))
579
+
580
+ def work(target: Target) -> FileResult:
581
+ size = target.entry.size if target.entry else 0
582
+ _notify(progress, ProgressEvent("start", target.volume, size))
583
+ outcome = _restore_one(
584
+ target, out_root, force, update, seven_zip, password, retries, max_bytes
585
+ )
586
+ outcome.folder = target.entry.folder if target.entry else ""
587
+ outcome.name = target.name
588
+ _log_result("unpack", outcome)
589
+ _notify(progress, ProgressEvent("finish", target.volume, size, outcome.status))
590
+ return outcome
591
+
592
+ with ThreadPoolExecutor(max_workers=workers) as executor:
593
+ outcomes = list(executor.map(work, targets))
594
+
595
+ result = RunResult(entries=outcomes)
596
+ if any(target.entry is None for target in targets):
597
+ result.warnings.append(
598
+ "No manifest found for some archives; their checksums were not verified."
599
+ )
600
+ return result
601
+
602
+
603
+ def _destination_folder(out_root: Path, folder: str) -> Path:
604
+ """Where files stored under ``folder`` are restored, kept inside ``out_root``."""
605
+ destination = _folder_path(out_root, folder)
606
+ if not folder:
607
+ return destination
608
+
609
+ if os.name == "nt" and any(unsafe_on_windows(part) for part in folder.split("/")):
610
+ raise InputError(f"The stored folder {folder!r} has names Windows cannot use.")
611
+
612
+ root = out_root.resolve()
613
+ if root not in destination.resolve().parents:
614
+ raise InputError(f"The stored folder {folder!r} points outside the output.")
615
+ return destination
616
+
617
+
618
+ def _restore_one(
619
+ target: Target,
620
+ out_root: Path,
621
+ force: bool,
622
+ update: bool,
623
+ seven_zip: str | Path | None,
624
+ password: str | None,
625
+ retries: int,
626
+ max_bytes: int | None,
627
+ ) -> FileResult:
628
+ volume = target.volume
629
+ entry = target.entry
630
+ try:
631
+ limit = max_bytes
632
+ if entry: # the manifest says how big the file is; never accept more
633
+ limit = entry.size if limit is None else min(limit, entry.size)
634
+
635
+ folder = entry.folder if entry else ""
636
+ destination_dir = _destination_folder(out_root, folder)
637
+ destination = destination_dir / target.name
638
+ if destination.exists() and not force:
639
+ if not update:
640
+ return FileResult(
641
+ volume,
642
+ Status.SKIPPED,
643
+ f"{target.name} already exists. Use force to overwrite.",
644
+ )
645
+ if entry is None:
646
+ return FileResult(
647
+ volume,
648
+ Status.SKIPPED,
649
+ f"{target.name} exists and there is no manifest to compare with.",
650
+ )
651
+ if _matches(destination, entry):
652
+ return FileResult(volume, Status.SKIPPED, "Already up to date.")
653
+
654
+ if entry:
655
+ if entry.encrypted and not password:
656
+ return FileResult(volume, Status.FAILED, ENCRYPTED_MESSAGE)
657
+ problems = entry.check_volumes(target.base_dir)
658
+ if problems:
659
+ return FileResult(volume, Status.FAILED, "; ".join(problems))
660
+
661
+ backend = engine_for_volume(volume, seven_zip)
662
+ out_root.mkdir(parents=True, exist_ok=True)
663
+
664
+ with tempfile.TemporaryDirectory(dir=out_root, prefix=STAGING_PREFIX) as tmp:
665
+ restored = _retry(
666
+ lambda: backend.unpack(
667
+ volume, Path(tmp), force=True, password=password, max_bytes=limit
668
+ ),
669
+ retries,
670
+ (OSError,),
671
+ )
672
+
673
+ if entry:
674
+ names = [path.name for path in restored]
675
+ # macOS may hand back the same name in another Unicode form.
676
+ if [_nfc(n) for n in names] != [_nfc(entry.name)]:
677
+ return FileResult(
678
+ volume,
679
+ Status.FAILED,
680
+ f"Archive contains {names}, manifest expects {entry.name!r}.",
681
+ )
682
+ if hash_file(restored[0]) != entry.sha256:
683
+ return FileResult(
684
+ volume,
685
+ Status.FAILED,
686
+ f"Restored {entry.name} does not match its checksum.",
687
+ )
688
+
689
+ destination_dir.mkdir(parents=True, exist_ok=True)
690
+ final = []
691
+ for path in restored:
692
+ name = entry.name if entry else path.name
693
+ os.replace(path, destination_dir / name)
694
+ final.append(destination_dir / name)
695
+
696
+ size = sum(path.stat().st_size for path in final)
697
+ checked = "checksum verified" if entry else "checksum not verified"
698
+ message = f"{format_size(size)} restored, {checked}"
699
+ return FileResult(volume, Status.SUCCESS, message, size, restored=final)
700
+ except (BatchVaultError, OSError) as exc:
701
+ return FileResult(volume, Status.FAILED, str(exc))
702
+
703
+
704
+ def _nfc(name: str) -> str:
705
+ return unicodedata.normalize("NFC", name)
706
+
707
+
708
+ def _matches(path: Path, entry: ManifestEntry) -> bool:
709
+ return path.is_file() and path.stat().st_size == entry.size and (
710
+ hash_file(path) == entry.sha256
711
+ )
712
+
713
+
714
+ def verify(
715
+ source: str | Path,
716
+ *,
717
+ against: str | Path | None = None,
718
+ jobs: int = 0,
719
+ seven_zip: str | Path | None = None,
720
+ password: str | None = None,
721
+ public_key: str | Path | None = None,
722
+ progress: Progress | None = None,
723
+ ) -> RunResult:
724
+ """Check archives are complete and readable, without restoring anything.
725
+
726
+ With a manifest, volume sizes and checksums are checked. ``against`` is a
727
+ folder of the original files, compared by SHA-256 (manifest required); each
728
+ original is looked for by name in that folder and then in its subfolders.
729
+ ``public_key`` also requires a valid manifest signature.
730
+ """
731
+ targets = _resolve(source, public_key)
732
+
733
+ workers, _ = _split_jobs(jobs, len(targets))
734
+ originals = Path(against).expanduser() if against else None
735
+
736
+ _notify(progress, _plan_event(Path(source), targets))
737
+
738
+ def work(target: Target) -> FileResult:
739
+ size = target.entry.size if target.entry else 0
740
+ _notify(progress, ProgressEvent("start", target.volume, size))
741
+ outcome = _verify_one(target, originals, seven_zip, password)
742
+ outcome.folder = target.entry.folder if target.entry else ""
743
+ outcome.name = target.name
744
+ _log_result("verify", outcome)
745
+ _notify(progress, ProgressEvent("finish", target.volume, size, outcome.status))
746
+ return outcome
747
+
748
+ with ThreadPoolExecutor(max_workers=workers) as executor:
749
+ outcomes = list(executor.map(work, targets))
750
+
751
+ result = RunResult(entries=outcomes)
752
+ if any(target.entry is None for target in targets):
753
+ result.warnings.append(
754
+ "No manifest found for some archives; only their internal integrity "
755
+ "checks were run."
756
+ )
757
+ return result
758
+
759
+
760
+ def _find_originals(root: Path, folder: str, name: str) -> list[Path]:
761
+ """The original called ``name``: at its stored folder under ``root``, else
762
+ directly in ``root``, else anywhere below it."""
763
+ for candidate in (_folder_path(root, folder) / name, root / name):
764
+ if candidate.is_file():
765
+ return [candidate]
766
+ return [path for path in root.rglob(name) if path.is_file()]
767
+
768
+
769
+ def _verify_one(
770
+ target: Target,
771
+ originals: Path | None,
772
+ seven_zip: str | Path | None,
773
+ password: str | None,
774
+ ) -> FileResult:
775
+ volume = target.volume
776
+ entry = target.entry
777
+ try:
778
+ if entry:
779
+ if entry.encrypted and not password:
780
+ return FileResult(volume, Status.FAILED, ENCRYPTED_MESSAGE)
781
+ problems = entry.check_volumes(target.base_dir)
782
+ if problems:
783
+ return FileResult(volume, Status.FAILED, "; ".join(problems))
784
+
785
+ engine_for_volume(volume, seven_zip).test(volume, password=password)
786
+
787
+ if originals and entry:
788
+ candidates = _find_originals(originals, entry.folder, entry.name)
789
+ if not candidates:
790
+ message = f"Original not found under {originals}: {entry.name}"
791
+ return FileResult(volume, Status.FAILED, message)
792
+ if not any(hash_file(path) == entry.sha256 for path in candidates):
793
+ message = f"{entry.name} differs from the archive."
794
+ return FileResult(volume, Status.FAILED, message)
795
+
796
+ if entry:
797
+ message = "volumes, checksums and integrity test OK"
798
+ else:
799
+ message = "integrity test OK (no manifest, so checksums were not checked)"
800
+ if originals and entry:
801
+ message += "; matches the original"
802
+ return FileResult(volume, Status.SUCCESS, message, entry.size if entry else 0)
803
+ except (BatchVaultError, OSError) as exc:
804
+ return FileResult(volume, Status.FAILED, str(exc))
805
+
806
+
807
+ def inspect(source: str | Path) -> list[InspectEntry]:
808
+ """Describe archives from their manifest (or file names when there is none)."""
809
+ found = []
810
+ for target in resolve_targets(source):
811
+ entry = target.entry
812
+ if entry is None:
813
+ volumes = existing_volumes(
814
+ target.volume.parent, target.volume.name.rsplit(".", 1)[0]
815
+ )
816
+ found.append(
817
+ InspectEntry(
818
+ name=target.name,
819
+ path=target.volume,
820
+ engine="7zip" if ".7z." in target.volume.name.lower() else "native",
821
+ stored_size=sum(v.stat().st_size for v in volumes),
822
+ volumes=len(volumes),
823
+ )
824
+ )
825
+ continue
826
+
827
+ paths = [target.base_dir / volume.file for volume in entry.volumes]
828
+ found.append(
829
+ InspectEntry(
830
+ name=entry.name,
831
+ folder=entry.folder,
832
+ path=paths[0],
833
+ engine=entry.engine,
834
+ format=entry.format,
835
+ level=entry.level,
836
+ encrypted=entry.encrypted,
837
+ size=entry.size,
838
+ stored_size=sum(volume.size for volume in entry.volumes),
839
+ volumes=len(paths),
840
+ complete=all(path.is_file() for path in paths),
841
+ )
842
+ )
843
+ return found
844
+
845
+
846
+ def list_files(
847
+ folder: str | Path,
848
+ *,
849
+ include: Iterable[str] = (),
850
+ exclude: Iterable[str] = (),
851
+ ) -> list[FileEntry]:
852
+ """Files under ``folder`` (recursively), filtered by include/exclude patterns.
853
+
854
+ Patterns match against the path relative to ``folder``.
855
+ """
856
+ root = Path(folder).expanduser()
857
+ if not root.is_dir():
858
+ raise InputError(f"Folder not found: {root}")
859
+
860
+ path_filter = PathFilter(include, exclude)
861
+ # Record the folder's parent so that packing the listing keeps the folder's
862
+ # own name (Resources/...), not just what is inside it.
863
+ recorded = root.resolve().parent
864
+ return [
865
+ FileEntry(path.resolve(), path.stat().st_size, recorded)
866
+ for path in sorted(root.rglob("*"))
867
+ if path.is_file() and path_filter.matches(path.relative_to(root).as_posix())
868
+ ]
869
+
870
+
871
+ def list_archives(folder: str | Path) -> list[FileEntry]:
872
+ """Archives under ``folder``: from its manifest if present, else by scanning."""
873
+ root = Path(folder).expanduser()
874
+ if not root.is_dir():
875
+ raise InputError(f"Folder not found: {root}")
876
+
877
+ try:
878
+ targets = resolve_targets(root)
879
+ except InputError:
880
+ return []
881
+ return [
882
+ FileEntry(target.volume.resolve(), target.entry.size if target.entry else None)
883
+ for target in targets
884
+ ]