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/config.py ADDED
@@ -0,0 +1,169 @@
1
+ """Project configuration from ``batchvault.toml`` or ``[tool.batchvault]``.
2
+
3
+ Layout (keys use the same names as the command-line options)::
4
+
5
+ [defaults] # applies to every command that has the option
6
+ jobs = 4
7
+
8
+ [log]
9
+ level = "DEBUG" # --log-level
10
+ file = "run.log" # --log-file
11
+
12
+ [notify]
13
+ url = "https://hooks.example.com/..." # --notify
14
+ on = "failure" # --notify-on
15
+
16
+ [pack] # per command: pack, unpack, verify, inspect
17
+ level = 7
18
+ volume-size = "100m"
19
+ include = ["*.mp4"]
20
+
21
+ Command-line options override the file. List options (``include``,
22
+ ``exclude``) from the file and the command line are combined.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import tomllib
28
+ from pathlib import Path
29
+
30
+ from batchvault.errors import ConfigError
31
+
32
+ CONFIG_FILE = "batchvault.toml"
33
+ PYPROJECT = "pyproject.toml"
34
+
35
+ COMMAND_TABLES = ("pack", "unpack", "verify", "inspect")
36
+ # Tables that map their own key names onto command-line options.
37
+ SECTION_KEYS = {
38
+ "log": {"level": "log_level", "file": "log_file"},
39
+ "notify": {"url": "notify", "on": "notify_on"},
40
+ }
41
+ KNOWN_TABLES = {"defaults", *COMMAND_TABLES, *SECTION_KEYS}
42
+
43
+ # Options that run a program, send data out, choose which keys to trust, read a
44
+ # password from somewhere or delete files. A config file found automatically in
45
+ # the current or a parent folder may belong to someone else (a cloned
46
+ # repository, a downloaded folder), so it may not set these. A file named with
47
+ # --config or BATCHVAULT_CONFIG is trusted.
48
+ RESTRICTED_OPTIONS = frozenset(
49
+ {
50
+ "seven_zip",
51
+ "notify",
52
+ "delete_source",
53
+ "yes",
54
+ "public_key",
55
+ "sign_key",
56
+ "password_file",
57
+ "log_file",
58
+ }
59
+ )
60
+
61
+
62
+ def _tool_section(pyproject: Path) -> dict | None:
63
+ try:
64
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
65
+ except (OSError, tomllib.TOMLDecodeError):
66
+ return None
67
+ section = data.get("tool", {}).get("batchvault")
68
+ return section if isinstance(section, dict) else None
69
+
70
+
71
+ def find_config(start: Path | None = None) -> Path | None:
72
+ """Look for a config file in ``start`` and then each parent folder."""
73
+ folder = (start or Path.cwd()).resolve()
74
+ for candidate in (folder, *folder.parents):
75
+ if (candidate / CONFIG_FILE).is_file():
76
+ return candidate / CONFIG_FILE
77
+ pyproject = candidate / PYPROJECT
78
+ if pyproject.is_file() and _tool_section(pyproject) is not None:
79
+ return pyproject
80
+ return None
81
+
82
+
83
+ def load_config(path: Path) -> dict:
84
+ try:
85
+ text = path.read_text(encoding="utf-8")
86
+ data = tomllib.loads(text)
87
+ except OSError as exc:
88
+ raise ConfigError(f"Cannot read config file {path}: {exc}") from exc
89
+ except tomllib.TOMLDecodeError as exc:
90
+ raise ConfigError(f"Config file {path} is not valid TOML: {exc}") from exc
91
+
92
+ if path.name == PYPROJECT:
93
+ data = data.get("tool", {}).get("batchvault", {})
94
+
95
+ unknown = sorted(set(data) - KNOWN_TABLES)
96
+ if unknown:
97
+ raise ConfigError(
98
+ f"{path}: unknown section(s): {', '.join(unknown)}. "
99
+ f"Known: {', '.join(sorted(KNOWN_TABLES))}."
100
+ )
101
+ for name, table in data.items():
102
+ if not isinstance(table, dict):
103
+ raise ConfigError(f"{path}: [{name}] must be a table")
104
+ return data
105
+
106
+
107
+ def reject_restricted(config: dict, path: Path) -> None:
108
+ """Refuse restricted options in a config file that was found automatically."""
109
+ for table_name, table in config.items():
110
+ mapping = SECTION_KEYS.get(table_name)
111
+ for key in table:
112
+ dest = mapping.get(key, key) if mapping else key.replace("-", "_")
113
+ if dest in RESTRICTED_OPTIONS:
114
+ raise ConfigError(
115
+ f"{path} sets '{key}' in [{table_name}]. A config file found "
116
+ "automatically cannot set options that run programs, send data, "
117
+ "choose trusted keys, read passwords or delete files. If you "
118
+ f"trust this file, run with: --config {path}"
119
+ )
120
+
121
+
122
+ def _check_value(dest: str, value, action, where: str) -> None:
123
+ if action.choices is not None and value not in action.choices:
124
+ choices = ", ".join(str(choice) for choice in action.choices)
125
+ raise ConfigError(f"{where}: '{dest}' must be one of: {choices}")
126
+
127
+ if action.nargs == 0: # on/off flag
128
+ if not isinstance(value, bool):
129
+ raise ConfigError(f"{where}: '{dest}' must be true or false")
130
+ elif action.type is int:
131
+ if isinstance(value, bool) or not isinstance(value, int):
132
+ raise ConfigError(f"{where}: '{dest}' must be a whole number")
133
+ elif action.default is not None and isinstance(action.default, list):
134
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
135
+ raise ConfigError(f"{where}: '{dest}' must be a list of strings")
136
+ elif not isinstance(value, str | int | float) or isinstance(value, bool):
137
+ raise ConfigError(f"{where}: '{dest}' has an unsupported value")
138
+
139
+
140
+ def defaults_for(config: dict, command: str, actions: dict) -> dict:
141
+ """Option defaults for ``command`` (``actions`` maps option name to Action)."""
142
+ merged: dict = {}
143
+
144
+ for key, value in config.get("defaults", {}).items():
145
+ dest = key.replace("-", "_")
146
+ if dest in actions:
147
+ _check_value(dest, value, actions[dest], "[defaults]")
148
+ merged[dest] = value
149
+
150
+ for section, mapping in SECTION_KEYS.items():
151
+ for key, value in config.get(section, {}).items():
152
+ dest = mapping.get(key)
153
+ if dest is None:
154
+ raise ConfigError(
155
+ f"Unknown key '{key}' in [{section}]. "
156
+ f"Known: {', '.join(mapping)}."
157
+ )
158
+ if dest in actions:
159
+ _check_value(dest, value, actions[dest], f"[{section}]")
160
+ merged[dest] = value
161
+
162
+ for key, value in config.get(command, {}).items():
163
+ dest = key.replace("-", "_")
164
+ if dest not in actions:
165
+ raise ConfigError(f"Unknown option '{key}' in [{command}].")
166
+ _check_value(dest, value, actions[dest], f"[{command}]")
167
+ merged[dest] = value
168
+
169
+ return merged
batchvault/crypto.py ADDED
@@ -0,0 +1,193 @@
1
+ """Optional encryption for the native engine.
2
+
3
+ Needs the ``cryptography`` package (``pip install "batchvault[crypto]"``).
4
+
5
+ An encrypted archive is a header followed by one record per compressed block
6
+ and a final terminator record. Each record is AES-256-GCM encrypted with a
7
+ unique nonce; the header, the record number and a "last record" flag are
8
+ authenticated, so reordering, dropping or truncating records is detected. The
9
+ key comes from the password with scrypt and a random per-archive salt.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import io
16
+ import os
17
+ import struct
18
+
19
+ from batchvault.errors import EngineError, EngineNotFoundError
20
+
21
+ MAGIC = b"BVENC1\x00\x00"
22
+ FORMAT_IDS = {"xz": 1, "gzip": 2, "bzip2": 3, "store": 4}
23
+ _ID_FORMATS = {value: key for key, value in FORMAT_IDS.items()}
24
+ SALT_SIZE = 16
25
+ PREFIX_SIZE = 4
26
+ HEADER_SIZE = len(MAGIC) + 2 + SALT_SIZE + PREFIX_SIZE
27
+
28
+ # scrypt cost: N = 2**log2_n (about 128 MiB of memory at the default), r = 8, p = 1.
29
+ # The value used is stored in the archive header so the default can rise later,
30
+ # and header values outside this range are refused: a damaged or hostile header
31
+ # must not be able to ask for terabytes of memory.
32
+ DEFAULT_LOG2_N = 17
33
+ KDF_LOG2_N_RANGE = (14, 18)
34
+ _SCRYPT_R = 8
35
+ _SCRYPT_P = 1
36
+
37
+ _LENGTH = struct.Struct(">I")
38
+ _INDEX = struct.Struct(">Q")
39
+ # A record holds one compressed block (16 MiB by default); anything near this
40
+ # size is damage, and the limit stops a bad length from allocating gigabytes.
41
+ _MAX_RECORD = 128 * 1024 * 1024
42
+
43
+ INSTALL_HINT = (
44
+ "Encryption and signing need the optional 'cryptography' package: "
45
+ 'pip install "batchvault[crypto]"'
46
+ )
47
+
48
+
49
+ def require_cryptography() -> None:
50
+ try:
51
+ import cryptography # noqa: F401
52
+ except ImportError as exc:
53
+ raise EngineNotFoundError(INSTALL_HINT) from exc
54
+
55
+
56
+ def make_header(fmt: str) -> bytes:
57
+ return (
58
+ MAGIC
59
+ + bytes([FORMAT_IDS[fmt], DEFAULT_LOG2_N])
60
+ + os.urandom(SALT_SIZE + PREFIX_SIZE)
61
+ )
62
+
63
+
64
+ def is_encrypted_header(data: bytes) -> bool:
65
+ return data.startswith(MAGIC)
66
+
67
+
68
+ def parse_header(data: bytes) -> tuple[str, int, bytes, bytes]:
69
+ """Return ``(format, log2_n, salt, nonce_prefix)`` from an archive header."""
70
+ if len(data) < HEADER_SIZE or not is_encrypted_header(data):
71
+ raise EngineError("Not an encrypted BatchVault archive.")
72
+ fmt = _ID_FORMATS.get(data[len(MAGIC)])
73
+ if fmt is None:
74
+ raise EngineError("Unknown format in encrypted archive header.")
75
+
76
+ log2_n = data[len(MAGIC) + 1]
77
+ low, high = KDF_LOG2_N_RANGE
78
+ if not low <= log2_n <= high:
79
+ raise EngineError("Unsupported key setting in the encrypted archive header.")
80
+
81
+ start = len(MAGIC) + 2
82
+ salt = data[start : start + SALT_SIZE]
83
+ return fmt, log2_n, salt, data[start + SALT_SIZE : HEADER_SIZE]
84
+
85
+
86
+ class BlockCipher:
87
+ """Encrypts and decrypts numbered records for one archive."""
88
+
89
+ def __init__(self, password: str, header: bytes):
90
+ require_cryptography()
91
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
92
+
93
+ _, log2_n, salt, prefix = parse_header(header)
94
+ n = 1 << log2_n
95
+ key = hashlib.scrypt(
96
+ password.encode("utf-8"),
97
+ salt=salt,
98
+ n=n,
99
+ r=_SCRYPT_R,
100
+ p=_SCRYPT_P,
101
+ maxmem=130 * n * _SCRYPT_R, # scrypt needs about 128 * n * r bytes
102
+ dklen=32,
103
+ )
104
+ self._aes = AESGCM(key)
105
+ self._prefix = prefix
106
+ self._header = header
107
+
108
+ def _nonce(self, index: int) -> bytes:
109
+ return self._prefix + _INDEX.pack(index)
110
+
111
+ def _aad(self, index: int, last: bool) -> bytes:
112
+ return self._header + _INDEX.pack(index) + (b"\x01" if last else b"\x00")
113
+
114
+ def encrypt(self, index: int, data: bytes, last: bool = False) -> bytes:
115
+ """Return a length-prefixed encrypted record."""
116
+ sealed = self._aes.encrypt(self._nonce(index), data, self._aad(index, last))
117
+ return _LENGTH.pack(len(sealed)) + sealed
118
+
119
+ def decrypt(self, index: int, sealed: bytes) -> tuple[bytes, bool]:
120
+ """Return ``(data, is_last)``; raise EngineError on a wrong password."""
121
+ from cryptography.exceptions import InvalidTag
122
+
123
+ for last in (False, True):
124
+ try:
125
+ data = self._aes.decrypt(
126
+ self._nonce(index), sealed, self._aad(index, last)
127
+ )
128
+ except InvalidTag:
129
+ continue
130
+ return data, last
131
+ raise EngineError("Wrong password, or the encrypted archive is damaged.")
132
+
133
+
134
+ def _read_exact(source, count: int) -> bytes:
135
+ chunks = []
136
+ while count:
137
+ chunk = source.read(count)
138
+ if not chunk:
139
+ break
140
+ chunks.append(chunk)
141
+ count -= len(chunk)
142
+ return b"".join(chunks)
143
+
144
+
145
+ class DecryptingReader(io.RawIOBase):
146
+ """Read-only stream of the decrypted (still compressed) block data."""
147
+
148
+ def __init__(self, source, cipher: BlockCipher):
149
+ super().__init__()
150
+ self._source = source
151
+ self._cipher = cipher
152
+ self._buffer = memoryview(b"")
153
+ self._index = 0
154
+ self._done = False
155
+
156
+ def readable(self) -> bool:
157
+ return True
158
+
159
+ def _next_record(self) -> None:
160
+ head = _read_exact(self._source, _LENGTH.size)
161
+ if len(head) < _LENGTH.size:
162
+ raise EngineError("Encrypted archive is truncated (no end marker).")
163
+
164
+ (length,) = _LENGTH.unpack(head)
165
+ if length > _MAX_RECORD:
166
+ raise EngineError("Encrypted archive is damaged (bad record size).")
167
+
168
+ sealed = _read_exact(self._source, length)
169
+ if len(sealed) < length:
170
+ raise EngineError("Encrypted archive is truncated.")
171
+
172
+ data, self._done = self._cipher.decrypt(self._index, sealed)
173
+ self._index += 1
174
+ self._buffer = memoryview(data)
175
+
176
+ def readinto(self, buffer) -> int:
177
+ view = memoryview(buffer).cast("B")
178
+ while not len(self._buffer):
179
+ if self._done:
180
+ return 0
181
+ self._next_record()
182
+
183
+ count = min(len(view), len(self._buffer))
184
+ view[:count] = self._buffer[:count]
185
+ self._buffer = self._buffer[count:]
186
+ return count
187
+
188
+
189
+ def open_decrypted(source, password: str) -> tuple[str, DecryptingReader]:
190
+ """Read the header from ``source``; return the inner format and a reader."""
191
+ header = _read_exact(source, HEADER_SIZE)
192
+ fmt = parse_header(header)[0]
193
+ return fmt, DecryptingReader(source, BlockCipher(password, header))
batchvault/doctor.py ADDED
@@ -0,0 +1,67 @@
1
+ """Checks that BatchVault can run on this machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+
11
+ from batchvault.engines.sevenzip import find_7zip
12
+ from batchvault.errors import EngineNotFoundError
13
+ from batchvault.logs import default_log_dir
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Check:
18
+ name: str
19
+ ok: bool
20
+ detail: str
21
+ required: bool = False # a failed required check makes doctor exit non-zero
22
+
23
+
24
+ def _module(name: str, label: str, required: bool = False) -> Check:
25
+ try:
26
+ __import__(name)
27
+ except ImportError:
28
+ return Check(label, False, "not available in this Python build", required)
29
+ return Check(label, True, "available", required)
30
+
31
+
32
+ def _log_dir_check() -> Check:
33
+ folder = default_log_dir()
34
+ try:
35
+ folder.mkdir(parents=True, exist_ok=True)
36
+ with tempfile.NamedTemporaryFile(dir=folder):
37
+ pass
38
+ except OSError as exc:
39
+ return Check("Log folder", False, f"{folder} is not writable: {exc}")
40
+ return Check("Log folder", True, str(folder))
41
+
42
+
43
+ def run_checks(seven_zip: str | None = None) -> list[Check]:
44
+ checks = [
45
+ Check(
46
+ "Python",
47
+ sys.version_info >= (3, 11, 4), # safe tar extraction filters
48
+ f"{platform.python_version()} on {platform.system()}",
49
+ required=True,
50
+ ),
51
+ _module("zlib", "zlib (gzip, store)", required=True),
52
+ _module("lzma", "lzma (xz format)"),
53
+ _module("bz2", "bz2 (bzip2 format)"),
54
+ ]
55
+
56
+ try:
57
+ checks.append(Check("7-Zip (optional)", True, str(find_7zip(seven_zip))))
58
+ except EngineNotFoundError as exc:
59
+ checks.append(Check("7-Zip (optional)", False, str(exc)))
60
+
61
+ checks.append(_log_dir_check())
62
+ checks.append(Check("CPU threads", True, str(os.cpu_count() or 1)))
63
+ return checks
64
+
65
+
66
+ def passed(checks: list[Check]) -> bool:
67
+ return all(check.ok for check in checks if check.required)
File without changes
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from batchvault.options import PackOptions
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Capabilities:
12
+ """What an engine supports, used to warn about unsupported settings."""
13
+
14
+ levels: range
15
+ threads_per_file: bool
16
+ archive_suffix: str
17
+
18
+
19
+ class Engine(ABC):
20
+ """Common interface implemented by every compression engine."""
21
+
22
+ name: str
23
+ capabilities: Capabilities
24
+
25
+ @abstractmethod
26
+ def is_available(self) -> bool:
27
+ """True if the engine can run on this machine."""
28
+
29
+ @abstractmethod
30
+ def pack(self, source: Path, dest_dir: Path, options: PackOptions) -> list[Path]:
31
+ """Archive ``source`` into volumes inside ``dest_dir``; return the volumes.
32
+
33
+ Encrypts when ``options.password`` is set. Raises OutputExistsError if
34
+ volumes already exist and ``options.force`` is false. Existing volumes
35
+ are only removed once the new set is complete.
36
+ """
37
+
38
+ @abstractmethod
39
+ def unpack(
40
+ self,
41
+ volume: Path,
42
+ output_dir: Path,
43
+ *,
44
+ force: bool = False,
45
+ password: str | None = None,
46
+ max_bytes: int | None = None,
47
+ ) -> list[Path]:
48
+ """Restore the files in the volume set containing ``volume``.
49
+
50
+ Raises OutputExistsError if a destination exists and ``force`` is false.
51
+ With ``max_bytes``, an archive that would expand beyond that many bytes
52
+ is refused before anything is written.
53
+ """
54
+
55
+ @abstractmethod
56
+ def test(self, volume: Path, *, password: str | None = None) -> None:
57
+ """Check the volume set's integrity; raise EngineError if it fails."""