hbkit 0.5.2__tar.gz → 0.5.3__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.
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3
4
+
5
+ ### Fixed
6
+
7
+ - Archives stored without compression (`data_compress_type` 0, "none" in `doctor`) now
8
+ restore. Every chunk of such an archive failed before. Found and diagnosed by @tabrindle
9
+ on two 12 TB archives, with the patch this fix builds on (#9). A stored chunk that is
10
+ itself valid zlib data, such as a git object, comes back as stored: hbkit keeps the
11
+ decoding whose MD5 matches.
12
+
3
13
  ## 0.5.2
4
14
 
5
15
  No change to how hbkit reads or restores archives.
@@ -210,6 +210,12 @@ The codec comes from `data_compress_type` in `_Syno_TaskConfig`. The vendor disp
210
210
  lz4-hc share a decompressor. A robust reader attempts LZ4 and falls back to zlib. [D for
211
211
  the mapping, V for LZ4]
212
212
 
213
+ **0 = none**: the chunk is stored uncompressed. `compressed length` equals `uncompressed
214
+ length` in every bucket record and the payload is the chunk's own bytes. Seen on two DSM
215
+ archives from 2020 with compression off (issue #9). A stored payload can itself be valid
216
+ zlib or LZ4 data (a git object is a zlib stream), so a reader keeps the decoding whose MD5
217
+ matches, and tries the stored bytes first when the task says type 0. [V]
218
+
213
219
  Existing open-source tools implement only LZ4 and report corruption on some chunks; the
214
220
  zlib path is the likely explanation.
215
221
 
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hbkit
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Recover files from Synology Hyper Backup (.hbk) archives without Synology software
5
- Project-URL: Homepage, https://yordilorenzo.github.io/hbkit/
6
- Project-URL: Documentation, https://yordilorenzo.github.io/hbkit/restore-without-nas/
5
+ Project-URL: Homepage, https://hbkit.dev/
6
+ Project-URL: Documentation, https://hbkit.dev/restore-without-nas/
7
7
  Project-URL: Source, https://github.com/YordiLorenzo/hbkit
8
8
  Project-URL: Issues, https://github.com/YordiLorenzo/hbkit/issues
9
9
  Project-URL: Format spec, https://github.com/YordiLorenzo/hbkit/blob/main/FORMAT.md
@@ -46,7 +46,7 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
46
46
  a tree, and pull out what you want. Works headless on Linux and macOS, including Apple silicon, where Synology's
47
47
  own Hyper Backup Explorer is awkward or unavailable.
48
48
 
49
- Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
49
+ Website: **[hbkit.dev](https://hbkit.dev/)**
50
50
 
51
51
  ```sh
52
52
  brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
@@ -79,9 +79,9 @@ for you. Otherwise: `brew install lz4`, `sudo apt install liblz4-1`, or
79
79
  ### Restore without a NAS
80
80
 
81
81
  The step-by-step guide is on the site:
82
- [Restore a Hyper Backup without a Synology NAS](https://yordilorenzo.github.io/hbkit/restore-without-nas/).
82
+ [Restore a Hyper Backup without a Synology NAS](https://hbkit.dev/restore-without-nas/).
83
83
  If Hyper Backup says the backup is corrupted, start with
84
- [what you can still recover](https://yordilorenzo.github.io/hbkit/damaged-backup/).
84
+ [what you can still recover](https://hbkit.dev/damaged-backup/).
85
85
 
86
86
  hbkit never writes to the archive. If it is your only copy, copy the whole `.hbk` folder
87
87
  to another disk first and run hbkit on the copy.
@@ -12,7 +12,7 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
12
12
  a tree, and pull out what you want. Works headless on Linux and macOS, including Apple silicon, where Synology's
13
13
  own Hyper Backup Explorer is awkward or unavailable.
14
14
 
15
- Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
15
+ Website: **[hbkit.dev](https://hbkit.dev/)**
16
16
 
17
17
  ```sh
18
18
  brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
@@ -45,9 +45,9 @@ for you. Otherwise: `brew install lz4`, `sudo apt install liblz4-1`, or
45
45
  ### Restore without a NAS
46
46
 
47
47
  The step-by-step guide is on the site:
48
- [Restore a Hyper Backup without a Synology NAS](https://yordilorenzo.github.io/hbkit/restore-without-nas/).
48
+ [Restore a Hyper Backup without a Synology NAS](https://hbkit.dev/restore-without-nas/).
49
49
  If Hyper Backup says the backup is corrupted, start with
50
- [what you can still recover](https://yordilorenzo.github.io/hbkit/damaged-backup/).
50
+ [what you can still recover](https://hbkit.dev/damaged-backup/).
51
51
 
52
52
  hbkit never writes to the archive. If it is your only copy, copy the whole `.hbk` folder
53
53
  to another disk first and run hbkit on the copy.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.5.2"
7
+ version = "0.5.3"
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"
@@ -34,8 +34,8 @@ dependencies = ["textual>=0.80", "pynacl>=1.5", "cryptography>=41"]
34
34
  dev = ["pytest>=7"]
35
35
 
36
36
  [project.urls]
37
- Homepage = "https://yordilorenzo.github.io/hbkit/"
38
- Documentation = "https://yordilorenzo.github.io/hbkit/restore-without-nas/"
37
+ Homepage = "https://hbkit.dev/"
38
+ Documentation = "https://hbkit.dev/restore-without-nas/"
39
39
  Source = "https://github.com/YordiLorenzo/hbkit"
40
40
  Issues = "https://github.com/YordiLorenzo/hbkit/issues"
41
41
  "Format spec" = "https://github.com/YordiLorenzo/hbkit/blob/main/FORMAT.md"
@@ -1,6 +1,6 @@
1
1
  """hbkit - recover files from Synology Hyper Backup (.hbk) archives without Synology software."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.5.3"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -11,7 +11,8 @@ Format (reverse engineered; all integers BIG-ENDIAN):
11
11
  u32 comp_len, u32 offset, u32 uncomp_len, md5[16] of DECOMPRESSED chunk,
12
12
  u32 crc32 of record[0:28]
13
13
  -> same-named .bucket @offset, comp_len bytes: raw LZ4 BLOCK (zlib is the fallback codec;
14
- SYNO::Backup::decompress dispatches type 1/2/4 = lz4 / lz4-hc / zlib)
14
+ SYNO::Backup::decompress dispatches type 1/2/4 = lz4 / lz4-hc / zlib; type 0 stores the
15
+ chunk uncompressed, comp_len == uncomp_len)
15
16
 
16
17
  Index families are <N>.idx shards concatenated in numeric order; records start at stream
17
18
  offset 64; only shard 0 carries the 64-byte header (magic 0x7053A86E).
@@ -127,15 +128,34 @@ def lz4():
127
128
  return _lz4
128
129
 
129
130
 
130
- def _decompress(raw: bytes, ulen: int) -> bytes | None:
131
- """A chunk payload: a raw LZ4 block, else zlib (compress type 4). None if neither fits."""
131
+ def _decodings(raw: bytes, ulen: int, stored_first: bool):
132
+ """The ways a chunk payload can decode to `ulen` bytes: a raw LZ4 block, zlib (compress
133
+ type 4), or the payload as stored (compress type 0, where the stored length equals the
134
+ uncompressed one). A stored payload can itself be valid LZ4 or zlib data (a git object
135
+ is a zlib stream), so the caller keeps the first decoding whose MD5 matches. An archive
136
+ whose task says type 0 tries the stored bytes first.
137
+ """
138
+ if stored_first and len(raw) == ulen:
139
+ yield raw
132
140
  dst = ctypes.create_string_buffer(ulen)
133
141
  if lz4().LZ4_decompress_safe(raw, dst, len(raw), ulen) == ulen:
134
- return dst.raw
142
+ yield dst.raw
135
143
  try:
136
- return zlib.decompress(raw)
144
+ z = zlib.decompress(raw)
137
145
  except zlib.error:
138
- return None
146
+ z = None
147
+ if z is not None:
148
+ yield z
149
+ if not stored_first and len(raw) == ulen:
150
+ yield raw
151
+
152
+
153
+ def _decode(raw: bytes, ulen: int, md5: bytes, verify: bool, stored_first: bool) -> bytes | None:
154
+ """The first decoding whose MD5 matches, or the first decoding at all when not verifying."""
155
+ for data in _decodings(raw, ulen, stored_first):
156
+ if not verify or hashlib.md5(data).digest() == md5:
157
+ return data
158
+ return None
139
159
 
140
160
 
141
161
  # ------------------------------------------------------------ path resolution
@@ -347,6 +367,7 @@ class Archive:
347
367
  self._bdat: OrderedDict[int, object] = OrderedDict()
348
368
  self._brec: dict[int, int] = {}
349
369
  self._bkey: dict[int, int] = {} # bucket id -> index into crypto.data_keys
370
+ self._stored_first = self.task_config().get("data_compress_type") == "0"
350
371
  self._password = None
351
372
  # The version list is stored in the clear: check the selector before unlocking,
352
373
  # so a typo or a missing version is reported without asking for a password.
@@ -622,11 +643,10 @@ class Archive:
622
643
  md5 = rec[12:28]
623
644
  if self.crypto is not None:
624
645
  return self._decrypt(bid, boff, raw, ulen, md5)
625
- data = _decompress(raw, ulen)
646
+ data = _decode(raw, ulen, md5, verify, self._stored_first)
626
647
  if data is None:
627
- raise ValueError(f"decompress failed b{bid}@{boff} want={ulen}")
628
- if verify and hashlib.md5(data).digest() != md5:
629
- raise ValueError(f"MD5 mismatch b{bid}@{boff}")
648
+ raise ValueError(f"MD5 mismatch b{bid}@{boff}: no decoding matches (want={ulen})"
649
+ if verify else f"decompress failed b{bid}@{boff} want={ulen}")
630
650
  return data
631
651
 
632
652
  def _decrypt(self, bid: int, boff: int, raw: bytes, ulen: int, md5: bytes) -> bytes:
@@ -640,8 +660,8 @@ class Archive:
640
660
  n = len(self.crypto.data_keys)
641
661
  first = self._bkey.get(bid, 0)
642
662
  for k in (first, *(i for i in range(n) if i != first)):
643
- data = _decompress(self.crypto.decrypt_chunk(raw, k), ulen)
644
- if data is not None and hashlib.md5(data).digest() == md5:
663
+ data = _decode(self.crypto.decrypt_chunk(raw, k), ulen, md5, True, self._stored_first)
664
+ if data is not None:
645
665
  self._bkey[bid] = k
646
666
  return data
647
667
  raise ValueError(f"no data key decodes b{bid}@{boff} ({n} tried, want={ulen})")
@@ -89,8 +89,11 @@ class _Crypto:
89
89
 
90
90
 
91
91
  def build(root: str, versions: list[V], encrypted: bool = False,
92
- with_version_info: bool = True) -> str:
93
- """Write `<root>/synth.hbk` holding `versions` (version ids 1..n). Returns its path."""
92
+ with_version_info: bool = True, compress_type: int = 1) -> str:
93
+ """Write `<root>/synth.hbk` holding `versions` (version ids 1..n). Returns its path.
94
+
95
+ `compress_type` 1 stores LZ4 blocks; 0 stores chunks uncompressed, as some real archives do.
96
+ """
94
97
  arc = os.path.join(root, "synth.hbk")
95
98
  cfg, pool = f"{arc}/Config", f"{arc}/Pool"
96
99
  for d in (f"{cfg}/virtual_file.index", f"{cfg}/file_chunk1.index",
@@ -113,7 +116,7 @@ def build(root: str, versions: list[V], encrypted: bool = False,
113
116
  keys = []
114
117
  for c in parts:
115
118
  if c not in chunks:
116
- comp = _lz4(c)
119
+ comp = _lz4(c) if compress_type else c
117
120
  if crypto:
118
121
  comp = crypto.chunk(comp, vi)
119
122
  rec = struct.pack(">III", len(comp), len(bdat), len(c)) + \
@@ -185,7 +188,7 @@ def build(root: str, versions: list[V], encrypted: bool = False,
185
188
  db.close()
186
189
 
187
190
  conf = ['[task_config]', 'name="synth"', 'host_name="synth-host"',
188
- 'data_compress_type=1', f'enable_data_encrypt={"true" if crypto else "false"}']
191
+ f'data_compress_type={compress_type}', f'enable_data_encrypt={"true" if crypto else "false"}']
189
192
  if crypto:
190
193
  conf.append(f'unikey="{UNIKEY}"')
191
194
  with open(f"{cfg}/public.pem", "wb") as fh:
@@ -13,6 +13,7 @@ import subprocess
13
13
  import sys
14
14
  import tempfile
15
15
  import time
16
+ import zlib
16
17
 
17
18
  import pytest
18
19
 
@@ -262,6 +263,7 @@ def _multi_key_archive():
262
263
  arc.crypto = ArchiveCrypto(pw, unikey, pub, [(nb.crypto_box_seal(k, pub),
263
264
  nb.crypto_box_seal(iv, pub)) for k, iv in keys])
264
265
  arc._brec, arc._bkey, buckets, plain = {}, {}, {}, []
266
+ arc._stored_first = False # an LZ4 archive (data_compress_type 1)
265
267
  lib.LZ4_compress_default.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
266
268
  ctypes.c_int, ctypes.c_int]
267
269
  for bid, (key, iv) in enumerate([*keys, (os.urandom(32), os.urandom(16))]):
@@ -808,6 +810,26 @@ def _hbk(tmp_path, *args, password=None):
808
810
  capture_output=True, text=True, env=env, input="")
809
811
 
810
812
 
813
+ @pytest.mark.parametrize("encrypted", [False, True], ids=["plain", "encrypted"])
814
+ def test_uncompressed_archive_restores(tmp_path, encrypted):
815
+ """Hyper Backup can store chunks without compression (data_compress_type 0): the
816
+ stored length equals the real one and the payload is the file's own bytes (issue #9).
817
+ The last chunk is itself a valid zlib stream, as a git object is: it must come back as
818
+ stored, not decompressed."""
819
+ synth = _synth()
820
+ body = [b"H" * 3000 + os.urandom(1000), os.urandom(5000), zlib.compress(b"blob 12\0" * 40)]
821
+ root = synth.build(str(tmp_path / "arc"), [synth.V({"firebird/raw.bak": body}, T0)],
822
+ encrypted=encrypted, compress_type=0)
823
+ pw = synth.PASSWORD if encrypted else None
824
+ r = _hbk(tmp_path, root, "doctor", password=pw)
825
+ assert r.returncode == 0, r.stdout + r.stderr
826
+ assert "chunk codec : none" in r.stdout
827
+ out = tmp_path / "out"
828
+ r = _hbk(tmp_path, root, "get", "/db_staging/firebird/raw.bak", out, password=pw)
829
+ assert r.returncode == 0, r.stdout + r.stderr
830
+ assert (out / "db_staging/firebird/raw.bak").read_bytes() == b"".join(body)
831
+
832
+
811
833
  GLOB = "/db_staging/firebird/*.gbk"
812
834
  DB_ONE = "db_staging/firebird/db one.gbk"
813
835
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes