parse-sdk 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.
parse_sdk/config.py ADDED
@@ -0,0 +1,349 @@
1
+ """Config resolution.
2
+
3
+ Resolution order, highest first:
4
+ 1. explicit kwarg to the generated root client (`Reddit(api_key=...)`)
5
+ 2. environment variable
6
+ 3. credentials file (~/.config/parse/credentials)
7
+ 4. baked-in default
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ipaddress
13
+ import json
14
+ import os
15
+ import tempfile
16
+ import urllib.parse
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ DEFAULT_BASE_URL = "https://api.parse.bot"
22
+ CANONICAL_HOST = "api.parse.bot"
23
+ _TRUSTED_HOSTS = frozenset({
24
+ "api.parse.bot", # canonical production host (https); opt others in via PARSE_EXTRA_TRUSTED_HOSTS.
25
+ # Explicit allowlist, NOT `*.parse.bot` minus a denylist: fail-closed on unknown
26
+ # subdomains. Source of truth = backend k8s IngressRoutes; to add a future SDK-facing
27
+ # host, add it here (https-only, exact match — no wildcard).
28
+ })
29
+ # Opt-in escape hatch for staging/preview QA. Empty by default, so prod behavior
30
+ # is UNCHANGED — a user must explicitly set PARSE_EXTRA_TRUSTED_HOSTS (comma- or
31
+ # space-separated hostnames) to let the API key ride to those hosts. Still
32
+ # https-only in _host_is_trusted (the key never travels plaintext to a non-loopback
33
+ # host), so the worst a typo does is widen trust to another https host the user named.
34
+ _EXTRA_TRUSTED_HOSTS_ENV = "PARSE_EXTRA_TRUSTED_HOSTS"
35
+ CREDENTIALS_PATH = Path.home() / ".config" / "parse" / "credentials"
36
+
37
+
38
+ def extra_trusted_hosts() -> frozenset:
39
+ """Hosts the user has explicitly opted into trusting via
40
+ ``PARSE_EXTRA_TRUSTED_HOSTS`` (comma/space separated). Lowercased and
41
+ trailing-dot-stripped to match ``_host_is_trusted``'s normalization. Empty
42
+ set when the env var is unset/blank — the fail-closed default.
43
+
44
+ Read fresh from the environment on every call (not cached at import) so a
45
+ test/monkeypatch toggling the env var takes effect, and so a long-lived
46
+ process picks up an env change without restart."""
47
+ raw = os.environ.get(_EXTRA_TRUSTED_HOSTS_ENV, "")
48
+ return frozenset(
49
+ h.strip().lower().rstrip(".")
50
+ for h in raw.replace(",", " ").split()
51
+ if h.strip()
52
+ )
53
+
54
+
55
+ def _host_is_trusted(url: str) -> bool:
56
+ """SOLE arbiter of whether Config.api_key may be non-None for a base_url.
57
+
58
+ Package-internal by convention, but intentionally imported by runtime sink
59
+ code and tests so every key-bearing path shares this exact predicate.
60
+
61
+ Self-contained and fail-closed, so it is the FULL local invariant for a
62
+ hand-built Config too (not only resolve()'s output). Trusts a host ONLY when:
63
+ - it is ASCII (C5: the matched host then IS the host httpx connects to —
64
+ non-ASCII / IDNA-confusable hosts are rejected here, not normalized), AND
65
+ - the scheme is http(s) (C2: no ftp/file/gopher, even on loopback), AND
66
+ - it is the canonical host over https, OR a user-opted-in extra host over
67
+ https (PARSE_EXTRA_TRUSTED_HOSTS — empty by default), OR syntactic loopback.
68
+ Pure function over urlsplit().hostname — NO DNS.
69
+
70
+ Invariant (corrected, C5): when this returns True, the ASCII host it matched
71
+ denotes the SAME DNS host httpx connects to (verified across case-fold /
72
+ trailing-dot / userinfo). This is NOT a byte-identity claim over all inputs —
73
+ a non-ASCII host is rejected here while httpx would IDNA-normalize it; that is
74
+ the safe (withhold) direction.
75
+ """
76
+ try:
77
+ parts = urllib.parse.urlsplit(url)
78
+ host = parts.hostname # None for malformed authority; already lowercased
79
+ except ValueError:
80
+ # urlsplit() (and .hostname) raise ValueError on a malformed bracketed
81
+ # IPv6 authority (e.g. "http://[fe80::1"). This predicate is the sole,
82
+ # self-contained trust arbiter — it must be TOTAL and fail closed
83
+ # (return False), never raise an exception its four call sites
84
+ # (resolve / require_key / _request / save_credentials) don't expect.
85
+ return False
86
+ if host is None:
87
+ return False
88
+ if not host.isascii(): # C5: reject confusables; keep gate-host == httpx-host
89
+ return False
90
+ if host.endswith("."): # root-anchored FQDN == same DNS host
91
+ host = host[:-1]
92
+ if parts.scheme not in ("http", "https"): # C2: no non-http(s), even on loopback
93
+ return False
94
+ if parts.scheme == "https" and host in _TRUSTED_HOSTS:
95
+ return True
96
+ if parts.scheme == "https" and host in extra_trusted_hosts():
97
+ # User-opted-in staging/preview host (PARSE_EXTRA_TRUSTED_HOSTS).
98
+ # https-only — same plaintext-leak guard as the canonical host.
99
+ return True
100
+ if host == "localhost":
101
+ return True
102
+ try:
103
+ return ipaddress.ip_address(host).is_loopback # 127.0.0.0/8, ::1
104
+ except ValueError:
105
+ return False # a name (incl. 127.0.0.1.evil.tld) is not loopback
106
+
107
+
108
+ def _validate_base_url(url: str) -> str:
109
+ """Structurally validate a resolved base_url before the API key is attached
110
+ to requests against it.
111
+
112
+ The X-API-Key header is sent on every sync/runtime call to this host, so a
113
+ base_url sourced from env/credentials-file/--base-url must at least be a
114
+ real http(s) URL with a host and no embedded credentials. We deliberately do
115
+ NOT mandate https: the suite and local proxies use ``http://`` endpoints, so
116
+ forcing https would break legitimate non-TLS targets. Callers who need TLS
117
+ enforcement can check the scheme themselves.
118
+ """
119
+ parts = urllib.parse.urlsplit(url)
120
+ if parts.scheme not in ("http", "https"):
121
+ raise RuntimeError(
122
+ f"Parse base_url must be an http(s) URL; got scheme {parts.scheme!r} in {url!r}."
123
+ )
124
+ if not parts.hostname:
125
+ raise RuntimeError(f"Parse base_url has no host: {url!r}.")
126
+ if parts.username or parts.password:
127
+ raise RuntimeError(
128
+ "Parse base_url must not embed credentials (user:pass@host); "
129
+ "set the API key via PARSE_API_KEY or `parse login` instead."
130
+ )
131
+ return url
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class Config:
136
+ # repr=False: a Config travels through tracebacks / logged exception context,
137
+ # and the secrets must never ride along in its repr.
138
+ api_key: Optional[str] = field(repr=False)
139
+ base_url: str
140
+ # OAuth access token from `parse login --web` (host-gated exactly like
141
+ # api_key). Used as ``Authorization: Bearer`` when no api_key is present.
142
+ access_token: Optional[str] = field(default=None, repr=False)
143
+
144
+ def require_key(self) -> str:
145
+ # Type-invariant, not a runtime re-check: resolve() is the sole writer
146
+ # that gates the key, but a hand-built Config(api_key=k, base_url=evil)
147
+ # must withhold too — this is the gate for any Config not produced by
148
+ # resolve(). The predicate is self-contained (rejects non-http(s) and
149
+ # non-ASCII hosts), so this check is the full local invariant.
150
+ trusted = _host_is_trusted(self.base_url)
151
+ if self.api_key and trusted:
152
+ return self.api_key
153
+ if not trusted:
154
+ raise RuntimeError(
155
+ f"Parse API requests to {self.base_url!r} cannot carry an API key — "
156
+ f"it is not a trusted Parse API host. Point base_url at "
157
+ f"https://{CANONICAL_HOST} (the default) or a loopback URL for local dev."
158
+ )
159
+ raise RuntimeError(
160
+ "No Parse API key found. Set PARSE_API_KEY, run `parse login`, "
161
+ "or pass api_key= to the generated root client."
162
+ )
163
+
164
+ def auth_headers(self) -> dict:
165
+ """The auth header dict to attach to a Parse request: ``X-API-Key`` when
166
+ an api_key is set, else ``Authorization: Bearer`` from a web-login token.
167
+ Raises RuntimeError (untrusted host, or no credential) — the single
168
+ chokepoint both the CLI transport and the runtime sink use, so the
169
+ key↔host / token↔host binding holds everywhere.
170
+ """
171
+ trusted = _host_is_trusted(self.base_url)
172
+ if not trusted:
173
+ raise RuntimeError(
174
+ f"Parse API requests to {self.base_url!r} cannot carry credentials — "
175
+ f"it is not a trusted Parse API host. Point base_url at "
176
+ f"https://{CANONICAL_HOST} (the default) or a loopback URL for local dev."
177
+ )
178
+ if self.api_key:
179
+ return {"X-API-Key": self.api_key}
180
+ if self.access_token:
181
+ return {"Authorization": f"Bearer {self.access_token}"}
182
+ raise RuntimeError(
183
+ "No Parse credentials found. Run `parse login` (API key) or "
184
+ "`parse login --web` (browser), or set PARSE_API_KEY."
185
+ )
186
+
187
+
188
+ def _read_credentials_file() -> dict:
189
+ # Single fd for stat + chmod + read: O_NOFOLLOW refuses a symlinked creds
190
+ # file outright (save_credentials never writes one) and fstat/fchmod
191
+ # operate on the OPEN fd — no path re-lookup, so the check-then-chmod
192
+ # window of the old is_symlink()/chmod() pair (TOCTOU) is unrepresentable.
193
+ try:
194
+ fd = os.open(CREDENTIALS_PATH, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
195
+ except OSError: # missing, unreadable, or ELOOP (symlink under O_NOFOLLOW)
196
+ return {}
197
+ try:
198
+ with os.fdopen(fd, "r") as f:
199
+ # Re-tighten a pre-existing loose file at the single read
200
+ # chokepoint (save_credentials already writes 0600).
201
+ if os.fstat(f.fileno()).st_mode & 0o077:
202
+ try:
203
+ os.fchmod(f.fileno(), 0o600)
204
+ except (OSError, AttributeError):
205
+ pass
206
+ return json.loads(f.read())
207
+ except Exception:
208
+ return {}
209
+
210
+
211
+ def resolve(
212
+ api_key: Optional[str] = None,
213
+ base_url: Optional[str] = None,
214
+ ) -> Config:
215
+ creds = _read_credentials_file()
216
+ key = api_key or os.environ.get("PARSE_API_KEY") or creds.get("api_key")
217
+ url = base_url or os.environ.get("PARSE_API_BASE_URL") or creds.get("base_url") or DEFAULT_BASE_URL
218
+ if not isinstance(url, str):
219
+ # A hand-edited credentials file can carry a non-string base_url
220
+ # (e.g. ``"base_url": 123``); surface a clear error instead of an opaque
221
+ # AttributeError from the rstrip/validate below.
222
+ raise RuntimeError(
223
+ f"Parse base_url must be a string; got {type(url).__name__} ({url!r}). "
224
+ "Check ~/.config/parse/credentials and the PARSE_API_BASE_URL env var."
225
+ )
226
+ url = _validate_base_url(url.rstrip("/")) # runs FIRST: rejects userinfo / non-http(s) / hostless
227
+ # OAuth access token from `parse login --web` (env override first, then the
228
+ # creds file's `oauth` block). Gated by the SAME host predicate as the key.
229
+ _o = creds.get("oauth")
230
+ oauth = _o if isinstance(_o, dict) else {}
231
+ token = os.environ.get("PARSE_ACCESS_TOKEN") or oauth.get("access_token")
232
+ token = token if isinstance(token, str) and token else None
233
+ # Gate the SECRETS, not the URL: base_url is always preserved (raw HTTP against
234
+ # any structurally-valid host keeps working), but key/token only ride to the
235
+ # canonical prod host (https), loopback, or an opted-in extra host. This makes
236
+ # "a Config carrying a real credential for an untrusted host" unrepresentable.
237
+ trusted = _host_is_trusted(url)
238
+ return Config(
239
+ api_key=(key if trusted else None),
240
+ base_url=url,
241
+ access_token=(token if trusted else None),
242
+ )
243
+
244
+
245
+ def read_oauth() -> dict:
246
+ """The stored ``oauth`` block ({access_token, refresh_token, expires_at,
247
+ client_id}) from the credentials file, or {} — used by the refresh path."""
248
+ creds = _read_credentials_file()
249
+ oauth = creds.get("oauth")
250
+ return oauth if isinstance(oauth, dict) else {}
251
+
252
+
253
+ def save_oauth_credentials(
254
+ *, access_token: str, refresh_token: Optional[str], expires_at: Optional[int],
255
+ client_id: Optional[str], base_url: str,
256
+ ) -> str:
257
+ """Persist an `oauth` block to the credentials file (0600, atomic, no symlink
258
+ follow — same writer as save_credentials). Refuses an untrusted host, so a
259
+ web-login token is never stored against a host it would then leak to. Returns
260
+ the saved base_url."""
261
+ base_url = base_url.rstrip("/")
262
+ _validate_base_url(base_url)
263
+ if not _host_is_trusted(base_url):
264
+ raise RuntimeError(
265
+ f"Refusing to save web-login credentials for {base_url!r} — it is not a "
266
+ f"trusted Parse API host. Use https://{CANONICAL_HOST} (the default), a "
267
+ f"loopback URL, or an opted-in PARSE_EXTRA_TRUSTED_HOSTS host."
268
+ )
269
+ current = _read_credentials_file()
270
+ current["base_url"] = base_url
271
+ current["oauth"] = {
272
+ "access_token": access_token,
273
+ "refresh_token": refresh_token,
274
+ "expires_at": expires_at,
275
+ "client_id": client_id,
276
+ }
277
+ _write_credentials_dict(current)
278
+ return base_url
279
+
280
+
281
+ def save_credentials(api_key: Optional[str] = None, base_url: Optional[str] = None) -> str:
282
+ """Persist key/url to the credentials file with 0600 perms — atomically and
283
+ without ever writing through a symlink at the target path. Returns the
284
+ base_url now stored in the file (so callers can echo what was SAVED, not what
285
+ a later env-var override would resolve to).
286
+
287
+ The file holds a plaintext API key, so it must never be world-readable, even
288
+ momentarily. We write to a fresh 0600 temp file in the same directory
289
+ (``mkstemp`` uses ``O_CREAT|O_EXCL``, so there is no pre-existing inode to
290
+ follow) and then ``os.replace`` it over the target. ``os.replace`` swaps the
291
+ NAME: a symlink planted at ``CREDENTIALS_PATH`` is replaced, never written
292
+ through — closing both the world-readable write window and the symlink-follow
293
+ redirect.
294
+
295
+ Write-path host gate: the EFFECTIVE merged base_url (the file's, when the
296
+ caller passes none) must be a trusted host on EVERY write — raising before
297
+ anything is created or modified. A persisted key paired with an untrusted
298
+ host is unrepresentable; saving a key over a pre-existing poisoned file
299
+ refuses instead of arming it.
300
+ """
301
+ current = _read_credentials_file()
302
+ if api_key is not None:
303
+ current["api_key"] = api_key
304
+ if base_url is not None:
305
+ current["base_url"] = base_url.rstrip("/")
306
+ effective = current.get("base_url", DEFAULT_BASE_URL)
307
+ if not isinstance(effective, str):
308
+ raise RuntimeError(
309
+ f"Parse base_url must be a string; got {type(effective).__name__} "
310
+ f"({effective!r}). Check ~/.config/parse/credentials."
311
+ )
312
+ _validate_base_url(effective)
313
+ if not _host_is_trusted(effective):
314
+ raise RuntimeError(
315
+ f"Refusing to save credentials for {effective!r} — it is not a trusted "
316
+ f"Parse API host, so the saved key would leak there on every request. "
317
+ f"Use https://{CANONICAL_HOST} (the default) or a loopback URL for local dev."
318
+ )
319
+ _write_credentials_dict(current)
320
+ return current.get("base_url", DEFAULT_BASE_URL)
321
+
322
+
323
+ def _write_credentials_dict(data: dict) -> None:
324
+ """Atomically write the credentials dict at 0600 without following a symlink
325
+ at the target (mkstemp uses O_CREAT|O_EXCL; os.replace swaps the NAME, so a
326
+ planted symlink is replaced, never written through). Shared by
327
+ save_credentials + save_oauth_credentials so the secret-handling is identical."""
328
+ parent = CREDENTIALS_PATH.parent
329
+ parent.mkdir(parents=True, exist_ok=True)
330
+ try:
331
+ parent.chmod(0o700)
332
+ except OSError:
333
+ pass
334
+ payload = json.dumps(data, indent=2)
335
+ fd, tmp = tempfile.mkstemp(dir=parent, prefix=".credentials.", suffix=".tmp")
336
+ try:
337
+ os.write(fd, payload.encode("utf-8"))
338
+ os.close(fd)
339
+ os.replace(tmp, CREDENTIALS_PATH)
340
+ except BaseException:
341
+ try:
342
+ os.close(fd)
343
+ except OSError:
344
+ pass
345
+ try:
346
+ os.unlink(tmp)
347
+ except OSError:
348
+ pass
349
+ raise