mail-address-semantics/mas_core/discovery.py
tegwick a12e1c4370 MAS-WP-0005-T07: v0.2 draft bundle; mas_core accepts v0.1 and v0.2 statutes
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
2026-10-18 22:19:34 +02:00

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)