Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 252470@bnt-lap001 Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
257 lines
9.6 KiB
Python
257 lines
9.6 KiB
Python
"""Exact-host MAS statute discovery.
|
|
|
|
`discover(host)` fetches https://HOST/.well-known/mas and returns either a
|
|
`Discovered` (validated statute) or a typed `DiscoveryFailure`. It never
|
|
walks parent domains. Redirects are followed only within the same origin
|
|
(scheme, host, port); cross-host redirects and scheme downgrades fail.
|
|
|
|
Discovery failure never invalidates an email address; it only means MAS
|
|
namespace semantics are unconfirmed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.client
|
|
import json
|
|
import ssl
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Callable, Optional, Union
|
|
from urllib.parse import urljoin, urlsplit
|
|
|
|
from .statute import (
|
|
SUPPORTED_VERSIONS,
|
|
InheritanceCycle,
|
|
Statute,
|
|
StatuteInvalid,
|
|
build_statute,
|
|
canonical_host,
|
|
resolve_inheritance,
|
|
)
|
|
|
|
WELL_KNOWN_PATH = "/.well-known/mas"
|
|
MAX_BYTES = 256 * 1024
|
|
MAX_REDIRECTS = 5
|
|
|
|
|
|
class FailureKind(str, Enum):
|
|
InvalidHost = "InvalidHost"
|
|
NotFound = "NotFound"
|
|
HttpFailure = "HttpFailure"
|
|
TransportFailure = "TransportFailure"
|
|
InvalidContentType = "InvalidContentType"
|
|
CrossHostRedirect = "CrossHostRedirect"
|
|
SchemeDowngrade = "SchemeDowngrade"
|
|
TooManyRedirects = "TooManyRedirects"
|
|
NotJson = "NotJson"
|
|
UnsupportedVersion = "UnsupportedVersion"
|
|
SchemaInvalid = "SchemaInvalid"
|
|
SemanticInvalid = "SemanticInvalid"
|
|
NamespaceMismatch = "NamespaceMismatch"
|
|
InheritanceCycle = "InheritanceCycle"
|
|
InheritanceFailure = "InheritanceFailure"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DiscoveryFailure:
|
|
host: str
|
|
url: str
|
|
kind: FailureKind
|
|
detail: str
|
|
cached: bool = False
|
|
ok = False
|
|
|
|
def to_dict(self) -> dict:
|
|
return {"ok": False, "host": self.host, "url": self.url, "failure": self.kind.value,
|
|
"detail": self.detail, "cached": self.cached}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Discovered:
|
|
host: str
|
|
url: str
|
|
statute: Statute
|
|
cached: bool = False
|
|
ok = True
|
|
|
|
def to_dict(self) -> dict:
|
|
return {"ok": True, "host": self.host, "url": self.url, "cached": self.cached,
|
|
"statute": self.statute.to_dict()}
|
|
|
|
|
|
DiscoveryResult = Union[Discovered, DiscoveryFailure]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Response:
|
|
status: int
|
|
headers: dict # lower-cased header names
|
|
body: bytes
|
|
|
|
|
|
Transport = Callable[[str, float], Response]
|
|
|
|
|
|
def http_transport(url: str, timeout: float) -> Response:
|
|
"""One GET, no redirect following, bounded body. Raises OSError on failure."""
|
|
parts = urlsplit(url)
|
|
if parts.scheme == "https":
|
|
conn = http.client.HTTPSConnection(parts.hostname, parts.port, timeout=timeout,
|
|
context=ssl.create_default_context())
|
|
else:
|
|
conn = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout)
|
|
try:
|
|
conn.request("GET", parts.path or "/", headers={"Accept": "application/json",
|
|
"User-Agent": "mas-core/0.1"})
|
|
resp = conn.getresponse()
|
|
body = resp.read(MAX_BYTES + 1)
|
|
return Response(resp.status, {k.lower(): v for k, v in resp.getheaders()}, body)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class _Fail(Exception):
|
|
def __init__(self, kind: FailureKind, detail: str, url: str):
|
|
super().__init__(detail)
|
|
self.kind, self.detail, self.url = kind, detail, url
|
|
|
|
|
|
def _origin(url: str):
|
|
p = urlsplit(url)
|
|
default = {"https": 443, "http": 80}.get(p.scheme)
|
|
return (p.scheme, (p.hostname or "").lower(), p.port or default)
|
|
|
|
|
|
def _fetch_json(url: str, transport: Transport, timeout: float) -> tuple[str, object]:
|
|
"""GET with same-origin redirects only; returns (final_url, decoded JSON)."""
|
|
start = _origin(url)
|
|
for _ in range(MAX_REDIRECTS + 1):
|
|
try:
|
|
resp = transport(url, timeout)
|
|
except (OSError, http.client.HTTPException) as e:
|
|
raise _Fail(FailureKind.TransportFailure, f"{type(e).__name__}: {e}", url)
|
|
if resp.status in (301, 302, 303, 307, 308):
|
|
loc = resp.headers.get("location")
|
|
if not loc:
|
|
raise _Fail(FailureKind.HttpFailure, f"{resp.status} without Location", url)
|
|
nxt = urljoin(url, loc)
|
|
o = _origin(nxt)
|
|
if o[0] != start[0]:
|
|
kind = FailureKind.SchemeDowngrade if o[0] == "http" else FailureKind.CrossHostRedirect
|
|
raise _Fail(kind, f"redirect to {nxt} changes scheme", url)
|
|
if o != start:
|
|
raise _Fail(FailureKind.CrossHostRedirect,
|
|
f"redirect to {nxt} would transfer MAS authority", url)
|
|
url = nxt
|
|
continue
|
|
if resp.status == 404 or resp.status == 410:
|
|
raise _Fail(FailureKind.NotFound, f"HTTP {resp.status}", url)
|
|
if not 200 <= resp.status < 300:
|
|
raise _Fail(FailureKind.HttpFailure, f"HTTP {resp.status}", url)
|
|
ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
if not (ctype == "application/json" or (ctype.startswith("application/") and ctype.endswith("+json"))):
|
|
raise _Fail(FailureKind.InvalidContentType, f"content-type {ctype or '(none)'!r}", url)
|
|
if len(resp.body) > MAX_BYTES:
|
|
raise _Fail(FailureKind.HttpFailure, f"statute exceeds {MAX_BYTES} bytes", url)
|
|
try:
|
|
return url, json.loads(resp.body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
raise _Fail(FailureKind.NotJson, str(e), url)
|
|
raise _Fail(FailureKind.TooManyRedirects, f"more than {MAX_REDIRECTS} redirects", url)
|
|
|
|
|
|
def _validate(doc, host: str, url: str) -> Statute:
|
|
if isinstance(doc, dict) and "mas_version" in doc and doc["mas_version"] not in SUPPORTED_VERSIONS:
|
|
raise _Fail(FailureKind.UnsupportedVersion, f"mas_version {doc['mas_version']!r}", url)
|
|
try:
|
|
return build_statute(doc, host, statute_url=url)
|
|
except StatuteInvalid as e:
|
|
if "namespace-mismatch" in e.codes:
|
|
raise _Fail(FailureKind.NamespaceMismatch, str(e), url)
|
|
if "inheritance-cycle" in e.codes:
|
|
raise _Fail(FailureKind.InheritanceCycle, str(e), url)
|
|
kind = FailureKind.SchemaInvalid if e.layer == "schema" else FailureKind.SemanticInvalid
|
|
raise _Fail(kind, str(e), url)
|
|
|
|
|
|
def statute_url(host: str, *, scheme: str = "https", port: Optional[int] = None) -> str:
|
|
netloc = host if port is None else f"{host}:{port}"
|
|
return f"{scheme}://{netloc}{WELL_KNOWN_PATH}"
|
|
|
|
|
|
@dataclass
|
|
class DiscoveryCache:
|
|
"""Minimal cache. Successes and failures are stored and expire separately;
|
|
a cached failure is returned as the same typed failure, marked cached."""
|
|
|
|
success_ttl: float = 3600.0
|
|
failure_ttl: float = 60.0
|
|
clock: Callable[[], float] = time.monotonic
|
|
_ok: dict = field(default_factory=dict)
|
|
_fail: dict = field(default_factory=dict)
|
|
|
|
def get(self, key) -> Optional[DiscoveryResult]:
|
|
for store in (self._ok, self._fail):
|
|
hit = store.get(key)
|
|
if hit and hit[0] > self.clock():
|
|
r = hit[1]
|
|
return type(r)(**{**r.__dict__, "cached": True})
|
|
store.pop(key, None)
|
|
return None
|
|
|
|
def put(self, key, result: DiscoveryResult) -> None:
|
|
if result.ok:
|
|
self._fail.pop(key, None)
|
|
self._ok[key] = (self.clock() + self.success_ttl, result)
|
|
else:
|
|
self._fail[key] = (self.clock() + self.failure_ttl, result)
|
|
|
|
|
|
def discover(host: str, *, transport: Transport = http_transport, timeout: float = 5.0,
|
|
scheme: str = "https", port: Optional[int] = None,
|
|
resolve_inherits: bool = True,
|
|
cache: Optional[DiscoveryCache] = None) -> DiscoveryResult:
|
|
"""Discover the statute for exactly `host`. Never consults parent domains.
|
|
|
|
`scheme="http"` and `port` exist only for local fixture servers in tests;
|
|
production discovery is HTTPS on the default port.
|
|
"""
|
|
if scheme not in ("https", "http"):
|
|
raise ValueError("scheme must be https (or http for local test fixtures)")
|
|
raw_host = host
|
|
host = canonical_host(host)
|
|
url = statute_url(host, scheme=scheme, port=port)
|
|
if not host or any(c in host for c in "/@:?# ") :
|
|
return DiscoveryFailure(raw_host, url, FailureKind.InvalidHost, "not a hostname")
|
|
key = (scheme, host, port)
|
|
if cache is not None:
|
|
hit = cache.get(key)
|
|
if hit is not None:
|
|
return hit
|
|
result = _discover_uncached(host, url, transport, timeout, resolve_inherits)
|
|
if cache is not None:
|
|
cache.put(key, result)
|
|
return result
|
|
|
|
|
|
def _discover_uncached(host, url, transport, timeout, resolve_inherits) -> DiscoveryResult:
|
|
try:
|
|
final_url, doc = _fetch_json(url, transport, timeout)
|
|
statute = _validate(doc, host, url)
|
|
if statute.inherits and resolve_inherits:
|
|
def fetch_parent(purl: str) -> Statute:
|
|
phost = urlsplit(purl).hostname or ""
|
|
try:
|
|
_, pdoc = _fetch_json(purl, transport, timeout)
|
|
return _validate(pdoc, phost, purl)
|
|
except _Fail as e:
|
|
raise _Fail(FailureKind.InheritanceFailure,
|
|
f"parent {purl}: {e.kind.value}: {e.detail}", url)
|
|
try:
|
|
statute = resolve_inheritance(statute, url, fetch_parent)
|
|
except InheritanceCycle as e:
|
|
raise _Fail(FailureKind.InheritanceCycle, str(e), url)
|
|
return Discovered(host, url, statute)
|
|
except _Fail as e:
|
|
return DiscoveryFailure(host, e.url, e.kind, e.detail)
|