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
174 lines
6.5 KiB
Python
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
|