From 3350d3cfbf4298a299d3223f4d6e7784be96713f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 20:32:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20the=20punktfunk://=20grammar=20?= =?UTF-8?q?=E2=80=94=20one=20parser,=20one=20emitter,=20one=20vector=20fil?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/` 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/[?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) --- clients/shared/deeplink-vectors.json | 311 +++++++++++ crates/pf-client-core/src/deeplink.rs | 754 ++++++++++++++++++++++++++ crates/pf-client-core/src/lib.rs | 4 + crates/pf-client-core/src/profiles.rs | 12 +- 4 files changed, 1076 insertions(+), 5 deletions(-) create mode 100644 clients/shared/deeplink-vectors.json create mode 100644 crates/pf-client-core/src/deeplink.rs 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=