MAS-WP-0005-T02: SCP-1 quoted local-parts equivalent on receipt
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
This commit is contained in:
parent
5b57a9941e
commit
0a2d811444
9 changed files with 70 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue