hbkit 0.4.4__tar.gz → 0.4.6__tar.gz
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.
- {hbkit-0.4.4 → hbkit-0.4.6}/FORMAT.md +19 -6
- {hbkit-0.4.4 → hbkit-0.4.6}/PKG-INFO +4 -3
- {hbkit-0.4.4 → hbkit-0.4.6}/README.md +3 -2
- {hbkit-0.4.4 → hbkit-0.4.6}/pyproject.toml +1 -1
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/__init__.py +1 -1
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/archive.py +65 -28
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/cli.py +26 -1
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/crypto.py +13 -5
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/doctor.py +16 -5
- {hbkit-0.4.4 → hbkit-0.4.6}/tests/test_hbkit.py +102 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/.gitignore +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/LICENSE +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/packaging/README.md +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/index.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/manifest.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/mount.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/runner.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.6}/src/hbkit/tui.py +0 -0
|
@@ -16,8 +16,9 @@ in file-signature databases, or in DFIR tooling.
|
|
|
16
16
|
|
|
17
17
|
Coverage caveat: this was derived from **one** archive — DSM 7, Hyper Backup 4.1.2-4039,
|
|
18
18
|
unencrypted, LZ4, single version, single pool, plus a second small **encrypted** archive
|
|
19
|
-
from the same DSM version.
|
|
20
|
-
|
|
19
|
+
from the same DSM version. A user's 7-version encrypted archive (Hyper Backup 4.2.2,
|
|
20
|
+
issue #4) later corrected §7.2. Variant handling marked [D] is implemented from
|
|
21
|
+
disassembly but has never met a real archive of that kind.
|
|
21
22
|
|
|
22
23
|
---
|
|
23
24
|
|
|
@@ -254,7 +255,7 @@ Extra files in an encrypted archive:
|
|
|
254
255
|
|---|---|
|
|
255
256
|
| `Config/public.pem[.n]` | raw 32-byte X25519 **public** key (not PEM despite the name) |
|
|
256
257
|
| `Config/encKeys[.n]` | header only: magic `ekhtar`, u16 version, u8 KeyWrapType |
|
|
257
|
-
| `Pool/vkey.db[.n]` | `vkey(version_id, rsa_vkey, rsa_vkey_iv, checksum, ref_count)
|
|
258
|
+
| `Pool/vkey.db[.n]` | `vkey(version_id, rsa_vkey, rsa_vkey_iv, checksum, ref_count)`, one row per backup version |
|
|
258
259
|
|
|
259
260
|
Despite the `rsa_` column names, no RSA is used in this version: the blobs are libsodium
|
|
260
261
|
**sealed boxes** (X25519 + XSalsa20-Poly1305). Sizes give it away — 80 = 48 overhead + 32
|
|
@@ -282,10 +283,22 @@ iv = crypto_box_seal_open(vkey.rsa_vkey_iv, pub, priv) # 16 bytes
|
|
|
282
283
|
plain = LZ4( PKCS7-strip( AES-256-CBC-decrypt(ciphertext, key, iv) ) )
|
|
283
284
|
```
|
|
284
285
|
|
|
286
|
+
**Every backup version has its own key and IV**: one `vkey.db` row per version, all
|
|
287
|
+
unsealed with the same keypair, and each version encrypts only the chunks it writes.
|
|
288
|
+
Dedup means a file from the newest version can mix chunks from all of them. On a
|
|
289
|
+
7-version archive (Hyper Backup 4.2.2, issue #4), every one of 399,676 chunks decoded
|
|
290
|
+
under exactly one key, the keys covered contiguous bucket ranges, and each row's
|
|
291
|
+
`ref_count` equalled the number of buckets in its range. [V, from that report]
|
|
292
|
+
|
|
293
|
+
No field found so far names the version that wrote a bucket, so hbkit tries the key that
|
|
294
|
+
last worked for the bucket, then the others, and accepts one only when the decompressed
|
|
295
|
+
output matches the chunk's MD5. A wrong key can occasionally still produce a valid LZ4
|
|
296
|
+
block of the right length, so the MD5 test is required even when verification is off.
|
|
297
|
+
|
|
285
298
|
The IV is fixed per backup version, so identical plaintext chunks produce identical
|
|
286
|
-
ciphertext — visible directly in a bucket file, and a useful sanity
|
|
287
|
-
implementing. The `uncomp_len` and MD5 in the bucket index still describe the
|
|
288
|
-
decompressed plaintext, so the existing verification works unchanged.
|
|
299
|
+
ciphertext within a version — visible directly in a bucket file, and a useful sanity
|
|
300
|
+
check when implementing. The `uncomp_len` and MD5 in the bucket index still describe the
|
|
301
|
+
*final* decompressed plaintext, so the existing verification works unchanged.
|
|
289
302
|
|
|
290
303
|
### 7.3 Filenames [V]
|
|
291
304
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hbkit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: Recover files from Synology Hyper Backup (.hbk) archives without Synology software
|
|
5
5
|
Project-URL: Homepage, https://github.com/YordiLorenzo/hbkit
|
|
6
6
|
Project-URL: Source, https://github.com/YordiLorenzo/hbkit
|
|
@@ -280,8 +280,9 @@ Read this before trusting it with the only copy of anything.
|
|
|
280
280
|
contain plaintext names (not file contents). Delete it if that matters to you.
|
|
281
281
|
- **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
|
|
282
282
|
archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
|
|
283
|
-
the same DSM version, both verified byte-exact
|
|
284
|
-
|
|
283
|
+
the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
|
|
284
|
+
Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
|
|
285
|
+
Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
|
|
285
286
|
are implemented from disassembly but have not met a real archive of that kind. `doctor`
|
|
286
287
|
exists precisely so you can find out in seconds rather than mid-restore.
|
|
287
288
|
- **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
|
|
@@ -251,8 +251,9 @@ Read this before trusting it with the only copy of anything.
|
|
|
251
251
|
contain plaintext names (not file contents). Delete it if that matters to you.
|
|
252
252
|
- **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
|
|
253
253
|
archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
|
|
254
|
-
the same DSM version, both verified byte-exact
|
|
255
|
-
|
|
254
|
+
the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
|
|
255
|
+
Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
|
|
256
|
+
Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
|
|
256
257
|
are implemented from disassembly but have not met a real archive of that kind. `doctor`
|
|
257
258
|
exists precisely so you can find out in seconds rather than mid-restore.
|
|
258
259
|
- **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
|
|
@@ -81,6 +81,17 @@ def lz4():
|
|
|
81
81
|
return _lz4
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _decompress(raw: bytes, ulen: int) -> bytes | None:
|
|
85
|
+
"""A chunk payload: a raw LZ4 block, else zlib (compress type 4). None if neither fits."""
|
|
86
|
+
dst = ctypes.create_string_buffer(ulen)
|
|
87
|
+
if lz4().LZ4_decompress_safe(raw, dst, len(raw), ulen) == ulen:
|
|
88
|
+
return dst.raw
|
|
89
|
+
try:
|
|
90
|
+
return zlib.decompress(raw)
|
|
91
|
+
except zlib.error:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
84
95
|
# ------------------------------------------------------------ path resolution
|
|
85
96
|
|
|
86
97
|
_SHARD = re.compile(r"^(\d+)\.idx(?:\.(\d+))?$")
|
|
@@ -118,8 +129,17 @@ class Cat:
|
|
|
118
129
|
Shards are a fixed SHARD_SIZE except the last, so offsets are computed rather than
|
|
119
130
|
measured. That matters enormously on network mounts: stat-ing every shard cost ~2,600
|
|
120
131
|
round-trips on a 3 TB archive (minutes before reading a byte). We now do one directory
|
|
121
|
-
listing plus a single stat of the final shard.
|
|
122
|
-
|
|
132
|
+
listing plus a single stat of the final shard.
|
|
133
|
+
|
|
134
|
+
The numbering can have GAPS: a real archive was missing 7.idx from one family and
|
|
135
|
+
27.idx from another. A shard's logical position therefore comes from its filename,
|
|
136
|
+
never from its position in the sorted list - compacting the list slides every shard
|
|
137
|
+
after a gap 8 MiB early, and the wrong bytes still parse as plausible records. That
|
|
138
|
+
was issue #1: valid chunk lists read as garbage for any file stored past a gap.
|
|
139
|
+
|
|
140
|
+
Reading inside a gap returns nothing rather than the neighbouring shard's bytes, so
|
|
141
|
+
a hole surfaces as a short read and then a loud failure upstream, never as silent
|
|
142
|
+
corruption.
|
|
123
143
|
"""
|
|
124
144
|
|
|
125
145
|
def __init__(self, d: str):
|
|
@@ -136,12 +156,13 @@ class Cat:
|
|
|
136
156
|
found[n] = (gen, f)
|
|
137
157
|
if not found:
|
|
138
158
|
raise FileNotFoundError(f"no .idx shards in {d}")
|
|
139
|
-
|
|
140
|
-
|
|
159
|
+
shard_ids = sorted(found)
|
|
160
|
+
self.files = [found[k][1] for k in shard_ids]
|
|
141
161
|
last = os.path.getsize(os.path.join(d, self.files[-1]))
|
|
142
|
-
self.sizes = [SHARD_SIZE] * (
|
|
143
|
-
self.starts = [
|
|
144
|
-
self.total =
|
|
162
|
+
self.sizes = [SHARD_SIZE] * (len(self.files) - 1) + [last]
|
|
163
|
+
self.starts = [k * SHARD_SIZE for k in shard_ids]
|
|
164
|
+
self.total = self.starts[-1] + last
|
|
165
|
+
self._positions = dict(zip(shard_ids, range(len(shard_ids))))
|
|
145
166
|
self._fh: dict[int, object] = {}
|
|
146
167
|
# Shard 0 carries a 64-byte header that declares the record size, so readers do
|
|
147
168
|
# not have to hardcode per-DSM-version layouts.
|
|
@@ -160,15 +181,18 @@ class Cat:
|
|
|
160
181
|
"""Read n bytes at logical offset off, spanning shards as needed."""
|
|
161
182
|
out = b""
|
|
162
183
|
i = off // SHARD_SIZE
|
|
163
|
-
while n > 0
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
while n > 0:
|
|
185
|
+
pos = self._positions.get(i)
|
|
186
|
+
if pos is None:
|
|
187
|
+
break
|
|
188
|
+
st = self.starts[pos]
|
|
189
|
+
take = min(n, st + self.sizes[pos] - off)
|
|
166
190
|
if take <= 0:
|
|
167
191
|
break
|
|
168
|
-
fh = self._fh.get(
|
|
192
|
+
fh = self._fh.get(pos)
|
|
169
193
|
if fh is None:
|
|
170
|
-
fh = self._fh[
|
|
171
|
-
|
|
194
|
+
fh = self._fh[pos] = open(os.path.join(self.d, self.files[pos]), "rb",
|
|
195
|
+
buffering=INDEX_BUF)
|
|
172
196
|
fh.seek(off - st)
|
|
173
197
|
got = fh.read(take)
|
|
174
198
|
out += got
|
|
@@ -267,6 +291,7 @@ class Archive:
|
|
|
267
291
|
self._bidx: OrderedDict[int, bytes] = OrderedDict()
|
|
268
292
|
self._bdat: OrderedDict[int, object] = OrderedDict()
|
|
269
293
|
self._brec: dict[int, int] = {}
|
|
294
|
+
self._bkey: dict[int, int] = {} # bucket id -> index into crypto.data_keys
|
|
270
295
|
self._password = None
|
|
271
296
|
if self.is_encrypted():
|
|
272
297
|
if password is None:
|
|
@@ -298,7 +323,7 @@ class Archive:
|
|
|
298
323
|
return out
|
|
299
324
|
|
|
300
325
|
def _open_crypto(self, password: str):
|
|
301
|
-
"""Unwrap
|
|
326
|
+
"""Unwrap every version's data key using the password. Raises WrongPassword on mismatch."""
|
|
302
327
|
import sqlite3
|
|
303
328
|
|
|
304
329
|
from .crypto import ArchiveCrypto
|
|
@@ -310,11 +335,11 @@ class Archive:
|
|
|
310
335
|
with open(resolve(f"{self.root}/Config", "public.pem"), "rb") as fh:
|
|
311
336
|
pub = fh.read()
|
|
312
337
|
db = sqlite3.connect(f"file:{resolve(f'{self.root}/Pool', 'vkey.db')}?immutable=1", uri=True)
|
|
313
|
-
|
|
338
|
+
rows = db.execute("SELECT rsa_vkey, rsa_vkey_iv FROM vkey ORDER BY version_id").fetchall()
|
|
314
339
|
db.close()
|
|
315
|
-
if not
|
|
340
|
+
if not rows:
|
|
316
341
|
raise UnsupportedArchive("encrypted archive has no rows in vkey db")
|
|
317
|
-
return ArchiveCrypto(password, unikey, pub, bytes(
|
|
342
|
+
return ArchiveCrypto(password, unikey, pub, [(bytes(k), bytes(iv)) for k, iv in rows])
|
|
318
343
|
|
|
319
344
|
def decrypt_name(self, stored: str) -> str:
|
|
320
345
|
"""Decrypt a stored filename, or return it unchanged for plaintext archives."""
|
|
@@ -402,21 +427,33 @@ class Archive:
|
|
|
402
427
|
raise ValueError(f"bucket index CRC32 mismatch b{bid}@{boff}")
|
|
403
428
|
fh.seek(off)
|
|
404
429
|
raw = fh.read(clen)
|
|
430
|
+
md5 = rec[12:28]
|
|
405
431
|
if self.crypto is not None:
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if n != ulen:
|
|
412
|
-
try:
|
|
413
|
-
data = zlib.decompress(raw) # compress type 4
|
|
414
|
-
except Exception:
|
|
415
|
-
raise ValueError(f"decompress failed b{bid}@{boff} lz4={n} want={ulen}") from None
|
|
416
|
-
if verify and hashlib.md5(data).digest() != rec[12:28]:
|
|
432
|
+
return self._decrypt(bid, boff, raw, ulen, md5)
|
|
433
|
+
data = _decompress(raw, ulen)
|
|
434
|
+
if data is None:
|
|
435
|
+
raise ValueError(f"decompress failed b{bid}@{boff} want={ulen}")
|
|
436
|
+
if verify and hashlib.md5(data).digest() != md5:
|
|
417
437
|
raise ValueError(f"MD5 mismatch b{bid}@{boff}")
|
|
418
438
|
return data
|
|
419
439
|
|
|
440
|
+
def _decrypt(self, bid: int, boff: int, raw: bytes, ulen: int, md5: bytes) -> bytes:
|
|
441
|
+
"""Decrypt and decompress one chunk of an encrypted archive.
|
|
442
|
+
|
|
443
|
+
Each backup version encrypts its chunks with its own data key, and nothing records
|
|
444
|
+
which version wrote a bucket. Try the key that last worked for this bucket, then
|
|
445
|
+
the others, and accept a key only when the output matches the chunk's MD5, whatever
|
|
446
|
+
`verify` says: a wrong key can still decompress to the right length.
|
|
447
|
+
"""
|
|
448
|
+
n = len(self.crypto.data_keys)
|
|
449
|
+
first = self._bkey.get(bid, 0)
|
|
450
|
+
for k in (first, *(i for i in range(n) if i != first)):
|
|
451
|
+
data = _decompress(self.crypto.decrypt_chunk(raw, k), ulen)
|
|
452
|
+
if data is not None and hashlib.md5(data).digest() == md5:
|
|
453
|
+
self._bkey[bid] = k
|
|
454
|
+
return data
|
|
455
|
+
raise ValueError(f"no data key decodes b{bid}@{boff} ({n} tried, want={ulen})")
|
|
456
|
+
|
|
420
457
|
def extract(self, ovf: int, size: int, verify: bool = True, out=None, on_bytes=None):
|
|
421
458
|
"""Rebuild one file. Writes to `out` if given (returns byte count), else returns bytes.
|
|
422
459
|
|
|
@@ -231,7 +231,23 @@ def main() -> int:
|
|
|
231
231
|
|
|
232
232
|
if cmd == "doctor":
|
|
233
233
|
from . import doctor
|
|
234
|
-
|
|
234
|
+
sample = None
|
|
235
|
+
for flag in ("-s", "-n", "--sample"):
|
|
236
|
+
if flag in a[2:]:
|
|
237
|
+
i = a.index(flag, 2)
|
|
238
|
+
try:
|
|
239
|
+
sample = int(a[i + 1])
|
|
240
|
+
except (IndexError, ValueError):
|
|
241
|
+
print(f"{flag} requires a positive integer", file=sys.stderr)
|
|
242
|
+
return 2
|
|
243
|
+
if sample < 1:
|
|
244
|
+
print(f"{flag} requires a positive integer", file=sys.stderr)
|
|
245
|
+
return 2
|
|
246
|
+
break
|
|
247
|
+
kw = {"password": resolve_password(password)}
|
|
248
|
+
if sample is not None:
|
|
249
|
+
kw["sample"] = sample
|
|
250
|
+
r = doctor.diagnose(archive, **kw)
|
|
235
251
|
print(doctor.render(r))
|
|
236
252
|
return 0 if (r.ok and not r.blockers) else 1
|
|
237
253
|
|
|
@@ -252,6 +268,15 @@ def main() -> int:
|
|
|
252
268
|
print(f" share {s} versions={arc.share_versions(s)}")
|
|
253
269
|
return 0
|
|
254
270
|
|
|
271
|
+
if cmd in ("get", "verify"):
|
|
272
|
+
# globs match paths, which start with "/", so a leading "-" is always a stray option
|
|
273
|
+
opts = [x for x in a[2:] if x.startswith("-")]
|
|
274
|
+
if opts:
|
|
275
|
+
print(f"unknown option {opts[0]}" + (
|
|
276
|
+
" (hbkit always reads the newest backup version)" if opts[0] == "--version" else ""),
|
|
277
|
+
file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
|
|
255
280
|
arc, db = open_index(archive, password)
|
|
256
281
|
|
|
257
282
|
if cmd == "list":
|
|
@@ -13,6 +13,10 @@ instant correctness check - a wrong password is detected before reading any data
|
|
|
13
13
|
viv = crypto_box_seal_open(vkey.rsa_vkey_iv) # 64B sealed box -> 16B AES IV
|
|
14
14
|
chunk = LZ4( unpad( AES-256-CBC(ciphertext, vkey, viv) ) )
|
|
15
15
|
|
|
16
|
+
vkey.db holds one (vkey, viv) row per backup version, and each version encrypts the
|
|
17
|
+
chunks it writes with its own pair. Nothing records which version wrote a chunk, so a
|
|
18
|
+
reader tries each pair and keeps the one whose output matches the chunk's MD5.
|
|
19
|
+
|
|
16
20
|
Filenames are encrypted separately, with their own key, and stored base64 with '/'
|
|
17
21
|
replaced by '_' (illegal in a filename):
|
|
18
22
|
|
|
@@ -69,7 +73,8 @@ class ArchiveCrypto:
|
|
|
69
73
|
"""Holds the unwrapped keys for one encrypted archive."""
|
|
70
74
|
|
|
71
75
|
def __init__(self, password: str, unikey: str, public_key: bytes,
|
|
72
|
-
|
|
76
|
+
sealed_keys: list[tuple[bytes, bytes]]):
|
|
77
|
+
"""`sealed_keys` is the (rsa_vkey, rsa_vkey_iv) rows of vkey.db, one per version."""
|
|
73
78
|
nb, Cipher, algorithms, modes = _deps()
|
|
74
79
|
self._Cipher, self._algorithms, self._modes = Cipher, algorithms, modes
|
|
75
80
|
|
|
@@ -80,8 +85,10 @@ class ArchiveCrypto:
|
|
|
80
85
|
if pub != public_key:
|
|
81
86
|
raise WrongPassword("password does not match this archive")
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
self.
|
|
88
|
+
# (key, iv) per backup version, in version order, without repeats
|
|
89
|
+
self.data_keys = list(dict.fromkeys(
|
|
90
|
+
(nb.crypto_box_seal_open(k, pub, priv), nb.crypto_box_seal_open(iv, pub, priv))
|
|
91
|
+
for k, iv in sealed_keys))
|
|
85
92
|
self.fn_key = hashlib.sha256(priv + uni).digest()
|
|
86
93
|
self.fn_iv = hashlib.md5(uni + FILENAME_IV_SALT).digest()
|
|
87
94
|
|
|
@@ -89,8 +96,9 @@ class ArchiveCrypto:
|
|
|
89
96
|
d = self._Cipher(self._algorithms.AES(key), self._modes.CBC(iv)).decryptor()
|
|
90
97
|
return unpad(d.update(ct) + d.finalize())
|
|
91
98
|
|
|
92
|
-
def decrypt_chunk(self, ct: bytes) -> bytes:
|
|
93
|
-
|
|
99
|
+
def decrypt_chunk(self, ct: bytes, which: int) -> bytes:
|
|
100
|
+
key, iv = self.data_keys[which]
|
|
101
|
+
return self._dec(ct, key, iv)
|
|
94
102
|
|
|
95
103
|
def decrypt_name(self, stored: str) -> str:
|
|
96
104
|
"""Decrypt one path component. '_' stands in for '/' in the base64 alphabet."""
|
|
@@ -74,6 +74,8 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
74
74
|
codec = CODECS.get(cfg.get("data_compress_type", ""), f"unknown({cfg.get('data_compress_type')})")
|
|
75
75
|
r.fact("chunk codec", codec)
|
|
76
76
|
r.fact("encrypted", "yes (unlocked)" if arc.crypto else "no")
|
|
77
|
+
if arc.crypto:
|
|
78
|
+
r.fact("data keys", len(arc.crypto.data_keys))
|
|
77
79
|
r.fact("dedup across files", cfg.get("support_cross_file_dedup", "?"))
|
|
78
80
|
|
|
79
81
|
# --- blockers -------------------------------------------------------
|
|
@@ -125,8 +127,8 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
125
127
|
if len(vs) > 1:
|
|
126
128
|
multi.append(f"{s}={vs}")
|
|
127
129
|
if multi:
|
|
128
|
-
r.warnings.append("multiple backup versions present; the newest
|
|
129
|
-
|
|
130
|
+
r.warnings.append("multiple backup versions present; hbkit reads the newest: "
|
|
131
|
+
+ "; ".join(multi))
|
|
130
132
|
|
|
131
133
|
# --- prove it by actually rebuilding files ---------------------------
|
|
132
134
|
if not r.blockers:
|
|
@@ -141,10 +143,16 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
141
143
|
except Exception as e: # noqa: BLE001
|
|
142
144
|
r.warnings.append(f"share {s}: cannot read file list ({e})")
|
|
143
145
|
continue
|
|
144
|
-
|
|
146
|
+
# One random file from each of `sample` equal slices of the virtual_file stream,
|
|
147
|
+
# which backups append to, so the newest files are always covered. On an
|
|
148
|
+
# encrypted archive their new chunks sit under the newest version's own key.
|
|
149
|
+
rows = sorted((x for x in rows if stat.S_ISREG(x[3] or 0)), key=lambda x: x[2])
|
|
145
150
|
if rows:
|
|
146
151
|
rnd = random.Random(seed or 1234)
|
|
147
|
-
|
|
152
|
+
k = min(sample, len(rows))
|
|
153
|
+
for i in range(k):
|
|
154
|
+
row = rows[rnd.randrange(i * len(rows) // k, (i + 1) * len(rows) // k)]
|
|
155
|
+
picked.append((s, *row[:3]))
|
|
148
156
|
|
|
149
157
|
if not picked:
|
|
150
158
|
r.warnings.append("no regular files found to sample")
|
|
@@ -168,6 +176,9 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
168
176
|
el = max(time.time() - t0, 1e-6)
|
|
169
177
|
r.check(f"rebuilt {len(picked)} sampled files, all chunks verified",
|
|
170
178
|
bad == 0, f"{ok} ok, {bad} failed" + (f" - {errs[0]}" if errs else ""))
|
|
179
|
+
if arc.crypto:
|
|
180
|
+
r.fact("data keys used", f"{len(set(arc._bkey.values()))} of "
|
|
181
|
+
f"{len(arc.crypto.data_keys)} by the sample")
|
|
171
182
|
r.fact("sample throughput", f"{human(nbytes)} in {el:.1f}s "
|
|
172
183
|
f"({nbytes/el/1e6:,.0f} MB/s, single-threaded)")
|
|
173
184
|
arc.close()
|
|
@@ -202,7 +213,7 @@ def main() -> int:
|
|
|
202
213
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
203
214
|
ap.add_argument("archive")
|
|
204
215
|
ap.add_argument("-p", "--password", help="for encrypted archives")
|
|
205
|
-
ap.add_argument("-n", "--sample", type=int, default=10,
|
|
216
|
+
ap.add_argument("-n", "-s", "--sample", type=int, default=10,
|
|
206
217
|
help="files to rebuild per share as proof (default 10)")
|
|
207
218
|
a = ap.parse_args()
|
|
208
219
|
r = diagnose(a.archive, a.sample, password=a.password)
|
|
@@ -231,6 +231,78 @@ def test_encrypted_names_and_content():
|
|
|
231
231
|
arc.close()
|
|
232
232
|
|
|
233
233
|
|
|
234
|
+
# ------------------------------------- one data key per backup version (no archive needed)
|
|
235
|
+
|
|
236
|
+
def _multi_key_archive():
|
|
237
|
+
"""An Archive holding just enough state for read_chunk: bucket N contains one chunk
|
|
238
|
+
encrypted with backup version N+1's data key, the way vkey.db lays them out."""
|
|
239
|
+
import ctypes
|
|
240
|
+
import hashlib
|
|
241
|
+
import io
|
|
242
|
+
import struct
|
|
243
|
+
import zlib
|
|
244
|
+
|
|
245
|
+
nb = pytest.importorskip("nacl.bindings")
|
|
246
|
+
try:
|
|
247
|
+
lib = hbk.lz4()
|
|
248
|
+
except RuntimeError:
|
|
249
|
+
pytest.skip("liblz4 not available")
|
|
250
|
+
from cryptography.hazmat.primitives import padding
|
|
251
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
252
|
+
|
|
253
|
+
from hbkit.crypto import ARGON2_ALG, ARGON2_MEMLIMIT, ARGON2_OPSLIMIT, ArchiveCrypto
|
|
254
|
+
|
|
255
|
+
pw, unikey = "pw", "unikey"
|
|
256
|
+
seed = nb.crypto_pwhash_alg(32, pw.encode(), hashlib.md5(unikey.encode()).digest(),
|
|
257
|
+
ARGON2_OPSLIMIT, ARGON2_MEMLIMIT, ARGON2_ALG)
|
|
258
|
+
pub, _ = nb.crypto_box_seed_keypair(seed)
|
|
259
|
+
keys = [(os.urandom(32), os.urandom(16)) for _ in range(3)]
|
|
260
|
+
|
|
261
|
+
arc = object.__new__(hbk.Archive)
|
|
262
|
+
arc.crypto = ArchiveCrypto(pw, unikey, pub, [(nb.crypto_box_seal(k, pub),
|
|
263
|
+
nb.crypto_box_seal(iv, pub)) for k, iv in keys])
|
|
264
|
+
arc._brec, arc._bkey, buckets, plain = {}, {}, {}, []
|
|
265
|
+
lib.LZ4_compress_default.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
|
|
266
|
+
ctypes.c_int, ctypes.c_int]
|
|
267
|
+
for bid, (key, iv) in enumerate([*keys, (os.urandom(32), os.urandom(16))]):
|
|
268
|
+
data = f"chunk written by version {bid + 1}. ".encode() * 40
|
|
269
|
+
dst = ctypes.create_string_buffer(len(data) + 64)
|
|
270
|
+
n = lib.LZ4_compress_default(data, dst, len(data), len(dst))
|
|
271
|
+
lz = dst.raw[:n]
|
|
272
|
+
pad = padding.PKCS7(128).padder()
|
|
273
|
+
enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
|
|
274
|
+
ct = enc.update(pad.update(lz) + pad.finalize()) + enc.finalize()
|
|
275
|
+
rec = struct.pack(">III", len(ct), 0, len(data)) + hashlib.md5(data).digest()
|
|
276
|
+
buckets[bid] = (rec + struct.pack(">I", zlib.crc32(rec)), io.BytesIO(ct))
|
|
277
|
+
arc._brec[bid] = 32
|
|
278
|
+
plain.append(data)
|
|
279
|
+
arc._bucket = buckets.__getitem__
|
|
280
|
+
return arc, plain
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_chunks_decode_under_the_key_of_the_version_that_wrote_them():
|
|
284
|
+
"""Issue #4: vkey.db has a row per backup version, each a different AES key. Only the
|
|
285
|
+
first was ever loaded, so every chunk a later version wrote failed to decompress."""
|
|
286
|
+
arc, plain = _multi_key_archive()
|
|
287
|
+
for bid in range(3):
|
|
288
|
+
assert arc.read_chunk(bid, 0, verify=False) == plain[bid]
|
|
289
|
+
assert arc._bkey == {0: 0, 1: 1, 2: 2}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_a_chunk_no_key_decodes_is_an_error_even_without_verify():
|
|
293
|
+
arc, _ = _multi_key_archive()
|
|
294
|
+
with pytest.raises(ValueError, match="no data key decodes b3@0"):
|
|
295
|
+
arc.read_chunk(3, 0, verify=False)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_verify_rejects_options_it_does_not_take():
|
|
299
|
+
"""`verify '*' --version 3` used to verify the newest version and say nothing."""
|
|
300
|
+
r = subprocess.run([sys.executable, "-m", "hbkit.cli", "/no/such/archive",
|
|
301
|
+
"verify", "*", "--version", "3"], capture_output=True, text=True)
|
|
302
|
+
assert r.returncode == 2
|
|
303
|
+
assert "unknown option --version" in r.stderr
|
|
304
|
+
|
|
305
|
+
|
|
234
306
|
# ------------------------------------------------- shard maths (no archive needed)
|
|
235
307
|
|
|
236
308
|
def _make_shards(tmp_path, sizes, shard_size, monkeypatch):
|
|
@@ -287,6 +359,18 @@ def test_cat_single_shard_still_works(tmp_path, monkeypatch):
|
|
|
287
359
|
c.close()
|
|
288
360
|
|
|
289
361
|
|
|
362
|
+
def test_cat_preserves_sparse_shard_offsets(tmp_path, monkeypatch):
|
|
363
|
+
from hbkit import archive as A
|
|
364
|
+
blob = _make_shards(tmp_path, [128, 10], 128, monkeypatch)
|
|
365
|
+
(tmp_path / "1.idx.2").rename(tmp_path / "2.idx.2")
|
|
366
|
+
c = A.Cat(str(tmp_path))
|
|
367
|
+
assert c.starts == [0, 256]
|
|
368
|
+
assert c.total == 266
|
|
369
|
+
assert c.read(128, 1) == b""
|
|
370
|
+
assert c.read(256, 10) == blob[128:]
|
|
371
|
+
c.close()
|
|
372
|
+
|
|
373
|
+
|
|
290
374
|
# --------------------------------------------------- rclone mount helper (no network)
|
|
291
375
|
|
|
292
376
|
def test_mount_profiles_pick_the_right_cache_mode(monkeypatch):
|
|
@@ -480,6 +564,24 @@ def test_doctor_honours_the_password_environment_variable(monkeypatch, flag, env
|
|
|
480
564
|
assert seen["pw"] == want
|
|
481
565
|
|
|
482
566
|
|
|
567
|
+
@pytest.mark.parametrize("flag", ["-s", "-n", "--sample"])
|
|
568
|
+
def test_doctor_forwards_sample_count(monkeypatch, flag):
|
|
569
|
+
from hbkit import cli, doctor
|
|
570
|
+
|
|
571
|
+
seen = {}
|
|
572
|
+
|
|
573
|
+
class Result:
|
|
574
|
+
ok, blockers = True, []
|
|
575
|
+
|
|
576
|
+
monkeypatch.setattr(doctor, "diagnose",
|
|
577
|
+
lambda a, sample=10, password=None:
|
|
578
|
+
(seen.update(sample=sample), Result())[1])
|
|
579
|
+
monkeypatch.setattr(doctor, "render", lambda r: "")
|
|
580
|
+
monkeypatch.setattr(sys, "argv", ["hbk", "/some/archive", "doctor", flag, "2"])
|
|
581
|
+
assert cli.main() == 0
|
|
582
|
+
assert seen["sample"] == 2
|
|
583
|
+
|
|
584
|
+
|
|
483
585
|
def test_lazycats_lists_shards_before_any_are_opened():
|
|
484
586
|
"""`doctor` describes an archive without extracting from it, so nothing has opened a
|
|
485
587
|
file_chunk shard by the time it prints them. Iterating the dict reported an empty list
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|