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.
- {hbkit-0.5.2 → hbkit-0.5.3}/CHANGELOG.md +10 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/FORMAT.md +6 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/PKG-INFO +6 -6
- {hbkit-0.5.2 → hbkit-0.5.3}/README.md +3 -3
- {hbkit-0.5.2 → hbkit-0.5.3}/pyproject.toml +3 -3
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/__init__.py +1 -1
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/archive.py +32 -12
- {hbkit-0.5.2 → hbkit-0.5.3}/tests/synth.py +7 -4
- {hbkit-0.5.2 → hbkit-0.5.3}/tests/test_hbkit.py +22 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/.gitignore +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/LICENSE +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/contrib/art/README.md +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/packaging/README.md +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/cli.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/crypto.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/doctor.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/index.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/manifest.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/mount.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/runner.py +0 -0
- {hbkit-0.5.2 → hbkit-0.5.3}/src/hbkit/tui.py +0 -0
|
@@ -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.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: Recover files from Synology Hyper Backup (.hbk) archives without Synology software
|
|
5
|
-
Project-URL: Homepage, https://
|
|
6
|
-
Project-URL: Documentation, https://
|
|
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: **[
|
|
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://
|
|
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://
|
|
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: **[
|
|
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://
|
|
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://
|
|
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.
|
|
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://
|
|
38
|
-
Documentation = "https://
|
|
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"
|
|
@@ -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
|
|
131
|
-
"""
|
|
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
|
-
|
|
142
|
+
yield dst.raw
|
|
135
143
|
try:
|
|
136
|
-
|
|
144
|
+
z = zlib.decompress(raw)
|
|
137
145
|
except zlib.error:
|
|
138
|
-
|
|
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 =
|
|
646
|
+
data = _decode(raw, ulen, md5, verify, self._stored_first)
|
|
626
647
|
if data is None:
|
|
627
|
-
raise ValueError(f"
|
|
628
|
-
|
|
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 =
|
|
644
|
-
if data is not None
|
|
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=
|
|
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
|