hbkit 0.4.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hbkit
3
- Version: 0.4.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.4"
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.4"
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
@@ -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
 
@@ -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):
@@ -480,6 +492,24 @@ def test_doctor_honours_the_password_environment_variable(monkeypatch, flag, env
480
492
  assert seen["pw"] == want
481
493
 
482
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
+
483
513
  def test_lazycats_lists_shards_before_any_are_opened():
484
514
  """`doctor` describes an archive without extracting from it, so nothing has opened a
485
515
  file_chunk shard by the time it prints them. Iterating the dict reported an empty list
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