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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hbkit
3
- Version: 0.4.6
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
- # hbkit
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
  [![PyPI](https://img.shields.io/pypi/v/hbkit)](https://pypi.org/project/hbkit/)
33
35
  [![CI](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml/badge.svg)](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
+ ![hbkit's terminal browser: a backup's shares as a folder tree with sizes and file counts, two photo folders ticked, and a panel with the selection, the destination's free space and a Start recovery button](https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/tui.png)
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
- # hbkit
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
  [![PyPI](https://img.shields.io/pypi/v/hbkit)](https://pypi.org/project/hbkit/)
4
6
  [![CI](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml/badge.svg)](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
+ ![hbkit's terminal browser: a backup's shares as a folder tree with sizes and file counts, two photo folders ticked, and a panel with the selection, the destination's free space and a Start recovery button](https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/tui.png)
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`).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.6"
7
+ version = "0.4.8"
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.6"
3
+ __version__ = "0.4.8"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -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 sys.stdin.isatty():
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
- if not probe:
535
- w.update(Text("invalid path", style="bold red"))
536
- return
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 = os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
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