D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/<uuid>` for a
while; this is the same grammar, generalised and given a Rust implementation the GTK and
WinUI shells, the session and the coming CLI all share:
punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]
The rule the grammar exists to keep is that a URL may only ever do what a click on an
existing card could do, minus trust decisions. So it carries REFERENCES to things that
already exist on this device — never values: no resolution, no bitrate, no codec, so a
web page cannot shape a session beyond picking among the user's own configurations. And
`pair` is not a route: it parses, and refuses, so a link can never start a trust
ceremony. Everything hostile is rejected here, once, for every front-end — 2 KB total
cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is an
error, not a shrug), control characters refused after decoding, launch ids held to the
charset the host and Decky already agree on, and `fp=` that must actually be 64 hex.
Resolution follows the same order everywhere: stable record id → unique host name →
`addr[:port]`, with `host=`+`fp=` as the reinstall recovery path, which is why
self-emitted links carry all three. An ambiguous name refuses rather than guessing; a
stale id with no recovery address refuses rather than dialling "11111111-…" as if it
were a hostname; an unknown-but-plausible address becomes the confirmation sheet, never
an auto-connect.
`pf://` parses as an input alias and is never emitted or registered — claiming a
two-letter scheme on MSIX/Apple is unconditional squatting, and a link that resolves on
one platform only is a trap.
The 44-case `clients/shared/deeplink-vectors.json` is the cross-language contract: this
module's tests run every case, and the Swift and Kotlin ports will run the same file, so
three parsers cannot drift into three different security postures. Refusal codes are
part of that contract, not just the happy path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
12 KiB
JSON
312 lines
12 KiB
JSON
{
|
|
"$comment": "Cross-language contract for the punktfunk:// grammar (design/client-deep-links.md §2). The Rust (crates/pf-client-core/src/deeplink.rs), Swift (PunktfunkShared/DeepLink.swift) and Kotlin parsers each run every case in their own test suite, so the three cannot drift into three different security postures. `expect` fields absent from a case must parse as absent; `emit` is the canonical URL the parsed link must serialise back to (note that pf:// never round-trips — it is an accepted input alias, never an output). `error` is the stable refusal code, not a message.",
|
|
"version": 1,
|
|
"cases": [
|
|
{
|
|
"name": "apple-shipped-uuid",
|
|
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "11111111-2222-4333-8444-555555555555"
|
|
},
|
|
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555"
|
|
},
|
|
{
|
|
"name": "apple-shipped-launch",
|
|
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "11111111-2222-4333-8444-555555555555",
|
|
"launch": "steam:570"
|
|
},
|
|
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570"
|
|
},
|
|
{
|
|
"name": "bare-reference-without-a-route",
|
|
"url": "punktfunk://11111111-2222-4333-8444-555555555555",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "11111111-2222-4333-8444-555555555555"
|
|
}
|
|
},
|
|
{
|
|
"name": "scheme-case-is-ignored",
|
|
"url": "PunktFunk://CONNECT/Desk",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk"
|
|
}
|
|
},
|
|
{
|
|
"name": "pf-alias-parses-but-is-never-emitted",
|
|
"url": "pf://connect/Desk",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk"
|
|
},
|
|
"emit": "punktfunk://connect/Desk"
|
|
},
|
|
{
|
|
"name": "trailing-slash-tolerated",
|
|
"url": "punktfunk://connect/Desk/",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk"
|
|
}
|
|
},
|
|
{
|
|
"name": "address-reference-with-port",
|
|
"url": "punktfunk://connect/192.168.1.50:9777",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "192.168.1.50:9777"
|
|
}
|
|
},
|
|
{
|
|
"name": "percent-encoded-host-name",
|
|
"url": "punktfunk://connect/Wohnzimmer%20PC",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Wohnzimmer PC"
|
|
},
|
|
"emit": "punktfunk://connect/Wohnzimmer%20PC"
|
|
},
|
|
{
|
|
"name": "self-emitted-full-form",
|
|
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "11111111-2222-4333-8444-555555555555",
|
|
"fp": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
"host_addr": "192.168.1.50",
|
|
"host_port": 7777,
|
|
"launch": "steam:570",
|
|
"profile": "aaaaaaaaaaaa"
|
|
},
|
|
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
|
|
},
|
|
{
|
|
"name": "fingerprint-is-normalised-to-lowercase",
|
|
"url": "punktfunk://connect/Desk?fp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
}
|
|
},
|
|
{
|
|
"name": "host-parameter-defaults-to-9777",
|
|
"url": "punktfunk://connect/Desk?host=192.168.1.50",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"host_addr": "192.168.1.50",
|
|
"host_port": 9777
|
|
}
|
|
},
|
|
{
|
|
"name": "host-parameter-ipv6-bracketed",
|
|
"url": "punktfunk://connect/Desk?host=%5B::1%5D:7777",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"host_addr": "::1",
|
|
"host_port": 7777
|
|
}
|
|
},
|
|
{
|
|
"name": "wake-route-parses",
|
|
"url": "punktfunk://wake/Desk",
|
|
"expect": {
|
|
"route": "wake",
|
|
"host_ref": "Desk"
|
|
}
|
|
},
|
|
{
|
|
"name": "browse-route-parses",
|
|
"url": "punktfunk://browse/Desk",
|
|
"expect": {
|
|
"route": "browse",
|
|
"host_ref": "Desk"
|
|
}
|
|
},
|
|
{
|
|
"name": "profile-by-name",
|
|
"url": "punktfunk://connect/Desk?profile=Work",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"profile": "Work"
|
|
}
|
|
},
|
|
{
|
|
"name": "unknown-parameters-are-ignored",
|
|
"url": "punktfunk://connect/Desk?bogus=1&launch=steam:1",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"launch": "steam:1"
|
|
}
|
|
},
|
|
{
|
|
"name": "empty-parameter-is-absent-not-an-error",
|
|
"url": "punktfunk://connect/Desk?launch=&profile=Work",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"profile": "Work"
|
|
}
|
|
},
|
|
{
|
|
"name": "first-occurrence-of-a-parameter-wins",
|
|
"url": "punktfunk://connect/Desk?fp=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
}
|
|
},
|
|
{
|
|
"name": "fragment-is-dropped",
|
|
"url": "punktfunk://connect/Desk#whatever",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk"
|
|
}
|
|
},
|
|
{
|
|
"name": "a-query-hidden-in-the-fragment-is-not-parsed",
|
|
"url": "punktfunk://connect/Desk#?launch=steam:570",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk"
|
|
}
|
|
},
|
|
{
|
|
"name": "non-ascii-label-survives-the-round-trip",
|
|
"url": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac",
|
|
"expect": {
|
|
"route": "connect",
|
|
"host_ref": "Desk",
|
|
"name": "Büro · Mac"
|
|
},
|
|
"emit": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac"
|
|
},
|
|
{
|
|
"name": "another-scheme-is-not-ours",
|
|
"url": "https://example.com/connect/Desk",
|
|
"error": "not-our-scheme"
|
|
},
|
|
{
|
|
"name": "scheme-without-authority-is-not-ours",
|
|
"url": "punktfunk:/connect/Desk",
|
|
"error": "not-our-scheme"
|
|
},
|
|
{
|
|
"name": "pairing-is-never-a-route",
|
|
"url": "punktfunk://pair/1234",
|
|
"error": "pair-refused"
|
|
},
|
|
{
|
|
"name": "bare-pair-is-refused-too",
|
|
"url": "punktfunk://pair",
|
|
"error": "pair-refused"
|
|
},
|
|
{
|
|
"name": "undefined-route-refuses",
|
|
"url": "punktfunk://teardown/Desk",
|
|
"error": "unknown-route"
|
|
},
|
|
{
|
|
"name": "route-without-a-host-reference",
|
|
"url": "punktfunk://connect/",
|
|
"error": "missing-host-ref"
|
|
},
|
|
{
|
|
"name": "bare-route-word-is-not-a-host-reference",
|
|
"url": "punktfunk://connect",
|
|
"error": "missing-host-ref"
|
|
},
|
|
{
|
|
"name": "truncated-percent-escape",
|
|
"url": "punktfunk://connect/Desk%2",
|
|
"error": "bad-escape"
|
|
},
|
|
{
|
|
"name": "non-hex-percent-escape",
|
|
"url": "punktfunk://connect/De%zzsk",
|
|
"error": "bad-escape"
|
|
},
|
|
{
|
|
"name": "invalid-utf8-escape",
|
|
"url": "punktfunk://connect/De%FFsk",
|
|
"error": "bad-escape"
|
|
},
|
|
{
|
|
"name": "newline-smuggled-into-the-reference",
|
|
"url": "punktfunk://connect/Desk%0A",
|
|
"error": "control-char"
|
|
},
|
|
{
|
|
"name": "nul-byte-smuggled-into-the-reference",
|
|
"url": "punktfunk://connect/Desk%00",
|
|
"error": "control-char"
|
|
},
|
|
{
|
|
"name": "fingerprint-too-short",
|
|
"url": "punktfunk://connect/Desk?fp=abc",
|
|
"error": "bad-fingerprint"
|
|
},
|
|
{
|
|
"name": "fingerprint-not-hex",
|
|
"url": "punktfunk://connect/Desk?fp=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
|
"error": "bad-fingerprint"
|
|
},
|
|
{
|
|
"name": "host-parameter-with-an-unparsable-port",
|
|
"url": "punktfunk://connect/Desk?host=box:notaport",
|
|
"error": "bad-host-param"
|
|
},
|
|
{
|
|
"name": "launch-id-with-a-space",
|
|
"url": "punktfunk://connect/Desk?launch=steam%20570",
|
|
"error": "bad-launch-id"
|
|
},
|
|
{
|
|
"name": "launch-id-with-a-quote",
|
|
"url": "punktfunk://connect/Desk?launch=%22evil%22",
|
|
"error": "bad-launch-id"
|
|
},
|
|
{
|
|
"name": "launch-id-with-a-shell-metacharacter",
|
|
"url": "punktfunk://connect/Desk?launch=steam:570%60id%60",
|
|
"error": "bad-launch-id"
|
|
},
|
|
{
|
|
"name": "launch-id-over-the-cap",
|
|
"url": "punktfunk://connect/Desk?launch=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"error": "param-too-long"
|
|
},
|
|
{
|
|
"name": "profile-reference-over-the-cap",
|
|
"url": "punktfunk://connect/Desk?profile=ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp",
|
|
"error": "param-too-long"
|
|
},
|
|
{
|
|
"name": "name-over-the-cap",
|
|
"url": "punktfunk://connect/Desk?name=nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
|
|
"error": "param-too-long"
|
|
},
|
|
{
|
|
"name": "host-reference-over-the-cap",
|
|
"url": "punktfunk://connect/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
|
|
"error": "param-too-long"
|
|
},
|
|
{
|
|
"name": "url-over-the-total-cap",
|
|
"url": "punktfunk://connect/Desk?name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
"error": "too-long"
|
|
}
|
|
]
|
|
}
|