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.
@@ -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.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. Older record layouts
284
- (16-byte `chunk_index`, 28-byte bucket records), zlib chunks and multi-version archives
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. Older record layouts
255
- (16-byte `chunk_index`, 28-byte bucket records), zlib chunks and multi-version archives
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.4"
7
+ version = "0.4.6"
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.4"
3
+ __version__ = "0.4.6"
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+))?$")
@@ -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. If the assumption were ever wrong, a
122
- short read surfaces as a chunk MD5 failure - loudly - never as silent corruption.
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
- self.files = [found[k][1] for k in sorted(found)]
140
- n = len(self.files)
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] * (n - 1) + [last]
143
- self.starts = [i * SHARD_SIZE for i in range(n)]
144
- self.total = (n - 1) * SHARD_SIZE + last
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 and i < len(self.files):
164
- st = self.starts[i]
165
- take = min(n, st + self.sizes[i] - off)
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(i)
192
+ fh = self._fh.get(pos)
169
193
  if fh is None:
170
- fh = self._fh[i] = open(os.path.join(self.d, self.files[i]), "rb",
171
- buffering=INDEX_BUF)
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 the version key using the password. Raises WrongPassword on mismatch."""
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
- 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()
314
339
  db.close()
315
- if not row:
340
+ if not rows:
316
341
  raise UnsupportedArchive("encrypted archive has no rows in vkey db")
317
- 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])
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
- raw = self.crypto.decrypt_chunk(raw)
407
- clen = len(raw)
408
- dst = ctypes.create_string_buffer(ulen)
409
- n = lz4().LZ4_decompress_safe(raw, dst, clen, ulen)
410
- data = dst.raw[:n] if n > 0 else b""
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
- r = doctor.diagnose(archive, password=resolve_password(password))
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
- 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()
@@ -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