hbkit 0.2.1__tar.gz → 0.3.0__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.4
2
2
  Name: hbkit
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -143,27 +143,69 @@ readable by rebuilding a random sample of real files with full checksum verifica
143
143
 
144
144
  ## Network mounts (rclone / S3 / R2)
145
145
 
146
- Opening an archive no longer measures every index shard. Shards are a fixed 8 MiB except
147
- the last, so offsets are computed instead — one directory listing and a single stat per
148
- index family, and `file_chunk<N>.index` families are opened only if a file references them.
149
- On a 3 TB archive that removed roughly **2,600 network round-trips** from startup.
146
+ You can point hbkit at a bucket mounted with rclone instead of copying the archive down
147
+ first. Opening an archive no longer measures every index shard — shards are a fixed 8 MiB
148
+ except the last, so offsets are computed: one directory listing and a single stat per index
149
+ family, and `file_chunk<N>.index` families open only if a file references them. On a 3 TB
150
+ archive that removed roughly **2,600 network round-trips** from startup.
150
151
 
151
- If you do mount a bucket, the flags matter more than anything hbkit does:
152
+ ### Setting up the mount
153
+
154
+ hbkit can drive `rclone` for you. It stores no credentials and talks to no cloud API —
155
+ `rclone config` still owns all of that — it just picks flags that are easy to get wrong.
152
156
 
153
157
  ```sh
154
- rclone mount r2:bucket ~/mnt/r2 --read-only \
155
- --vfs-cache-mode off \ # range requests; 'full' downloads whole files
156
- --dir-cache-time 72h # first listing of ~2000 shards is slow, then cached
158
+ rclone config # add an s3 remote; for R2 pick "Cloudflare"
159
+ hbk remotes # what's configured
160
+ hbk mount r2:mybucket ~/mnt/backup --for browse # mount + pre-warm
161
+ hbk ~/mnt/backup/target.hbk doctor
162
+ hbk unmount ~/mnt/backup
157
163
  ```
158
164
 
159
- **Do not use `--vfs-cache-mode full`.** hbkit reads a 32-byte index record and a ~5 KB
160
- chunk at a time; in `full` mode each of those pulls an entire file, so a 10 KB extraction
161
- downloaded ~82 MB (an 8 MiB index shard plus a ~50 MB bucket) and took ten minutes.
162
-
163
- Even configured well, a network mount is dramatically slower than local storage — the
164
- access pattern is thousands of small scattered reads. **If you can, copy the archive to a
165
- local disk first.** Treat mounted-bucket recovery as workable for pulling out a handful of
166
- files, not for restoring terabytes.
165
+ `--for browse` (default) uses range requests; `--for restore` uses whole-file fetching as
166
+ read-ahead with the cache capped at 50G (`--cache-size 10G` to change it). `--no-warm`
167
+ skips pre-warming, `--dry-run` just prints the rclone command.
168
+
169
+ Measured on a 3 TB archive in Cloudflare R2:
170
+
171
+ | | before tuning | with `hbk mount` |
172
+ |---|---|---|
173
+ | cold mount + directory pre-warm | ~12 min | **16 s** |
174
+ | opening the archive | 9 min 21 s | **1.8 s** |
175
+ | extracting a 10 KB file | 584 s | **11 s** |
176
+
177
+ Three things get you there, and you can apply them by hand if you'd rather not use
178
+ `hbk mount`:
179
+
180
+ - **`--no-modtime`** — the big one. Without it rclone issues a HEAD per object just to fill
181
+ in modification times for a listing: ~0.27 s per entry, so the 2,021-shard `chunk_index`
182
+ directory took **546.8 s**. With it, **4.07 s** — 134× faster. hbkit never uses the
183
+ modtimes of an archive's internal files (restored mtimes come from the archive's own
184
+ metadata), so this costs nothing.
185
+ - **`--dir-cache-time 72h`** — the first listing of a large shard directory is the expensive
186
+ one; cache it and every later open is instant.
187
+ - **pre-warming** — `hbk mount` walks the index directories once up front, so the wait
188
+ happens visibly at mount time instead of looking like a hang on your first command. Run
189
+ it separately with `hbk warm ~/mnt/backup`.
190
+
191
+ ### Choosing `--vfs-cache-mode` — it depends on what you're doing
192
+
193
+ `hbk mount --for` picks this for you, but if you mount by hand: rclone's `full` mode
194
+ downloads **whole files** while `off` issues **range requests**.
195
+
196
+ | What you're doing | Mode | Why |
197
+ |---|---|---|
198
+ | First index build | `full` | Reads one share database end to end (356 MB on a 1.1M-file archive). Sequential — what whole-file fetching is good at. One time; cached in `~/.cache/hbkit` after. |
199
+ | Browsing, `list`, the TUI tree | either | Served from the local index, no network at all. |
200
+ | Pulling out a few files | `off` | hbkit reads a 32-byte index record and a ~5 KB chunk at a time. In `full` mode each pulls a whole file: a measured 10 KB extraction fetched ~82 MB and took ten minutes. |
201
+ | Bulk restoring a folder | `full` | You touch most of each ~50 MB bucket anyway, so whole-file fetching becomes read-ahead. **Cap it** with `--vfs-cache-max-size`, or it will fill your disk. |
202
+
203
+ ### Expectations
204
+
205
+ Even configured well, a mounted bucket is dramatically slower than local storage — the
206
+ access pattern is thousands of small scattered reads, and each one crosses the network.
207
+ **If you can, copy the archive to a local disk first.** Start with one small folder and
208
+ watch the throughput before committing to a large restore.
167
209
 
168
210
  ## Performance
169
211
 
@@ -114,27 +114,69 @@ readable by rebuilding a random sample of real files with full checksum verifica
114
114
 
115
115
  ## Network mounts (rclone / S3 / R2)
116
116
 
117
- Opening an archive no longer measures every index shard. Shards are a fixed 8 MiB except
118
- the last, so offsets are computed instead — one directory listing and a single stat per
119
- index family, and `file_chunk<N>.index` families are opened only if a file references them.
120
- On a 3 TB archive that removed roughly **2,600 network round-trips** from startup.
117
+ You can point hbkit at a bucket mounted with rclone instead of copying the archive down
118
+ first. Opening an archive no longer measures every index shard — shards are a fixed 8 MiB
119
+ except the last, so offsets are computed: one directory listing and a single stat per index
120
+ family, and `file_chunk<N>.index` families open only if a file references them. On a 3 TB
121
+ archive that removed roughly **2,600 network round-trips** from startup.
121
122
 
122
- If you do mount a bucket, the flags matter more than anything hbkit does:
123
+ ### Setting up the mount
124
+
125
+ hbkit can drive `rclone` for you. It stores no credentials and talks to no cloud API —
126
+ `rclone config` still owns all of that — it just picks flags that are easy to get wrong.
123
127
 
124
128
  ```sh
125
- rclone mount r2:bucket ~/mnt/r2 --read-only \
126
- --vfs-cache-mode off \ # range requests; 'full' downloads whole files
127
- --dir-cache-time 72h # first listing of ~2000 shards is slow, then cached
129
+ rclone config # add an s3 remote; for R2 pick "Cloudflare"
130
+ hbk remotes # what's configured
131
+ hbk mount r2:mybucket ~/mnt/backup --for browse # mount + pre-warm
132
+ hbk ~/mnt/backup/target.hbk doctor
133
+ hbk unmount ~/mnt/backup
128
134
  ```
129
135
 
130
- **Do not use `--vfs-cache-mode full`.** hbkit reads a 32-byte index record and a ~5 KB
131
- chunk at a time; in `full` mode each of those pulls an entire file, so a 10 KB extraction
132
- downloaded ~82 MB (an 8 MiB index shard plus a ~50 MB bucket) and took ten minutes.
133
-
134
- Even configured well, a network mount is dramatically slower than local storage — the
135
- access pattern is thousands of small scattered reads. **If you can, copy the archive to a
136
- local disk first.** Treat mounted-bucket recovery as workable for pulling out a handful of
137
- files, not for restoring terabytes.
136
+ `--for browse` (default) uses range requests; `--for restore` uses whole-file fetching as
137
+ read-ahead with the cache capped at 50G (`--cache-size 10G` to change it). `--no-warm`
138
+ skips pre-warming, `--dry-run` just prints the rclone command.
139
+
140
+ Measured on a 3 TB archive in Cloudflare R2:
141
+
142
+ | | before tuning | with `hbk mount` |
143
+ |---|---|---|
144
+ | cold mount + directory pre-warm | ~12 min | **16 s** |
145
+ | opening the archive | 9 min 21 s | **1.8 s** |
146
+ | extracting a 10 KB file | 584 s | **11 s** |
147
+
148
+ Three things get you there, and you can apply them by hand if you'd rather not use
149
+ `hbk mount`:
150
+
151
+ - **`--no-modtime`** — the big one. Without it rclone issues a HEAD per object just to fill
152
+ in modification times for a listing: ~0.27 s per entry, so the 2,021-shard `chunk_index`
153
+ directory took **546.8 s**. With it, **4.07 s** — 134× faster. hbkit never uses the
154
+ modtimes of an archive's internal files (restored mtimes come from the archive's own
155
+ metadata), so this costs nothing.
156
+ - **`--dir-cache-time 72h`** — the first listing of a large shard directory is the expensive
157
+ one; cache it and every later open is instant.
158
+ - **pre-warming** — `hbk mount` walks the index directories once up front, so the wait
159
+ happens visibly at mount time instead of looking like a hang on your first command. Run
160
+ it separately with `hbk warm ~/mnt/backup`.
161
+
162
+ ### Choosing `--vfs-cache-mode` — it depends on what you're doing
163
+
164
+ `hbk mount --for` picks this for you, but if you mount by hand: rclone's `full` mode
165
+ downloads **whole files** while `off` issues **range requests**.
166
+
167
+ | What you're doing | Mode | Why |
168
+ |---|---|---|
169
+ | First index build | `full` | Reads one share database end to end (356 MB on a 1.1M-file archive). Sequential — what whole-file fetching is good at. One time; cached in `~/.cache/hbkit` after. |
170
+ | Browsing, `list`, the TUI tree | either | Served from the local index, no network at all. |
171
+ | Pulling out a few files | `off` | hbkit reads a 32-byte index record and a ~5 KB chunk at a time. In `full` mode each pulls a whole file: a measured 10 KB extraction fetched ~82 MB and took ten minutes. |
172
+ | Bulk restoring a folder | `full` | You touch most of each ~50 MB bucket anyway, so whole-file fetching becomes read-ahead. **Cap it** with `--vfs-cache-max-size`, or it will fill your disk. |
173
+
174
+ ### Expectations
175
+
176
+ Even configured well, a mounted bucket is dramatically slower than local storage — the
177
+ access pattern is thousands of small scattered reads, and each one crosses the network.
178
+ **If you can, copy the archive to a local disk first.** Start with one small folder and
179
+ watch the throughput before committing to a large restore.
138
180
 
139
181
  ## Performance
140
182
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.2.1"
7
+ version = "0.3.0"
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.2.1"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -8,12 +8,19 @@
8
8
  hbk <archive> verify <glob> [-j N] integrity-check, write nothing
9
9
  hbk <archive> tui browse in the full-screen UI
10
10
 
11
+ hbk mount <remote:bucket> <dir> [--for browse|restore] [--no-warm]
12
+ hbk warm <dir> pre-cache dir listings
13
+ hbk unmount <dir> unmount it
14
+ hbk remotes list configured rclone remotes
15
+
11
16
  Encrypted archives: add -p/--password, or set HBK_PASSWORD, or you'll be prompted.
12
17
 
13
18
  <archive> is a .hbk directory, or any drive/folder containing one.
14
19
  Globs match the full archive path, which starts with the share name.
15
20
  Extraction is resumable: correctly-sized files are skipped.
16
21
 
22
+ hbk mount r2:mybucket ~/mnt/backup --for browse
23
+ hbk ~/mnt/backup/target.hbk doctor
17
24
  hbk /Volumes/Backup doctor
18
25
  hbk /Volumes/Backup list holiday
19
26
  hbk /Volumes/Backup get "/Photos/2019/*" ~/restore
@@ -149,9 +156,47 @@ def main() -> int:
149
156
  i = a.index(flag)
150
157
  password = a[i + 1]
151
158
  del a[i:i + 2]
152
- if len(a) < 2:
159
+ if len(a) < 2 and (not a or a[0] != "remotes"):
153
160
  print(__doc__)
154
161
  return 1
162
+ if a[0] in ("mount", "unmount", "remotes", "warm"):
163
+ from . import mount as m
164
+ try:
165
+ if a[0] == "remotes":
166
+ rs = m.remotes()
167
+ print("\n".join(f" {r}" for r in rs) if rs
168
+ else " no rclone remotes configured - run: rclone config")
169
+ return 0
170
+ if a[0] == "warm":
171
+ if len(a) < 2:
172
+ print("usage: hbk warm <dir>")
173
+ return 1
174
+ return m.warm(a[1])
175
+ if a[0] == "unmount":
176
+ if len(a) < 2:
177
+ print("usage: hbk unmount <dir>")
178
+ return 1
179
+ return m.unmount(a[1])
180
+ if len(a) < 3:
181
+ print("usage: hbk mount <remote:bucket> <dir> [--for browse|restore]")
182
+ return 1
183
+ prof, rest = "browse", a[3:]
184
+ if "--for" in rest:
185
+ i = rest.index("--for")
186
+ prof = rest[i + 1]
187
+ cache = None
188
+ if "--cache-size" in rest:
189
+ cache = rest[rest.index("--cache-size") + 1]
190
+ rc = m.mount(a[1], a[2], prof, cache, dry_run="--dry-run" in rest)
191
+ if rc == 0 and "--dry-run" not in rest and "--no-warm" not in rest:
192
+ print("\npre-warming directory cache (first open over a network mount "
193
+ "is slow; doing it here so later commands are instant)")
194
+ m.warm(a[2])
195
+ return rc
196
+ except (m.RcloneMissing, ValueError) as e:
197
+ print(f"{e}", file=sys.stderr)
198
+ return 2
199
+
155
200
  archive, cmd = a[0], a[1]
156
201
 
157
202
  if cmd == "doctor":
@@ -0,0 +1,174 @@
1
+ """Convenience wrapper around `rclone mount` for archives kept in object storage.
2
+
3
+ This stores no credentials and talks to no cloud API - `rclone config` still owns all of
4
+ that. All it does is pick the flags, because the flags are easy to get badly wrong:
5
+ rclone's `--vfs-cache-mode full` fetches *whole files*, and hbkit reads a 32-byte index
6
+ record and a ~5 KB chunk at a time, so a 10 KB extraction can pull ~82 MB. Meanwhile
7
+ `off` issues range requests, which is wrong for the one-time index build (a single
8
+ sequential read of a share database that can be hundreds of MB).
9
+
10
+ So the mode depends on the job, and that is what `--for` selects:
11
+
12
+ browse -> off, range requests; cheap metadata, good for cherry-picking files
13
+ restore -> full, whole-file fetch acts as read-ahead when pulling a whole folder,
14
+ capped so it cannot fill the disk
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+
23
+ PROFILES = {
24
+ # name: (vfs-cache-mode, extra flags, one-line rationale)
25
+ "browse": ("off", [],
26
+ "range requests - best for opening an archive and pulling a few files"),
27
+ "restore": ("full", ["--vfs-cache-max-size", "50G"],
28
+ "whole-file fetch as read-ahead for bulk restores, cache capped at 50G"),
29
+ }
30
+
31
+ # --no-modtime is the single biggest win. Without it rclone issues a HEAD per object just
32
+ # to fill in modification times for a directory listing, which is ~0.27s per entry: the
33
+ # 2,021-shard chunk_index directory took 546.8s cold. With it, 4.07s - a 134x speedup.
34
+ # hbkit never uses the modtimes of an archive's internal files (restored file mtimes come
35
+ # from the archive's own SQLite metadata), so dropping them costs nothing.
36
+ COMMON = ["--read-only", "--no-modtime", "--dir-cache-time", "72h", "--daemon"]
37
+
38
+
39
+ class RcloneMissing(Exception):
40
+ pass
41
+
42
+
43
+ def require_rclone() -> str:
44
+ p = shutil.which("rclone")
45
+ if not p:
46
+ raise RcloneMissing(
47
+ "rclone is not installed. Install it (brew install rclone / "
48
+ "apt install rclone), then run `rclone config` to add your S3/R2 remote.")
49
+ return p
50
+
51
+
52
+ def remotes() -> list[str]:
53
+ """Configured rclone remotes, e.g. ['r2:', 's3:']."""
54
+ try:
55
+ out = subprocess.run([require_rclone(), "listremotes"],
56
+ capture_output=True, text=True, timeout=15)
57
+ return [x for x in out.stdout.split() if x]
58
+ except (RcloneMissing, subprocess.SubprocessError):
59
+ return []
60
+
61
+
62
+ def is_mounted(path: str) -> bool:
63
+ path = os.path.abspath(os.path.expanduser(path))
64
+ try:
65
+ out = subprocess.run(["mount"], capture_output=True, text=True, timeout=10).stdout
66
+ except subprocess.SubprocessError:
67
+ return False
68
+ return any(f" {path} " in ln or ln.endswith(f" {path}") for ln in out.splitlines())
69
+
70
+
71
+ def build_command(remote: str, mountpoint: str, profile: str = "browse",
72
+ cache_size: str | None = None) -> list[str]:
73
+ if profile not in PROFILES:
74
+ raise ValueError(f"unknown profile {profile!r}; choose from {', '.join(PROFILES)}")
75
+ mode, extra, _ = PROFILES[profile]
76
+ extra = list(extra)
77
+ if cache_size and mode == "full":
78
+ extra = ["--vfs-cache-max-size", cache_size]
79
+ return ([require_rclone(), "mount", remote, os.path.abspath(os.path.expanduser(mountpoint)),
80
+ "--vfs-cache-mode", mode] + extra + COMMON)
81
+
82
+
83
+ def mount(remote: str, mountpoint: str, profile: str = "browse",
84
+ cache_size: str | None = None, dry_run: bool = False) -> int:
85
+ mp = os.path.abspath(os.path.expanduser(mountpoint))
86
+ if is_mounted(mp):
87
+ print(f"already mounted: {mp}")
88
+ return 0
89
+ cmd = build_command(remote, mp, profile, cache_size)
90
+ mode, _, why = PROFILES[profile]
91
+ print(f"profile {profile}: --vfs-cache-mode {mode} ({why})")
92
+ print(" " + " ".join(cmd))
93
+ if dry_run:
94
+ return 0
95
+ os.makedirs(mp, exist_ok=True)
96
+ r = subprocess.run(cmd, capture_output=True, text=True)
97
+ if r.returncode != 0:
98
+ print((r.stderr or r.stdout).strip(), file=sys.stderr)
99
+ return r.returncode
100
+ # --daemon returns immediately; wait for the mountpoint to become live
101
+ import time
102
+ for _ in range(60):
103
+ if is_mounted(mp) and os.listdir(mp):
104
+ break
105
+ time.sleep(0.5)
106
+ if not is_mounted(mp):
107
+ print(f"rclone returned success but {mp} is not mounted", file=sys.stderr)
108
+ return 1
109
+ print(f"mounted {remote} at {mp}")
110
+ for e in sorted(os.listdir(mp))[:10]:
111
+ print(f" {e}")
112
+ return 0
113
+
114
+
115
+ # Directories hbkit lists when opening an archive. rclone caches a listing for
116
+ # --dir-cache-time, so walking these once up front turns a ~9 minute "did it hang?" on
117
+ # the user's first command into a visible, attributable wait here. Measured on a 3 TB
118
+ # archive over R2: 9m21s cold, then 1.6s, then 0.07s.
119
+ def warm(mountpoint: str, quiet: bool = False) -> int:
120
+ """Populate rclone's directory cache for every .hbk archive under `mountpoint`."""
121
+ import time
122
+ mp = os.path.abspath(os.path.expanduser(mountpoint))
123
+ archives = []
124
+ for e in sorted(os.listdir(mp)):
125
+ d = os.path.join(mp, e)
126
+ if os.path.isdir(d) and os.path.isdir(os.path.join(d, "Config")):
127
+ archives.append(d)
128
+ if not archives:
129
+ return 0
130
+ n = 0
131
+ t0 = time.time()
132
+ for arc in archives:
133
+ targets = [arc, os.path.join(arc, "Config"), os.path.join(arc, "Pool")]
134
+ for sub in ("Config/virtual_file.index", "Pool/chunk_index", "Config/@Share"):
135
+ targets.append(os.path.join(arc, sub))
136
+ cfg = os.path.join(arc, "Config")
137
+ try:
138
+ targets += [os.path.join(cfg, x) for x in os.listdir(cfg)
139
+ if x.startswith("file_chunk") and os.path.isdir(os.path.join(cfg, x))]
140
+ except OSError:
141
+ pass
142
+ share = os.path.join(arc, "Config", "@Share")
143
+ if os.path.isdir(share):
144
+ targets += [os.path.join(share, x) for x in os.listdir(share)]
145
+ for t in targets:
146
+ if not os.path.isdir(t):
147
+ continue
148
+ if not quiet:
149
+ print(f" warming {os.path.relpath(t, mp)} ...", end="", flush=True)
150
+ s0 = time.time()
151
+ try:
152
+ c = len(os.listdir(t))
153
+ except OSError as e:
154
+ c = -1
155
+ if not quiet:
156
+ print(f" {c} entries, {time.time()-s0:.1f}s")
157
+ n += 1
158
+ if not quiet:
159
+ print(f"warmed {n} directories in {time.time()-t0:.0f}s "
160
+ f"(cached for --dir-cache-time; later opens are instant)")
161
+ return 0
162
+
163
+
164
+ def unmount(mountpoint: str) -> int:
165
+ mp = os.path.abspath(os.path.expanduser(mountpoint))
166
+ if not is_mounted(mp):
167
+ print(f"not mounted: {mp}")
168
+ return 0
169
+ for cmd in (["umount", mp], ["fusermount", "-u", mp], ["diskutil", "unmount", "force", mp]):
170
+ if shutil.which(cmd[0]) and subprocess.run(cmd, capture_output=True).returncode == 0:
171
+ print(f"unmounted {mp}")
172
+ return 0
173
+ print(f"could not unmount {mp}", file=sys.stderr)
174
+ return 1
@@ -285,3 +285,56 @@ def test_cat_single_shard_still_works(tmp_path, monkeypatch):
285
285
  c = A.Cat(str(tmp_path))
286
286
  assert c.total == 100 and c.read(70, 20) == blob[70:90]
287
287
  c.close()
288
+
289
+
290
+ # --------------------------------------------------- rclone mount helper (no network)
291
+
292
+ def test_mount_profiles_pick_the_right_cache_mode(monkeypatch):
293
+ from hbkit import mount as m
294
+ monkeypatch.setattr(m, "require_rclone", lambda: "/usr/bin/rclone")
295
+ browse = m.build_command("r2:b", "/tmp/mp", "browse")
296
+ restore = m.build_command("r2:b", "/tmp/mp", "restore")
297
+ assert "off" == browse[browse.index("--vfs-cache-mode") + 1]
298
+ assert "full" == restore[restore.index("--vfs-cache-mode") + 1]
299
+ # bulk restores cache whole ~50MB buckets, so the cache MUST be capped
300
+ assert "--vfs-cache-max-size" in restore
301
+ assert "--vfs-cache-max-size" not in browse
302
+
303
+
304
+ def test_mount_command_is_always_read_only_and_skips_modtimes(monkeypatch):
305
+ """--no-modtime avoids a HEAD per object: 2021-entry listing 546.8s -> 4.07s."""
306
+ from hbkit import mount as m
307
+ monkeypatch.setattr(m, "require_rclone", lambda: "/usr/bin/rclone")
308
+ for profile in ("browse", "restore"):
309
+ cmd = m.build_command("r2:b", "/tmp/mp", profile)
310
+ assert "--read-only" in cmd, profile
311
+ assert "--no-modtime" in cmd, profile
312
+ assert "--dir-cache-time" in cmd, profile
313
+
314
+
315
+ def test_mount_rejects_unknown_profile(monkeypatch):
316
+ from hbkit import mount as m
317
+ monkeypatch.setattr(m, "require_rclone", lambda: "/usr/bin/rclone")
318
+ with pytest.raises(ValueError):
319
+ m.build_command("r2:b", "/tmp/mp", "nonsense")
320
+
321
+
322
+ def test_mount_cache_size_override(monkeypatch):
323
+ from hbkit import mount as m
324
+ monkeypatch.setattr(m, "require_rclone", lambda: "/usr/bin/rclone")
325
+ cmd = m.build_command("r2:b", "/tmp/mp", "restore", cache_size="10G")
326
+ assert cmd[cmd.index("--vfs-cache-max-size") + 1] == "10G"
327
+
328
+
329
+ def test_missing_rclone_is_a_clear_error(monkeypatch):
330
+ from hbkit import mount as m
331
+ monkeypatch.setattr(m.shutil, "which", lambda _: None)
332
+ with pytest.raises(m.RcloneMissing) as e:
333
+ m.require_rclone()
334
+ assert "rclone config" in str(e.value)
335
+
336
+
337
+ def test_warm_ignores_a_directory_with_no_archives(tmp_path):
338
+ from hbkit import mount as m
339
+ (tmp_path / "not-an-archive").mkdir()
340
+ assert m.warm(str(tmp_path), quiet=True) == 0
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