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.
- {hbkit-0.4.4 → hbkit-0.4.5}/PKG-INFO +1 -1
- {hbkit-0.4.4 → hbkit-0.4.5}/pyproject.toml +1 -1
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/__init__.py +1 -1
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/archive.py +26 -13
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/cli.py +17 -1
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/doctor.py +1 -1
- {hbkit-0.4.4 → hbkit-0.4.5}/tests/test_hbkit.py +30 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/.gitignore +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/FORMAT.md +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/LICENSE +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/README.md +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/packaging/README.md +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/crypto.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/index.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/manifest.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/mount.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/runner.py +0 -0
- {hbkit-0.4.4 → hbkit-0.4.5}/src/hbkit/tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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
|