bhshell 0.1.0__py3-none-any.whl

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.
Files changed (59) hide show
  1. bhshell/__init__.py +3 -0
  2. bhshell/__main__.py +3 -0
  3. bhshell/cli.py +55 -0
  4. bhshell/client/__init__.py +0 -0
  5. bhshell/client/auth.py +74 -0
  6. bhshell/client/client.py +337 -0
  7. bhshell/config.py +162 -0
  8. bhshell/errors.py +73 -0
  9. bhshell/library/asrep-roastable-users.cyp +3 -0
  10. bhshell/library/dcsync-principals.cyp +3 -0
  11. bhshell/library/domain-admins-members.cyp +3 -0
  12. bhshell/library/domain-trusts.cyp +3 -0
  13. bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
  14. bhshell/library/kerberoastable-users.cyp +3 -0
  15. bhshell/library/owned-objects.cyp +3 -0
  16. bhshell/library/owned-to-tier-zero.cyp +3 -0
  17. bhshell/library/sid-history.cyp +3 -0
  18. bhshell/library/tier-zero-users.cyp +3 -0
  19. bhshell/library/unconstrained-delegation.cyp +3 -0
  20. bhshell/models/__init__.py +0 -0
  21. bhshell/models/entities.py +402 -0
  22. bhshell/models/results.py +83 -0
  23. bhshell/queries/__init__.py +0 -0
  24. bhshell/queries/cypher.py +155 -0
  25. bhshell/queries/domains.py +136 -0
  26. bhshell/queries/edges.py +109 -0
  27. bhshell/queries/hunt.py +675 -0
  28. bhshell/queries/library.py +94 -0
  29. bhshell/queries/marking.py +121 -0
  30. bhshell/queries/paths.py +369 -0
  31. bhshell/queries/search.py +196 -0
  32. bhshell/queries/service.py +529 -0
  33. bhshell/render/__init__.py +0 -0
  34. bhshell/render/export.py +79 -0
  35. bhshell/render/graph.py +326 -0
  36. bhshell/render/layout.py +138 -0
  37. bhshell/render/png.py +44 -0
  38. bhshell/render/styles.py +5 -0
  39. bhshell/render/svg.py +495 -0
  40. bhshell/render/views.py +337 -0
  41. bhshell/shell/__init__.py +0 -0
  42. bhshell/shell/app.py +189 -0
  43. bhshell/shell/commands/__init__.py +0 -0
  44. bhshell/shell/commands/base.py +60 -0
  45. bhshell/shell/commands/connection.py +236 -0
  46. bhshell/shell/commands/cypher.py +97 -0
  47. bhshell/shell/commands/domains.py +80 -0
  48. bhshell/shell/commands/entities.py +88 -0
  49. bhshell/shell/commands/hunt.py +151 -0
  50. bhshell/shell/commands/library.py +56 -0
  51. bhshell/shell/commands/marking.py +59 -0
  52. bhshell/shell/commands/paths.py +166 -0
  53. bhshell/shell/commands/search.py +125 -0
  54. bhshell-0.1.0.dist-info/METADATA +134 -0
  55. bhshell-0.1.0.dist-info/RECORD +59 -0
  56. bhshell-0.1.0.dist-info/WHEEL +5 -0
  57. bhshell-0.1.0.dist-info/entry_points.txt +2 -0
  58. bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. bhshell-0.1.0.dist-info/top_level.txt +1 -0
bhshell/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """bhshell - an interactive shell for BloodHound."""
2
+
3
+ __version__ = "0.1.0"
bhshell/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from bhshell.cli import main
2
+
3
+ raise SystemExit(main())
bhshell/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from cmd2.string_utils import quote_if_needed
10
+
11
+ from bhshell import __version__
12
+ from bhshell.client.client import normalize_url
13
+ from bhshell.config import Config
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ p = argparse.ArgumentParser(prog="bhshell", description="Interactive shell for BloodHound.")
18
+ p.add_argument("--url", help="BH backend URL (persisted; default http://127.0.0.1:8080/)")
19
+ p.add_argument("-k", "--insecure", action="store_true", help="disable TLS certificate verification (persisted; 'tls on' re-enables it)")
20
+ p.add_argument("--no-resume", action="store_true", help="do not resume the cached session")
21
+ p.add_argument("-c", "--command", action="append", default=[], metavar="CMD",
22
+ help="run CMD and exit (repeatable)")
23
+ p.add_argument("-s", "--script", metavar="FILE", help="run commands from FILE and exit")
24
+ p.add_argument("--debug", action="store_true", help="show tracebacks for errors")
25
+ p.add_argument("--version", action="version", version=f"bhshell {__version__}")
26
+ return p
27
+
28
+
29
+ def main(argv: Sequence[str] | None = None) -> int:
30
+ args = build_parser().parse_args(argv)
31
+ config = Config.load()
32
+ changed = False
33
+ if args.url:
34
+ try:
35
+ config.url = normalize_url(args.url)
36
+ except ValueError as exc:
37
+ print(f"error: {exc}", file=sys.stderr)
38
+ return 2
39
+ changed = True
40
+ if args.insecure:
41
+ config.verify_tls = False
42
+ changed = True
43
+ if changed:
44
+ config.save()
45
+
46
+ from bhshell.shell.app import BHShell # imported late so '--help' stays fast
47
+
48
+ app = BHShell(config, debug=args.debug, resume=not args.no_resume)
49
+ if args.command or args.script:
50
+ commands = list(args.command)
51
+ if args.script:
52
+ commands.append(f"run_script {quote_if_needed(args.script)}")
53
+ app.runcmds_plus_hooks(commands)
54
+ return app.exit_code or (1 if app.command_failures else 0)
55
+ return app.cmdloop() or 0
File without changes
bhshell/client/auth.py ADDED
@@ -0,0 +1,74 @@
1
+ """Request authenticators for the BH API.
2
+
3
+ Two schemes are supported:
4
+
5
+ * `JWTAuth` - `Authorization: Bearer <session_token>` obtained from
6
+ `POST /api/v2/login`.
7
+ * `HMACAuth` - BH API tokens (Token ID + Token Key). Each request is
8
+ signed with a chained HMAC-SHA256 as documented by SpecterOps:
9
+
10
+ 1. `k1 = HMAC(token_key, METHOD + URI)`
11
+ 2. `k2 = HMAC(k1, RequestDate[:13])` (RFC3339 date truncated to the hour)
12
+ 3. `signature = HMAC(k2, body)`
13
+
14
+ Headers: `Authorization: bhesignature <token_id>`, `RequestDate` and
15
+ `Signature` (base64).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import hashlib
22
+ import hmac
23
+ from collections.abc import Callable
24
+ from datetime import datetime
25
+ from urllib.parse import urlsplit
26
+
27
+ from requests import PreparedRequest
28
+ from requests.auth import AuthBase
29
+
30
+
31
+ class JWTAuth(AuthBase):
32
+ def __init__(self, token: str) -> None:
33
+ self.token = token
34
+
35
+ def __call__(self, r: PreparedRequest) -> PreparedRequest:
36
+ r.headers["Authorization"] = f"Bearer {self.token}"
37
+ return r
38
+
39
+
40
+ class HMACAuth(AuthBase):
41
+ def __init__(
42
+ self,
43
+ token_id: str,
44
+ token_key: str,
45
+ *,
46
+ clock: Callable[[], datetime] | None = None,
47
+ ) -> None:
48
+ self.token_id = token_id
49
+ self._key = token_key.encode()
50
+ self._clock = clock or (lambda: datetime.now().astimezone())
51
+
52
+ @staticmethod
53
+ def _request_uri(url: str) -> str:
54
+ parts = urlsplit(url)
55
+ return parts.path + (f"?{parts.query}" if parts.query else "")
56
+
57
+ def sign(self, method: str, uri: str, body: bytes | None, request_date: str) -> str:
58
+ digester = hmac.new(self._key, None, hashlib.sha256)
59
+ digester.update(f"{method.upper()}{uri}".encode())
60
+ digester = hmac.new(digester.digest(), None, hashlib.sha256)
61
+ digester.update(request_date[:13].encode())
62
+ digester = hmac.new(digester.digest(), None, hashlib.sha256)
63
+ if body:
64
+ digester.update(body)
65
+ return base64.b64encode(digester.digest()).decode()
66
+
67
+ def __call__(self, r: PreparedRequest) -> PreparedRequest:
68
+ request_date = self._clock().isoformat("T")
69
+ body = r.body.encode() if isinstance(r.body, str) else r.body
70
+ signature = self.sign(r.method or "GET", self._request_uri(r.url or ""), body, request_date)
71
+ r.headers["Authorization"] = f"bhesignature {self.token_id}"
72
+ r.headers["RequestDate"] = request_date
73
+ r.headers["Signature"] = signature
74
+ return r
@@ -0,0 +1,337 @@
1
+ """BH REST API v2 transport: auth, retries, error mapping and pagination.
2
+
3
+ Returns decoded JSON as-is; bhshell.queries makes sense of it."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import json
9
+ import time
10
+ from collections.abc import Iterator
11
+ from typing import Any, Literal
12
+ from urllib.parse import urljoin, urlsplit
13
+
14
+ import requests
15
+ from requests.adapters import HTTPAdapter
16
+ from urllib3.util.retry import Retry
17
+
18
+ from bhshell import __version__
19
+ from bhshell.client.auth import HMACAuth, JWTAuth
20
+ from bhshell.config import DEFAULT_URL
21
+ from bhshell.errors import (
22
+ ApiError,
23
+ AuthError,
24
+ ConnectionFailed,
25
+ CypherDisabledError,
26
+ ForbiddenError,
27
+ LoginFailed,
28
+ MFARequired,
29
+ NotAuthenticated,
30
+ NotFoundError,
31
+ )
32
+
33
+ AuthMode = Literal["none", "jwt", "hmac"]
34
+
35
+ _MFA_MARKERS = ("otp", "one time", "one-time", "mfa", "totp")
36
+
37
+
38
+ def normalize_url(url: str) -> str:
39
+ url = url.strip()
40
+ if not url:
41
+ return DEFAULT_URL
42
+ if "://" not in url:
43
+ url = f"http://{url}"
44
+ parts = urlsplit(url)
45
+ if parts.scheme not in ("http", "https") or not parts.netloc:
46
+ raise ValueError(f"Invalid backend URL: {url!r}")
47
+ return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}/"
48
+
49
+
50
+ def route_missing(exc: NotFoundError) -> bool:
51
+ """Whether a 404 means the endpoint does not exist (Go's router), not that nothing was found."""
52
+ return "page not found" in exc.message.lower()
53
+
54
+
55
+ def jwt_claims(token: str) -> dict[str, Any]:
56
+ """The payload of a JWT, not verified."""
57
+ try:
58
+ payload = token.split(".")[1]
59
+ payload += "=" * (-len(payload) % 4)
60
+ return json.loads(base64.urlsafe_b64decode(payload))
61
+ except (IndexError, ValueError):
62
+ return {}
63
+
64
+
65
+ class BHClient:
66
+ def __init__(
67
+ self,
68
+ base_url: str = DEFAULT_URL,
69
+ *,
70
+ verify_tls: bool = True,
71
+ timeout: tuple[float, float] = (5.0, 60.0),
72
+ page_size: int = 500,
73
+ ) -> None:
74
+ self.base_url = normalize_url(base_url)
75
+ self.verify_tls = verify_tls
76
+ self.timeout = timeout
77
+ self.page_size = page_size
78
+ self.auth_mode: AuthMode = "none"
79
+ self.username: str = ""
80
+ self.token_id: str = ""
81
+ self.jwt: str = ""
82
+ self._auth: requests.auth.AuthBase | None = None
83
+
84
+ self._session = requests.Session()
85
+ self._session.headers.update({"User-Agent": f"bhshell/{__version__}", "Accept": "application/json"})
86
+ retry = Retry(
87
+ total=3,
88
+ backoff_factor=0.5,
89
+ status_forcelist=(429, 502, 503, 504),
90
+ # Only idempotent methods, and never after a read timeout: a slow Cypher
91
+ # query (POST) would otherwise run up to four times on the server.
92
+ read=False,
93
+ allowed_methods=frozenset({"GET", "PUT", "DELETE"}),
94
+ respect_retry_after_header=True,
95
+ raise_on_status=False,
96
+ )
97
+ adapter = HTTPAdapter(max_retries=retry)
98
+ self._session.mount("http://", adapter)
99
+ self._session.mount("https://", adapter)
100
+
101
+ @property
102
+ def host(self) -> str:
103
+ return urlsplit(self.base_url).netloc
104
+
105
+ @property
106
+ def is_authenticated(self) -> bool:
107
+ return self._auth is not None
108
+
109
+ def set_url(self, url: str) -> None:
110
+ """Credentials are dropped when the URL changes."""
111
+ new = normalize_url(url)
112
+ if new != self.base_url:
113
+ self.clear_auth()
114
+ self.base_url = new
115
+
116
+ def set_jwt(self, token: str, username: str = "") -> None:
117
+ self._auth = JWTAuth(token)
118
+ self.auth_mode = "jwt"
119
+ self.jwt = token
120
+ self.username = username
121
+ self.token_id = ""
122
+
123
+ def set_hmac(self, token_id: str, token_key: str) -> None:
124
+ self._auth = HMACAuth(token_id, token_key)
125
+ self.auth_mode = "hmac"
126
+ self.token_id = token_id
127
+ self.jwt = ""
128
+ self.username = ""
129
+
130
+ def auth_state(self) -> tuple[Any, ...]:
131
+ return (self._auth, self.auth_mode, self.username, self.token_id, self.jwt)
132
+
133
+ def restore_auth(self, state: tuple[Any, ...]) -> None:
134
+ self._auth, self.auth_mode, self.username, self.token_id, self.jwt = state
135
+
136
+ def clear_auth(self) -> None:
137
+ self._auth = None
138
+ self.auth_mode = "none"
139
+ self.username = ""
140
+ self.token_id = ""
141
+ self.jwt = ""
142
+
143
+ def token_expiry(self) -> float | None:
144
+ if self.auth_mode != "jwt":
145
+ return None
146
+ exp = jwt_claims(self.jwt).get("exp")
147
+ return float(exp) if isinstance(exp, (int, float)) else None
148
+
149
+ def token_expired(self) -> bool:
150
+ exp = self.token_expiry()
151
+ return exp is not None and exp <= time.time()
152
+
153
+ def request(
154
+ self,
155
+ method: str,
156
+ path: str,
157
+ *,
158
+ params: dict[str, Any] | None = None,
159
+ json_body: Any = None,
160
+ auth: bool = True,
161
+ ) -> Any:
162
+ if auth and self._auth is None:
163
+ raise NotAuthenticated("Not authenticated")
164
+ url = urljoin(self.base_url, path.lstrip("/"))
165
+ try:
166
+ resp = self._session.request(
167
+ method,
168
+ url,
169
+ params=params,
170
+ json=json_body,
171
+ auth=self._auth if auth else None,
172
+ timeout=self.timeout,
173
+ verify=self.verify_tls,
174
+ )
175
+ except requests.exceptions.SSLError as exc:
176
+ raise ConnectionFailed(
177
+ f"TLS error talking to {self.host}: {exc}",
178
+ hint="Use 'tls off' for self-signed certificates.",
179
+ ) from exc
180
+ except requests.exceptions.Timeout as exc:
181
+ raise ConnectionFailed(f"Request to {url} timed out") from exc
182
+ except requests.exceptions.RequestException as exc:
183
+ raise ConnectionFailed(f"Cannot reach BH at {self.base_url} ({exc.__class__.__name__})") from exc
184
+ return self._handle(resp)
185
+
186
+ @staticmethod
187
+ def _error_message(resp: requests.Response) -> str:
188
+ try:
189
+ body = resp.json()
190
+ except ValueError:
191
+ return resp.text.strip()[:300] or resp.reason or "unknown error"
192
+ if isinstance(body, dict):
193
+ errors = body.get("errors")
194
+ if isinstance(errors, list) and errors:
195
+ msgs = [e.get("message", "") for e in errors if isinstance(e, dict)]
196
+ if any(msgs):
197
+ return "; ".join(m for m in msgs if m)
198
+ for key in ("message", "error"):
199
+ if isinstance(body.get(key), str):
200
+ return body[key]
201
+ return str(body)[:300]
202
+
203
+ def _handle(self, resp: requests.Response) -> Any:
204
+ if resp.status_code < 300:
205
+ if not resp.content:
206
+ return None
207
+ try:
208
+ return resp.json()
209
+ except ValueError as exc:
210
+ raise ApiError(resp.status_code, "Response is not valid JSON") from exc
211
+ msg = self._error_message(resp)
212
+ if resp.status_code == 401:
213
+ raise AuthError(f"Unauthorized: {msg}")
214
+ if resp.status_code == 403:
215
+ raise ForbiddenError(f"Forbidden: {msg}")
216
+ if resp.status_code == 404:
217
+ raise NotFoundError(msg or "Not found")
218
+ raise ApiError(resp.status_code, msg)
219
+
220
+ def get(self, path: str, **params: Any) -> Any:
221
+ return self.request("GET", path, params=params or None)
222
+
223
+ def paginate(self, path: str, *, limit: int | None = None, **params: Any) -> Iterator[dict[str, Any]]:
224
+ skip = 0
225
+ yielded = 0
226
+ while True:
227
+ page = self.get(path, skip=skip, limit=self.page_size, **params) or {}
228
+ items = page.get("data") or []
229
+ if isinstance(items, dict): # e.g. asset-group-tags selectors: {"data": {"selectors": [...]}}
230
+ items = next((v for v in items.values() if isinstance(v, list)), [])
231
+ if not items:
232
+ return
233
+ for item in items:
234
+ yield item
235
+ yielded += 1
236
+ if limit is not None and yielded >= limit:
237
+ return
238
+ count = page.get("count")
239
+ skip += len(items)
240
+ # Trust the total when there is one: the server may cap `limit` below our page size.
241
+ done = (skip >= count) if isinstance(count, int) else (len(items) < self.page_size)
242
+ if done:
243
+ return
244
+
245
+ def login(self, username: str, password: str, otp: str | None = None) -> dict[str, Any]:
246
+ """Switch to JWT auth. Returns the login response's `data` (user_id, auth_expired, session_token)."""
247
+ body: dict[str, Any] = {"login_method": "secret", "username": username, "secret": password}
248
+ if otp:
249
+ body["otp"] = otp
250
+ try:
251
+ resp = self.request("POST", "api/v2/login", json_body=body, auth=False)
252
+ except (AuthError, ApiError) as exc:
253
+ text = exc.message.lower()
254
+ if not otp and any(marker in text for marker in _MFA_MARKERS):
255
+ raise MFARequired("A one-time password is required for this account") from exc
256
+ if isinstance(exc, AuthError):
257
+ raise LoginFailed("Login failed: invalid username or password") from exc
258
+ raise
259
+ data = (resp or {}).get("data") or {}
260
+ token = data.get("session_token")
261
+ if not token:
262
+ raise ApiError(200, "Login response did not contain a session token")
263
+ self.set_jwt(token, username)
264
+ return data
265
+
266
+ def logout(self) -> None:
267
+ try:
268
+ if self.auth_mode == "jwt":
269
+ self.request("POST", "api/v2/logout")
270
+ except (AuthError, NotFoundError, ApiError, ConnectionFailed):
271
+ pass
272
+ finally:
273
+ self.clear_auth()
274
+
275
+ def self_info(self) -> dict[str, Any]:
276
+ # BH answers unauthenticated requests here with 200 {"data": null} instead of 401.
277
+ data = (self.get("api/v2/self") or {}).get("data")
278
+ if not data:
279
+ raise AuthError("Not authenticated (server returned no principal)")
280
+ return data
281
+
282
+ def version(self) -> dict[str, Any]:
283
+ return (self.get("api/version") or {}).get("data") or {}
284
+
285
+ def cypher(self, query: str, include_properties: bool = True) -> dict[str, Any]:
286
+ body = {"query": query, "include_properties": include_properties}
287
+ try:
288
+ resp = self.request("POST", "api/v2/graphs/cypher", json_body=body)
289
+ except NotFoundError as exc: # also how BH answers an empty result
290
+ if route_missing(exc):
291
+ raise CypherDisabledError("The cypher endpoint does not exist on this server") from exc
292
+ return {"nodes": {}, "edges": [], "literals": []}
293
+ except ForbiddenError as exc:
294
+ raise CypherDisabledError(f"Cypher queries are not permitted: {exc.message}") from exc
295
+ except ApiError as exc:
296
+ if exc.status in (405, 501):
297
+ raise CypherDisabledError(f"Cypher queries are disabled: {exc.detail}") from exc
298
+ raise
299
+ data = (resp or {}).get("data") or {}
300
+ nodes = data.get("nodes") or {}
301
+ edges = data.get("edges") or []
302
+ return {"nodes": nodes, "edges": edges, "literals": data.get("literals") or []}
303
+
304
+ def search(self, query: str, kind: str | None = None) -> list[dict[str, Any]]:
305
+ params: dict[str, Any] = {"q": query}
306
+ if kind:
307
+ params["type"] = kind
308
+ resp = self.get("api/v2/search", **params) or {}
309
+ data = resp.get("data") or []
310
+ return data if isinstance(data, list) else []
311
+
312
+ def entity(self, kind: str, objectid: str) -> dict[str, Any]:
313
+ resp = self.get(f"api/v2/{_collection(kind)}/{objectid}", counts="false") or {}
314
+ data = resp.get("data") or {}
315
+ return data.get("props") or data
316
+
317
+ def entity_related(
318
+ self, kind: str, objectid: str, relation: str, *, limit: int | None = None
319
+ ) -> list[dict[str, Any]]:
320
+ path = f"api/v2/{_collection(kind)}/{objectid}/{relation}"
321
+ return list(self.paginate(path, limit=limit, type="list"))
322
+
323
+
324
+ def _collection(kind: str) -> str:
325
+ mapping = {
326
+ "user": "users",
327
+ "computer": "computers",
328
+ "group": "groups",
329
+ "domain": "domains",
330
+ "gpo": "gpos",
331
+ "ou": "ous",
332
+ "container": "containers",
333
+ }
334
+ try:
335
+ return mapping[kind.lower()]
336
+ except KeyError as exc:
337
+ raise ValueError(f"No REST collection for kind {kind!r}") from exc
bhshell/config.py ADDED
@@ -0,0 +1,162 @@
1
+ """Persistent configuration and session cache.
2
+
3
+ Layout (overridable with the `BHSHELL_HOME` environment variable):
4
+
5
+ ~/.bhshell/
6
+ config.toml # backend URL, TLS, timeouts, last username, query folders
7
+ session.json # cached JWT (mode 0600)
8
+ history # cmd2 command history
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import os
16
+ import tomllib
17
+ from dataclasses import asdict, dataclass, fields
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ DEFAULT_URL = "http://127.0.0.1:8080/"
22
+
23
+
24
+ def config_home() -> Path:
25
+ home = Path(os.environ.get("BHSHELL_HOME", Path.home() / ".bhshell")).expanduser()
26
+ home.mkdir(mode=0o700, parents=True, exist_ok=True)
27
+ return home
28
+
29
+
30
+ # Lowest accepted value of numeric settings (`display_rows` 0 means "all rows").
31
+ _MINIMUMS: dict[str, float] = {"connect_timeout": 1, "read_timeout": 1, "page_size": 1, "max_results": 1,
32
+ "display_rows": 0}
33
+
34
+
35
+ def parse_bool(value: str) -> bool:
36
+ lowered = value.strip().lower()
37
+ if lowered in ("1", "true", "yes", "on"):
38
+ return True
39
+ if lowered in ("0", "false", "no", "off"):
40
+ return False
41
+ raise ValueError(f"not a boolean: {value!r}")
42
+
43
+
44
+ def _toml_value(value: Any) -> str:
45
+ if isinstance(value, bool):
46
+ return "true" if value else "false"
47
+ if isinstance(value, (int, float)):
48
+ return repr(value)
49
+ # JSON string escaping is a valid subset of TOML basic strings.
50
+ return json.dumps(str(value))
51
+
52
+
53
+ def _write_private(path: Path, content: str) -> None:
54
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
55
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
56
+ fh.write(content)
57
+ os.chmod(path, 0o600)
58
+
59
+
60
+ @dataclass
61
+ class Config:
62
+ url: str = DEFAULT_URL
63
+ verify_tls: bool = True
64
+ connect_timeout: float = 5.0
65
+ read_timeout: float = 60.0
66
+ last_username: str = ""
67
+ page_size: int = 500
68
+ max_results: int = 5000
69
+ display_rows: int = 100
70
+ cache_session: bool = True
71
+ # Extra folders of .cyp queries for 'query', separated like PATH (':' on Unix, ';' on Windows).
72
+ query_dirs: str = ""
73
+
74
+ @classmethod
75
+ def path(cls) -> Path:
76
+ return config_home() / "config.toml"
77
+
78
+ @classmethod
79
+ def load(cls) -> Config:
80
+ """Unknown keys and invalid values are ignored."""
81
+ path = cls.path()
82
+ if not path.exists():
83
+ return cls()
84
+ try:
85
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
86
+ except (OSError, tomllib.TOMLDecodeError):
87
+ return cls()
88
+ known = {f.name for f in fields(cls)}
89
+ cfg = cls()
90
+ for key, value in data.items():
91
+ if key in known:
92
+ try:
93
+ setattr(cfg, key, cls.coerce(key, value))
94
+ except (TypeError, ValueError):
95
+ pass
96
+ return cfg
97
+
98
+ @classmethod
99
+ def coerce(cls, key: str, value: Any) -> Any:
100
+ """Convert `value` to the type of setting `key`.
101
+
102
+ Raises TypeError for a value of the wrong type, ValueError for an invalid one.
103
+ """
104
+ default = getattr(cls(), key)
105
+ if isinstance(default, bool):
106
+ return value if isinstance(value, bool) else parse_bool(str(value))
107
+ if isinstance(default, (int, float)):
108
+ if isinstance(value, bool):
109
+ raise TypeError(f"expected a number, got {value!r}")
110
+ number = type(default)(value)
111
+ if not math.isfinite(number):
112
+ raise ValueError(f"not a finite number: {value!r}")
113
+ minimum = _MINIMUMS.get(key)
114
+ if minimum is not None and number < minimum:
115
+ raise ValueError(f"must be at least {minimum}")
116
+ return number
117
+ if key == "url":
118
+ from bhshell.client.client import normalize_url # late: the client imports this module
119
+
120
+ return normalize_url(str(value))
121
+ return str(value)
122
+
123
+ def save(self) -> None:
124
+ lines = ["# bhshell configuration", *(f"{k} = {_toml_value(v)}" for k, v in asdict(self).items())]
125
+ _write_private(self.path(), "\n".join(lines) + "\n")
126
+
127
+
128
+ @dataclass
129
+ class CachedSession:
130
+ url: str
131
+ token: str
132
+ username: str = ""
133
+
134
+ @staticmethod
135
+ def path() -> Path:
136
+ return config_home() / "session.json"
137
+
138
+ @classmethod
139
+ def load(cls) -> CachedSession | None:
140
+ path = cls.path()
141
+ try:
142
+ data = json.loads(path.read_text(encoding="utf-8"))
143
+ session = cls(url=data["url"], token=data["token"], username=data.get("username", ""))
144
+ except (OSError, ValueError, KeyError, TypeError):
145
+ return None
146
+ if not all(isinstance(v, str) for v in (session.url, session.token, session.username)):
147
+ return None
148
+ return session
149
+
150
+ def save(self) -> None:
151
+ _write_private(self.path(), json.dumps(asdict(self)))
152
+
153
+ @classmethod
154
+ def clear(cls) -> None:
155
+ try:
156
+ cls.path().unlink()
157
+ except FileNotFoundError:
158
+ pass
159
+
160
+
161
+ def history_file() -> Path:
162
+ return config_home() / "history"