hbkit 0.4.3__tar.gz → 0.4.5__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.3 → hbkit-0.4.5}/.gitignore +3 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/PKG-INFO +4 -2
- {hbkit-0.4.3 → hbkit-0.4.5}/README.md +2 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/packaging/README.md +10 -4
- {hbkit-0.4.3 → hbkit-0.4.5}/pyproject.toml +1 -1
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/__init__.py +1 -1
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/archive.py +36 -13
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/cli.py +17 -1
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/doctor.py +2 -2
- {hbkit-0.4.3 → hbkit-0.4.5}/tests/test_hbkit.py +40 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/FORMAT.md +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/LICENSE +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/crypto.py +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/index.py +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/manifest.py +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/mount.py +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/runner.py +0 -0
- {hbkit-0.4.3 → hbkit-0.4.5}/src/hbkit/tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hbkit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
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
|
|
@@ -288,6 +288,8 @@ Read this before trusting it with the only copy of anything.
|
|
|
288
288
|
bytes, which is the one thing a recovery tool must never do.
|
|
289
289
|
- **Whole-file dedup** (`off_virtual_file = -1`, files living in `Pool/file_pool`) is not
|
|
290
290
|
decoded. One file in 501,278 in the reference archive.
|
|
291
|
+
- **Windows is untested for extraction.** CI installs the package and checks that it
|
|
292
|
+
imports, but no archive has been rebuilt on Windows. Linux and macOS are verified.
|
|
291
293
|
|
|
292
294
|
## The format
|
|
293
295
|
|
|
@@ -259,6 +259,8 @@ Read this before trusting it with the only copy of anything.
|
|
|
259
259
|
bytes, which is the one thing a recovery tool must never do.
|
|
260
260
|
- **Whole-file dedup** (`off_virtual_file = -1`, files living in `Pool/file_pool`) is not
|
|
261
261
|
decoded. One file in 501,278 in the reference archive.
|
|
262
|
+
- **Windows is untested for extraction.** CI installs the package and checks that it
|
|
263
|
+
imports, but no archive has been rebuilt on Windows. Linux and macOS are verified.
|
|
262
264
|
|
|
263
265
|
## The format
|
|
264
266
|
|
|
@@ -8,7 +8,7 @@ the hash, nothing else.
|
|
|
8
8
|
| ------ | ---------- | -------- |
|
|
9
9
|
| Homebrew | — | [YordiLorenzo/homebrew-tap](https://github.com/YordiLorenzo/homebrew-tap) |
|
|
10
10
|
| AUR | `aur/PKGBUILD`, `aur/.SRCINFO` | `ssh://aur@aur.archlinux.org/hbkit.git` |
|
|
11
|
-
| nixpkgs | `nix/package.nix` | `pkgs/by-name/hb/hbkit/package.nix` |
|
|
11
|
+
| nixpkgs | `nix/package.nix` | `pkgs/by-name/hb/hbkit/package.nix` ([PR #550225](https://github.com/NixOS/nixpkgs/pull/550225)) |
|
|
12
12
|
|
|
13
13
|
## A note on liblz4
|
|
14
14
|
|
|
@@ -17,15 +17,21 @@ still builds and installs cleanly, then fails the first time someone extracts a
|
|
|
17
17
|
recipe here must declare it, and should ideally set `HBK_LZ4` to an absolute path so the
|
|
18
18
|
library resolves regardless of the loader search path.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
Homebrew and Nix both wrap the two entry points with `HBK_LZ4` for exactly this reason —
|
|
21
|
+
Nix has no global library directory at all, so the name alone resolves to nothing. Arch
|
|
22
|
+
puts `liblz4.so.1` on the default loader path, so a plain dependency is enough there.
|
|
23
|
+
|
|
24
|
+
The same shape caused a worse bug: workers are spawned as `sys.executable -m hbkit.runner`,
|
|
25
|
+
and a wrapper script leaves `sys.executable` pointing at an interpreter that cannot import
|
|
26
|
+
hbkit. Fixed in 0.4.3 by passing the parent's `sys.path` down. Any recipe that wraps the
|
|
27
|
+
entry points needs at least 0.4.3.
|
|
22
28
|
|
|
23
29
|
## AUR
|
|
24
30
|
|
|
25
31
|
```sh
|
|
26
32
|
git clone ssh://aur@aur.archlinux.org/hbkit.git
|
|
27
33
|
cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO hbkit/
|
|
28
|
-
cd hbkit && git add PKGBUILD .SRCINFO && git commit -m "hbkit 0.4.
|
|
34
|
+
cd hbkit && git add PKGBUILD .SRCINFO && git commit -m "hbkit 0.4.3" && git push
|
|
29
35
|
```
|
|
30
36
|
|
|
31
37
|
`.SRCINFO` must match `PKGBUILD`; regenerate it on Arch with `makepkg --printsrcinfo >
|
|
@@ -118,8 +118,17 @@ class Cat:
|
|
|
118
118
|
Shards are a fixed SHARD_SIZE except the last, so offsets are computed rather than
|
|
119
119
|
measured. That matters enormously on network mounts: stat-ing every shard cost ~2,600
|
|
120
120
|
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
|
-
|
|
121
|
+
listing plus a single stat of the final shard.
|
|
122
|
+
|
|
123
|
+
The numbering can have GAPS: a real archive was missing 7.idx from one family and
|
|
124
|
+
27.idx from another. A shard's logical position therefore comes from its filename,
|
|
125
|
+
never from its position in the sorted list - compacting the list slides every shard
|
|
126
|
+
after a gap 8 MiB early, and the wrong bytes still parse as plausible records. That
|
|
127
|
+
was issue #1: valid chunk lists read as garbage for any file stored past a gap.
|
|
128
|
+
|
|
129
|
+
Reading inside a gap returns nothing rather than the neighbouring shard's bytes, so
|
|
130
|
+
a hole surfaces as a short read and then a loud failure upstream, never as silent
|
|
131
|
+
corruption.
|
|
123
132
|
"""
|
|
124
133
|
|
|
125
134
|
def __init__(self, d: str):
|
|
@@ -136,12 +145,13 @@ class Cat:
|
|
|
136
145
|
found[n] = (gen, f)
|
|
137
146
|
if not found:
|
|
138
147
|
raise FileNotFoundError(f"no .idx shards in {d}")
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
shard_ids = sorted(found)
|
|
149
|
+
self.files = [found[k][1] for k in shard_ids]
|
|
141
150
|
last = os.path.getsize(os.path.join(d, self.files[-1]))
|
|
142
|
-
self.sizes = [SHARD_SIZE] * (
|
|
143
|
-
self.starts = [
|
|
144
|
-
self.total =
|
|
151
|
+
self.sizes = [SHARD_SIZE] * (len(self.files) - 1) + [last]
|
|
152
|
+
self.starts = [k * SHARD_SIZE for k in shard_ids]
|
|
153
|
+
self.total = self.starts[-1] + last
|
|
154
|
+
self._positions = dict(zip(shard_ids, range(len(shard_ids))))
|
|
145
155
|
self._fh: dict[int, object] = {}
|
|
146
156
|
# Shard 0 carries a 64-byte header that declares the record size, so readers do
|
|
147
157
|
# not have to hardcode per-DSM-version layouts.
|
|
@@ -160,15 +170,18 @@ class Cat:
|
|
|
160
170
|
"""Read n bytes at logical offset off, spanning shards as needed."""
|
|
161
171
|
out = b""
|
|
162
172
|
i = off // SHARD_SIZE
|
|
163
|
-
while n > 0
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
while n > 0:
|
|
174
|
+
pos = self._positions.get(i)
|
|
175
|
+
if pos is None:
|
|
176
|
+
break
|
|
177
|
+
st = self.starts[pos]
|
|
178
|
+
take = min(n, st + self.sizes[pos] - off)
|
|
166
179
|
if take <= 0:
|
|
167
180
|
break
|
|
168
|
-
fh = self._fh.get(
|
|
181
|
+
fh = self._fh.get(pos)
|
|
169
182
|
if fh is None:
|
|
170
|
-
fh = self._fh[
|
|
171
|
-
|
|
183
|
+
fh = self._fh[pos] = open(os.path.join(self.d, self.files[pos]), "rb",
|
|
184
|
+
buffering=INDEX_BUF)
|
|
172
185
|
fh.seek(off - st)
|
|
173
186
|
got = fh.read(take)
|
|
174
187
|
out += got
|
|
@@ -210,6 +223,16 @@ class _LazyCats(dict):
|
|
|
210
223
|
def values(self):
|
|
211
224
|
return [v for v in dict.values(self)]
|
|
212
225
|
|
|
226
|
+
@property
|
|
227
|
+
def known(self) -> list[int]:
|
|
228
|
+
"""Every shard id the archive has, opened or not.
|
|
229
|
+
|
|
230
|
+
Iterating the dict yields only what something has already touched, so a
|
|
231
|
+
caller that just wants to *describe* the archive - `doctor` - has to ask
|
|
232
|
+
for this instead, or it reports an archive with shards as having none.
|
|
233
|
+
"""
|
|
234
|
+
return sorted(self._dirs)
|
|
235
|
+
|
|
213
236
|
|
|
214
237
|
def is_archive(d: str) -> bool:
|
|
215
238
|
return os.path.isdir(os.path.join(d, "Pool")) and os.path.isdir(os.path.join(d, "Config"))
|
|
@@ -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
|
|
|
@@ -88,7 +88,7 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
88
88
|
r.fact("virtual_file record", f"{arc.vf.record_size} B")
|
|
89
89
|
r.fact("chunk_index record", f"{arc.ci.record_size} B "
|
|
90
90
|
f"({'v3' if arc.ci.record_size == 29 else 'v1/v2'})")
|
|
91
|
-
r.fact("file_chunk shards", ", ".join(str(k) for k in
|
|
91
|
+
r.fact("file_chunk shards", ", ".join(str(k) for k in arc.fc.known))
|
|
92
92
|
r.check("virtual_file layout known", arc.vf.record_size == 56, f"{arc.vf.record_size} B")
|
|
93
93
|
r.check("chunk_index layout known", arc.ci.record_size in (16, 29), f"{arc.ci.record_size} B")
|
|
94
94
|
|
|
@@ -202,7 +202,7 @@ def main() -> int:
|
|
|
202
202
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
203
203
|
ap.add_argument("archive")
|
|
204
204
|
ap.add_argument("-p", "--password", help="for encrypted archives")
|
|
205
|
-
ap.add_argument("-n", "--sample", type=int, default=10,
|
|
205
|
+
ap.add_argument("-n", "-s", "--sample", type=int, default=10,
|
|
206
206
|
help="files to rebuild per share as proof (default 10)")
|
|
207
207
|
a = ap.parse_args()
|
|
208
208
|
r = diagnose(a.archive, a.sample, password=a.password)
|
|
@@ -287,6 +287,18 @@ def test_cat_single_shard_still_works(tmp_path, monkeypatch):
|
|
|
287
287
|
c.close()
|
|
288
288
|
|
|
289
289
|
|
|
290
|
+
def test_cat_preserves_sparse_shard_offsets(tmp_path, monkeypatch):
|
|
291
|
+
from hbkit import archive as A
|
|
292
|
+
blob = _make_shards(tmp_path, [128, 10], 128, monkeypatch)
|
|
293
|
+
(tmp_path / "1.idx.2").rename(tmp_path / "2.idx.2")
|
|
294
|
+
c = A.Cat(str(tmp_path))
|
|
295
|
+
assert c.starts == [0, 256]
|
|
296
|
+
assert c.total == 266
|
|
297
|
+
assert c.read(128, 1) == b""
|
|
298
|
+
assert c.read(256, 10) == blob[128:]
|
|
299
|
+
c.close()
|
|
300
|
+
|
|
301
|
+
|
|
290
302
|
# --------------------------------------------------- rclone mount helper (no network)
|
|
291
303
|
|
|
292
304
|
def test_mount_profiles_pick_the_right_cache_mode(monkeypatch):
|
|
@@ -478,3 +490,31 @@ def test_doctor_honours_the_password_environment_variable(monkeypatch, flag, env
|
|
|
478
490
|
monkeypatch.setattr(sys, "argv", argv)
|
|
479
491
|
assert cli.main() == 0
|
|
480
492
|
assert seen["pw"] == want
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.parametrize("flag", ["-s", "-n", "--sample"])
|
|
496
|
+
def test_doctor_forwards_sample_count(monkeypatch, flag):
|
|
497
|
+
from hbkit import cli, doctor
|
|
498
|
+
|
|
499
|
+
seen = {}
|
|
500
|
+
|
|
501
|
+
class Result:
|
|
502
|
+
ok, blockers = True, []
|
|
503
|
+
|
|
504
|
+
monkeypatch.setattr(doctor, "diagnose",
|
|
505
|
+
lambda a, sample=10, password=None:
|
|
506
|
+
(seen.update(sample=sample), Result())[1])
|
|
507
|
+
monkeypatch.setattr(doctor, "render", lambda r: "")
|
|
508
|
+
monkeypatch.setattr(sys, "argv", ["hbk", "/some/archive", "doctor", flag, "2"])
|
|
509
|
+
assert cli.main() == 0
|
|
510
|
+
assert seen["sample"] == 2
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_lazycats_lists_shards_before_any_are_opened():
|
|
514
|
+
"""`doctor` describes an archive without extracting from it, so nothing has opened a
|
|
515
|
+
file_chunk shard by the time it prints them. Iterating the dict reported an empty list
|
|
516
|
+
and made a healthy archive look malformed."""
|
|
517
|
+
cats = hbk._LazyCats({4: "/nonexistent/file_chunk4.index",
|
|
518
|
+
0: "/nonexistent/file_chunk0.index"})
|
|
519
|
+
assert cats.known == [0, 4]
|
|
520
|
+
assert len(cats) == 0, "listing shards must not open them"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|