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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
import zlib
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from batchvault import crypto
|
|
10
|
+
from batchvault.blocks import (
|
|
11
|
+
BLOCK_SIZE,
|
|
12
|
+
BlockWriter,
|
|
13
|
+
check_format,
|
|
14
|
+
open_decompressed,
|
|
15
|
+
sniff_archive,
|
|
16
|
+
)
|
|
17
|
+
from batchvault.engines.base import Capabilities, Engine
|
|
18
|
+
from batchvault.errors import EngineError, OutputExistsError
|
|
19
|
+
from batchvault.names import unsafe_on_windows as _unsafe_on_windows
|
|
20
|
+
from batchvault.options import MIB, PackOptions
|
|
21
|
+
from batchvault.volumes import (
|
|
22
|
+
VolumeReader,
|
|
23
|
+
VolumeWriter,
|
|
24
|
+
existing_volumes,
|
|
25
|
+
find_volumes,
|
|
26
|
+
install_volumes,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import lzma
|
|
31
|
+
|
|
32
|
+
_LZMA_ERRORS: tuple[type[Exception], ...] = (lzma.LZMAError,)
|
|
33
|
+
except ImportError:
|
|
34
|
+
_LZMA_ERRORS = ()
|
|
35
|
+
|
|
36
|
+
# Errors raised while reading damaged, truncated or unreadable archive data.
|
|
37
|
+
_READ_ERRORS = (tarfile.TarError, EOFError, zlib.error, OSError, *_LZMA_ERRORS)
|
|
38
|
+
|
|
39
|
+
_STREAM_BUFFER = MIB
|
|
40
|
+
_CHUNK = MIB
|
|
41
|
+
# Data allowed after the tar end marker; a real archive has only padding.
|
|
42
|
+
_MAX_TRAILING = 4 * MIB
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
46
|
+
"""Drop ownership so archives do not depend on the user who made them."""
|
|
47
|
+
info.uid = info.gid = 0
|
|
48
|
+
info.uname = info.gname = ""
|
|
49
|
+
return info
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Budget:
|
|
53
|
+
"""Caps the total size of the files restored from one archive."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, limit: int | None):
|
|
56
|
+
self.remaining = limit
|
|
57
|
+
|
|
58
|
+
def charge(self, member: tarfile.TarInfo) -> None:
|
|
59
|
+
if self.remaining is None:
|
|
60
|
+
return
|
|
61
|
+
if member.size > self.remaining:
|
|
62
|
+
raise EngineError(
|
|
63
|
+
f"{member.name!r} would expand beyond the allowed size; "
|
|
64
|
+
"the archive was not restored."
|
|
65
|
+
)
|
|
66
|
+
self.remaining -= member.size
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_member(member: tarfile.TarInfo, windows: bool | None = None) -> None:
|
|
70
|
+
"""Accept only a plain, single-part regular file name that is safe to write."""
|
|
71
|
+
if windows is None:
|
|
72
|
+
windows = os.name == "nt"
|
|
73
|
+
|
|
74
|
+
path = PurePosixPath(member.name)
|
|
75
|
+
if (
|
|
76
|
+
not member.isfile()
|
|
77
|
+
or path.is_absolute()
|
|
78
|
+
or len(path.parts) != 1
|
|
79
|
+
or path.name in ("", ".", "..")
|
|
80
|
+
or "\\" in member.name
|
|
81
|
+
or (windows and _unsafe_on_windows(member.name))
|
|
82
|
+
):
|
|
83
|
+
raise EngineError(f"Unsupported or unsafe entry in archive: {member.name!r}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NativeEngine(Engine):
|
|
87
|
+
"""Standard-library engine: tar stream, block-parallel compression."""
|
|
88
|
+
|
|
89
|
+
name = "native"
|
|
90
|
+
capabilities = Capabilities(
|
|
91
|
+
levels=range(0, 10), threads_per_file=True, archive_suffix=".bv"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def __init__(self, block_size: int = BLOCK_SIZE):
|
|
95
|
+
self._block_size = block_size
|
|
96
|
+
|
|
97
|
+
def is_available(self) -> bool:
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def pack(self, source: Path, dest_dir: Path, options: PackOptions) -> list[Path]:
|
|
101
|
+
if not source.is_file():
|
|
102
|
+
raise FileNotFoundError(f"Source file not found: {source}")
|
|
103
|
+
check_format(options.format)
|
|
104
|
+
|
|
105
|
+
base = f"{source.name}{self.capabilities.archive_suffix}"
|
|
106
|
+
existing = existing_volumes(dest_dir, base)
|
|
107
|
+
if existing and not options.force:
|
|
108
|
+
raise OutputExistsError(
|
|
109
|
+
f"Archive volumes already exist in {dest_dir}. Use --force to replace."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
header = cipher = None
|
|
113
|
+
if options.password:
|
|
114
|
+
header = crypto.make_header(options.format)
|
|
115
|
+
cipher = crypto.BlockCipher(options.password, header)
|
|
116
|
+
|
|
117
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
# Build in a temporary folder so a failed run never damages what exists.
|
|
120
|
+
with tempfile.TemporaryDirectory(dir=dest_dir, prefix=".batchvault-") as tmp:
|
|
121
|
+
with VolumeWriter(Path(tmp), base, options.volume_size) as volumes:
|
|
122
|
+
with BlockWriter(
|
|
123
|
+
volumes,
|
|
124
|
+
options.format,
|
|
125
|
+
options.level,
|
|
126
|
+
self._block_size,
|
|
127
|
+
options.jobs,
|
|
128
|
+
cipher=cipher,
|
|
129
|
+
header=header or b"",
|
|
130
|
+
) as blocks:
|
|
131
|
+
with tarfile.open(
|
|
132
|
+
fileobj=blocks,
|
|
133
|
+
mode="w|",
|
|
134
|
+
format=tarfile.PAX_FORMAT,
|
|
135
|
+
bufsize=_STREAM_BUFFER,
|
|
136
|
+
) as tar:
|
|
137
|
+
tar.add(source, arcname=source.name, filter=_normalize)
|
|
138
|
+
|
|
139
|
+
return install_volumes(volumes.volumes, dest_dir, existing)
|
|
140
|
+
|
|
141
|
+
def unpack(
|
|
142
|
+
self,
|
|
143
|
+
volume: Path,
|
|
144
|
+
output_dir: Path,
|
|
145
|
+
*,
|
|
146
|
+
force: bool = False,
|
|
147
|
+
password: str | None = None,
|
|
148
|
+
max_bytes: int | None = None,
|
|
149
|
+
) -> list[Path]:
|
|
150
|
+
volumes = find_volumes(volume)
|
|
151
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
budget = _Budget(max_bytes)
|
|
153
|
+
|
|
154
|
+
def handle(member, tar) -> None:
|
|
155
|
+
budget.charge(member) # refuse a decompression bomb before writing
|
|
156
|
+
self._extract(member, tar, staging)
|
|
157
|
+
|
|
158
|
+
with tempfile.TemporaryDirectory(dir=output_dir, prefix=".batchvault-") as tmp:
|
|
159
|
+
staging = Path(tmp)
|
|
160
|
+
self._read(volumes, handle, password)
|
|
161
|
+
|
|
162
|
+
extracted = sorted(staging.iterdir())
|
|
163
|
+
if not extracted:
|
|
164
|
+
raise EngineError(f"No files found in archive {volumes[0]}")
|
|
165
|
+
|
|
166
|
+
conflicts = [
|
|
167
|
+
output_dir / item.name
|
|
168
|
+
for item in extracted
|
|
169
|
+
if (output_dir / item.name).exists()
|
|
170
|
+
]
|
|
171
|
+
if conflicts and not force:
|
|
172
|
+
names = ", ".join(path.name for path in conflicts)
|
|
173
|
+
raise OutputExistsError(
|
|
174
|
+
f"Destination already exists: {names}. Use --force to overwrite."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
restored = []
|
|
178
|
+
for item in extracted:
|
|
179
|
+
target = output_dir / item.name
|
|
180
|
+
os.replace(item, target)
|
|
181
|
+
restored.append(target)
|
|
182
|
+
|
|
183
|
+
return restored
|
|
184
|
+
|
|
185
|
+
def test(self, volume: Path, *, password: str | None = None) -> None:
|
|
186
|
+
self._read(find_volumes(volume), self._drain_member, password)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _extract(member, tar, staging: Path) -> None:
|
|
190
|
+
# Belt and braces next to _check_member and the "data" filter: whatever
|
|
191
|
+
# the name looks like, the file must end up inside the staging folder.
|
|
192
|
+
root = staging.resolve()
|
|
193
|
+
if root not in (root / member.name).resolve().parents:
|
|
194
|
+
raise EngineError(f"Unsafe entry in archive: {member.name!r}")
|
|
195
|
+
tar.extract(member, staging, filter="data")
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _drain_member(member, tar) -> None:
|
|
199
|
+
handle = tar.extractfile(member)
|
|
200
|
+
while handle.read(_CHUNK):
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _read(volumes: list[Path], handle_member, password: str | None) -> None:
|
|
205
|
+
"""Stream the archive and call ``handle_member(member, tar)`` for each file."""
|
|
206
|
+
fmt, encrypted = sniff_archive(volumes[0])
|
|
207
|
+
if encrypted and not password:
|
|
208
|
+
raise EngineError(
|
|
209
|
+
f"{volumes[0].name} is encrypted. A password is required."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
with VolumeReader(volumes) as reader:
|
|
214
|
+
source = reader
|
|
215
|
+
if encrypted:
|
|
216
|
+
fmt, source = crypto.open_decrypted(reader, password)
|
|
217
|
+
|
|
218
|
+
with open_decompressed(fmt, source) as stream:
|
|
219
|
+
with tarfile.open(fileobj=stream, mode="r|") as tar:
|
|
220
|
+
for member in tar:
|
|
221
|
+
_check_member(member)
|
|
222
|
+
handle_member(member, tar)
|
|
223
|
+
|
|
224
|
+
# Read to the very end so a truncated stream is detected. A
|
|
225
|
+
# real archive has almost nothing after the tar end marker.
|
|
226
|
+
trailing = 0
|
|
227
|
+
while chunk := stream.read(_CHUNK):
|
|
228
|
+
trailing += len(chunk)
|
|
229
|
+
if trailing > _MAX_TRAILING:
|
|
230
|
+
raise EngineError(
|
|
231
|
+
f"{volumes[0].name} has unexpected data after the "
|
|
232
|
+
"end of the archive."
|
|
233
|
+
)
|
|
234
|
+
except _READ_ERRORS as exc:
|
|
235
|
+
raise EngineError(
|
|
236
|
+
f"Archive is corrupt or incomplete ({volumes[0].name}): {exc}"
|
|
237
|
+
) from exc
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from batchvault.engines.base import Engine
|
|
6
|
+
from batchvault.engines.native import NativeEngine
|
|
7
|
+
from batchvault.engines.sevenzip import SevenZipEngine, find_7zip
|
|
8
|
+
from batchvault.errors import InputError
|
|
9
|
+
from batchvault.volumes import split_volume_name
|
|
10
|
+
|
|
11
|
+
ENGINE_CHOICES = ("auto", "native", "7zip")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_engine(name: str = "auto", seven_zip: str | Path | None = None) -> Engine:
|
|
15
|
+
"""Return the engine to create archives with.
|
|
16
|
+
|
|
17
|
+
``auto`` uses 7-Zip when it is found (or when a path to it was given) and
|
|
18
|
+
falls back to the native engine.
|
|
19
|
+
"""
|
|
20
|
+
if name == "native":
|
|
21
|
+
return NativeEngine()
|
|
22
|
+
|
|
23
|
+
if name in ("7zip", "7z"):
|
|
24
|
+
find_7zip(seven_zip) # raises EngineNotFoundError with install help
|
|
25
|
+
return SevenZipEngine(seven_zip)
|
|
26
|
+
|
|
27
|
+
if name == "auto":
|
|
28
|
+
if seven_zip:
|
|
29
|
+
find_7zip(seven_zip)
|
|
30
|
+
return SevenZipEngine(seven_zip)
|
|
31
|
+
sevenzip = SevenZipEngine()
|
|
32
|
+
return sevenzip if sevenzip.is_available() else NativeEngine()
|
|
33
|
+
|
|
34
|
+
choices = ", ".join(ENGINE_CHOICES)
|
|
35
|
+
raise InputError(f"Unknown engine {name!r}. Choose from: {choices}.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def engine_for_volume(volume: Path, seven_zip: str | Path | None = None) -> Engine:
|
|
39
|
+
"""Pick the engine that can read a volume, from its file name."""
|
|
40
|
+
parsed = split_volume_name(volume.name)
|
|
41
|
+
if parsed is None:
|
|
42
|
+
raise InputError(f"Not an archive volume: {volume}")
|
|
43
|
+
if parsed[0].lower().endswith(".7z"):
|
|
44
|
+
find_7zip(seven_zip)
|
|
45
|
+
return SevenZipEngine(seven_zip)
|
|
46
|
+
return NativeEngine()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def option_warnings(
|
|
50
|
+
engine: Engine, format_given: str | None, level: int
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
"""Settings the chosen engine cannot honour exactly."""
|
|
53
|
+
warnings = []
|
|
54
|
+
if engine.name == "7zip" and format_given not in (None, "xz"):
|
|
55
|
+
warnings.append(
|
|
56
|
+
f"The 7-Zip engine always writes 7z (LZMA2); --format {format_given} "
|
|
57
|
+
"is ignored."
|
|
58
|
+
)
|
|
59
|
+
if engine.name == "native" and format_given == "bzip2" and level == 0:
|
|
60
|
+
warnings.append("bzip2 has no level 0; level 1 was used.")
|
|
61
|
+
return warnings
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from batchvault.engines.base import Capabilities, Engine
|
|
10
|
+
from batchvault.errors import EngineError, EngineNotFoundError, OutputExistsError
|
|
11
|
+
from batchvault.options import PackOptions
|
|
12
|
+
from batchvault.volumes import existing_volumes, find_volumes, install_volumes
|
|
13
|
+
|
|
14
|
+
ENV_VAR = "BATCHVAULT_7Z"
|
|
15
|
+
COMMANDS = ("7zz", "7z", "7za", "7zr")
|
|
16
|
+
WINDOWS_PATHS = (
|
|
17
|
+
Path(r"C:\Program Files\7-Zip\7z.exe"),
|
|
18
|
+
Path(r"C:\Program Files (x86)\7-Zip\7z.exe"),
|
|
19
|
+
)
|
|
20
|
+
OUTPUT_TAIL_LINES = 15
|
|
21
|
+
LIMIT_POLL_SECONDS = 0.05
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _search_path(command: str) -> Path | None:
|
|
25
|
+
"""Find ``command`` in the folders listed in ``PATH``.
|
|
26
|
+
|
|
27
|
+
Unlike ``shutil.which``, this never looks in the current folder (Windows does
|
|
28
|
+
that first) and ignores empty and relative ``PATH`` entries, so a program
|
|
29
|
+
planted in the folder you happen to be working in is never run.
|
|
30
|
+
"""
|
|
31
|
+
cwd = Path.cwd().resolve()
|
|
32
|
+
if sys.platform == "win32":
|
|
33
|
+
extensions = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
|
|
34
|
+
names = [command + extension.lower() for extension in extensions if extension]
|
|
35
|
+
else:
|
|
36
|
+
names = [command]
|
|
37
|
+
|
|
38
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
39
|
+
folder = Path(entry) if entry else None
|
|
40
|
+
if folder is None or not folder.is_absolute():
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
if folder.resolve() == cwd:
|
|
44
|
+
continue
|
|
45
|
+
except OSError:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
for name in names:
|
|
49
|
+
candidate = folder / name
|
|
50
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
51
|
+
return candidate
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_7zip(explicit: str | Path | None = None) -> Path:
|
|
56
|
+
"""Locate a 7-Zip command-line executable.
|
|
57
|
+
|
|
58
|
+
Order: explicit path, ``BATCHVAULT_7Z``, ``PATH`` (never the current folder),
|
|
59
|
+
then well-known Windows install folders.
|
|
60
|
+
"""
|
|
61
|
+
configured = explicit or os.environ.get(ENV_VAR)
|
|
62
|
+
if configured:
|
|
63
|
+
path = Path(configured).expanduser()
|
|
64
|
+
if path.is_file():
|
|
65
|
+
return path
|
|
66
|
+
raise EngineNotFoundError(f"7-Zip executable not found: {configured}")
|
|
67
|
+
|
|
68
|
+
for command in COMMANDS:
|
|
69
|
+
found = _search_path(command)
|
|
70
|
+
if found:
|
|
71
|
+
return found
|
|
72
|
+
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
for candidate in WINDOWS_PATHS:
|
|
75
|
+
if candidate.is_file():
|
|
76
|
+
return candidate
|
|
77
|
+
|
|
78
|
+
raise EngineNotFoundError(
|
|
79
|
+
"Could not find 7-Zip. Install it from https://www.7-zip.org/ (7zz on "
|
|
80
|
+
f"Linux and macOS), or set the path with --7z or {ENV_VAR}."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_pack_command(
|
|
85
|
+
executable: Path, archive: Path, source: Path, options: PackOptions
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
command = [
|
|
88
|
+
str(executable),
|
|
89
|
+
"a",
|
|
90
|
+
"-t7z",
|
|
91
|
+
f"-mx={options.level}",
|
|
92
|
+
"-m0=LZMA2",
|
|
93
|
+
f"-mmt={max(options.jobs, 1)}",
|
|
94
|
+
f"-v{options.volume_size}b",
|
|
95
|
+
"-y",
|
|
96
|
+
# A file name containing * or ? is a name, not a pattern; without this
|
|
97
|
+
# 7-Zip would archive every neighbour it matches.
|
|
98
|
+
"-spd",
|
|
99
|
+
]
|
|
100
|
+
if options.password:
|
|
101
|
+
# 7-Zip only takes the password on its command line, where other users
|
|
102
|
+
# of this machine could see it in the process list while it runs.
|
|
103
|
+
command += [f"-p{options.password}", "-mhe=on"]
|
|
104
|
+
return [*command, str(archive), str(source)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _password_args(password: str | None) -> list[str]:
|
|
108
|
+
return [f"-p{password}"] if password else []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_unpack_command(
|
|
112
|
+
executable: Path, first_volume: Path, target: Path, password: str | None = None
|
|
113
|
+
):
|
|
114
|
+
return [
|
|
115
|
+
str(executable),
|
|
116
|
+
"e",
|
|
117
|
+
str(first_volume),
|
|
118
|
+
f"-o{target}",
|
|
119
|
+
"-y",
|
|
120
|
+
"-spd",
|
|
121
|
+
*_password_args(password),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_test_command(
|
|
126
|
+
executable: Path, first_volume: Path, password: str | None = None
|
|
127
|
+
) -> list[str]:
|
|
128
|
+
return [
|
|
129
|
+
str(executable), "t", str(first_volume), "-spd", *_password_args(password)
|
|
130
|
+
] # fmt: skip
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def folder_size(folder: Path) -> int:
|
|
134
|
+
"""Total size of the files below ``folder`` (files that vanish are ignored)."""
|
|
135
|
+
total = 0
|
|
136
|
+
for root, _, names in os.walk(folder):
|
|
137
|
+
for name in names:
|
|
138
|
+
try:
|
|
139
|
+
total += (Path(root) / name).stat().st_size
|
|
140
|
+
except OSError:
|
|
141
|
+
continue
|
|
142
|
+
return total
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SevenZipEngine(Engine):
|
|
146
|
+
name = "7zip"
|
|
147
|
+
capabilities = Capabilities(
|
|
148
|
+
levels=range(0, 10), threads_per_file=True, archive_suffix=".7z"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def __init__(self, executable: str | Path | None = None):
|
|
152
|
+
self._explicit = executable
|
|
153
|
+
self._executable: Path | None = None
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def executable(self) -> Path:
|
|
157
|
+
if self._executable is None:
|
|
158
|
+
self._executable = find_7zip(self._explicit)
|
|
159
|
+
return self._executable
|
|
160
|
+
|
|
161
|
+
def is_available(self) -> bool:
|
|
162
|
+
try:
|
|
163
|
+
find_7zip(self._explicit)
|
|
164
|
+
except EngineNotFoundError:
|
|
165
|
+
return False
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _failure(code: int, output: str) -> EngineError:
|
|
170
|
+
tail = "\n".join(output.strip().splitlines()[-OUTPUT_TAIL_LINES:])
|
|
171
|
+
return EngineError(f"7-Zip failed (exit code {code}).\n{tail}".rstrip())
|
|
172
|
+
|
|
173
|
+
def _run(self, command: list[str]) -> str:
|
|
174
|
+
"""Run 7-Zip; return its standard output, raise EngineError on failure."""
|
|
175
|
+
result = subprocess.run(
|
|
176
|
+
command,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
stdin=subprocess.DEVNULL, # never wait for a password prompt
|
|
179
|
+
text=True,
|
|
180
|
+
encoding="utf-8",
|
|
181
|
+
errors="replace",
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
raise self._failure(result.returncode, result.stderr or result.stdout)
|
|
186
|
+
return result.stdout
|
|
187
|
+
|
|
188
|
+
def _run_limited(self, command: list[str], watch: Path, max_bytes: int) -> str:
|
|
189
|
+
"""Like ``_run``, but stop the program if ``watch`` grows past ``max_bytes``.
|
|
190
|
+
|
|
191
|
+
7-Zip writes as it extracts, so a decompression bomb is cut off while it
|
|
192
|
+
is still small instead of filling the disk. Output goes to a temporary
|
|
193
|
+
file so a full pipe can never stall the program.
|
|
194
|
+
"""
|
|
195
|
+
too_big = EngineError(
|
|
196
|
+
f"The archive would expand beyond the allowed size ({max_bytes} bytes); "
|
|
197
|
+
"extraction was stopped."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
with tempfile.TemporaryFile() as output:
|
|
201
|
+
process = subprocess.Popen(
|
|
202
|
+
command,
|
|
203
|
+
stdin=subprocess.DEVNULL,
|
|
204
|
+
stdout=output,
|
|
205
|
+
stderr=subprocess.STDOUT,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
code = process.wait(timeout=LIMIT_POLL_SECONDS)
|
|
211
|
+
break
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
if folder_size(watch) > max_bytes:
|
|
214
|
+
raise too_big from None
|
|
215
|
+
finally:
|
|
216
|
+
if process.poll() is None:
|
|
217
|
+
process.kill()
|
|
218
|
+
process.wait()
|
|
219
|
+
|
|
220
|
+
output.seek(0)
|
|
221
|
+
text = output.read().decode("utf-8", errors="replace")
|
|
222
|
+
|
|
223
|
+
if folder_size(watch) > max_bytes: # it finished between two checks
|
|
224
|
+
raise too_big
|
|
225
|
+
if code != 0:
|
|
226
|
+
raise self._failure(code, text)
|
|
227
|
+
return text
|
|
228
|
+
|
|
229
|
+
def pack(self, source: Path, dest_dir: Path, options: PackOptions) -> list[Path]:
|
|
230
|
+
if not source.is_file():
|
|
231
|
+
raise FileNotFoundError(f"Source file not found: {source}")
|
|
232
|
+
|
|
233
|
+
base = f"{source.name}{self.capabilities.archive_suffix}"
|
|
234
|
+
existing = existing_volumes(dest_dir, base)
|
|
235
|
+
if existing and not options.force:
|
|
236
|
+
raise OutputExistsError(
|
|
237
|
+
f"Archive volumes already exist in {dest_dir}. Use --force to replace."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
|
|
242
|
+
# Build in a temporary folder so a failed run never damages what exists.
|
|
243
|
+
with tempfile.TemporaryDirectory(dir=dest_dir, prefix=".batchvault-") as tmp:
|
|
244
|
+
staging = Path(tmp)
|
|
245
|
+
self._run(
|
|
246
|
+
build_pack_command(self.executable, staging / base, source, options)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
built = existing_volumes(staging, base)
|
|
250
|
+
if not built:
|
|
251
|
+
raise EngineError(f"7-Zip produced no volumes for {source}")
|
|
252
|
+
|
|
253
|
+
return install_volumes(built, dest_dir, existing)
|
|
254
|
+
|
|
255
|
+
def unpack(
|
|
256
|
+
self,
|
|
257
|
+
volume: Path,
|
|
258
|
+
output_dir: Path,
|
|
259
|
+
*,
|
|
260
|
+
force: bool = False,
|
|
261
|
+
password: str | None = None,
|
|
262
|
+
max_bytes: int | None = None,
|
|
263
|
+
) -> list[Path]:
|
|
264
|
+
first = find_volumes(volume)[0]
|
|
265
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
|
|
267
|
+
with tempfile.TemporaryDirectory(dir=output_dir, prefix=".batchvault-") as tmp:
|
|
268
|
+
staging = Path(tmp)
|
|
269
|
+
command = build_unpack_command(self.executable, first, staging, password)
|
|
270
|
+
if max_bytes is None:
|
|
271
|
+
self._run(command)
|
|
272
|
+
else:
|
|
273
|
+
self._run_limited(command, staging, max_bytes)
|
|
274
|
+
|
|
275
|
+
# Never accept links or other special files from an archive.
|
|
276
|
+
if any(p.is_symlink() or not p.is_file() for p in staging.iterdir()):
|
|
277
|
+
raise EngineError(
|
|
278
|
+
f"{first.name} contains a link or special file, which is refused."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
extracted = sorted(staging.iterdir())
|
|
282
|
+
if not extracted:
|
|
283
|
+
raise EngineError(f"7-Zip extracted no files from {first}")
|
|
284
|
+
|
|
285
|
+
conflicts = [output_dir / p.name for p in extracted]
|
|
286
|
+
conflicts = [path for path in conflicts if path.exists()]
|
|
287
|
+
if conflicts and not force:
|
|
288
|
+
names = ", ".join(path.name for path in conflicts)
|
|
289
|
+
raise OutputExistsError(
|
|
290
|
+
f"Destination already exists: {names}. Use --force to overwrite."
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
restored = []
|
|
294
|
+
for item in extracted:
|
|
295
|
+
target = output_dir / item.name
|
|
296
|
+
os.replace(item, target)
|
|
297
|
+
restored.append(target)
|
|
298
|
+
|
|
299
|
+
return restored
|
|
300
|
+
|
|
301
|
+
def test(self, volume: Path, *, password: str | None = None) -> None:
|
|
302
|
+
first = find_volumes(volume)[0]
|
|
303
|
+
self._run(build_test_command(self.executable, first, password))
|
batchvault/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class BatchVaultError(Exception):
|
|
2
|
+
"""Base class for all BatchVault errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EngineNotFoundError(BatchVaultError):
|
|
6
|
+
"""The requested compression engine is not available."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OptionsError(BatchVaultError, ValueError):
|
|
10
|
+
"""An option value is invalid."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(BatchVaultError):
|
|
14
|
+
"""The configuration file is unreadable or contains invalid settings."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SignatureError(BatchVaultError):
|
|
18
|
+
"""A manifest signature is missing, invalid or cannot be created."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InputError(BatchVaultError):
|
|
22
|
+
"""An input file, list or path is missing or unusable."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EngineError(BatchVaultError):
|
|
26
|
+
"""A compression engine failed to run or reported an error."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OutputExistsError(BatchVaultError):
|
|
30
|
+
"""The destination already exists and replacing it was not requested."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ManifestError(BatchVaultError):
|
|
34
|
+
"""A manifest is missing, malformed or fails validation."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VolumeError(BatchVaultError):
|
|
38
|
+
"""Archive volumes are missing, incomplete or corrupt."""
|
batchvault/eta.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
# Wait for this much real run time before trusting the measured speed.
|
|
4
|
+
MIN_SECONDS = 5.0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_duration(seconds):
|
|
8
|
+
seconds = max(0, int(seconds))
|
|
9
|
+
hours, remainder = divmod(seconds, 3600)
|
|
10
|
+
minutes, secs = divmod(remainder, 60)
|
|
11
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EtaEstimator:
|
|
15
|
+
"""ETA from the average speed of the whole run so far.
|
|
16
|
+
|
|
17
|
+
Speed is bytes completed divided by the time since the first file started,
|
|
18
|
+
so per-file overhead is included.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, clock=time.monotonic):
|
|
22
|
+
self.clock = clock
|
|
23
|
+
self.started_at = None
|
|
24
|
+
|
|
25
|
+
def start(self):
|
|
26
|
+
if self.started_at is None:
|
|
27
|
+
self.started_at = self.clock()
|
|
28
|
+
|
|
29
|
+
def estimate(self, done_bytes, total_bytes):
|
|
30
|
+
"""Seconds remaining, or None while there is not enough data yet."""
|
|
31
|
+
if self.started_at is None or done_bytes <= 0:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
elapsed = self.clock() - self.started_at
|
|
35
|
+
if elapsed < MIN_SECONDS:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
remaining = max(0, total_bytes - done_bytes)
|
|
39
|
+
return remaining / (done_bytes / elapsed)
|