hbkit 0.4.5__tar.gz → 0.4.7__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.
@@ -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. Variant handling marked [D] is implemented from disassembly but
20
- has never met a real archive of that kind.
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 check when
287
- implementing. The `uncomp_len` and MD5 in the bucket index still describe the *final*
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.5
3
+ Version: 0.4.7
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
@@ -27,7 +27,9 @@ Provides-Extra: dev
27
27
  Requires-Dist: pytest>=7; extra == 'dev'
28
28
  Description-Content-Type: text/markdown
29
29
 
30
- # hbkit
30
+ <p align="center">
31
+ <img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
32
+ </p>
31
33
 
32
34
  [![PyPI](https://img.shields.io/pypi/v/hbkit)](https://pypi.org/project/hbkit/)
33
35
  [![CI](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml/badge.svg)](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
@@ -39,6 +41,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
39
41
  a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
40
42
  own Hyper Backup Explorer is awkward or unavailable.
41
43
 
44
+ Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
45
+
42
46
  ```sh
43
47
  brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
44
48
 
@@ -77,22 +81,7 @@ case is a loud failure naming the file.
77
81
 
78
82
  ## The TUI
79
83
 
80
- ```
81
- ⭘ Hyper Backup Recovery 17:11:17
82
- ┌──────────────────────────────────────────────────────────────┐ │
83
- │ search filename… (/) │ │ Selection
84
- └──────────────────────────────────────────────────────────────┘ │ 197,607 files
85
- ▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
86
- ├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
87
- ├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
88
- ├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
89
- ├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
90
- ├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
91
- │ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
92
- │ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
93
- │ Start recovery
94
- a All n Clear d Destination r Recover / Search q Quit │
95
- ```
84
+ ![hbkit's terminal browser: a backup's shares as a folder tree with sizes and file counts, two photo folders ticked, and a panel with the selection, the destination's free space and a Start recovery button](https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/tui.png)
96
85
 
97
86
  `space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
98
87
  `ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
@@ -280,8 +269,9 @@ Read this before trusting it with the only copy of anything.
280
269
  contain plaintext names (not file contents). Delete it if that matters to you.
281
270
  - **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
282
271
  archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
283
- the same DSM version, both verified byte-exact. Older record layouts
284
- (16-byte `chunk_index`, 28-byte bucket records), zlib chunks and multi-version archives
272
+ the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
273
+ Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
274
+ Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
285
275
  are implemented from disassembly but have not met a real archive of that kind. `doctor`
286
276
  exists precisely so you can find out in seconds rather than mid-restore.
287
277
  - **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
@@ -1,4 +1,6 @@
1
- # hbkit
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
3
+ </p>
2
4
 
3
5
  [![PyPI](https://img.shields.io/pypi/v/hbkit)](https://pypi.org/project/hbkit/)
4
6
  [![CI](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml/badge.svg)](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
@@ -10,6 +12,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
10
12
  a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
11
13
  own Hyper Backup Explorer is awkward or unavailable.
12
14
 
15
+ Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
16
+
13
17
  ```sh
14
18
  brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
15
19
 
@@ -48,22 +52,7 @@ case is a loud failure naming the file.
48
52
 
49
53
  ## The TUI
50
54
 
51
- ```
52
- ⭘ Hyper Backup Recovery 17:11:17
53
- ┌──────────────────────────────────────────────────────────────┐ │
54
- │ search filename… (/) │ │ Selection
55
- └──────────────────────────────────────────────────────────────┘ │ 197,607 files
56
- ▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
57
- ├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
58
- ├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
59
- ├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
60
- ├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
61
- ├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
62
- │ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
63
- │ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
64
- │ Start recovery
65
- a All n Clear d Destination r Recover / Search q Quit │
66
- ```
55
+ ![hbkit's terminal browser: a backup's shares as a folder tree with sizes and file counts, two photo folders ticked, and a panel with the selection, the destination's free space and a Start recovery button](https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/tui.png)
67
56
 
68
57
  `space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
69
58
  `ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
@@ -251,8 +240,9 @@ Read this before trusting it with the only copy of anything.
251
240
  contain plaintext names (not file contents). Delete it if that matters to you.
252
241
  - **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
253
242
  archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
254
- the same DSM version, both verified byte-exact. Older record layouts
255
- (16-byte `chunk_index`, 28-byte bucket records), zlib chunks and multi-version archives
243
+ the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
244
+ Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
245
+ Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
256
246
  are implemented from disassembly but have not met a real archive of that kind. `doctor`
257
247
  exists precisely so you can find out in seconds rather than mid-restore.
258
248
  - **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
@@ -0,0 +1,22 @@
1
+ # Art
2
+
3
+ Every image in `docs/images/` and `site/` is built from the files here, so the screenshots
4
+ can be redone whenever the TUI changes. Run from anywhere:
5
+
6
+ ```sh
7
+ pip install -e . fonttools brotli # hbkit itself renders the TUI screenshot
8
+ brew install webp imagemagick # plus Google Chrome, used headless
9
+ contrib/art/render.sh
10
+ ```
11
+
12
+ | source | builds |
13
+ |---|---|
14
+ | `site/images/icon.svg` | the icon, drawn by hand; everything else derives from it |
15
+ | `logo.py` | `docs/images/logo.svg` and `logo-light.svg`: the icon and the wordmark, in Outfit SemiBold as outlines |
16
+ | `render_tui.py`, `tui.html` | `docs/images/tui.png` and the site's `tui-*.webp`: hbkit's real browser screen over a made-up archive |
17
+ | `banner.html` | `docs/images/banner.png`, the README header |
18
+ | `social.html` | `site/social-preview.png`, the link preview card |
19
+ | `icon.html` | `docs/images/icon-1024.png` |
20
+
21
+ The demo archive in `render_tui.py` is invented: no real backup or personal data goes into
22
+ any image. Outfit is under the SIL Open Font License (`site/fonts/OFL.txt`).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.5"
7
+ version = "0.4.7"
8
8
  description = "Recover files from Synology Hyper Backup (.hbk) archives without Synology software"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  """hbkit - recover files from Synology Hyper Backup (.hbk) archives without Synology software."""
2
2
 
3
- __version__ = "0.4.5"
3
+ __version__ = "0.4.7"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -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+))?$")
@@ -280,6 +291,7 @@ class Archive:
280
291
  self._bidx: OrderedDict[int, bytes] = OrderedDict()
281
292
  self._bdat: OrderedDict[int, object] = OrderedDict()
282
293
  self._brec: dict[int, int] = {}
294
+ self._bkey: dict[int, int] = {} # bucket id -> index into crypto.data_keys
283
295
  self._password = None
284
296
  if self.is_encrypted():
285
297
  if password is None:
@@ -311,7 +323,7 @@ class Archive:
311
323
  return out
312
324
 
313
325
  def _open_crypto(self, password: str):
314
- """Unwrap the version key using the password. Raises WrongPassword on mismatch."""
326
+ """Unwrap every version's data key using the password. Raises WrongPassword on mismatch."""
315
327
  import sqlite3
316
328
 
317
329
  from .crypto import ArchiveCrypto
@@ -323,11 +335,11 @@ class Archive:
323
335
  with open(resolve(f"{self.root}/Config", "public.pem"), "rb") as fh:
324
336
  pub = fh.read()
325
337
  db = sqlite3.connect(f"file:{resolve(f'{self.root}/Pool', 'vkey.db')}?immutable=1", uri=True)
326
- row = db.execute("SELECT rsa_vkey, rsa_vkey_iv FROM vkey ORDER BY version_id").fetchone()
338
+ rows = db.execute("SELECT rsa_vkey, rsa_vkey_iv FROM vkey ORDER BY version_id").fetchall()
327
339
  db.close()
328
- if not row:
340
+ if not rows:
329
341
  raise UnsupportedArchive("encrypted archive has no rows in vkey db")
330
- return ArchiveCrypto(password, unikey, pub, bytes(row[0]), bytes(row[1]))
342
+ return ArchiveCrypto(password, unikey, pub, [(bytes(k), bytes(iv)) for k, iv in rows])
331
343
 
332
344
  def decrypt_name(self, stored: str) -> str:
333
345
  """Decrypt a stored filename, or return it unchanged for plaintext archives."""
@@ -415,21 +427,33 @@ class Archive:
415
427
  raise ValueError(f"bucket index CRC32 mismatch b{bid}@{boff}")
416
428
  fh.seek(off)
417
429
  raw = fh.read(clen)
430
+ md5 = rec[12:28]
418
431
  if self.crypto is not None:
419
- raw = self.crypto.decrypt_chunk(raw)
420
- clen = len(raw)
421
- dst = ctypes.create_string_buffer(ulen)
422
- n = lz4().LZ4_decompress_safe(raw, dst, clen, ulen)
423
- data = dst.raw[:n] if n > 0 else b""
424
- if n != ulen:
425
- try:
426
- data = zlib.decompress(raw) # compress type 4
427
- except Exception:
428
- raise ValueError(f"decompress failed b{bid}@{boff} lz4={n} want={ulen}") from None
429
- 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:
430
437
  raise ValueError(f"MD5 mismatch b{bid}@{boff}")
431
438
  return data
432
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
+
433
457
  def extract(self, ovf: int, size: int, verify: bool = True, out=None, on_bytes=None):
434
458
  """Rebuild one file. Writes to `out` if given (returns byte count), else returns bytes.
435
459
 
@@ -268,6 +268,15 @@ def main() -> int:
268
268
  print(f" share {s} versions={arc.share_versions(s)}")
269
269
  return 0
270
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
+
271
280
  arc, db = open_index(archive, password)
272
281
 
273
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
- sealed_vkey: bytes, sealed_viv: bytes):
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
- self.key = nb.crypto_box_seal_open(sealed_vkey, pub, priv)
84
- self.iv = nb.crypto_box_seal_open(sealed_viv, pub, priv)
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
- return self._dec(ct, self.key, self.iv)
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 is used by default "
129
- "(--version selects another): " + "; ".join(multi))
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
- rows = [x for x in rows if stat.S_ISREG(x[3] or 0)]
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
- picked += [(s, *rnd.choice(rows)[:3]) for _ in range(min(sample, len(rows)))]
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()
@@ -526,14 +526,19 @@ class BrowserScreen(Screen):
526
526
  (f"{n}", "bold"), (" item(s)", "dim")))
527
527
  self.update_space()
528
528
 
529
+ def dest(self) -> str:
530
+ """The destination as typed, with ~ and relative paths resolved as a shell would."""
531
+ return os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
532
+
529
533
  def update_space(self):
530
- probe = self.query_one("#dest", Input).value
531
- while probe and not os.path.exists(probe):
532
- probe = os.path.dirname(probe)
533
534
  w = self.query_one("#space-info", Static)
534
- if not probe:
535
- w.update(Text("invalid path", style="bold red"))
536
- return
535
+ probe = self.dest()
536
+ while not os.path.exists(probe):
537
+ parent = os.path.dirname(probe)
538
+ if parent == probe: # a drive that isn't there
539
+ w.update(Text("invalid path", style="bold red"))
540
+ return
541
+ probe = parent
537
542
  free = shutil.disk_usage(probe).free
538
543
  _, need, _ = self.sel.totals()
539
544
  if need > free:
@@ -595,7 +600,7 @@ class BrowserScreen(Screen):
595
600
  if not self.sel.chosen:
596
601
  self.notify("select something first — space to tick", severity="warning")
597
602
  return
598
- dest = os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
603
+ dest = self.dest()
599
604
  files = self.sel.files()
600
605
  if not files:
601
606
  self.notify("selection contains no extractable files", severity="error")
@@ -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):
@@ -518,3 +590,40 @@ def test_lazycats_lists_shards_before_any_are_opened():
518
590
  0: "/nonexistent/file_chunk0.index"})
519
591
  assert cats.known == [0, 4]
520
592
  assert len(cats) == 0, "listing shards must not open them"
593
+
594
+
595
+ @pytest.mark.parametrize("typed", ["~/hbkit-test-not-there/restore", "restore/here"])
596
+ def test_tui_free_space_follows_home_and_relative_destinations(tmp_path, monkeypatch, typed):
597
+ """Typing ~/restore showed "invalid path" under the destination, though the restore
598
+ itself expanded ~ fine. A relative path did the same."""
599
+ import asyncio
600
+ import sqlite3
601
+ from types import SimpleNamespace
602
+
603
+ from hbkit import tui
604
+
605
+ db = tmp_path / "index.db"
606
+ con = sqlite3.connect(db)
607
+ con.execute("CREATE TABLE node(id INTEGER PRIMARY KEY, parent INTEGER, name TEXT, "
608
+ "isdir INTEGER, size INTEGER, nfiles INTEGER, ovf INTEGER, mtime INTEGER, "
609
+ "share TEXT)")
610
+ con.execute("INSERT INTO node VALUES (1, NULL, 'share', 1, 10, 1, NULL, 0, 'share')")
611
+ con.commit()
612
+ con.close()
613
+ monkeypatch.chdir(tmp_path)
614
+
615
+ class App(tui.HBKApp):
616
+ def on_mount(self, event):
617
+ event.prevent_default() # skip the archive picker
618
+ self.push_screen(tui.BrowserScreen(SimpleNamespace(root="demo"), tui.Index(str(db))))
619
+
620
+ async def space_line():
621
+ app = App(1)
622
+ async with app.run_test() as pilot:
623
+ await pilot.pause()
624
+ app.screen.query_one("#dest", tui.Input).value = typed
625
+ await pilot.pause()
626
+ return str(app.screen.query_one("#space-info", tui.Static).render())
627
+
628
+ line = asyncio.run(space_line())
629
+ assert "free" in line and "invalid" not in line, line
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes