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/__init__.py +61 -0
- batchvault/__main__.py +3 -0
- batchvault/api.py +884 -0
- batchvault/blocks.py +176 -0
- batchvault/cli.py +806 -0
- batchvault/config.py +169 -0
- batchvault/crypto.py +193 -0
- batchvault/doctor.py +67 -0
- batchvault/engines/__init__.py +0 -0
- batchvault/engines/base.py +57 -0
- batchvault/engines/native.py +237 -0
- batchvault/engines/registry.py +61 -0
- batchvault/engines/sevenzip.py +303 -0
- batchvault/errors.py +38 -0
- batchvault/eta.py +39 -0
- batchvault/filters.py +51 -0
- batchvault/inputs.py +226 -0
- batchvault/levels.py +169 -0
- batchvault/locate.py +140 -0
- batchvault/logs.py +86 -0
- batchvault/manifest.py +210 -0
- batchvault/models.py +164 -0
- batchvault/names.py +36 -0
- batchvault/notify.py +73 -0
- batchvault/options.py +71 -0
- batchvault/py.typed +0 -0
- batchvault/security.py +40 -0
- batchvault/signing.py +152 -0
- batchvault/ui.py +298 -0
- batchvault/volumes.py +201 -0
- batchvault-0.1.0.dist-info/METADATA +306 -0
- batchvault-0.1.0.dist-info/RECORD +35 -0
- batchvault-0.1.0.dist-info/WHEEL +4 -0
- batchvault-0.1.0.dist-info/entry_points.txt +2 -0
- batchvault-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|