hbkit 0.4.6__tar.gz → 0.4.8__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.6 → hbkit-0.4.8}/PKG-INFO +7 -18
- {hbkit-0.4.6 → hbkit-0.4.8}/README.md +6 -17
- hbkit-0.4.8/contrib/art/README.md +22 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/pyproject.toml +1 -1
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/__init__.py +1 -1
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/cli.py +18 -1
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/tui.py +12 -7
- {hbkit-0.4.6 → hbkit-0.4.8}/tests/test_hbkit.py +57 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/.gitignore +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/FORMAT.md +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/LICENSE +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/packaging/README.md +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/archive.py +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/crypto.py +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/doctor.py +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/index.py +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/manifest.py +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/mount.py +0 -0
- {hbkit-0.4.6 → hbkit-0.4.8}/src/hbkit/runner.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.8
|
|
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
|
|
@@ -27,7 +27,9 @@ Provides-Extra: dev
|
|
|
27
27
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
|
|
32
|
+
</p>
|
|
31
33
|
|
|
32
34
|
[](https://pypi.org/project/hbkit/)
|
|
33
35
|
[](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
|
|
@@ -39,6 +41,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
|
|
|
39
41
|
a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
|
|
40
42
|
own Hyper Backup Explorer is awkward or unavailable.
|
|
41
43
|
|
|
44
|
+
Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
|
|
45
|
+
|
|
42
46
|
```sh
|
|
43
47
|
brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
|
|
44
48
|
|
|
@@ -77,22 +81,7 @@ case is a loud failure naming the file.
|
|
|
77
81
|
|
|
78
82
|
## The TUI
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
⭘ Hyper Backup Recovery 17:11:17
|
|
82
|
-
┌──────────────────────────────────────────────────────────────┐ │
|
|
83
|
-
│ search filename… (/) │ │ Selection
|
|
84
|
-
└──────────────────────────────────────────────────────────────┘ │ 197,607 files
|
|
85
|
-
▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
|
|
86
|
-
├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
|
|
87
|
-
├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
|
|
88
|
-
├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
|
|
89
|
-
├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
|
|
90
|
-
├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
|
|
91
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
|
|
92
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
|
|
93
|
-
│ Start recovery
|
|
94
|
-
a All n Clear d Destination r Recover / Search q Quit │
|
|
95
|
-
```
|
|
84
|
+

|
|
96
85
|
|
|
97
86
|
`space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
|
|
98
87
|
`ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
5
|
[](https://pypi.org/project/hbkit/)
|
|
4
6
|
[](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
|
|
@@ -10,6 +12,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
|
|
|
10
12
|
a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
|
|
11
13
|
own Hyper Backup Explorer is awkward or unavailable.
|
|
12
14
|
|
|
15
|
+
Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
|
|
16
|
+
|
|
13
17
|
```sh
|
|
14
18
|
brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
|
|
15
19
|
|
|
@@ -48,22 +52,7 @@ case is a loud failure naming the file.
|
|
|
48
52
|
|
|
49
53
|
## The TUI
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
⭘ Hyper Backup Recovery 17:11:17
|
|
53
|
-
┌──────────────────────────────────────────────────────────────┐ │
|
|
54
|
-
│ search filename… (/) │ │ Selection
|
|
55
|
-
└──────────────────────────────────────────────────────────────┘ │ 197,607 files
|
|
56
|
-
▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
|
|
57
|
-
├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
|
|
58
|
-
├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
|
|
59
|
-
├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
|
|
60
|
-
├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
|
|
61
|
-
├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
|
|
62
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
|
|
63
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
|
|
64
|
-
│ Start recovery
|
|
65
|
-
a All n Clear d Destination r Recover / Search q Quit │
|
|
66
|
-
```
|
|
55
|
+

|
|
67
56
|
|
|
68
57
|
`space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
|
|
69
58
|
`ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Art
|
|
2
|
+
|
|
3
|
+
Every image in `docs/images/` and `site/` is built from the files here, so the screenshots
|
|
4
|
+
can be redone whenever the TUI changes. Run from anywhere:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install -e . fonttools brotli # hbkit itself renders the TUI screenshot
|
|
8
|
+
brew install webp imagemagick # plus Google Chrome, used headless
|
|
9
|
+
contrib/art/render.sh
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| source | builds |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `site/images/icon.svg` | the icon, drawn by hand; everything else derives from it |
|
|
15
|
+
| `logo.py` | `docs/images/logo.svg` and `logo-light.svg`: the icon and the wordmark, in Outfit SemiBold as outlines |
|
|
16
|
+
| `render_tui.py`, `tui.html` | `docs/images/tui.png` and the site's `tui-*.webp`: hbkit's real browser screen over a made-up archive |
|
|
17
|
+
| `banner.html` | `docs/images/banner.png`, the README header |
|
|
18
|
+
| `social.html` | `site/social-preview.png`, the link preview card |
|
|
19
|
+
| `icon.html` | `docs/images/icon-1024.png` |
|
|
20
|
+
|
|
21
|
+
The demo archive in `render_tui.py` is invented: no real backup or personal data goes into
|
|
22
|
+
any image. Outfit is under the SIL Open Font License (`site/fonts/OFL.txt`).
|
|
@@ -92,6 +92,23 @@ def resolve_password(password):
|
|
|
92
92
|
return password or os.environ.get("HBK_PASSWORD")
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def stdin_is_interactive():
|
|
96
|
+
"""True when a password prompt can be answered: stdin is a real terminal/console."""
|
|
97
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
98
|
+
return False
|
|
99
|
+
if os.name != "nt":
|
|
100
|
+
return True
|
|
101
|
+
# `< NUL` is a character device, so isatty() is True; only a real console has a mode
|
|
102
|
+
import ctypes
|
|
103
|
+
import msvcrt
|
|
104
|
+
try:
|
|
105
|
+
h = msvcrt.get_osfhandle(sys.stdin.fileno())
|
|
106
|
+
except OSError:
|
|
107
|
+
return False
|
|
108
|
+
mode = ctypes.c_uint32()
|
|
109
|
+
return bool(ctypes.windll.kernel32.GetConsoleMode(ctypes.c_void_p(h), ctypes.byref(mode)))
|
|
110
|
+
|
|
111
|
+
|
|
95
112
|
def open_archive(archive, password=None):
|
|
96
113
|
"""Open, prompting for a password only if the archive turns out to be encrypted."""
|
|
97
114
|
try:
|
|
@@ -99,7 +116,7 @@ def open_archive(archive, password=None):
|
|
|
99
116
|
except hbk.NeedPassword:
|
|
100
117
|
pw = resolve_password(password)
|
|
101
118
|
if not pw:
|
|
102
|
-
if not
|
|
119
|
+
if not stdin_is_interactive():
|
|
103
120
|
sys.exit("archive is encrypted - pass --password or set HBK_PASSWORD")
|
|
104
121
|
pw = getpass.getpass("archive password: ")
|
|
105
122
|
try:
|
|
@@ -526,14 +526,19 @@ class BrowserScreen(Screen):
|
|
|
526
526
|
(f"{n}", "bold"), (" item(s)", "dim")))
|
|
527
527
|
self.update_space()
|
|
528
528
|
|
|
529
|
+
def dest(self) -> str:
|
|
530
|
+
"""The destination as typed, with ~ and relative paths resolved as a shell would."""
|
|
531
|
+
return os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
|
|
532
|
+
|
|
529
533
|
def update_space(self):
|
|
530
|
-
probe = self.query_one("#dest", Input).value
|
|
531
|
-
while probe and not os.path.exists(probe):
|
|
532
|
-
probe = os.path.dirname(probe)
|
|
533
534
|
w = self.query_one("#space-info", Static)
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
535
|
+
probe = self.dest()
|
|
536
|
+
while not os.path.exists(probe):
|
|
537
|
+
parent = os.path.dirname(probe)
|
|
538
|
+
if parent == probe: # a drive that isn't there
|
|
539
|
+
w.update(Text("invalid path", style="bold red"))
|
|
540
|
+
return
|
|
541
|
+
probe = parent
|
|
537
542
|
free = shutil.disk_usage(probe).free
|
|
538
543
|
_, need, _ = self.sel.totals()
|
|
539
544
|
if need > free:
|
|
@@ -595,7 +600,7 @@ class BrowserScreen(Screen):
|
|
|
595
600
|
if not self.sel.chosen:
|
|
596
601
|
self.notify("select something first — space to tick", severity="warning")
|
|
597
602
|
return
|
|
598
|
-
dest =
|
|
603
|
+
dest = self.dest()
|
|
599
604
|
files = self.sel.files()
|
|
600
605
|
if not files:
|
|
601
606
|
self.notify("selection contains no extractable files", severity="error")
|
|
@@ -303,6 +303,26 @@ def test_verify_rejects_options_it_does_not_take():
|
|
|
303
303
|
assert "unknown option --version" in r.stderr
|
|
304
304
|
|
|
305
305
|
|
|
306
|
+
def test_encrypted_archive_with_null_stdin_fails_instead_of_prompting(tmp_path):
|
|
307
|
+
"""On Windows `< NUL` is a character device, so isatty() says True and getpass used
|
|
308
|
+
to wait on the console forever. Only enough of an archive to reach NeedPassword."""
|
|
309
|
+
import struct
|
|
310
|
+
header = struct.pack(">16I", hbk.MAGIC, 0, 0, 0, 56, *([0] * 11))
|
|
311
|
+
root = tmp_path / "enc.hbk"
|
|
312
|
+
for d in ("Config/virtual_file.index", "Pool/chunk_index",
|
|
313
|
+
"Config/file_chunk1.index", "Pool/0"):
|
|
314
|
+
(root / d).mkdir(parents=True)
|
|
315
|
+
(root / "Config/virtual_file.index/0.idx").write_bytes(header)
|
|
316
|
+
(root / "Pool/chunk_index/0.idx").write_bytes(header)
|
|
317
|
+
(root / "_Syno_TaskConfig").write_text('enable_data_encrypt="true"\n')
|
|
318
|
+
env = {k: v for k, v in os.environ.items() if k != "HBK_PASSWORD"}
|
|
319
|
+
r = subprocess.run([sys.executable, "-m", "hbkit.cli", str(root), "list"],
|
|
320
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
321
|
+
env=env, timeout=30, check=False)
|
|
322
|
+
assert r.returncode != 0, r.stderr
|
|
323
|
+
assert "pass --password or set HBK_PASSWORD" in r.stderr
|
|
324
|
+
|
|
325
|
+
|
|
306
326
|
# ------------------------------------------------- shard maths (no archive needed)
|
|
307
327
|
|
|
308
328
|
def _make_shards(tmp_path, sizes, shard_size, monkeypatch):
|
|
@@ -590,3 +610,40 @@ def test_lazycats_lists_shards_before_any_are_opened():
|
|
|
590
610
|
0: "/nonexistent/file_chunk0.index"})
|
|
591
611
|
assert cats.known == [0, 4]
|
|
592
612
|
assert len(cats) == 0, "listing shards must not open them"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@pytest.mark.parametrize("typed", ["~/hbkit-test-not-there/restore", "restore/here"])
|
|
616
|
+
def test_tui_free_space_follows_home_and_relative_destinations(tmp_path, monkeypatch, typed):
|
|
617
|
+
"""Typing ~/restore showed "invalid path" under the destination, though the restore
|
|
618
|
+
itself expanded ~ fine. A relative path did the same."""
|
|
619
|
+
import asyncio
|
|
620
|
+
import sqlite3
|
|
621
|
+
from types import SimpleNamespace
|
|
622
|
+
|
|
623
|
+
from hbkit import tui
|
|
624
|
+
|
|
625
|
+
db = tmp_path / "index.db"
|
|
626
|
+
con = sqlite3.connect(db)
|
|
627
|
+
con.execute("CREATE TABLE node(id INTEGER PRIMARY KEY, parent INTEGER, name TEXT, "
|
|
628
|
+
"isdir INTEGER, size INTEGER, nfiles INTEGER, ovf INTEGER, mtime INTEGER, "
|
|
629
|
+
"share TEXT)")
|
|
630
|
+
con.execute("INSERT INTO node VALUES (1, NULL, 'share', 1, 10, 1, NULL, 0, 'share')")
|
|
631
|
+
con.commit()
|
|
632
|
+
con.close()
|
|
633
|
+
monkeypatch.chdir(tmp_path)
|
|
634
|
+
|
|
635
|
+
class App(tui.HBKApp):
|
|
636
|
+
def on_mount(self, event):
|
|
637
|
+
event.prevent_default() # skip the archive picker
|
|
638
|
+
self.push_screen(tui.BrowserScreen(SimpleNamespace(root="demo"), tui.Index(str(db))))
|
|
639
|
+
|
|
640
|
+
async def space_line():
|
|
641
|
+
app = App(1)
|
|
642
|
+
async with app.run_test() as pilot:
|
|
643
|
+
await pilot.pause()
|
|
644
|
+
app.screen.query_one("#dest", tui.Input).value = typed
|
|
645
|
+
await pilot.pause()
|
|
646
|
+
return str(app.screen.query_one("#space-info", tui.Static).render())
|
|
647
|
+
|
|
648
|
+
line = asyncio.run(space_line())
|
|
649
|
+
assert "free" in line and "invalid" not in line, line
|
|
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
|