diff --git a/clients/shared/deeplink-vectors.json b/clients/shared/deeplink-vectors.json new file mode 100644 index 00000000..12a9f61e --- /dev/null +++ b/clients/shared/deeplink-vectors.json @@ -0,0 +1,311 @@ +{ + "$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" + } + ] +} diff --git a/crates/pf-client-core/src/deeplink.rs b/crates/pf-client-core/src/deeplink.rs new file mode 100644 index 00000000..b880ad7d --- /dev/null +++ b/crates/pf-client-core/src/deeplink.rs @@ -0,0 +1,754 @@ +//! The `punktfunk://` URL grammar — one parser/emitter for Linux, Windows, the session and +//! the CLI (design/client-deep-links.md §2). Swift (`PunktfunkShared/DeepLink.swift`) and +//! Kotlin keep their own ports; all three are held together by the shared vector file +//! `clients/shared/deeplink-vectors.json`, which this module's tests consume verbatim. +//! +//! ```text +//! punktfunk://connect/[?fp=<64-hex>][&host=][&launch=] +//! [&profile=][&name=