mail-address-semantics/mas_core/parser.py
tegwick 5b57a9941e MAS-WP-0005-T02: SCP-2 lower-case resource types (spec v0.1.1)
Grammar, statute schema key pattern, parser (is_resource_type, also used
by statute lint), reference parser, spec 6.1 and a new CHANGELOG. Fixtures
p1-042..045; prop-case-001 Ticket=PR42 is now invalid.

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 18:08:36 +02:00

174 lines
6.5 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
import re
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("#%&'*-/?_`{|}~")
# v0.1.1 (SCP-2): lower-case words joined by single "-" or "_".
_RESOURCE_TYPE = re.compile(r"[a-z0-9]+(?:[_-][a-z0-9]+)*")
VALUE_CHARS = ATOM_CHARS | frozenset("=") # resource-id-char and value-char
RESERVED_OPERATORS = frozenset("$^")
def is_resource_type(name: str) -> bool:
return _RESOURCE_TYPE.fullmatch(name) is not None
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 is_resource_type(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