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.
@@ -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.3
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
- The Homebrew formula wraps both entry points with `HBK_LZ4` for exactly this reason. Arch
21
- and Nix put their libraries on the default search path, so a plain dependency is enough.
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.2" && git push
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 >
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.3"
7
+ version = "0.4.5"
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.3"
3
+ __version__ = "0.4.5"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -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. If the assumption were ever wrong, a
122
- short read surfaces as a chunk MD5 failure - loudly - never as silent corruption.
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
- self.files = [found[k][1] for k in sorted(found)]
140
- n = len(self.files)
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] * (n - 1) + [last]
143
- self.starts = [i * SHARD_SIZE for i in range(n)]
144
- self.total = (n - 1) * SHARD_SIZE + last
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 and i < len(self.files):
164
- st = self.starts[i]
165
- take = min(n, st + self.sizes[i] - off)
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(i)
181
+ fh = self._fh.get(pos)
169
182
  if fh is None:
170
- fh = self._fh[i] = open(os.path.join(self.d, self.files[i]), "rb",
171
- buffering=INDEX_BUF)
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
- r = doctor.diagnose(archive, password=resolve_password(password))
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 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
 
@@ -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