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.

@@ -8,3 +8,6 @@ build/
8
8
  .pytest_cache/
9
9
  .DS_Store
10
10
  *.part
11
+
12
+ # Local-only fixtures: hashes of source files that only exist on this machine.
13
+ .local/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hbkit
3
- Version: 0.4.2
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 lz4 # or: sudo apt install liblz4-1
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
- `liblz4` is a runtime requirement — chunks are raw LZ4 blocks. It is present on most
53
- systems already; set `HBK_LZ4` if yours lives somewhere unusual.
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 lz4 # or: sudo apt install liblz4-1
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
- `liblz4` is a runtime requirement — chunks are raw LZ4 blocks. It is present on most
24
- systems already; set `HBK_LZ4` if yours lives somewhere unusual.
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
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.2"
7
+ version = "0.4.4"
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.2"
3
+ __version__ = "0.4.4"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -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 or os.environ.get("HBK_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 sorted(arc.fc)))
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