"""Tests for RFC 6570 URI template parsing, expansion, and matching.""" import dataclasses import random import string import pytest from mcp.shared.uri_template import DEFAULT_MAX_URI_LENGTH, InvalidUriTemplate, UriTemplate, Variable def test_parse_literal_only(): tmpl = UriTemplate.parse("file://docs/readme.txt") assert tmpl.variables == [] assert tmpl.variable_names == [] assert str(tmpl) == "file://docs/readme.txt" @pytest.mark.parametrize( ("value", "expected"), [ ("file://docs/{name}", True), ("file://docs/readme.txt", False), ("", False), ("{a}", True), ("{", False), ("}", False), ("}{", False), ("prefix{+path}/suffix", True), ("{invalid syntax but still a template}", True), ], ) def test_is_template(value: str, expected: bool): assert UriTemplate.is_template(value) is expected def test_parse_simple_variable(): tmpl = UriTemplate.parse("file://docs/{name}") assert tmpl.variables == [Variable(name="name", operator="")] assert tmpl.variable_names == ["name"] @pytest.mark.parametrize( ("template", "operator"), [ ("{+path}", "+"), ("{#frag}", "#"), ("{.ext}", "."), ("{/seg}", "/"), ("{;param}", ";"), ("{?q}", "?"), ("{&next}", "&"), ], ) def test_parse_all_operators(template: str, operator: str): tmpl = UriTemplate.parse(template) (var,) = tmpl.variables assert var.operator == operator assert var.explode is False def test_parse_multiple_variables_in_expression(): tmpl = UriTemplate.parse("{?q,lang,page}") assert tmpl.variable_names == ["q", "lang", "page"] assert all(v.operator == "?" for v in tmpl.variables) def test_parse_multiple_expressions(): tmpl = UriTemplate.parse("db://{table}/{id}{?format}") assert tmpl.variable_names == ["table", "id", "format"] ops = [v.operator for v in tmpl.variables] assert ops == ["", "", "?"] @pytest.mark.parametrize( ("template", "expected"), [ ("logs://{service}{?a,b}", frozenset({"a", "b"})), ("logs://{service}{?a,b}{&c}", frozenset({"a", "b", "c"})), ("logs://{service}", frozenset[str]()), # A lone {&...} never emits the leading ? that lenient query # matching splits on, so it is matched strictly: c must be # present in the URI and is not an optional query variable. ("logs://{service}{&c}", frozenset[str]()), ], ) def test_query_variable_names(template: str, expected: frozenset[str]): """query_variable_names is exactly the set match() treats as optional: the trailing {?...}/{&...} variables a client may omit from the URI.""" assert UriTemplate.parse(template).query_variable_names == expected def test_parse_explode_modifier(): tmpl = UriTemplate.parse("/files{/path*}") (var,) = tmpl.variables assert var.name == "path" assert var.operator == "/" assert var.explode is True @pytest.mark.parametrize("template", ["{.labels*}", "{;params*}"]) def test_parse_explode_supported_operators(template: str): tmpl = UriTemplate.parse(template) assert tmpl.variables[0].explode is True def test_parse_mixed_explode_and_plain(): tmpl = UriTemplate.parse("{/path*}{?q}") assert tmpl.variables == [ Variable(name="path", operator="/", explode=True), Variable(name="q", operator="?"), ] def test_parse_varname_with_dots_and_underscores(): tmpl = UriTemplate.parse("{foo_bar.baz}") assert tmpl.variable_names == ["foo_bar.baz"] def test_parse_rejects_unclosed_expression(): with pytest.raises(InvalidUriTemplate, match="Unclosed expression") as exc: UriTemplate.parse("file://{name") assert exc.value.position == 7 assert exc.value.template == "file://{name" def test_parse_rejects_empty_expression(): with pytest.raises(InvalidUriTemplate, match="Empty expression"): UriTemplate.parse("file://{}") def test_parse_rejects_operator_without_variable(): with pytest.raises(InvalidUriTemplate, match="operator but no variables"): UriTemplate.parse("{+}") @pytest.mark.parametrize( "name", [ "-bad", "bad-name", "bad name", "bad/name", # RFC §2.3: dots only between varchars, not consecutive or trailing "foo..bar", "foo.", # a single trailing newline slipped past `$` with re.match "foo\n", ], ) def test_parse_rejects_invalid_varname(name: str): with pytest.raises(InvalidUriTemplate, match="Invalid variable name"): UriTemplate.parse(f"{{{name}}}") def test_parse_accepts_dotted_varname(): t = UriTemplate.parse("{a.b.c}") assert t.variable_names == ["a.b.c"] def test_parse_rejects_empty_spec_in_list(): with pytest.raises(InvalidUriTemplate, match="Invalid variable name"): UriTemplate.parse("{a,,b}") def test_parse_rejects_prefix_modifier(): with pytest.raises(InvalidUriTemplate, match="Prefix modifier"): UriTemplate.parse("{var:3}") @pytest.mark.parametrize("template", ["{var*}", "{+var*}", "{#var*}", "{?var*}", "{&var*}"]) def test_parse_rejects_unsupported_explode(template: str): with pytest.raises(InvalidUriTemplate, match="Explode modifier"): UriTemplate.parse(template) @pytest.mark.parametrize( "template", [ "{/a*}/x{/b*}", # two explode vars: a literal between them doesn't help # Multi-var + expression: each var is greedy (',' separates them) "{+a,b}", # Two {+var}/{#var} anywhere "{+a}/x/{+b}", "{+a},{+b}", "{#a}/x/{+b}", "{+a}.foo.{#b}", ], ) def test_parse_rejects_multiple_multi_segment_variables(template: str): # Two multi-segment variables make matching inherently ambiguous: # there is no principled way to decide which one absorbs an extra # segment. The linear scan can only partition the URI around a # single greedy slot. (Two ADJACENT multi-segment variables are # caught by the adjacency rule first; see the test below.) with pytest.raises(InvalidUriTemplate, match="more than one multi-segment"): UriTemplate.parse(template) @pytest.mark.parametrize( "template", [ # Two bounded variables "{a}{b}", "{.a}{b}", "{/a}{b}", "{;a}{b}", "{a}{b}X{+p}", "{+p}X{a}{b}", "pre{a}{b}post", # A bounded variable adjacent to the multi-segment variable "{a}{+b}", "{+a}{b}", "{#a}{b}", "{.a}{+b}", "{/a}{+b}", "x{name}{+path}y", "X{+a}{b}", "{+p}{n}", "{x}Y{+p}{n}", "{?a}{+b}x", # ... on either side, with a literal on the OTHER side "{a}-{+p}{b}", "{a}{+p}-{b}", "{name}{+path}{.ext}", "{base}{+p}{;k}", # ... or on both sides "{a}{+b}{c}", "{a}{+p}{b}Y{c}", "X{a}{+p}{b}Y{c}", "{a}{/p*}{b}", # An explode variable carries its operator's separators inside # the capture, so it emits no lead literal that could anchor it "{a}{/p*}", "{/seg}{;k*}", "item://{id}{;opts*}", # ifemp: the ';key' literal anchors the LEFT edge of {;key}, but # nothing separates its right edge from the multi-segment var "api{;key}{+rest}", # Two multi-segment variables that are ALSO adjacent "{/a*}{/b*}", "{/a*}{.b*}", "{.a*}{;b*}", "{/a*}{b}{.c*}", "{+a}{/b*}", ], ) def test_parse_rejects_adjacent_variables(template: str) -> None: # Two captures with no literal between them give the scan nothing to # anchor the boundary on — whether or not one of them is the # multi-segment variable. with pytest.raises(InvalidUriTemplate, match="adjacent with no literal separator"): UriTemplate.parse(template) @pytest.mark.parametrize( "template", [ "file://docs/{+path}", # + at end of template "file://{+path}.txt", # + followed by literal only "file://{+path}/edit", # + followed by literal only "api/{+path}{?v,page}", # + followed by query tail (split off before scan) "api/{+path}{&next}", # + followed by query-continuation "page{#section}", # # at end "{a}{#b}", # # emits a literal '#' that anchors the boundary "{+a}/sep/{b}", # + with bounded vars after "{+a},{b}", # Operators that emit their own lead character ('.', '/', ';name') # supply the literal anchor, so these are NOT adjacent variables. "{+a}{/b}", "{+a}{.b}", "{+a}{;b}", "{+path}{.ext}", "prefix/{+path}{.ext}", "tree://nodes{/path*}", "api{;key}/{+rest}", ], ) def test_parse_allows_single_multi_segment_variable(template: str): # One multi-segment variable is fine: the linear scan isolates it # between the prefix and suffix boundaries, and the scan never # backtracks so match time stays O(n) regardless of URI content. t = UriTemplate.parse(template) assert t is not None @pytest.mark.parametrize( "template", ["{x}/{x}", "{x,x}", "{a}{b}{a}", "{+x}/foo/{x}"], ) def test_parse_rejects_duplicate_variable_names(template: str): with pytest.raises(InvalidUriTemplate, match="appears more than once"): UriTemplate.parse(template) @pytest.mark.parametrize( "template", ["/x{?a}{?b}", "/x{?a}/y{?b}", "{?a}{&b}{?c}"], ) def test_parse_rejects_multiple_query_expressions(template: str) -> None: with pytest.raises(InvalidUriTemplate, match=r"more than one \{\?"): UriTemplate.parse(template) def test_query_tail_roundtrip_correct_spellings() -> None: for tmpl in ("/x{?a,b}", "/x{?a}{&b}"): t = UriTemplate.parse(tmpl) assert t.match(t.expand({"a": "1", "b": "2"})) == {"a": "1", "b": "2"} def test_invalid_uri_template_is_value_error(): with pytest.raises(ValueError): UriTemplate.parse("{}") @pytest.mark.parametrize( "template", [ "{{name}}", # nested open: body becomes "{name" "{a{b}c}", # brace inside expression "{{]{}}{}", # garbage soup "{a,{b}", # brace in comma list ], ) def test_parse_rejects_nested_braces(template: str): # Nested/stray { inside an expression lands in the varname and # fails the varname regex rather than needing special handling. with pytest.raises(InvalidUriTemplate, match="Invalid variable name"): UriTemplate.parse(template) @pytest.mark.parametrize( ("template", "position"), [ ("{", 0), ("{{", 0), ("file://{name", 7), ("{a}{", 3), ("}{", 1), # stray } is literal, then unclosed { ], ) def test_parse_rejects_unclosed_brace(template: str, position: int): with pytest.raises(InvalidUriTemplate, match="Unclosed") as exc: UriTemplate.parse(template) assert exc.value.position == position @pytest.mark.parametrize( "template", ["}}", "}", "a}b", "{a}}{b}"], ) def test_parse_treats_stray_close_brace_as_literal(template: str): # RFC 6570 §2.1 strictly excludes } from literals, but we accept it # for TypeScript SDK parity. A stray } almost always indicates a # typo; rejecting would be more helpful but would also break # cross-SDK behavior. tmpl = UriTemplate.parse(template) assert str(tmpl) == template def test_parse_stray_close_brace_between_expressions(): tmpl = UriTemplate.parse("{a}}{b}") assert tmpl.variable_names == ["a", "b"] def test_parse_rejects_oversized_template(): with pytest.raises(InvalidUriTemplate, match="maximum length"): UriTemplate.parse("x" * 101, max_length=100) def test_parse_rejects_too_many_variables(): template = "".join(f"{{v{i}}}" for i in range(11)) with pytest.raises(InvalidUriTemplate, match="maximum of 10 variables"): UriTemplate.parse(template, max_variables=10) def test_parse_counts_variables_not_expressions(): # A single {v0,v1,...} expression packs many variables under one # brace pair. Counting expressions would miss this. template = "{" + ",".join(f"v{i}" for i in range(11)) + "}" with pytest.raises(InvalidUriTemplate, match="maximum of 10 variables"): UriTemplate.parse(template, max_variables=10) def test_parse_custom_limits_allow_larger(): template = "/".join(f"{{v{i}}}" for i in range(20)) tmpl = UriTemplate.parse(template, max_variables=20) assert len(tmpl.variables) == 20 def test_equality_based_on_template_string(): a = UriTemplate.parse("file://{name}") b = UriTemplate.parse("file://{name}") c = UriTemplate.parse("file://{other}") assert a == b assert a != c assert hash(a) == hash(b) def test_frozen(): tmpl = UriTemplate.parse("{x}") with pytest.raises(dataclasses.FrozenInstanceError): tmpl.template = "changed" # type: ignore[misc] @pytest.mark.parametrize( ("template", "variables", "expected"), [ # Level 1: simple, encodes reserved chars ("{var}", {"var": "value"}, "value"), ("{var}", {"var": "hello world"}, "hello%20world"), ("{var}", {"var": "a/b"}, "a%2Fb"), ("file://docs/{name}", {"name": "readme.txt"}, "file://docs/readme.txt"), # Level 2: reserved expansion keeps / ? # etc. ("{+var}", {"var": "a/b/c"}, "a/b/c"), ("{+var}", {"var": "a?b#c"}, "a?b#c"), # RFC §3.2.3: reserved expansion passes through existing # pct-triplets unchanged; bare % is still encoded. ("{+var}", {"var": "path%2Fto"}, "path%2Fto"), ("{+var}", {"var": "50%"}, "50%25"), ("{+var}", {"var": "50%2"}, "50%252"), ("{+var}", {"var": "a%2Fb%20c"}, "a%2Fb%20c"), ("{#var}", {"var": "a%2Fb"}, "#a%2Fb"), # Simple expansion still encodes % unconditionally (triplet # preservation is reserved-only). ("{var}", {"var": "path%2Fto"}, "path%252Fto"), ("file://docs/{+path}", {"path": "src/main.py"}, "file://docs/src/main.py"), # Level 2: fragment ("{#var}", {"var": "section"}, "#section"), ("{#var}", {"var": "a/b"}, "#a/b"), # Level 3: label ("file{.ext}", {"ext": "txt"}, "file.txt"), # Level 3: path segment ("{/seg}", {"seg": "docs"}, "/docs"), # Level 3: path-style param ("{;id}", {"id": "42"}, ";id=42"), ("{;id}", {"id": ""}, ";id"), # Level 3: query ("{?q}", {"q": "search"}, "?q=search"), ("{?q}", {"q": ""}, "?q="), ("/search{?q,lang}", {"q": "mcp", "lang": "en"}, "/search?q=mcp&lang=en"), # Level 3: query continuation ("?a=1{&b}", {"b": "2"}, "?a=1&b=2"), # Multi-var in one expression ("{x,y}", {"x": "1", "y": "2"}, "1,2"), # {+x,y} is rejected at parse time: each var in a + expression # is multi-segment, and a template may only have one. # Sequence values, non-explode (comma-join) ("{/list}", {"list": ["a", "b", "c"]}, "/a,b,c"), ("{?list}", {"list": ["a", "b"]}, "?list=a,b"), # Explode: each item gets separator ("{/path*}", {"path": ["a", "b", "c"]}, "/a/b/c"), ("{.labels*}", {"labels": ["x", "y"]}, ".x.y"), ("{;keys*}", {"keys": ["a", "b"]}, ";keys=a;keys=b"), # RFC §3.2.7 ifemp: ; omits = for empty explode items ("{;keys*}", {"keys": ["a", "", "b"]}, ";keys=a;keys;keys=b"), # RFC §3.2.7 ifemp: ; omits = for empty, including non-explode list [""] ("{;name}", {"name": [""]}, ";name"), ("{;name}", {"name": ["", ""]}, ";name=,"), ("{?name}", {"name": [""]}, "?name="), ("{&name}", {"name": [""]}, "&name="), ("{;name}", {"name": ""}, ";name"), # Undefined variables omitted ("{?q,page}", {"q": "x"}, "?q=x"), ("{a,b}", {"a": "x"}, "x"), ("{?page}", {}, ""), # Empty sequence omitted ("{/path*}", {"path": []}, ""), # Literal-only template ("file://static", {}, "file://static"), ], ) def test_expand(template: str, variables: dict[str, str | list[str]], expected: str): assert UriTemplate.parse(template).expand(variables) == expected def test_expand_encodes_special_chars_in_simple(): t = UriTemplate.parse("{v}") assert t.expand({"v": "a&b=c"}) == "a%26b%3Dc" def test_expand_preserves_special_chars_in_reserved(): t = UriTemplate.parse("{+v}") assert t.expand({"v": "a&b=c"}) == "a&b=c" @pytest.mark.parametrize( "value", [42, None, 3.14, {"a": "b"}, ["ok", 42], b"bytes"], ) def test_expand_rejects_invalid_value_types(value: object): t = UriTemplate.parse("{v}") with pytest.raises(TypeError, match="must be str or a sequence of str"): t.expand({"v": value}) # type: ignore[dict-item] @pytest.mark.parametrize( ("template", "uri", "expected"), [ # Level 1: simple ("{var}", "hello", {"var": "hello"}), ("file://docs/{name}", "file://docs/readme.txt", {"name": "readme.txt"}), ("{a}/{b}", "foo/bar", {"a": "foo", "b": "bar"}), # Level 2: reserved allows / ("file://docs/{+path}", "file://docs/src/main.py", {"path": "src/main.py"}), ("{+var}", "a/b/c", {"var": "a/b/c"}), # Level 2: fragment ("page{#section}", "page#intro", {"section": "intro"}), # A multi-segment var next to an operator that emits its own # lead character: the lead ('.', '/', '#') is a literal anchor, # so these are NOT two adjacent variables. ("{+path}{/name}", "a/b/c/readme", {"path": "a/b/c", "name": "readme"}), ("{+path}{.ext}", "src/main.py", {"path": "src/main", "ext": "py"}), ("prefix/{+path}{.ext}", "prefix/a/b.txt", {"path": "a/b", "ext": "txt"}), ("{#section}{/page}", "#intro/1", {"section": "intro", "page": "1"}), # Bounded vars before the multi-segment var match lazily (first # anchor); those after match greedily (last anchor). ("{owner}@{+path}", "alice@src/main", {"owner": "alice", "path": "src/main"}), ("{+path}@{name}", "src@main@v1", {"path": "src@main", "name": "v1"}), # Level 3: label ("file{.ext}", "file.txt", {"ext": "txt"}), # Level 3: path segment ("api{/version}", "api/v1", {"version": "v1"}), # Level 3: path-style param ("item{;id}", "item;id=42", {"id": "42"}), ("item{;id}", "item;id", {"id": ""}), # Explode: ; emits name=value per item, match strips the prefix ("item{;keys*}", "item;keys=a;keys=b", {"keys": ["a", "b"]}), ("item{;keys*}", "item;keys=a;keys;keys=b", {"keys": ["a", "", "b"]}), ("item{;keys*}", "item", {"keys": []}), # Level 3: query. Lenient matching: partial, reordered, and # extra params are all accepted. Absent params stay absent. ("search{?q}", "search?q=hello", {"q": "hello"}), ("search{?q}", "search?q=", {"q": ""}), ("search{?q}", "search", {}), ("search{?q,lang}", "search?q=mcp&lang=en", {"q": "mcp", "lang": "en"}), ("search{?q,lang}", "search?lang=en&q=mcp", {"q": "mcp", "lang": "en"}), ("search{?q,lang}", "search?q=mcp", {"q": "mcp"}), ("search{?q,lang}", "search", {}), ("search{?q}", "search?q=mcp&utm=x&ref=y", {"q": "mcp"}), # URL-encoded query values are decoded ("search{?q}", "search?q=hello%20world", {"q": "hello world"}), # + is a literal sub-delim per RFC 3986, not a space (form-encoding) ("search{?q}", "search?q=C++", {"q": "C++"}), ("search{?q}", "search?q=1.0+build.5", {"q": "1.0+build.5"}), # Fragment is stripped before query parsing ("logs://{service}{?level}", "logs://api?level=error#section1", {"service": "api", "level": "error"}), ("search{?q}", "search#frag", {}), # Multiple ?/& expressions collected together ("api{?v}{&page,limit}", "api?limit=10&v=2", {"v": "2", "limit": "10"}), # Standalone {&var} falls through to the strict scan (expands # with & prefix, no ? for lenient matching to split on) ("api{&page}", "api&page=2", {"page": "2"}), # Literal ? in path portion falls through to the strict scan ("api?x{?page}", "api?x?page=2", {"page": "2"}), # {#...} or literal # in path portion falls through: lenient # matching would strip the fragment before the path scan sees it ("page{#section}{?q}", "page#intro?q=x", {"section": "intro", "q": "x"}), ("page#lit{?q}", "page#lit?q=x", {"q": "x"}), # Empty & segments in query are skipped ("search{?q}", "search?&q=hello&", {"q": "hello"}), # Duplicate query keys keep first value ("search{?q}", "search?q=first&q=second", {"q": "first"}), # Percent-encoded parameter names are NOT decoded: RFC 6570 # expansion never encodes names, so an encoded name cannot be # a legitimate match. Prevents HTTP parameter pollution. ("api://x{?token}", "api://x?%74oken=evil&token=real", {"token": "real"}), ("api://x{?token}", "api://x?%74oken=evil", {}), # Level 3: query continuation with literal ? falls back to # the strict scan (template-order, all-present required) ("?a=1{&b}", "?a=1&b=2", {"b": "2"}), # Explode: path segments as list ("/files{/path*}", "/files/a/b/c", {"path": ["a", "b", "c"]}), ("/files{/path*}", "/files", {"path": []}), ("/files{/path*}/edit", "/files/a/b/edit", {"path": ["a", "b"]}), # Explode: labels ("host{.labels*}", "host.example.com", {"labels": ["example", "com"]}), # Repeated-slash literals preserved exactly ("///{a}////{b}////", "///x////y////", {"a": "x", "b": "y"}), ], ) def test_match(template: str, uri: str, expected: dict[str, str | list[str]]): assert UriTemplate.parse(template).match(uri) == expected @pytest.mark.parametrize( ("template", "uri"), [ ("file://docs/{name}", "file://other/readme.txt"), ("{a}/{b}", "foo"), ("file{.ext}", "file"), ("static", "different"), # Anchoring: trailing extra component must not match. Guards # against a refactor from fullmatch() to match() or search(). ("/users/{id}", "/users/123/extra"), ("/users/{id}/posts/{pid}", "/users/1/posts/2/extra"), # Repeated-slash literal with wrong slash count ("///{a}////{b}////", "//x////y////"), # ; name boundary: {;id} must not match a longer parameter name ("item{;id}", "item;identity=john"), ("item{;id}", "item;ident"), # ; explode: wrong parameter name in any segment rejects the match ("item{;keys*}", "item;admin=true"), ("item{;keys*}", "item;keys=a;admin=true"), # Lenient-query branch: path portion fails to match ("api/{name}{?q}", "wrong/path?q=x"), # Lenient-query branch: ; explode name mismatch in path portion ("item{;keys*}{?q}", "item;wrong=x?q=1"), ], ) def test_match_no_match(template: str, uri: str): assert UriTemplate.parse(template).match(uri) is None def test_match_explode_preserves_empty_list_items(): # Splitting the explode capture on its separator yields a leading # empty item from the operator prefix; only that one is stripped. # Subsequent empties are legitimate values from the input list. t = UriTemplate.parse("{/path*}") assert t.match("/a//c") == {"path": ["a", "", "c"]} assert t.match("//a") == {"path": ["", "a"]} assert t.match("/a/") == {"path": ["a", ""]} t = UriTemplate.parse("host{.labels*}") assert t.match("host.a..c") == {"labels": ["a", "", "c"]} def test_match_adjacent_vars_disambiguated_by_literal(): # A literal between vars resolves the ambiguity. t = UriTemplate.parse("{a}-{b}") assert t.match("foo-bar") == {"a": "foo", "b": "bar"} @pytest.mark.parametrize( ("template", "variables"), [ # Leading literal appears inside the value: must anchor at # position 0, not rfind to the rightmost occurrence. ("prefix-{id}", {"id": "prefix-123"}), ("u{s}", {"s": "xu"}), ("_{x}", {"x": "_"}), ("~{v}~", {"v": "~~~"}), # Multi-occurrence with two vars: rfind correctly picks the # rightmost literal BETWEEN vars, first literal anchors at 0. ("L{a}L{b}", {"a": "xLy", "b": "z"}), # Leading literal with stop-char: earliest bound still applies. ("api/{name}", {"name": "api"}), ], ) def test_match_leading_literal_appears_in_value(template: str, variables: dict[str, str]): # Regression: the R->L scan used rfind for the preceding literal, # which lands inside the value when the template's leading literal # is a substring of the expanded value. The first atom must anchor # at position 0, not search. t = UriTemplate.parse(template) uri = t.expand(variables) assert t.match(uri) == variables @pytest.mark.parametrize( ("template", "uri"), [ # Greedy var whose suffix literal is absent from the input. ("{a}-{+b}x", "-" * 200), # Chained anchors that all appear in input but suffix fails. ("{a}L{b}L{c}L{d}M", "L" * 200), ], ) def test_match_no_backtracking_on_pathological_input(template: str, uri: str): # These patterns caused O(n²) or worse backtracking under the regex # matcher. The linear scan returns None without retrying splits. # (Correctness check only; we benchmark separately to avoid flaky # timing assertions in CI.) assert UriTemplate.parse(template).match(uri) is None @pytest.mark.parametrize( ("template", "uri"), [ # Prefix literal mismatch before a greedy var ("file://{+path}", "http://x"), # Suffix literal absent: the suffix scan fails before the prefix runs ("file://{+path}.txt", "file://x"), # Prefix anchor not found: {a} needs '@' before greedy but none exists ("{a}@{+path}", "no-at-sign-here"), # Prefix literal doesn't fit within suffix boundary ("foo{+a}oob", "fooob"), # Greedy scalar contains its own stop-char ({+var} stops at ?) ("api://{+path}", "api://foo?bar"), # Explode span doesn't start with its separator ("X{/path*}", "Xnoslash"), # Explode body contains a non-separator stop-char ("X{/path*}", "X/a?b"), # ifemp name continuation: the literal after {;key} doesn't start # at pos and there's no '=', so the URI's name kept going. ("api{;key}suffix/{+p}", "api;keyZ/x"), # Regression: suffix scan must not walk back into prefix territory. # Input is shorter than prefix+suffix literals — these used to # raise AssertionError instead of returning None. ("api://{+path}/{id}", "api://foo"), ("docs/{+path}/v/{name}", "docs/v/x"), ], ) def test_match_greedy_rejection_paths(template: str, uri: str): assert UriTemplate.parse(template).match(uri) is None @pytest.mark.parametrize( ("template", "uri", "expected"), [ # ifemp before a literal that itself starts with '=': the literal # check runs first so '=' is not mistaken for the ifemp separator. ("api{;key}=base/{+path}", "api;key=base/a/b", {"key": "", "path": "a/b"}), ("api{;key}=base/{+path}", "api;key=v=base/x", {"key": "v", "path": "x"}), ], ) def test_match_prefix_scan_edge_cases(template: str, uri: str, expected: dict[str, str]): assert UriTemplate.parse(template).match(uri) == expected @pytest.mark.parametrize( ("template", "uri", "expected"), [ # Suffix-side ifemp: '=' inside the value is preserved — the # value '=' is the first one after ;name, not the last. ("item{;id}", "item;id=a=b", {"id": "a=b"}), ("{;a}{;b}", ";a=x=y;b=z", {"a": "x=y", "b": "z"}), ], ) def test_match_suffix_ifemp_equals_in_value(template: str, uri: str, expected: dict[str, str]): assert UriTemplate.parse(template).match(uri) == expected def test_match_prefix_ifemp_empty_before_non_stop_literal(): # Regression: _scan_prefix rejected the empty-value case when the # following template literal starts with a non-stop-char. The # name-continuation guard saw 'X' after ';key' and assumed the # name continued, but 'X' is the template's next literal. t = UriTemplate.parse("api{;key}X{+rest}") # Non-empty round-trips fine: assert t.match(t.expand({"key": "abc", "rest": "/tail"})) == {"key": "abc", "rest": "/tail"} # Empty value (ifemp → bare ;key, then X) must also round-trip: uri = t.expand({"key": "", "rest": "/tail"}) assert uri == "api;keyX/tail" assert t.match(uri) == {"key": "", "rest": "/tail"} # But an actual name continuation still rejects: assert t.match("api;keyZX/tail") is None def test_match_large_uri_against_greedy_template(): # Large payload against a greedy template — the scan visits each # character once for the suffix anchor and once for the greedy # validation, so this is O(n) not O(n²). t = UriTemplate.parse("{+path}/end") body = "seg/" * 15000 result = t.match(body + "end") assert result == {"path": body[:-1]} # And the failing case returns None without retrying splits. assert t.match(body + "nope") is None def test_match_decodes_percent_encoding(): t = UriTemplate.parse("file://docs/{name}") assert t.match("file://docs/hello%20world.txt") == {"name": "hello world.txt"} def test_match_escapes_template_literals(): # Regression: previous impl didn't escape . in literals, making it # a regex wildcard. "fileXtxt" should NOT match "file.txt/{id}". t = UriTemplate.parse("file.txt/{id}") assert t.match("file.txt/42") == {"id": "42"} assert t.match("fileXtxt/42") is None @pytest.mark.parametrize( ("template", "uri", "expected"), [ # Percent-encoded delimiters round-trip through match/expand. # Path-safety validation belongs to ResourceSecurity, not here. ("file://docs/{name}", "file://docs/a%2Fb", {"name": "a/b"}), ("{var}", "a%3Fb", {"var": "a?b"}), ("{var}", "a%23b", {"var": "a#b"}), ("{var}", "a%26b", {"var": "a&b"}), ("file{.ext}", "file.a%2Eb", {"ext": "a.b"}), ("api{/v}", "api/a%2Fb", {"v": "a/b"}), ("search{?q}", "search?q=a%26b", {"q": "a&b"}), ("{;filter}", ";filter=a%3Bb", {"filter": "a;b"}), ], ) def test_match_encoded_delimiters_roundtrip(template: str, uri: str, expected: dict[str, str]): assert UriTemplate.parse(template).match(uri) == expected def test_match_reserved_expansion_handles_slash(): # {+var} allows literal / (not just encoded) t = UriTemplate.parse("{+path}") assert t.match("a%2Fb") == {"path": "a/b"} assert t.match("a/b") == {"path": "a/b"} def test_match_double_encoding_decoded_once(): # %252F is %2F encoded again. Single decode gives "%2F" (a literal # percent sign, a '2', and an 'F'). Guards against over-decoding. t = UriTemplate.parse("file://docs/{name}") assert t.match("file://docs/..%252Fetc") == {"name": "..%2Fetc"} def test_match_rejects_oversized_uri(): t = UriTemplate.parse("{var}") assert t.match("x" * 100, max_uri_length=50) is None def test_match_accepts_uri_within_custom_limit(): t = UriTemplate.parse("{var}") assert t.match("x" * 100, max_uri_length=200) == {"var": "x" * 100} def test_match_default_uri_length_limit(): t = UriTemplate.parse("{+var}") # Just at the limit: should match assert t.match("x" * DEFAULT_MAX_URI_LENGTH) is not None # One over: should reject assert t.match("x" * (DEFAULT_MAX_URI_LENGTH + 1)) is None def test_match_explode_encoded_separator_in_segment(): # An encoded separator inside a segment decodes as part of the value, # not as a split point. The split happens at literal separators only. t = UriTemplate.parse("/files{/path*}") assert t.match("/files/a%2Fb/c") == {"path": ["a/b", "c"]} @pytest.mark.parametrize( ("template", "variables"), [ ("{var}", {"var": "hello"}), ("file://docs/{name}", {"name": "readme.txt"}), ("file://docs/{+path}", {"path": "src/main.py"}), ("search{?q,lang}", {"q": "mcp", "lang": "en"}), ("file{.ext}", {"ext": "txt"}), ("/files{/path*}", {"path": ["a", "b", "c"]}), ("{var}", {"var": "hello world"}), ("item{;id}", {"id": "42"}), ("item{;id}", {"id": ""}), # Defined-but-empty values still emit the operator prefix; match # must accept the empty capture after it. ("page{#section}", {"section": ""}), ("file{.ext}", {"ext": ""}), ("api{/v}", {"v": ""}), ("x{name}y", {"name": ""}), ("item{;keys*}", {"keys": ["a", "b", "c"]}), ("item{;keys*}", {"keys": ["a", "", "b"]}), # Empty strings in explode lists round-trip for unnamed operators ("{/path*}", {"path": ["a", "", "c"]}), ("{/path*}", {"path": ["", "a"]}), ("host{.labels*}", {"labels": ["a", "", "c"]}), # Partial query expansion round-trips: expand omits undefined # vars, match leaves them absent from the result. ("logs://{service}{?since,level}", {"service": "api"}), ("logs://{service}{?since,level}", {"service": "api", "since": "1h"}), ("logs://{service}{?since,level}", {"service": "api", "since": "1h", "level": "error"}), ("api{;key}=base/{+path}", {"key": "", "path": "a/b"}), ], ) def test_roundtrip_expand_then_match(template: str, variables: dict[str, str | list[str]]): t = UriTemplate.parse(template) uri = t.expand(variables) assert t.match(uri) == variables def test_match_simple_var_accepts_empty() -> None: # RFC 6570 §3.2.2: {var} with var="" expands to nothing, so the inverse # must accept it. v1.x's [^/]+ regex did not — see migration guide. t = UriTemplate.parse("tickets://{ticket_id}") assert t.match("tickets://") == {"ticket_id": ""} assert t.match("tickets://42") == {"ticket_id": "42"} # --- Property tests over the generated template space ------------------------ # # The two tests below generate template strings instead of enumerating # examples, so the contracts they state are checked over the whole space # `parse()` accepts in a single deterministic run. The generator deliberately # produces strings the parser rejects (adjacent variables, two greedy # variables, unsupported explode placements, a second `{?...}` expression) and # relies on `parse()` to filter them: pre-selecting "known good" shapes would # only ever exercise the shapes someone already thought of. _PROPERTY_SEED = 20260626 _PROPERTY_OPERATORS = ["", "+", "#", ".", "/", ";", "?", "&"] # Literal runs draw from URI punctuation (`- . / ~ _`) plus uppercase letters. # Values draw only from lowercase letters and digits. The two alphabets are # disjoint, so a round-trip failure can never be explained away as a value # colliding with a literal, an operator prefix, or a separator. _LITERAL_CHARS = "XY-._~/Z" _VALUE_CHARS = string.ascii_lowercase + string.digits _FUZZ_CHARS = string.printable def _random_template(rng: random.Random) -> tuple[str, list[tuple[str, bool]]]: """Build a candidate template string plus the (name, explode) spec of each variable.""" parts: list[str] = [] specs: list[tuple[str, bool]] = [] for _ in range(rng.randint(1, 5)): if rng.random() < 0.45: parts.append("".join(rng.choice(_LITERAL_CHARS) for _ in range(rng.randint(1, 2)))) continue operator = rng.choice(_PROPERTY_OPERATORS) names: list[str] = [] # Multi-variable expressions and the explode modifier are produced for # every operator; `parse()` rejects the combinations it does not allow. for _ in range(2 if rng.random() < 0.2 else 1): name = f"v{len(specs)}" explode = rng.random() < 0.25 specs.append((name, explode)) names.append(f"{name}*" if explode else name) parts.append("{" + operator + ",".join(names) + "}") return "".join(parts), specs def _random_value(rng: random.Random) -> str: """Draw a short (possibly empty) value from the literal-disjoint alphabet.""" return "".join(rng.choice(_VALUE_CHARS) for _ in range(rng.randint(0, 4))) def _random_values(specs: list[tuple[str, bool]], rng: random.Random) -> dict[str, str | list[str]]: """Draw a value for every variable: a string, or a non-empty list for explode variables.""" return { name: [_random_value(rng) for _ in range(rng.randint(1, 3))] if explode else _random_value(rng) for name, explode in specs } def _mangled_inputs(uri: str, rng: random.Random) -> list[str]: """Mangle one expansion into a batch of candidate inputs for `match()`.""" candidates = [uri, "", uri[::-1], uri * 2] for _ in range(6): chars = list(uri) mutation = rng.randint(0, 2) if mutation == 0 and chars: del chars[rng.randrange(len(chars))] elif mutation == 1: chars.insert(rng.randint(0, len(chars)), rng.choice(_FUZZ_CHARS)) elif chars: chars[rng.randrange(len(chars))] = rng.choice(_FUZZ_CHARS) candidates.append("".join(chars)) candidates.extend("".join(rng.choice(_FUZZ_CHARS) for _ in range(rng.randint(0, 30))) for _ in range(3)) return candidates def test_match_inverts_expand_for_every_parseable_template() -> None: """For every template the parser accepts, matching the template's own expansion yields a value set that re-expands to the same URI. Exact equality with the original values is not required: a different pre-image (e.g. an explode list that flattens) is a correct answer as long as it re-expands identically. SDK-defined contract — RFC 6570 specifies only expansion, so `match()` is the inverse the SDK promises. """ rng = random.Random(_PROPERTY_SEED) accepted = 0 for _ in range(600): template, specs = _random_template(rng) try: t = UriTemplate.parse(template) except InvalidUriTemplate: continue accepted += 1 for _ in range(2): values = _random_values(specs, rng) uri = t.expand(values) got = t.match(uri) assert got is not None, f"{template!r} did not match its own expansion {uri!r} of {values!r}" assert t.expand(got) == uri, f"{template!r}: match({uri!r}) -> {got!r}, which re-expands differently" # Floor the accepted count so the property can never go vacuous: a future # change that rejects every generated template would otherwise pass silently. assert accepted >= 150 def test_match_never_raises() -> None: """`match()` returns a dict or None for every input string; it never raises. Each accepted template's own expansion is mangled (a character inserted, deleted, or replaced from a wide printable alphabet; emptied; reversed; doubled) alongside fully random strings. SDK-defined contract — a URI that does not fit the template is a non-match, not an error. """ rng = random.Random(_PROPERTY_SEED) calls = 0 for _ in range(600): template, specs = _random_template(rng) try: t = UriTemplate.parse(template) except InvalidUriTemplate: continue uri = t.expand(_random_values(specs, rng)) for candidate in _mangled_inputs(uri, rng): result = t.match(candidate) assert result is None or isinstance(result, dict), f"{template!r}: match({candidate!r}) -> {result!r}" calls += 1 # Floor the call count so the property can never go vacuous: a future # change that rejects every generated template would otherwise pass silently. assert calls >= 4000