diff --git a/mas-v0.1-spec/CHANGELOG.md b/mas-v0.1-spec/CHANGELOG.md index 08cf044..02ebe88 100644 --- a/mas-v0.1-spec/CHANGELOG.md +++ b/mas-v0.1-spec/CHANGELOG.md @@ -1,5 +1,11 @@ # MAS specification changelog +- **SCP-1, quoted equivalence.** A quoted local-part whose content is a + valid canonical MAS local-part (no quoted-pairs) is equivalent to the + unquoted form. Receivers treat them identically; generators emit the + unquoted form; canonical parsers still reject the quoted form (§4). + Fixture p1-040 note updated. + ## v0.1.1 (errata, in progress) Errata from the reference experiment and the live provider test diff --git a/mas-v0.1-spec/SPECIFICATION.md b/mas-v0.1-spec/SPECIFICATION.md index fe871dd..a916999 100644 --- a/mas-v0.1-spec/SPECIFICATION.md +++ b/mas-v0.1-spec/SPECIFICATION.md @@ -72,6 +72,13 @@ A MAS-aware implementation MAY transport such non-canonical email addresses as ordinary email, but MUST NOT claim that they are canonical MAS v0.1 addresses. +A quoted local-part whose content, after removing the quotes and with no +quoted-pairs, is a valid canonical MAS local-part is equivalent to that +unquoted form (RFC 5321 treats unnecessary quoting as equivalent, and MTAs +remove such quotes in transit). Receivers MUST treat the two identically. +Generators MUST emit the unquoted form. Parsers that check canonical syntax +still reject the quoted form. + ### 4.1 Target A target is non-empty. diff --git a/mas-v0.1-spec/conformance/addresses.json b/mas-v0.1-spec/conformance/addresses.json index 5347be7..8012efd 100644 --- a/mas-v0.1-spec/conformance/addresses.json +++ b/mas-v0.1-spec/conformance/addresses.json @@ -303,7 +303,8 @@ "local_part": "\"ticket=PR42\"", "profile": "p1", "valid": false, - "error": "quoted local-part outside canonical MAS v0.1" + "error": "quoted local-part outside canonical MAS v0.1", + "note": "v0.1.1 (SCP-1): not canonical, but a receiver treats it as equivalent to ticket=PR42" }, { "id": "p1-041", diff --git a/mas_conformance/addresses.json b/mas_conformance/addresses.json index 5347be7..8012efd 100644 --- a/mas_conformance/addresses.json +++ b/mas_conformance/addresses.json @@ -303,7 +303,8 @@ "local_part": "\"ticket=PR42\"", "profile": "p1", "valid": false, - "error": "quoted local-part outside canonical MAS v0.1" + "error": "quoted local-part outside canonical MAS v0.1", + "note": "v0.1.1 (SCP-1): not canonical, but a receiver treats it as equivalent to ticket=PR42" }, { "id": "p1-041", diff --git a/mas_core/parser.py b/mas_core/parser.py index f21408f..86838db 100644 --- a/mas_core/parser.py +++ b/mas_core/parser.py @@ -141,6 +141,25 @@ def parse_address(value: str) -> ParsedAddress: 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 diff --git a/mas_gateway/core.py b/mas_gateway/core.py index 9d378a9..1205fce 100644 --- a/mas_gateway/core.py +++ b/mas_gateway/core.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import Callable, Optional from mas_core.discovery import Discovered, DiscoveryCache, DiscoveryResult, discover -from mas_core.parser import MasSyntaxError, parse_address +from mas_core.parser import MasSyntaxError, parse_received_address from mas_core.policy import resolve from mas_core.statute import build_statute @@ -80,7 +80,7 @@ class Resolver: def build_event(recipient: str, message: dict, resolver: Resolver) -> dict: try: - addr = parse_address(recipient) + addr = parse_received_address(recipient) except MasSyntaxError as e: mas = {"valid": False, "error": e.code, "detail": str(e)} return {"mas_event_version": EVENT_VERSION, "recipient": recipient, "mas": mas, "message": message} diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index cd8ade8..9c7033b 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -130,8 +130,12 @@ def test_unconfirmed_namespace_is_not_acted_on(env): def test_non_mas_recipient_still_forwarded(env): _, _, _, resolver = env - ev = build_event(f'"quoted"@{D}', {}, resolver) - assert ev["mas"]["valid"] is False and ev["recipient"] == f'"quoted"@{D}' + ev = build_event(f'"not mas"@{D}', {}, resolver) + assert ev["mas"]["valid"] is False and ev["recipient"] == f'"not mas"@{D}' + # SCP-1: unnecessary quotes are equivalent to the unquoted form + ev = build_event(f'"ticket=GS-1"@{D}', {}, resolver) + assert ev["mas"]["valid"] is True and ev["mas"]["equivalence_key"] == "ticket=GS-1" + assert ev["recipient"] == f'"ticket=GS-1"@{D}' # the event keeps what arrived def test_pipe_transport_subprocess(env): diff --git a/tests/test_parser_conformance.py b/tests/test_parser_conformance.py index 265ebe6..45b17e5 100644 --- a/tests/test_parser_conformance.py +++ b/tests/test_parser_conformance.py @@ -12,6 +12,7 @@ from mas_core.parser import ( MasSyntaxError, parse_address, parse_local_part, + parse_received_address, validate_profile, ) @@ -111,3 +112,23 @@ def test_generator_rules(): p = parse_local_part(f["local_part"]) with pytest.raises(MasSyntaxError): generate_local_part(str(p.target), facet=p.facet.value, interaction=p.interaction.mode) + + +@pytest.mark.parametrize("raw, expected", [ + ('"ticket=PR42"@example.com', "ticket=PR42"), # SCP-1: unnecessary quotes + ('"ticket!callback"@example.com', "ticket!callback"), + ("ticket=PR42@example.com", "ticket=PR42"), +]) +def test_received_quoted_local_part_is_equivalent(raw, expected): + assert parse_received_address(raw).local.raw == expected + + +@pytest.mark.parametrize("raw", ['"a\\"b"@example.com', '"a b"@example.com', '"x@y"@example.com']) +def test_received_quoted_non_mas_stays_invalid(raw): + with pytest.raises(MasSyntaxError): + parse_received_address(raw) + + +def test_canonical_parser_still_rejects_quotes(): + with pytest.raises(MasSyntaxError): + parse_address('"ticket=PR42"@example.com') diff --git a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md index 600ccc0..2159ece 100644 --- a/workplans/MAS-WP-0005-spec-errata-v0-1-1.md +++ b/workplans/MAS-WP-0005-spec-errata-v0-1-1.md @@ -97,6 +97,11 @@ its own task. 141 tests pass, and the reference parser agrees with all 35 bundle fixtures. SCP-2 text corrected: `Ticket=PR42` becomes non-canonical, not opaque. +- 2026-10-18 SCP-1 done: §4 text; `mas_core.parser.unquote_local_part` and + `parse_received_address` (receiver side; the canonical parser stays + strict); the gateway now parses with them; fixture p1-040 note and + CHANGELOG. The end-to-end test now uses a real non-MAS recipient, + because `"quoted"@` is equivalent to `quoted@`. 148 tests pass. ## Reference service: case-robust ticket ids