mail-address-semantics/mas_core/parser.py
tegwick 59815701c1 Implement MAS v0.1 reference experiment (MAS-WP-0002 M0-M8, M9 first pass)
- mas_core: P2-maximal parser with required_profile, generator, statute
  schema + semantic lint + defaults-only inheritance, exact-host discovery
  with typed failures and same-origin redirects, pure policy resolver
- mas_conformance: vendored spec fixtures (drift-tested) + property and
  statute cases
- mas CLI: parse / explain / discover / lint-statute / test
- mas-gateway: generic mail -> MAS event -> HTTP (MTA pipe + webhook)
- examples: static great-service.example statute, reference ticket service
- docs/experiment-review.md: first-pass findings; live MTA test still open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 623586@bnt-lap001
Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8
2026-10-08 00:24:43 +02:00

167 lines
6.3 KiB
Python

"""Canonical MAS v0.1 local-part parser.
The parser is local and deterministic: it performs no I/O, consults no
statute, and never infers a semantic kind from ".", "-" or "_".
It always parses with the maximal (P2) grammar and records the profile the
syntax requires. Whether a namespace enables that profile is decided later,
by policy. `validate_profile` checks a parse against a parser conformance
class (P1 or P2) without re-parsing.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from .model import (
Facet,
Interaction,
OpaqueTarget,
ParsedAddress,
ParsedLocalPart,
RequiredProfile,
ResourceTarget,
)
LOCAL_PART_MAX_OCTETS = 64
_ALNUM = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
ATOM_CHARS = _ALNUM | frozenset("#%&'*-/?_`{|}~")
RESOURCE_TYPE_CHARS = _ALNUM | frozenset("-_")
VALUE_CHARS = ATOM_CHARS | frozenset("=") # resource-id-char and value-char
RESERVED_OPERATORS = frozenset("$^")
class MasSyntaxError(ValueError):
"""A local-part is not canonical MAS v0.1 syntax.
`code` is a stable machine-readable identifier; the message is for humans.
"""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
def _dot_string(s: str, allowed: frozenset) -> bool:
if not s or s.startswith(".") or s.endswith(".") or ".." in s:
return False
return all(c == "." or c in allowed for c in s)
def parse_local_part(value: str) -> ParsedLocalPart:
"""Parse a canonical MAS v0.1 local-part with the maximal P2 grammar."""
if not isinstance(value, str) or not value:
raise MasSyntaxError("empty", "empty local-part")
try:
encoded = value.encode("ascii")
except UnicodeEncodeError:
raise MasSyntaxError("non-ascii", "non-ASCII local-parts are outside canonical MAS v0.1")
if '"' in value or "\\" in value:
raise MasSyntaxError("quoted", "quoted or escaped local-parts are outside canonical MAS v0.1")
if "@" in value:
raise MasSyntaxError("at-sign", "'@' is not allowed in a local-part")
if any(c in RESERVED_OPERATORS for c in value):
raise MasSyntaxError("reserved-operator", "'$' and '^' are reserved in MAS v0.1")
if value.count("+") > 1:
raise MasSyntaxError("multiple-facets", "at most one '+' facet operator is allowed")
if value.count("!") > 1:
raise MasSyntaxError("multiple-interactions", "at most one '!' interaction operator is allowed")
plus, bang = value.find("+"), value.find("!")
if plus >= 0 and bang >= 0 and bang < plus:
raise MasSyntaxError("operator-order", "canonical order is TARGET[+FACET][!INTERACTION]")
rest = value
interaction: Optional[Interaction] = None
if bang >= 0:
rest, mode = rest[:bang], rest[bang + 1:]
if not mode:
raise MasSyntaxError("empty-interaction", "interaction mode is empty")
if not _dot_string(mode, VALUE_CHARS):
raise MasSyntaxError("invalid-interaction", f"invalid interaction mode {mode!r}")
interaction = Interaction(mode)
facet: Optional[Facet] = None
if plus >= 0:
rest, fval = rest[:plus], rest[plus + 1:]
if not fval:
raise MasSyntaxError("empty-facet", "facet is empty")
if not _dot_string(fval, VALUE_CHARS):
raise MasSyntaxError("invalid-facet", f"invalid facet {fval!r}")
facet = Facet(fval)
if not rest:
raise MasSyntaxError("empty-target", "target is empty")
if "=" in rest:
# Only the first "=" is structural; later ones are resource-id data.
rtype, rid = rest.split("=", 1)
if not rtype:
raise MasSyntaxError("empty-resource-type", "resource type is empty")
if not rid:
raise MasSyntaxError("empty-resource-id", "resource id is empty")
if not all(c in RESOURCE_TYPE_CHARS for c in rtype):
raise MasSyntaxError("invalid-resource-type", f"invalid resource type {rtype!r}")
if not _dot_string(rid, VALUE_CHARS):
raise MasSyntaxError("invalid-resource-id", f"invalid resource id {rid!r}")
target = ResourceTarget(rtype, rid)
else:
if not _dot_string(rest, ATOM_CHARS):
raise MasSyntaxError("invalid-target", f"invalid opaque target {rest!r}")
target = OpaqueTarget(rest)
if len(encoded) > LOCAL_PART_MAX_OCTETS:
raise MasSyntaxError("too-long", f"local-part is {len(encoded)} octets; SMTP limit is 64")
return ParsedLocalPart(raw=value, target=target, facet=facet, interaction=interaction)
def split_address(value: str) -> tuple[str, str]:
"""Split at the last '@'. The domain is lower-cased (DNS is case-insensitive)."""
if not isinstance(value, str) or "@" not in value:
raise MasSyntaxError("no-domain", "address has no '@domain'")
local, domain = value.rsplit("@", 1)
domain = domain.strip().rstrip(".").lower()
if not domain:
raise MasSyntaxError("no-domain", "address has an empty domain")
return local, domain
def parse_address(value: str) -> ParsedAddress:
local, domain = split_address(value)
return ParsedAddress(local=parse_local_part(local), domain=domain)
@dataclass(frozen=True)
class ValidationResult:
ok: bool
code: Optional[str] = None
message: Optional[str] = None
def __bool__(self) -> bool:
return self.ok
def _profile(p) -> RequiredProfile:
return p if isinstance(p, RequiredProfile) else RequiredProfile(str(p).upper())
def validate_profile(parsed, profile) -> ValidationResult:
"""Check a parse against a parser conformance class (P1 or P2)."""
local = parsed.local if isinstance(parsed, ParsedAddress) else parsed
profile = _profile(profile)
if profile is RequiredProfile.P1 and local.required_profile is RequiredProfile.P2:
return ValidationResult(False, "profile-p2-required",
"'!' interaction requires P2; P1 rejects it as a MAS operator")
return ValidationResult(True)
def parse_for_profile(value: str, profile) -> ParsedLocalPart:
"""Parse and enforce a parser conformance class; raises MasSyntaxError."""
parsed = parse_local_part(value)
result = validate_profile(parsed, profile)
if not result:
raise MasSyntaxError(result.code, result.message)
return parsed