runspec-webops-core 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.
@@ -0,0 +1,67 @@
1
+ """runspec-webops-core — pure-Python HTTPS/TLS + web-endpoint helpers.
2
+
3
+ This package has **no dependency on runspec** and ships **no runspec.toml and no
4
+ entry points**, so installing it exposes the helper functions for import without
5
+ surfacing any runnables (it is invisible to ``runspec local`` / ``runspec serve``
6
+ discovery). ``runspec-linux`` and ``runspec-windows`` both depend on it and wrap
7
+ each helper in a cross-platform runnable; a private (e.g. Nexus-hosted) package
8
+ can instead import these helpers directly and ship its own runnables.
9
+
10
+ Everything is stdlib only (``ssl`` / ``socket`` / ``http.client``). Each function
11
+ returns plain data and *raises* on failure (see ``runspec_webops_core.errors``).
12
+ """
13
+
14
+ from runspec_webops_core.dns import resolve
15
+ from runspec_webops_core.errors import ConnectError, ResolveError, WebopsCoreError
16
+ from runspec_webops_core.http import (
17
+ classify,
18
+ fetch_body,
19
+ fetch_headers,
20
+ probe,
21
+ trace_redirects,
22
+ )
23
+ from runspec_webops_core.openapi import (
24
+ SpecError,
25
+ describe_operation,
26
+ load_document,
27
+ parse_document,
28
+ summarize_spec,
29
+ )
30
+ from runspec_webops_core.tls import (
31
+ CertInfo,
32
+ TlsPeer,
33
+ cert_status,
34
+ chain_report,
35
+ connect_peer,
36
+ days_until,
37
+ leaf_report,
38
+ )
39
+
40
+ __all__ = [
41
+ # errors
42
+ "WebopsCoreError",
43
+ "ConnectError",
44
+ "ResolveError",
45
+ "SpecError",
46
+ # tls
47
+ "CertInfo",
48
+ "TlsPeer",
49
+ "connect_peer",
50
+ "leaf_report",
51
+ "chain_report",
52
+ "days_until",
53
+ "cert_status",
54
+ # http
55
+ "probe",
56
+ "classify",
57
+ "fetch_headers",
58
+ "fetch_body",
59
+ "trace_redirects",
60
+ # dns
61
+ "resolve",
62
+ # openapi
63
+ "load_document",
64
+ "parse_document",
65
+ "summarize_spec",
66
+ "describe_operation",
67
+ ]
@@ -0,0 +1,65 @@
1
+ """DNS resolution — forward (A/AAAA) and reverse (PTR) via stdlib ``socket``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+
7
+ from runspec_webops_core.errors import ResolveError
8
+
9
+
10
+ def _is_ip(value: str) -> bool:
11
+ for family in (socket.AF_INET, socket.AF_INET6):
12
+ try:
13
+ socket.inet_pton(family, value)
14
+ return True
15
+ except OSError:
16
+ continue
17
+ return False
18
+
19
+
20
+ def _reverse(ip: str) -> list[str]:
21
+ """PTR names for an address. Returns ``[]`` when there is no reverse record."""
22
+ try:
23
+ name, aliases, _ = socket.gethostbyaddr(ip)
24
+ except OSError:
25
+ return []
26
+ return [name, *aliases]
27
+
28
+
29
+ def resolve(host: str, want_reverse: bool = False) -> dict:
30
+ """Resolve ``host``.
31
+
32
+ A bare IP yields its PTR records. A name yields its A/AAAA addresses and
33
+ canonical name; with ``want_reverse`` each address is also reverse-resolved.
34
+ Raises :class:`ResolveError` when the name cannot be resolved.
35
+ """
36
+ if _is_ip(host):
37
+ return {"host": host, "is_ip": True, "reverse": {host: _reverse(host)}}
38
+
39
+ try:
40
+ infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP, flags=socket.AI_CANONNAME)
41
+ except socket.gaierror as e:
42
+ raise ResolveError(f"could not resolve {host!r}: {e}") from e
43
+
44
+ addresses: list[dict] = []
45
+ seen: set[tuple[int, str]] = set()
46
+ canonical = ""
47
+ for family, _type, _proto, canonname, sockaddr in infos:
48
+ if canonname:
49
+ canonical = canonname
50
+ ip = str(sockaddr[0])
51
+ key = (int(family), ip)
52
+ if key in seen:
53
+ continue
54
+ seen.add(key)
55
+ addresses.append({"ip": ip, "family": "ipv6" if family == socket.AF_INET6 else "ipv4"})
56
+
57
+ result: dict = {
58
+ "host": host,
59
+ "is_ip": False,
60
+ "addresses": addresses,
61
+ "canonical_name": canonical or None,
62
+ }
63
+ if want_reverse:
64
+ result["reverse"] = {entry["ip"]: _reverse(entry["ip"]) for entry in addresses}
65
+ return result
@@ -0,0 +1,19 @@
1
+ """Exceptions raised by the webops helpers.
2
+
3
+ The wrappers in ``runspec-linux`` / ``runspec-windows`` catch these to reproduce
4
+ the CLI/agent behaviour (a connect failure exits differently from a bad result).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class WebopsCoreError(Exception):
11
+ """Base class for all runspec-webops-core failures."""
12
+
13
+
14
+ class ConnectError(WebopsCoreError):
15
+ """A TLS/HTTP connection could not be established (refused, timed out, reset)."""
16
+
17
+
18
+ class ResolveError(WebopsCoreError):
19
+ """A DNS name could not be resolved."""
@@ -0,0 +1,224 @@
1
+ """HTTP(S) endpoint probing — stdlib ``http.client`` / ``ssl`` only.
2
+
3
+ A single request is issued and its status / timing / a few headers are captured;
4
+ the body is drained, not buffered. ``classify`` and the security-header analysis
5
+ are pure. Redirects are *not* followed by :func:`probe` — :func:`trace_redirects`
6
+ walks them explicitly so each hop is visible.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import http.client
12
+ import socket
13
+ import ssl
14
+ import time
15
+ from urllib.parse import urljoin, urlsplit
16
+
17
+ from runspec_webops_core.errors import ConnectError
18
+
19
+ _USER_AGENT = "runspec-webops"
20
+
21
+ # Response security headers worth surfacing — present value or flagged as missing.
22
+ SECURITY_HEADERS: dict[str, str] = {
23
+ "strict-transport-security": "strict_transport_security",
24
+ "content-security-policy": "content_security_policy",
25
+ "x-frame-options": "x_frame_options",
26
+ "x-content-type-options": "x_content_type_options",
27
+ "referrer-policy": "referrer_policy",
28
+ "permissions-policy": "permissions_policy",
29
+ }
30
+
31
+
32
+ def classify(status: int) -> str:
33
+ """Bucket an HTTP status code. Pure."""
34
+ if 200 <= status < 300:
35
+ return "ok"
36
+ if 300 <= status < 400:
37
+ return "redirect"
38
+ if 400 <= status < 500:
39
+ return "client-error"
40
+ if 500 <= status < 600:
41
+ return "server-error"
42
+ return "unknown"
43
+
44
+
45
+ def _request(url: str, timeout: float, method: str, insecure: bool) -> tuple[int, str, list[tuple[str, str]], float]:
46
+ """Issue one request and return ``(status, reason, headers, elapsed_ms)``.
47
+
48
+ Raises :class:`ConnectError` on any transport-level failure.
49
+ """
50
+ parts = urlsplit(url)
51
+ if parts.scheme not in ("http", "https"):
52
+ raise ConnectError(f"unsupported URL scheme: {parts.scheme!r} (expected http or https)")
53
+ if not parts.hostname:
54
+ raise ConnectError(f"no host in URL: {url!r}")
55
+
56
+ is_https = parts.scheme == "https"
57
+ port = parts.port or (443 if is_https else 80)
58
+ path = parts.path or "/"
59
+ if parts.query:
60
+ path = f"{path}?{parts.query}"
61
+
62
+ if is_https:
63
+ ctx = ssl.create_default_context()
64
+ if insecure:
65
+ ctx.check_hostname = False
66
+ ctx.verify_mode = ssl.CERT_NONE
67
+ conn: http.client.HTTPConnection = http.client.HTTPSConnection(parts.hostname, port, timeout=timeout, context=ctx)
68
+ else:
69
+ conn = http.client.HTTPConnection(parts.hostname, port, timeout=timeout)
70
+
71
+ start = time.monotonic()
72
+ try:
73
+ conn.request(method, path, headers={"User-Agent": _USER_AGENT, "Accept": "*/*"})
74
+ resp = conn.getresponse()
75
+ resp.read() # drain so the socket can be reused/closed cleanly
76
+ elapsed = round((time.monotonic() - start) * 1000, 1)
77
+ return resp.status, resp.reason or "", resp.getheaders(), elapsed
78
+ except (OSError, TimeoutError, ssl.SSLError, http.client.HTTPException, socket.gaierror) as e:
79
+ raise ConnectError(f"request to {url} failed: {e}") from e
80
+ finally:
81
+ conn.close()
82
+
83
+
84
+ def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_bytes: int = 10_000_000, max_redirects: int = 5) -> tuple[int, str]:
85
+ """GET a URL and return ``(status, body_text)``, following redirects.
86
+
87
+ Used to retrieve documents (e.g. an OpenAPI spec). The body is size-capped so
88
+ a runaway endpoint can't exhaust memory. Raises :class:`ConnectError` on a
89
+ transport failure or when the body exceeds ``max_bytes``.
90
+ """
91
+ current = url
92
+ seen: set[str] = set()
93
+ for _ in range(max_redirects + 1):
94
+ parts = urlsplit(current)
95
+ if parts.scheme not in ("http", "https"):
96
+ raise ConnectError(f"unsupported URL scheme: {parts.scheme!r} (expected http or https)")
97
+ if not parts.hostname:
98
+ raise ConnectError(f"no host in URL: {current!r}")
99
+ if current in seen:
100
+ raise ConnectError(f"redirect loop while fetching {url}")
101
+ seen.add(current)
102
+
103
+ is_https = parts.scheme == "https"
104
+ port = parts.port or (443 if is_https else 80)
105
+ path = parts.path or "/"
106
+ if parts.query:
107
+ path = f"{path}?{parts.query}"
108
+
109
+ if is_https:
110
+ ctx = ssl.create_default_context()
111
+ if insecure:
112
+ ctx.check_hostname = False
113
+ ctx.verify_mode = ssl.CERT_NONE
114
+ conn: http.client.HTTPConnection = http.client.HTTPSConnection(parts.hostname, port, timeout=timeout, context=ctx)
115
+ else:
116
+ conn = http.client.HTTPConnection(parts.hostname, port, timeout=timeout)
117
+
118
+ try:
119
+ conn.request("GET", path, headers={"User-Agent": _USER_AGENT, "Accept": "application/json, application/yaml;q=0.9, */*;q=0.8"})
120
+ resp = conn.getresponse()
121
+ if 300 <= resp.status < 400:
122
+ location = resp.getheader("location")
123
+ resp.read()
124
+ if location:
125
+ current = urljoin(current, location)
126
+ continue
127
+ raw = resp.read(max_bytes + 1)
128
+ if len(raw) > max_bytes:
129
+ raise ConnectError(f"response from {current} exceeds {max_bytes} bytes")
130
+ charset = "utf-8"
131
+ ctype = resp.getheader("content-type") or ""
132
+ if "charset=" in ctype:
133
+ charset = ctype.split("charset=", 1)[1].split(";")[0].strip() or "utf-8"
134
+ return resp.status, raw.decode(charset, errors="replace")
135
+ except (OSError, TimeoutError, ssl.SSLError, http.client.HTTPException, socket.gaierror) as e:
136
+ raise ConnectError(f"request to {current} failed: {e}") from e
137
+ finally:
138
+ conn.close()
139
+
140
+ raise ConnectError(f"too many redirects while fetching {url}")
141
+
142
+
143
+ def _header(headers: list[tuple[str, str]], name: str) -> str | None:
144
+ target = name.lower()
145
+ for key, value in headers:
146
+ if key.lower() == target:
147
+ return value
148
+ return None
149
+
150
+
151
+ def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool = False) -> dict:
152
+ """Single-request probe: status, timing, ``server`` / ``location`` headers."""
153
+ status, reason, headers, elapsed = _request(url, timeout, method, insecure)
154
+ return {
155
+ "url": url,
156
+ "status": status,
157
+ "status_text": reason,
158
+ "time_ms": elapsed,
159
+ "server": _header(headers, "server"),
160
+ "location": _header(headers, "location"),
161
+ "class": classify(status),
162
+ }
163
+
164
+
165
+ def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecure: bool = False) -> dict:
166
+ """Fetch response headers and report on the security headers (present/missing)."""
167
+ status, reason, headers, elapsed = _request(url, timeout, method, insecure)
168
+ header_map = {key.lower(): value for key, value in headers}
169
+ security: dict[str, str | None] = {}
170
+ missing: list[str] = []
171
+ for raw, friendly in SECURITY_HEADERS.items():
172
+ value = header_map.get(raw)
173
+ security[friendly] = value
174
+ if value is None:
175
+ missing.append(raw)
176
+ return {
177
+ "url": url,
178
+ "status": status,
179
+ "status_text": reason,
180
+ "time_ms": elapsed,
181
+ "class": classify(status),
182
+ "headers": dict(header_map),
183
+ "security": security,
184
+ "missing_security_headers": missing,
185
+ }
186
+
187
+
188
+ def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecure: bool = False) -> dict:
189
+ """Walk the redirect chain, recording each hop until a non-3xx or ``max_hops``."""
190
+ hops: list[dict] = []
191
+ seen: set[str] = set()
192
+ current = url
193
+ final_status = 0
194
+ for _ in range(max_hops + 1):
195
+ if current in seen:
196
+ hops.append({"url": current, "status": None, "status_text": "redirect loop", "location": None, "time_ms": 0.0})
197
+ break
198
+ seen.add(current)
199
+ status, reason, headers, elapsed = _request(current, timeout, "GET", insecure)
200
+ location = _header(headers, "location")
201
+ hops.append(
202
+ {
203
+ "url": current,
204
+ "status": status,
205
+ "status_text": reason,
206
+ "location": location,
207
+ "time_ms": elapsed,
208
+ }
209
+ )
210
+ final_status = status
211
+ if not (300 <= status < 400) or not location:
212
+ break
213
+ current = urljoin(current, location)
214
+ else:
215
+ # Loop exhausted without a terminal response.
216
+ hops[-1]["status_text"] = (hops[-1]["status_text"] or "") + " (max hops reached)"
217
+
218
+ return {
219
+ "url": url,
220
+ "hops": hops,
221
+ "final_url": hops[-1]["url"] if hops else url,
222
+ "final_status": final_status,
223
+ "redirect_count": max(len(hops) - 1, 0),
224
+ }
@@ -0,0 +1,317 @@
1
+ """OpenAPI / Swagger spec reading — condense an API's current shape to bounded JSON.
2
+
3
+ The point: let an agent (or operator) see the *live* shape of an internal API —
4
+ its operations, parameters, and request/response schemas — so it can write or
5
+ update the runspec runnables that call it, and re-check when the API drifts. The
6
+ output is small and bounded (a map, not the raw spec), so it stays cheap to feed
7
+ to a model.
8
+
9
+ JSON specs (FastAPI ``/openapi.json``, springdoc ``/v3/api-docs``,
10
+ ``swagger.json``) parse with the stdlib. YAML specs need the optional ``pyyaml``
11
+ extra. Both OpenAPI 3.x and Swagger 2.0 are understood. The fetch/read is the
12
+ only I/O; summarisation is pure.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Iterator
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from runspec_webops_core.errors import WebopsCoreError
23
+ from runspec_webops_core.http import fetch_body
24
+
25
+ # HTTP methods an OpenAPI path item may carry as operations.
26
+ _METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
27
+
28
+ # Defensive caps so the summary stays bounded regardless of spec size.
29
+ MAX_OPERATIONS = 300
30
+ _MAX_PROPERTIES = 60
31
+ _MAX_ENUM = 20
32
+ _MAX_SCHEMA_DEPTH = 3
33
+
34
+
35
+ class SpecError(WebopsCoreError):
36
+ """The spec could not be fetched, read, or parsed."""
37
+
38
+
39
+ # ── loading ───────────────────────────────────────────────────────────────────
40
+
41
+
42
+ def load_document(source: str, timeout: float = 15.0, insecure: bool = False) -> dict:
43
+ """Fetch (URL) or read (file path) a spec and parse it into a dict."""
44
+ if source.startswith(("http://", "https://")):
45
+ status, body = fetch_body(source, timeout=timeout, insecure=insecure)
46
+ if status >= 400:
47
+ raise SpecError(f"fetching {source} returned HTTP {status}")
48
+ else:
49
+ path = Path(source)
50
+ if not path.is_file():
51
+ raise SpecError(f"no such spec file: {source}")
52
+ body = path.read_text(encoding="utf-8", errors="replace")
53
+ return parse_document(body)
54
+
55
+
56
+ def parse_document(text: str) -> dict:
57
+ """Parse spec text as JSON, falling back to YAML when ``pyyaml`` is present."""
58
+ text = text.strip()
59
+ if not text:
60
+ raise SpecError("spec document is empty")
61
+ try:
62
+ data: Any = json.loads(text)
63
+ except json.JSONDecodeError:
64
+ data = _parse_yaml(text)
65
+ if not isinstance(data, dict):
66
+ raise SpecError("spec did not parse to an object")
67
+ if "openapi" not in data and "swagger" not in data:
68
+ raise SpecError("document is not an OpenAPI/Swagger spec (no 'openapi' or 'swagger' key)")
69
+ return data
70
+
71
+
72
+ def _parse_yaml(text: str) -> Any:
73
+ try:
74
+ import yaml # type: ignore[import-untyped] # noqa: PLC0415 — optional dependency, only needed for YAML specs
75
+ except ModuleNotFoundError as e:
76
+ raise SpecError(
77
+ "spec is not JSON and PyYAML is not installed — install the 'yaml' extra (pip install runspec-webops-core[yaml]) or point at the JSON spec endpoint (e.g. /openapi.json or /v3/api-docs)"
78
+ ) from e
79
+ try:
80
+ return yaml.safe_load(text)
81
+ except yaml.YAMLError as e:
82
+ raise SpecError(f"could not parse spec as JSON or YAML: {e}") from e
83
+
84
+
85
+ # ── $ref resolution ───────────────────────────────────────────────────────────
86
+
87
+
88
+ def _resolve_ref(doc: dict, ref: str) -> Any:
89
+ """Resolve a local JSON-pointer ``$ref`` (``#/...``); returns None if missing."""
90
+ if not ref.startswith("#/"):
91
+ return None
92
+ node: Any = doc
93
+ for part in ref[2:].split("/"):
94
+ part = part.replace("~1", "/").replace("~0", "~")
95
+ if isinstance(node, dict) and part in node:
96
+ node = node[part]
97
+ else:
98
+ return None
99
+ return node
100
+
101
+
102
+ def _deref(doc: dict, schema: Any, depth: int = 0) -> Any:
103
+ """Follow a single ``$ref`` (bounded), returning the referenced node."""
104
+ if not isinstance(schema, dict) or "$ref" not in schema:
105
+ return schema
106
+ if depth > _MAX_SCHEMA_DEPTH + 2:
107
+ return schema
108
+ target = _resolve_ref(doc, schema["$ref"])
109
+ if target is None:
110
+ return schema
111
+ return _deref(doc, target, depth + 1)
112
+
113
+
114
+ # ── schema summarising ────────────────────────────────────────────────────────
115
+
116
+
117
+ def _ref_name(ref: str) -> str:
118
+ return ref.rsplit("/", 1)[-1]
119
+
120
+
121
+ def short_type(doc: dict, schema: Any, depth: int = 0) -> str:
122
+ """A one-token type label for a schema (``string``, ``array<Order>``, ``User``)."""
123
+ if isinstance(schema, dict) and "$ref" in schema:
124
+ return _ref_name(schema["$ref"])
125
+ schema = _deref(doc, schema, depth)
126
+ if not isinstance(schema, dict):
127
+ return "any"
128
+ kind = schema.get("type")
129
+ if kind == "array":
130
+ items = schema.get("items", {})
131
+ if isinstance(items, dict) and "$ref" in items:
132
+ return f"array<{_ref_name(items['$ref'])}>"
133
+ return f"array<{short_type(doc, items, depth + 1)}>"
134
+ fmt = schema.get("format")
135
+ if isinstance(kind, str):
136
+ return f"{kind}({fmt})" if fmt else kind
137
+ if "properties" in schema:
138
+ return "object"
139
+ return "any"
140
+
141
+
142
+ def summarize_schema(doc: dict, schema: Any, depth: int = 0) -> dict:
143
+ """A bounded structural summary of a schema (type, properties, required, …)."""
144
+ if isinstance(schema, dict) and "$ref" in schema and depth >= _MAX_SCHEMA_DEPTH:
145
+ return {"$ref": _ref_name(schema["$ref"])}
146
+ resolved = _deref(doc, schema, depth)
147
+ if not isinstance(resolved, dict):
148
+ return {}
149
+
150
+ out: dict = {}
151
+ if isinstance(resolved.get("type"), str):
152
+ out["type"] = resolved["type"]
153
+ if "format" in resolved:
154
+ out["format"] = resolved["format"]
155
+ if "enum" in resolved and isinstance(resolved["enum"], list):
156
+ out["enum"] = resolved["enum"][:_MAX_ENUM]
157
+ if resolved.get("type") == "array" and "items" in resolved and depth < _MAX_SCHEMA_DEPTH:
158
+ out["items"] = summarize_schema(doc, resolved["items"], depth + 1)
159
+
160
+ props = resolved.get("properties")
161
+ if isinstance(props, dict):
162
+ if depth < _MAX_SCHEMA_DEPTH:
163
+ out["properties"] = {name: short_type(doc, sub, depth + 1) for name, sub in list(props.items())[:_MAX_PROPERTIES]}
164
+ if len(props) > _MAX_PROPERTIES:
165
+ out["properties_truncated"] = True
166
+ out.setdefault("type", "object")
167
+ if isinstance(resolved.get("required"), list):
168
+ out["required"] = resolved["required"]
169
+ return out
170
+
171
+
172
+ # ── operations ────────────────────────────────────────────────────────────────
173
+
174
+
175
+ def _iter_operations(doc: dict) -> Iterator[tuple[str, str, dict, list]]:
176
+ """Yield ``(path, METHOD, operation, shared_parameters)`` for every operation."""
177
+ paths = doc.get("paths")
178
+ if not isinstance(paths, dict):
179
+ return
180
+ for path, item in paths.items():
181
+ if not isinstance(item, dict):
182
+ continue
183
+ shared = item.get("parameters", []) if isinstance(item.get("parameters"), list) else []
184
+ for method in _METHODS:
185
+ op = item.get(method)
186
+ if isinstance(op, dict):
187
+ yield path, method.upper(), op, shared
188
+
189
+
190
+ def _servers(doc: dict) -> list[str]:
191
+ if isinstance(doc.get("servers"), list):
192
+ return [s["url"] for s in doc["servers"] if isinstance(s, dict) and isinstance(s.get("url"), str)]
193
+ # Swagger 2.0: host + basePath + schemes.
194
+ host = doc.get("host")
195
+ if not host:
196
+ return []
197
+ base = doc.get("basePath", "") or ""
198
+ raw_schemes = doc.get("schemes")
199
+ schemes = raw_schemes if isinstance(raw_schemes, list) else ["https"]
200
+ return [f"{scheme}://{host}{base}" for scheme in schemes]
201
+
202
+
203
+ def summarize_spec(doc: dict, *, tag: str | None = None, path_filter: str | None = None, max_operations: int = MAX_OPERATIONS) -> dict:
204
+ """Overview: API info, servers, and a bounded list of operations."""
205
+ info = doc.get("info", {}) if isinstance(doc.get("info"), dict) else {}
206
+ operations: list[dict] = []
207
+ total = 0
208
+ for path, method, op, _shared in _iter_operations(doc):
209
+ if tag is not None and tag not in (op.get("tags") or []):
210
+ continue
211
+ if path_filter is not None and path_filter not in path:
212
+ continue
213
+ total += 1
214
+ if len(operations) < max_operations:
215
+ summary = op.get("summary") or (op.get("description") or "")[:120] or None
216
+ operations.append(
217
+ {
218
+ "method": method,
219
+ "path": path,
220
+ "operation_id": op.get("operationId"),
221
+ "summary": summary,
222
+ "tags": op.get("tags") or [],
223
+ }
224
+ )
225
+ return {
226
+ "title": info.get("title"),
227
+ "version": info.get("version"),
228
+ "spec_version": doc.get("openapi") or doc.get("swagger"),
229
+ "servers": _servers(doc),
230
+ "operation_count": total,
231
+ "returned": len(operations),
232
+ "truncated": total > len(operations),
233
+ "operations": operations,
234
+ }
235
+
236
+
237
+ def _find_operation(doc: dict, operation_id: str | None, path: str | None, method: str | None) -> tuple[str, str, dict, list] | None:
238
+ want_method = method.upper() if method else None
239
+ for p, m, op, shared in _iter_operations(doc):
240
+ if operation_id is not None and op.get("operationId") == operation_id:
241
+ return p, m, op, shared
242
+ if path is not None and p == path and (want_method is None or m == want_method):
243
+ return p, m, op, shared
244
+ return None
245
+
246
+
247
+ def _parameter(doc: dict, raw: Any) -> dict | None:
248
+ raw = _deref(doc, raw)
249
+ if not isinstance(raw, dict):
250
+ return None
251
+ # OpenAPI 3 nests the type under `schema`; Swagger 2 puts `type` on the param.
252
+ schema = raw.get("schema")
253
+ if schema is None and "type" in raw:
254
+ schema = {"type": raw["type"], **({"format": raw["format"]} if "format" in raw else {})}
255
+ return {
256
+ "name": raw.get("name"),
257
+ "in": raw.get("in"),
258
+ "required": bool(raw.get("required")),
259
+ "description": raw.get("description"),
260
+ "type": short_type(doc, schema or {}),
261
+ "schema": summarize_schema(doc, schema) if schema else {},
262
+ }
263
+
264
+
265
+ def _request_body(doc: dict, op: dict) -> dict | None:
266
+ rb = op.get("requestBody")
267
+ if not isinstance(rb, dict):
268
+ return None
269
+ rb = _deref(doc, rb)
270
+ content = rb.get("content") if isinstance(rb.get("content"), dict) else {}
271
+ out: dict = {"required": bool(rb.get("required")), "content_types": list(content.keys()), "schema": None}
272
+ for ctype, media in content.items():
273
+ if isinstance(media, dict) and "schema" in media:
274
+ out["selected_content_type"] = ctype
275
+ out["schema"] = summarize_schema(doc, media["schema"])
276
+ break
277
+ return out
278
+
279
+
280
+ def _responses(doc: dict, op: dict) -> list[dict]:
281
+ out: list[dict] = []
282
+ raw_responses = op.get("responses")
283
+ responses = raw_responses if isinstance(raw_responses, dict) else {}
284
+ for code, resp in responses.items():
285
+ resp = _deref(doc, resp) if isinstance(resp, dict) else {}
286
+ schema = None
287
+ content = resp.get("content")
288
+ if isinstance(content, dict):
289
+ for media in content.values():
290
+ if isinstance(media, dict) and "schema" in media:
291
+ schema = summarize_schema(doc, media["schema"])
292
+ break
293
+ elif "schema" in resp: # Swagger 2.0
294
+ schema = summarize_schema(doc, resp["schema"])
295
+ out.append({"status": str(code), "description": resp.get("description"), "schema": schema})
296
+ return out
297
+
298
+
299
+ def describe_operation(doc: dict, *, operation_id: str | None = None, path: str | None = None, method: str | None = None) -> dict:
300
+ """Drill into one operation: parameters, request body, and responses (schemas resolved)."""
301
+ match = _find_operation(doc, operation_id, path, method)
302
+ if match is None:
303
+ target = operation_id or (f"{(method or '').upper()} {path}".strip() if path else None) or "(none given)"
304
+ raise SpecError(f"operation not found: {target}")
305
+ p, m, op, shared = match
306
+ parameters = [param for raw in [*shared, *(op.get("parameters") or [])] if (param := _parameter(doc, raw)) is not None]
307
+ return {
308
+ "method": m,
309
+ "path": p,
310
+ "operation_id": op.get("operationId"),
311
+ "summary": op.get("summary"),
312
+ "description": op.get("description"),
313
+ "tags": op.get("tags") or [],
314
+ "parameters": parameters,
315
+ "request_body": _request_body(doc, op),
316
+ "responses": _responses(doc, op),
317
+ }
@@ -0,0 +1,270 @@
1
+ """TLS certificate + connection inspection — stdlib ``ssl`` / ``socket`` only.
2
+
3
+ The inspection connection deliberately disables verification (``CERT_NONE``): a
4
+ cert *checker* must still complete the handshake against an expired, self-signed
5
+ or otherwise untrusted endpoint in order to report on it. The trust result is
6
+ captured separately by a second, *verifying* connection and surfaced via
7
+ ``authorized`` / ``authorization_error`` rather than raising.
8
+
9
+ The network I/O is confined to :func:`connect_peer`; the reporting functions
10
+ below are pure so they can be unit-tested without a socket.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import socket
17
+ import ssl
18
+ import tempfile
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ from runspec_webops_core.errors import ConnectError
24
+
25
+ # Certificate freshness buckets.
26
+ OK = "ok"
27
+ EXPIRING = "expiring"
28
+ EXPIRED = "expired"
29
+
30
+ # ── data model ────────────────────────────────────────────────────────────────
31
+
32
+
33
+ @dataclass
34
+ class CertInfo:
35
+ """One certificate's parsed fields plus its SHA-256 fingerprint."""
36
+
37
+ subject: str
38
+ issuer: str
39
+ valid_from: str
40
+ valid_to: str
41
+ alt_names: list[str] = field(default_factory=list)
42
+ serial_number: str = ""
43
+ fingerprint_sha256: str = ""
44
+ self_signed: bool = False
45
+
46
+
47
+ @dataclass
48
+ class TlsPeer:
49
+ """The result of a single TLS inspection connection."""
50
+
51
+ chain: list[CertInfo]
52
+ protocol: str | None
53
+ cipher_name: str | None
54
+ cipher_version: str | None
55
+ authorized: bool
56
+ authorization_error: str | None
57
+
58
+ @property
59
+ def leaf(self) -> CertInfo | None:
60
+ return self.chain[0] if self.chain else None
61
+
62
+
63
+ # ── pure reporting ────────────────────────────────────────────────────────────
64
+
65
+
66
+ def days_until(not_after: str, now: float | None = None) -> int:
67
+ """Whole days until an X.509 ``notAfter`` string (``'Jun 4 23:59:59 2026 GMT'``).
68
+
69
+ Negative when already expired. Raises ``ValueError`` when unparseable.
70
+ """
71
+ expiry = ssl.cert_time_to_seconds(not_after)
72
+ if now is None:
73
+ now = datetime.now(timezone.utc).timestamp()
74
+ return int((expiry - now) // 86_400)
75
+
76
+
77
+ def cert_status(days_remaining: int, warn_days: int) -> str:
78
+ """Bucket a certificate by days-to-expiry. Pure."""
79
+ if days_remaining < 0:
80
+ return EXPIRED
81
+ if days_remaining <= warn_days:
82
+ return EXPIRING
83
+ return OK
84
+
85
+
86
+ def _rdn_get(name: tuple, key: str) -> str:
87
+ """Pull a value out of an ``ssl`` RDN structure (tuple of relative-DN sets)."""
88
+ for rdn in name or ():
89
+ for pair in rdn:
90
+ if len(pair) == 2 and pair[0] == key:
91
+ return str(pair[1])
92
+ return ""
93
+
94
+
95
+ def _common_name(name: tuple) -> str:
96
+ return _rdn_get(name, "commonName")
97
+
98
+
99
+ def _issuer_label(name: tuple) -> str:
100
+ return _rdn_get(name, "commonName") or _rdn_get(name, "organizationName")
101
+
102
+
103
+ def _alt_names(san: tuple | None) -> list[str]:
104
+ """Flatten an ``ssl`` ``subjectAltName`` tuple into a list of DNS names."""
105
+ return [str(value) for (kind, value) in (san or ()) if kind == "DNS"]
106
+
107
+
108
+ def cert_info_from_ssl(info: dict, der: bytes) -> CertInfo:
109
+ """Build a :class:`CertInfo` from an ``ssl`` cert dict plus the DER bytes.
110
+
111
+ ``info`` is the shape returned by ``getpeercert()`` / ``Certificate.get_info()``.
112
+ ``self_signed`` is decided structurally (subject == issuer) rather than by a
113
+ trust check, so it holds for an inspection-only connection.
114
+ """
115
+ subject = _common_name(info.get("subject", ()))
116
+ issuer = _issuer_label(info.get("issuer", ()))
117
+ return CertInfo(
118
+ subject=subject,
119
+ issuer=issuer,
120
+ valid_from=str(info.get("notBefore", "")),
121
+ valid_to=str(info.get("notAfter", "")),
122
+ alt_names=_alt_names(info.get("subjectAltName")),
123
+ serial_number=str(info.get("serialNumber", "")),
124
+ fingerprint_sha256=_fingerprint(der),
125
+ self_signed=info.get("subject", ()) == info.get("issuer", ()),
126
+ )
127
+
128
+
129
+ def _fingerprint(der: bytes) -> str:
130
+ """Colon-separated uppercase SHA-256 of a DER certificate (OpenSSL style)."""
131
+ hexed = hashlib.sha256(der).hexdigest().upper()
132
+ return ":".join(hexed[i : i + 2] for i in range(0, len(hexed), 2))
133
+
134
+
135
+ def leaf_report(peer: TlsPeer, warn_days: int, now: float | None = None) -> dict:
136
+ """The cert-check view: leaf cert fields + freshness status + trust result."""
137
+ leaf = peer.leaf
138
+ if leaf is None:
139
+ raise ConnectError("no peer certificate was presented")
140
+ try:
141
+ days = days_until(leaf.valid_to, now)
142
+ status = cert_status(days, warn_days)
143
+ except ValueError:
144
+ days, status = -1, EXPIRED
145
+ return {
146
+ "subject": leaf.subject,
147
+ "issuer": leaf.issuer,
148
+ "valid_from": leaf.valid_from,
149
+ "valid_to": leaf.valid_to,
150
+ "days_remaining": days,
151
+ "status": status,
152
+ "alt_names": leaf.alt_names,
153
+ "serial_number": leaf.serial_number,
154
+ "fingerprint_sha256": leaf.fingerprint_sha256,
155
+ "authorized": peer.authorized,
156
+ "authorization_error": peer.authorization_error,
157
+ }
158
+
159
+
160
+ def chain_report(peer: TlsPeer, now: float | None = None) -> list[dict]:
161
+ """The cert-chain view: one entry per cert, leaf → … → root."""
162
+ out: list[dict] = []
163
+ for link in peer.chain:
164
+ try:
165
+ days = days_until(link.valid_to, now)
166
+ except ValueError:
167
+ days = -1
168
+ out.append(
169
+ {
170
+ "subject": link.subject,
171
+ "issuer": link.issuer,
172
+ "valid_to": link.valid_to,
173
+ "days_remaining": days,
174
+ "self_signed": link.self_signed,
175
+ }
176
+ )
177
+ return out
178
+
179
+
180
+ # ── network I/O ───────────────────────────────────────────────────────────────
181
+
182
+
183
+ def _peer_chain(ssock: ssl.SSLSocket) -> list[CertInfo]:
184
+ """Return the server-presented certificate chain (leaf first).
185
+
186
+ Prefers the public ``SSLSocket.get_unverified_chain()`` (Python 3.13+),
187
+ falls back to the underlying ``_sslobj`` method (3.10–3.12), and finally to
188
+ the leaf-only certificate via ``getpeercert`` when neither is available.
189
+ """
190
+ getter = getattr(ssock, "get_unverified_chain", None)
191
+ if getter is None:
192
+ sslobj = getattr(ssock, "_sslobj", None)
193
+ getter = getattr(sslobj, "get_unverified_chain", None) if sslobj is not None else None
194
+
195
+ if getter is not None:
196
+ try:
197
+ raw = getter()
198
+ except Exception:
199
+ raw = None
200
+ if raw:
201
+ out: list[CertInfo] = []
202
+ for cert in raw:
203
+ der = ssl.PEM_cert_to_DER_cert(cert.public_bytes())
204
+ out.append(cert_info_from_ssl(cert.get_info(), der))
205
+ return out
206
+
207
+ # Fallback: only the leaf is recoverable on this interpreter.
208
+ leaf_der = ssock.getpeercert(binary_form=True)
209
+ if not leaf_der:
210
+ return []
211
+ return [cert_info_from_ssl(_decode_der(leaf_der), leaf_der)]
212
+
213
+
214
+ def _decode_der(der: bytes) -> dict:
215
+ """Parse a DER certificate into the ``getpeercert`` dict via the stdlib decoder."""
216
+ pem = ssl.DER_cert_to_PEM_cert(der)
217
+ with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as fh:
218
+ fh.write(pem)
219
+ path = fh.name
220
+ try:
221
+ import _ssl # noqa: PLC0415 — stdlib helper, imported lazily for the fallback path
222
+
223
+ return dict(_ssl._test_decode_cert(path)) # type: ignore[attr-defined]
224
+ finally:
225
+ Path(path).unlink(missing_ok=True)
226
+
227
+
228
+ def _verify_trust(host: str, port: int, servername: str, timeout: float) -> tuple[bool, str | None]:
229
+ """Second connection with full verification — captures the trust verdict."""
230
+ ctx = ssl.create_default_context()
231
+ try:
232
+ with socket.create_connection((host, port), timeout=timeout) as sock, ctx.wrap_socket(sock, server_hostname=servername):
233
+ return True, None
234
+ except ssl.SSLCertVerificationError as e:
235
+ return False, getattr(e, "verify_message", None) or str(e)
236
+ except ssl.SSLError as e:
237
+ return False, str(e)
238
+ except (OSError, TimeoutError) as e:
239
+ # The unverified connection already succeeded, so a failure here is the
240
+ # trust handshake itself (e.g. a TLS alert), not unreachability.
241
+ return False, str(e)
242
+
243
+
244
+ def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername: str | None = None) -> TlsPeer:
245
+ """Open a TLS connection and capture the cert chain, protocol, cipher, trust.
246
+
247
+ Raises :class:`ConnectError` only when the endpoint is unreachable — an
248
+ untrusted / expired certificate is reported, not raised.
249
+ """
250
+ sni = servername or host
251
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
252
+ ctx.check_hostname = False
253
+ ctx.verify_mode = ssl.CERT_NONE
254
+ try:
255
+ with socket.create_connection((host, port), timeout=timeout) as sock, ctx.wrap_socket(sock, server_hostname=sni) as ssock:
256
+ protocol = ssock.version()
257
+ cipher = ssock.cipher()
258
+ chain = _peer_chain(ssock)
259
+ except (OSError, TimeoutError, ssl.SSLError) as e:
260
+ raise ConnectError(f"connection to {host}:{port} failed: {e}") from e
261
+
262
+ authorized, auth_error = _verify_trust(host, port, sni, timeout)
263
+ return TlsPeer(
264
+ chain=chain,
265
+ protocol=protocol,
266
+ cipher_name=cipher[0] if cipher else None,
267
+ cipher_version=cipher[1] if cipher else None,
268
+ authorized=authorized,
269
+ authorization_error=auth_error,
270
+ )
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-webops-core
3
+ Version: 0.1.0
4
+ Summary: Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable core behind the runspec web runnables (no runspec dependency, no runnables)
5
+ Requires-Python: >=3.10
6
+ Provides-Extra: dev
7
+ Requires-Dist: mypy; extra == 'dev'
8
+ Requires-Dist: pytest>=8.0; extra == 'dev'
9
+ Requires-Dist: pyyaml>=6.0; extra == 'dev'
10
+ Requires-Dist: ruff; extra == 'dev'
11
+ Requires-Dist: types-pyyaml; extra == 'dev'
12
+ Provides-Extra: yaml
13
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
@@ -0,0 +1,9 @@
1
+ runspec_webops_core/__init__.py,sha256=0Ps89jzfI58c4GCIkIvJIlhH7HsU9YaC6w4C0KODWqs,1739
2
+ runspec_webops_core/dns.py,sha256=eogx6Wej8zMnc4bMaIDUD27i8BVpcXLR6xnWIo7Urv0,2011
3
+ runspec_webops_core/errors.py,sha256=Ihv-Z8JIMHVJ06r8BSBoY3d5Q8MPAqpJimALlgT4FFI,550
4
+ runspec_webops_core/http.py,sha256=lVUYP8MMPZlGE88UEj3fqg9tykcXHLwxJu930M_T9lA,8687
5
+ runspec_webops_core/openapi.py,sha256=fegjPJRdV0C0D9nZrWY5nbF9Vs71Beb2ZaXaZZxGpBY,13087
6
+ runspec_webops_core/tls.py,sha256=7GEmEPv_Zj2sr64Fq1fQ1zYN2C9OowkksvE0jx3dkAc,9884
7
+ runspec_webops_core-0.1.0.dist-info/METADATA,sha256=E5ee_6uY8qvpX9VxcRd8_bPMLFgeA5JEp7_0NTECNb4,535
8
+ runspec_webops_core-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ runspec_webops_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any