"""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) def unquote_local_part(local: str) -> str: """Drop quotes a receiver cannot distinguish (v0.1.1, SCP-1). `"ticket=PR42"` is not canonical, but RFC 5321 treats it as equivalent to `ticket=PR42` and MTAs strip such quotes, so receivers must too. Quoted content with quoted-pairs or inner quotes is left alone and stays non-canonical. """ if len(local) >= 2 and local[0] == local[-1] == '"' and not any(c in '"\\' for c in local[1:-1]): return local[1:-1] return local def parse_received_address(value: str) -> ParsedAddress: """Parse a recipient as a receiver sees it: unnecessary quotes removed.""" local, domain = split_address(value) return ParsedAddress(local=parse_local_part(unquote_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