hbkit 0.4.2__tar.gz → 0.4.4__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.
Potentially problematic release.
This version of hbkit might be problematic. Click here for more details.
- {hbkit-0.4.2 → hbkit-0.4.4}/.gitignore +3 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/PKG-INFO +19 -6
- {hbkit-0.4.2 → hbkit-0.4.4}/README.md +17 -4
- hbkit-0.4.4/packaging/README.md +59 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/pyproject.toml +1 -1
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/__init__.py +1 -1
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/archive.py +10 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/cli.py +7 -2
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/doctor.py +1 -1
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/runner.py +6 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/tests/test_hbkit.py +73 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/FORMAT.md +0 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/LICENSE +0 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/crypto.py +0 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/index.py +0 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/manifest.py +0 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/src/hbkit/mount.py +0 -0
- {hbkit-0.4.2 → hbkit-0.4.4}/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.4
|
|
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
|
|
@@ -40,8 +40,7 @@ a tree, and pull out what you want. Works headless on Linux and macOS, including
|
|
|
40
40
|
own Hyper Backup Explorer is awkward or unavailable.
|
|
41
41
|
|
|
42
42
|
```sh
|
|
43
|
-
brew install
|
|
44
|
-
pip install hbkit
|
|
43
|
+
brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
|
|
45
44
|
|
|
46
45
|
hbk /Volumes/Backup doctor # can this archive be recovered?
|
|
47
46
|
hbk /Volumes/Backup doctor -p secret # encrypted? add a password
|
|
@@ -49,8 +48,20 @@ hbk-tui /Volumes/Backup # browse and select interactively
|
|
|
49
48
|
hbk /Volumes/Backup get "/Photos/*" ~/restore
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
### Other ways to install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install hbkit # any platform; also needs liblz4 (see below)
|
|
55
|
+
pipx install hbkit # same, kept in its own environment
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Arch users can build from [`packaging/aur/PKGBUILD`](packaging/aur/PKGBUILD); a nixpkgs
|
|
59
|
+
derivation lives in [`packaging/nix/package.nix`](packaging/nix/package.nix).
|
|
60
|
+
|
|
61
|
+
`liblz4` is a runtime requirement — chunks are raw LZ4 blocks, loaded with `dlopen`, so a
|
|
62
|
+
missing library only shows up when you extract something. The Homebrew formula installs it
|
|
63
|
+
for you. Otherwise: `brew install lz4`, `sudo apt install liblz4-1`, or
|
|
64
|
+
`sudo pacman -S lz4`. Set `HBK_LZ4` if yours lives somewhere unusual.
|
|
54
65
|
|
|
55
66
|
---
|
|
56
67
|
|
|
@@ -277,6 +288,8 @@ Read this before trusting it with the only copy of anything.
|
|
|
277
288
|
bytes, which is the one thing a recovery tool must never do.
|
|
278
289
|
- **Whole-file dedup** (`off_virtual_file = -1`, files living in `Pool/file_pool`) is not
|
|
279
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.
|
|
280
293
|
|
|
281
294
|
## The format
|
|
282
295
|
|
|
@@ -11,8 +11,7 @@ a tree, and pull out what you want. Works headless on Linux and macOS, including
|
|
|
11
11
|
own Hyper Backup Explorer is awkward or unavailable.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
brew install
|
|
15
|
-
pip install hbkit
|
|
14
|
+
brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
|
|
16
15
|
|
|
17
16
|
hbk /Volumes/Backup doctor # can this archive be recovered?
|
|
18
17
|
hbk /Volumes/Backup doctor -p secret # encrypted? add a password
|
|
@@ -20,8 +19,20 @@ hbk-tui /Volumes/Backup # browse and select interactively
|
|
|
20
19
|
hbk /Volumes/Backup get "/Photos/*" ~/restore
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
### Other ways to install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install hbkit # any platform; also needs liblz4 (see below)
|
|
26
|
+
pipx install hbkit # same, kept in its own environment
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Arch users can build from [`packaging/aur/PKGBUILD`](packaging/aur/PKGBUILD); a nixpkgs
|
|
30
|
+
derivation lives in [`packaging/nix/package.nix`](packaging/nix/package.nix).
|
|
31
|
+
|
|
32
|
+
`liblz4` is a runtime requirement — chunks are raw LZ4 blocks, loaded with `dlopen`, so a
|
|
33
|
+
missing library only shows up when you extract something. The Homebrew formula installs it
|
|
34
|
+
for you. Otherwise: `brew install lz4`, `sudo apt install liblz4-1`, or
|
|
35
|
+
`sudo pacman -S lz4`. Set `HBK_LZ4` if yours lives somewhere unusual.
|
|
25
36
|
|
|
26
37
|
---
|
|
27
38
|
|
|
@@ -248,6 +259,8 @@ Read this before trusting it with the only copy of anything.
|
|
|
248
259
|
bytes, which is the one thing a recovery tool must never do.
|
|
249
260
|
- **Whole-file dedup** (`off_virtual_file = -1`, files living in `Pool/file_pool`) is not
|
|
250
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.
|
|
251
264
|
|
|
252
265
|
## The format
|
|
253
266
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Packaging
|
|
2
|
+
|
|
3
|
+
Recipes for package repositories that keep their definitions outside this repo. Each one
|
|
4
|
+
pins a released sdist from PyPI, so bumping a version means changing the version string and
|
|
5
|
+
the hash, nothing else.
|
|
6
|
+
|
|
7
|
+
| Target | Files here | Lives at |
|
|
8
|
+
| ------ | ---------- | -------- |
|
|
9
|
+
| Homebrew | — | [YordiLorenzo/homebrew-tap](https://github.com/YordiLorenzo/homebrew-tap) |
|
|
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` ([PR #550225](https://github.com/NixOS/nixpkgs/pull/550225)) |
|
|
12
|
+
|
|
13
|
+
## A note on liblz4
|
|
14
|
+
|
|
15
|
+
hbkit `dlopen()`s liblz4 rather than linking it, so a package that forgets the dependency
|
|
16
|
+
still builds and installs cleanly, then fails the first time someone extracts a file. Every
|
|
17
|
+
recipe here must declare it, and should ideally set `HBK_LZ4` to an absolute path so the
|
|
18
|
+
library resolves regardless of the loader search path.
|
|
19
|
+
|
|
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.
|
|
28
|
+
|
|
29
|
+
## AUR
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
git clone ssh://aur@aur.archlinux.org/hbkit.git
|
|
33
|
+
cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO hbkit/
|
|
34
|
+
cd hbkit && git add PKGBUILD .SRCINFO && git commit -m "hbkit 0.4.3" && git push
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`.SRCINFO` must match `PKGBUILD`; regenerate it on Arch with `makepkg --printsrcinfo >
|
|
38
|
+
.SRCINFO`. The AUR rejects a push where the two disagree.
|
|
39
|
+
|
|
40
|
+
## nixpkgs
|
|
41
|
+
|
|
42
|
+
`nix/package.nix` goes to `pkgs/by-name/hb/hbkit/package.nix` in a nixpkgs fork. It
|
|
43
|
+
references a maintainer that must exist first, so `maintainers/maintainer-list.nix` needs:
|
|
44
|
+
|
|
45
|
+
```nix
|
|
46
|
+
yordilorenzo = {
|
|
47
|
+
email = "yordilorenzo@gmail.com";
|
|
48
|
+
github = "YordiLorenzo";
|
|
49
|
+
githubId = 7012112;
|
|
50
|
+
name = "Yordi de Kleijn";
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Build it before opening the PR:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
nix-build -A hbkit
|
|
58
|
+
nix run -f . hbkit -- --version
|
|
59
|
+
```
|
|
@@ -210,6 +210,16 @@ class _LazyCats(dict):
|
|
|
210
210
|
def values(self):
|
|
211
211
|
return [v for v in dict.values(self)]
|
|
212
212
|
|
|
213
|
+
@property
|
|
214
|
+
def known(self) -> list[int]:
|
|
215
|
+
"""Every shard id the archive has, opened or not.
|
|
216
|
+
|
|
217
|
+
Iterating the dict yields only what something has already touched, so a
|
|
218
|
+
caller that just wants to *describe* the archive - `doctor` - has to ask
|
|
219
|
+
for this instead, or it reports an archive with shards as having none.
|
|
220
|
+
"""
|
|
221
|
+
return sorted(self._dirs)
|
|
222
|
+
|
|
213
223
|
|
|
214
224
|
def is_archive(d: str) -> bool:
|
|
215
225
|
return os.path.isdir(os.path.join(d, "Pool")) and os.path.isdir(os.path.join(d, "Config"))
|
|
@@ -87,12 +87,17 @@ def all_files(db):
|
|
|
87
87
|
""")
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def resolve_password(password):
|
|
91
|
+
"""-p wins, then HBK_PASSWORD. None means "prompt if the archive turns out to need it"."""
|
|
92
|
+
return password or os.environ.get("HBK_PASSWORD")
|
|
93
|
+
|
|
94
|
+
|
|
90
95
|
def open_archive(archive, password=None):
|
|
91
96
|
"""Open, prompting for a password only if the archive turns out to be encrypted."""
|
|
92
97
|
try:
|
|
93
98
|
return hbk.Archive(archive, password=password)
|
|
94
99
|
except hbk.NeedPassword:
|
|
95
|
-
pw = password
|
|
100
|
+
pw = resolve_password(password)
|
|
96
101
|
if not pw:
|
|
97
102
|
if not sys.stdin.isatty():
|
|
98
103
|
sys.exit("archive is encrypted - pass --password or set HBK_PASSWORD")
|
|
@@ -226,7 +231,7 @@ def main() -> int:
|
|
|
226
231
|
|
|
227
232
|
if cmd == "doctor":
|
|
228
233
|
from . import doctor
|
|
229
|
-
r = doctor.diagnose(archive, password=password)
|
|
234
|
+
r = doctor.diagnose(archive, password=resolve_password(password))
|
|
230
235
|
print(doctor.render(r))
|
|
231
236
|
return 0 if (r.ok and not r.blockers) else 1
|
|
232
237
|
|
|
@@ -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
|
|
|
@@ -140,6 +140,12 @@ class Runner:
|
|
|
140
140
|
json.dump({"root": self.root, "dest": self.dest, "verify": self.verify,
|
|
141
141
|
"check": self.check, "strict": self.strict, "items": slice_}, fh)
|
|
142
142
|
env = dict(os.environ)
|
|
143
|
+
# sys.executable is the *unwrapped* interpreter. Where hbkit reaches
|
|
144
|
+
# sys.path through a wrapper script rather than site-packages - a Nix
|
|
145
|
+
# build, a PYTHONPATH-based distro package - that interpreter cannot
|
|
146
|
+
# import hbkit, and every worker dies before extracting a byte. Hand
|
|
147
|
+
# the parent's own path down so the child resolves what the parent did.
|
|
148
|
+
env["PYTHONPATH"] = os.pathsep.join(p for p in sys.path if p)
|
|
143
149
|
if self.password is not None: # env, not the job file - no secret on disk
|
|
144
150
|
env["HBK_PASSWORD"] = self.password
|
|
145
151
|
p = subprocess.Popen(
|
|
@@ -415,3 +415,76 @@ def test_cache_dir_prefers_new_name_but_honours_the_old_one(tmp_path, monkeypatc
|
|
|
415
415
|
assert hbki._default_cache().endswith("/.cache/hbk-recovery") # legacy honoured
|
|
416
416
|
(home / ".cache" / "hbkit").mkdir(parents=True)
|
|
417
417
|
assert hbki._default_cache().endswith("/.cache/hbkit") # new wins once present
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_runner_hands_its_import_path_to_workers(tmp_path, monkeypatch):
|
|
421
|
+
"""Workers are spawned as `sys.executable -m hbkit.runner`, and sys.executable is the
|
|
422
|
+
*unwrapped* interpreter. Where hbkit reaches sys.path through a wrapper script instead
|
|
423
|
+
of site-packages - a Nix build, a PYTHONPATH-based distro package - that interpreter
|
|
424
|
+
cannot import hbkit and every worker dies before extracting a byte. Caught by packaging
|
|
425
|
+
for Nix, invisible to a pip install."""
|
|
426
|
+
import io
|
|
427
|
+
|
|
428
|
+
from hbkit import runner as hbk_runner
|
|
429
|
+
|
|
430
|
+
seen = {}
|
|
431
|
+
|
|
432
|
+
class FakePopen:
|
|
433
|
+
def __init__(self, cmd, env=None, **kw):
|
|
434
|
+
seen["cmd"], seen["env"] = cmd, env
|
|
435
|
+
self.stdout, self.stderr = io.StringIO(""), io.StringIO("")
|
|
436
|
+
|
|
437
|
+
def poll(self):
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
def wait(self):
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
monkeypatch.setattr(hbk_runner.subprocess, "Popen", FakePopen)
|
|
444
|
+
r = hbk_runner.Runner("/archive", str(tmp_path), [("/a", 0, 1, 0)], n_workers=1)
|
|
445
|
+
r.start()
|
|
446
|
+
r.cleanup()
|
|
447
|
+
|
|
448
|
+
assert seen["cmd"][1:3] == ["-m", "hbkit.runner"]
|
|
449
|
+
paths = seen["env"]["PYTHONPATH"].split(os.pathsep)
|
|
450
|
+
assert any(os.path.isdir(os.path.join(p, "hbkit")) for p in paths), \
|
|
451
|
+
f"no path in PYTHONPATH provides hbkit: {paths}"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@pytest.mark.parametrize(("flag", "env", "want"), [
|
|
455
|
+
(None, "env", "env"), # the bug: doctor ignored HBK_PASSWORD entirely
|
|
456
|
+
("flag", "env", "flag"), # an explicit -p still wins
|
|
457
|
+
(None, None, None), # nothing set: leave it to the prompt
|
|
458
|
+
])
|
|
459
|
+
def test_doctor_honours_the_password_environment_variable(monkeypatch, flag, env, want):
|
|
460
|
+
"""`-p` and HBK_PASSWORD are documented as equivalent, but doctor forwarded only `-p`,
|
|
461
|
+
so `HBK_PASSWORD=... hbk <archive> doctor` called a readable encrypted archive
|
|
462
|
+
unrecoverable."""
|
|
463
|
+
from hbkit import cli, doctor
|
|
464
|
+
|
|
465
|
+
seen = {}
|
|
466
|
+
|
|
467
|
+
class Result:
|
|
468
|
+
ok, blockers = True, []
|
|
469
|
+
|
|
470
|
+
monkeypatch.setattr(doctor, "diagnose",
|
|
471
|
+
lambda a, password=None: (seen.update(pw=password), Result())[1])
|
|
472
|
+
monkeypatch.setattr(doctor, "render", lambda r: "")
|
|
473
|
+
monkeypatch.delenv("HBK_PASSWORD", raising=False)
|
|
474
|
+
if env:
|
|
475
|
+
monkeypatch.setenv("HBK_PASSWORD", env)
|
|
476
|
+
|
|
477
|
+
argv = ["hbk", "/some/archive", "doctor"] + (["-p", flag] if flag else [])
|
|
478
|
+
monkeypatch.setattr(sys, "argv", argv)
|
|
479
|
+
assert cli.main() == 0
|
|
480
|
+
assert seen["pw"] == want
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_lazycats_lists_shards_before_any_are_opened():
|
|
484
|
+
"""`doctor` describes an archive without extracting from it, so nothing has opened a
|
|
485
|
+
file_chunk shard by the time it prints them. Iterating the dict reported an empty list
|
|
486
|
+
and made a healthy archive look malformed."""
|
|
487
|
+
cats = hbk._LazyCats({4: "/nonexistent/file_chunk4.index",
|
|
488
|
+
0: "/nonexistent/file_chunk0.index"})
|
|
489
|
+
assert cats.known == [0, 4]
|
|
490
|
+
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
|