Files
punktfunk/crates/pf-client-core/src/deeplink.rs
T
enricobuehlerandClaude Opus 5 3350d3cfbf feat(client): the punktfunk:// grammar — one parser, one emitter, one vector file
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>
2026-07-28 20:32:32 +02:00

755 lines
31 KiB
Rust

//! 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/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
//! [&profile=<ref>][&name=<label>]
//! ```
//!
//! The invariant the grammar exists to keep: **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 — a host record, a settings profile, a library id — and never
//! values: no resolution, no bitrate, no codec. A web page must not be able to shape a
//! session beyond picking among the user's own configurations. `pair` is deliberately not a
//! route and never will be; pairing stays an interactive ceremony.
//!
//! `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever
//! *emits* or registers it (§2: claiming a two-letter scheme on MSIX/Apple is unconditional
//! squatting, and a link that resolves on one platform only is a trap).
use crate::trust::{KnownHost, KnownHosts};
/// Hostile-input caps (§8). The total is generous for a real link and small enough that a
/// pasted megabyte never reaches the decoder.
pub const MAX_URL_LEN: usize = 2048;
pub const MAX_HOST_REF_LEN: usize = 128;
pub const MAX_LAUNCH_LEN: usize = 128;
pub const MAX_PROFILE_LEN: usize = 64;
pub const MAX_NAME_LEN: usize = 64;
/// The default native port, as everywhere else in the clients.
pub const DEFAULT_PORT: u16 = 9777;
/// What the URL asks for. `Wake`/`Browse` are reserved in the grammar and parse today; a
/// front-end that hasn't implemented them refuses with a notice rather than silently
/// connecting — the grammar is the contract, per-platform support is not.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Route {
/// The default, and the only route an emitter builds today.
#[default]
Connect,
Wake,
Browse,
}
impl Route {
pub fn as_str(self) -> &'static str {
match self {
Route::Connect => "connect",
Route::Wake => "wake",
Route::Browse => "browse",
}
}
}
/// A parsed, validated link. Every field is already length- and charset-checked, so a
/// consumer never has to re-validate hostile input; what it still has to do is *resolve*
/// (§3): the references may name things that don't exist here.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct DeepLink {
pub route: Route,
/// The host reference as written: a stable record id, a host name, or `addr[:port]`.
pub host_ref: String,
/// Expected host certificate fingerprint, lowercase hex (64 chars).
pub fp: Option<String>,
/// Recovery address for a stable id that no longer resolves (store wiped, reinstall).
pub host: Option<(String, u16)>,
/// A store-qualified library id (`steam:570`) for the host to launch on arrival.
pub launch: Option<String>,
/// A settings-profile reference (id, or a unique name) — one-off, never rebinding.
pub profile: Option<String>,
/// Display label for the unknown-host confirmation sheet (external emitters).
pub name: Option<String>,
}
/// Why a URL was rejected. The `code` strings are the cross-language contract (the vector
/// file names them) — Swift and Kotlin report the same code for the same input, which is what
/// keeps three parsers from drifting into three different security postures.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ParseError {
/// Not a `punktfunk://` (or `pf://`) URL at all — the caller should ignore it, not warn.
NotOurScheme,
TooLong,
/// A route this grammar doesn't define.
UnknownRoute(String),
/// `punktfunk://pair/…` — pairing is an interactive ceremony, never a link (§2).
PairRefused,
MissingHostRef,
/// A `%` escape that isn't two hex digits, or a decode that isn't UTF-8.
BadEscape,
/// A control character survived decoding — no legitimate field contains one.
ControlChar,
/// A parameter past its cap; carries the parameter name.
ParamTooLong(&'static str),
/// `fp=` that isn't 64 hex characters.
BadFingerprint,
/// `host=` that isn't `addr[:port]` with a parsable port.
BadHostParam,
/// `launch=` outside the printable, shell-safe id charset the host and Decky agree on.
BadLaunchId,
}
impl ParseError {
/// The stable code shared with the Swift/Kotlin ports and the vector file.
pub fn code(&self) -> &'static str {
match self {
ParseError::NotOurScheme => "not-our-scheme",
ParseError::TooLong => "too-long",
ParseError::UnknownRoute(_) => "unknown-route",
ParseError::PairRefused => "pair-refused",
ParseError::MissingHostRef => "missing-host-ref",
ParseError::BadEscape => "bad-escape",
ParseError::ControlChar => "control-char",
ParseError::ParamTooLong(_) => "param-too-long",
ParseError::BadFingerprint => "bad-fingerprint",
ParseError::BadHostParam => "bad-host-param",
ParseError::BadLaunchId => "bad-launch-id",
}
}
/// A sentence for the notice a refusing front-end shows. Deliberately names the failing
/// reference: "a shortcut that can't honor its profile says so instead of streaming with
/// the wrong settings" (§10.6) applies to every refusal here.
pub fn message(&self) -> String {
match self {
ParseError::NotOurScheme => "That isn't a Punktfunk link.".into(),
ParseError::TooLong => "That link is too long to be genuine.".into(),
ParseError::UnknownRoute(r) => format!("Punktfunk links can't do \"{r}\"."),
ParseError::PairRefused => {
"Pairing can't be done from a link — pair the host in Punktfunk first.".into()
}
ParseError::MissingHostRef => "That link doesn't say which host to use.".into(),
ParseError::BadEscape | ParseError::ControlChar => {
"That link is malformed and was ignored.".into()
}
ParseError::ParamTooLong(p) => format!("That link's \"{p}\" value is too long."),
ParseError::BadFingerprint => "That link's host fingerprint isn't a valid one.".into(),
ParseError::BadHostParam => "That link's host address isn't valid.".into(),
ParseError::BadLaunchId => "That link's game id isn't a valid one.".into(),
}
}
}
/// Parse a `punktfunk://` (or `pf://`) URL. Everything hostile is rejected here, once, for
/// every front-end: over-long input, malformed escapes, control characters, out-of-charset
/// launch ids and fingerprints that aren't fingerprints.
pub fn parse(url: &str) -> Result<DeepLink, ParseError> {
if url.len() > MAX_URL_LEN {
return Err(ParseError::TooLong);
}
let (scheme, rest) = url.split_once("://").ok_or(ParseError::NotOurScheme)?;
if !scheme.eq_ignore_ascii_case("punktfunk") && !scheme.eq_ignore_ascii_case("pf") {
return Err(ParseError::NotOurScheme);
}
// A fragment is never part of this grammar; drop it rather than folding it into the last
// parameter (where it would smuggle unvalidated text past the caps).
let rest = rest.split('#').next().unwrap_or("");
let (path, query) = match rest.split_once('?') {
Some((p, q)) => (p, q),
None => (rest, ""),
};
let path = path.trim_end_matches('/');
let (route_word, host_ref_raw) = match path.split_once('/') {
Some((r, h)) => (r, h),
// A single segment: Apple's shipped links are always `connect/<uuid>`, but a bare
// reference is unambiguous as long as it isn't one of the route words — those stay
// routes (with a missing reference), so `punktfunk://pair` refuses instead of hunting
// for a host called "pair".
None if is_route_word(path) => (path, ""),
None => ("connect", path),
};
let route = match route_word.to_ascii_lowercase().as_str() {
"connect" => Route::Connect,
"wake" => Route::Wake,
"browse" => Route::Browse,
"pair" => return Err(ParseError::PairRefused),
other => return Err(ParseError::UnknownRoute(other.to_string())),
};
let host_ref = decode(host_ref_raw)?;
if host_ref.is_empty() {
return Err(ParseError::MissingHostRef);
}
if host_ref.chars().count() > MAX_HOST_REF_LEN {
return Err(ParseError::ParamTooLong("host-ref"));
}
let mut link = DeepLink {
route,
host_ref,
..Default::default()
};
for pair in query.split('&').filter(|s| !s.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
let key = decode(key)?.to_ascii_lowercase();
let value = decode(value)?;
if value.is_empty() {
continue; // `?launch=` with nothing after it is "not given", not an error.
}
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter
// must not turn an otherwise valid link into a refusal, and appending a second `fp=`
// must not be able to override the first.
match key.as_str() {
"fp" if link.fp.is_none() => {
let fp = value.to_ascii_lowercase();
if fp.len() != 64 || !fp.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ParseError::BadFingerprint);
}
link.fp = Some(fp);
}
"host" if link.host.is_none() => {
link.host = Some(parse_addr_port(&value).ok_or(ParseError::BadHostParam)?);
}
"launch" if link.launch.is_none() => {
if value.len() > MAX_LAUNCH_LEN {
return Err(ParseError::ParamTooLong("launch"));
}
if !is_safe_launch_id(&value) {
return Err(ParseError::BadLaunchId);
}
link.launch = Some(value);
}
"profile" if link.profile.is_none() => {
if value.chars().count() > MAX_PROFILE_LEN {
return Err(ParseError::ParamTooLong("profile"));
}
link.profile = Some(value);
}
"name" if link.name.is_none() => {
if value.chars().count() > MAX_NAME_LEN {
return Err(ParseError::ParamTooLong("name"));
}
link.name = Some(value);
}
_ => {}
}
}
Ok(link)
}
impl DeepLink {
/// The canonical URL for this link — always `punktfunk://`, never the `pf://` alias.
/// Self-emitted links carry the stable id AND `host`+`fp`, so a shortcut written today
/// still resolves after a reinstall wipes the store (§2, §5).
pub fn to_url(&self) -> String {
let mut s = format!(
"punktfunk://{}/{}",
self.route.as_str(),
encode(&self.host_ref)
);
let mut sep = '?';
let mut push = |s: &mut String, key: &str, value: &str| {
s.push(sep);
sep = '&';
s.push_str(key);
s.push('=');
s.push_str(&encode(value));
};
if let Some(fp) = &self.fp {
push(&mut s, "fp", fp);
}
if let Some((addr, port)) = &self.host {
let host = if *port == DEFAULT_PORT {
addr.clone()
} else if addr.contains(':') {
format!("[{addr}]:{port}") // literal IPv6 needs its brackets back
} else {
format!("{addr}:{port}")
};
push(&mut s, "host", &host);
}
if let Some(launch) = &self.launch {
push(&mut s, "launch", launch);
}
if let Some(profile) = &self.profile {
push(&mut s, "profile", profile);
}
if let Some(name) = &self.name {
push(&mut s, "name", name);
}
s
}
/// The self-emitted form for a saved host: id first (address-independent), with the
/// address and pin alongside so the link degrades to a confirmation sheet instead of a
/// dead click when the record is gone.
pub fn for_host(host: &KnownHost, launch: Option<&str>, profile: Option<&str>) -> DeepLink {
DeepLink {
route: Route::Connect,
host_ref: host
.id
.clone()
.unwrap_or_else(|| format!("{}:{}", host.addr, host.port)),
fp: (!host.fp_hex.is_empty()).then(|| host.fp_hex.clone()),
host: Some((host.addr.clone(), host.port)),
launch: launch.map(str::to_string),
profile: profile.map(str::to_string),
name: None,
}
}
/// True when this link's `fp` contradicts what we have pinned for that host — the link is
/// stale or lying, and the only safe answer is a hard refusal (§3.1).
pub fn pin_conflict(&self, host: &KnownHost) -> bool {
match (&self.fp, host.fp_hex.is_empty()) {
(Some(fp), false) => !fp.eq_ignore_ascii_case(&host.fp_hex),
_ => false,
}
}
}
/// What the local host store made of a link's references.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostResolution {
/// Index into `KnownHosts::hosts` — a record we already trust (subject to
/// [`DeepLink::pin_conflict`]).
Known(usize),
/// No record, but the link says where to dial: the confirmation sheet's input, from which
/// the normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
Unknown {
addr: String,
port: u16,
name: Option<String>,
fp: Option<String>,
},
/// The name matched more than one saved host — refuse with a notice, never guess (§8).
Ambiguous,
/// A reference that resolves to nothing and carries no address to fall back on.
Unresolvable,
}
/// Resolve a link's host reference against the local store, in the documented order: stable
/// record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
/// the recovery path — a self-emitted shortcut that outlived the record it was written from
/// still lands on the right box (degraded to the confirmation sheet).
///
/// Returns an index rather than a borrow so callers can keep mutating the store (rekey,
/// touch-last-used) without fighting the borrow checker.
pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
if let Some(i) = known
.hosts
.iter()
.position(|h| h.id.as_deref().is_some_and(|id| id == link.host_ref))
{
return HostResolution::Known(i);
}
let by_name: Vec<usize> = known
.hosts
.iter()
.enumerate()
.filter(|(_, h)| h.name.eq_ignore_ascii_case(&link.host_ref))
.map(|(i, _)| i)
.collect();
match by_name.len() {
1 => return HostResolution::Known(by_name[0]),
0 => {}
_ => return HostResolution::Ambiguous,
}
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
// other per-host lookup in the client matches (addr + port). The literal is only
// considered when the reference could BE an address: a stale record id must fall through
// to `host=` (or to a refusal), never be offered as a box to dial.
let literal = looks_like_address(&link.host_ref)
.then(|| parse_addr_port(&link.host_ref))
.flatten();
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
if let Some(i) = known
.hosts
.iter()
.position(|h| h.addr == candidate.0 && h.port == candidate.1)
{
return HostResolution::Known(i);
}
}
match literal.or_else(|| link.host.clone()) {
Some((addr, port)) => HostResolution::Unknown {
addr,
port,
name: link.name.clone(),
fp: link.fp.clone(),
},
None => HostResolution::Unresolvable,
}
}
/// Could this reference be a network address (an IP literal or a host name) rather than a
/// record id or a display name? Only then may an unmatched reference become "an unknown host
/// at this address" for the confirmation sheet. A stable id that no longer resolves is NOT an
/// address: offering to dial a UUID as a hostname would turn a wiped store into a confusing
/// dead end instead of the `host=`-driven recovery §2 specifies.
fn looks_like_address(s: &str) -> bool {
let uuid_shaped = s.len() == 36
&& s.char_indices().all(|(i, c)| match i {
8 | 13 | 18 | 23 => c == '-',
_ => c.is_ascii_hexdigit(),
});
!uuid_shaped
&& !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']'))
}
/// The reserved first path segments — everything the grammar routes on, plus `pair`, which is
/// reserved precisely so it can be refused rather than mistaken for a host name.
fn is_route_word(s: &str) -> bool {
matches!(
s.to_ascii_lowercase().as_str(),
"connect" | "wake" | "browse" | "pair"
)
}
/// `addr`, `addr:port`, `[v6]`, `[v6]:port` — `None` when the port isn't a number. A bare
/// IPv6 literal (`::1`) keeps its colons and takes the default port; anything else splits at
/// the last colon, like every other host-parsing site in the clients.
fn parse_addr_port(s: &str) -> Option<(String, u16)> {
if s.is_empty() {
return None;
}
if let Some(rest) = s.strip_prefix('[') {
let (addr, tail) = rest.split_once(']')?;
if addr.is_empty() {
return None;
}
return match tail {
"" => Some((addr.to_string(), DEFAULT_PORT)),
t => Some((addr.to_string(), t.strip_prefix(':')?.parse().ok()?)),
};
}
match s.rsplit_once(':') {
// `::1` and friends: the head still has a colon, so this isn't a port separator.
Some((head, _)) if head.contains(':') => Some((s.to_string(), DEFAULT_PORT)),
Some((addr, port)) if !addr.is_empty() => Some((addr.to_string(), port.parse().ok()?)),
Some(_) => None,
None => Some((s.to_string(), DEFAULT_PORT)),
}
}
/// The launch-id charset the whole product already agrees on: printable, non-space ASCII with
/// no shell metacharacters (Decky rides ids through Steam launch options as an env token, so
/// a quote or a backtick genuinely breaks something downstream). Validation only — the id is
/// opaque and the host matches it verbatim against its own library.
fn is_safe_launch_id(id: &str) -> bool {
!id.is_empty()
&& id
.bytes()
.all(|b| (0x21..=0x7e).contains(&b) && !br#""'\$`"#.contains(&b))
}
/// Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
/// UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray `\n`
/// or a half-escape end up inside a filename or a log line.
fn decode(s: &str) -> Result<String, ParseError> {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
let hex = bytes.get(i + 1..i + 3).ok_or(ParseError::BadEscape)?;
let hi = (hex[0] as char).to_digit(16).ok_or(ParseError::BadEscape)?;
let lo = (hex[1] as char).to_digit(16).ok_or(ParseError::BadEscape)?;
out.push((hi * 16 + lo) as u8);
i += 3;
}
b => {
out.push(b);
i += 1;
}
}
}
let text = String::from_utf8(out).map_err(|_| ParseError::BadEscape)?;
if text.chars().any(|c| c.is_control()) {
return Err(ParseError::ControlChar);
}
Ok(text)
}
/// Percent-encode for emission: unreserved characters plus `:` (legal in a query value and
/// left alone by Apple's `URLComponents`, so the three emitters agree on `steam:570`).
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trust::KnownHost;
fn host(name: &str, addr: &str, id: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: DEFAULT_PORT,
fp_hex: fp.into(),
paired: true,
id: Some(id.into()),
..Default::default()
}
}
/// Every case in the cross-language vector file, which the Swift and Kotlin ports consume
/// too — this is what keeps three parsers from drifting into three security postures.
#[test]
fn shared_vectors() {
let raw = include_str!("../../../clients/shared/deeplink-vectors.json");
let file: serde_json::Value = serde_json::from_str(raw).expect("vector file parses");
let cases = file["cases"].as_array().expect("cases array");
assert!(
cases.len() > 20,
"the vector file is the contract; keep it rich"
);
for case in cases {
let name = case["name"].as_str().unwrap();
let url = case["url"].as_str().unwrap();
let got = parse(url);
match case.get("error").and_then(|e| e.as_str()) {
Some(code) => {
let err = got.expect_err(&format!("{name}: expected {code}, parsed ok"));
assert_eq!(err.code(), code, "{name}");
}
None => {
let link = got.unwrap_or_else(|e| panic!("{name}: {e:?}"));
let want = &case["expect"];
assert_eq!(
link.route.as_str(),
want["route"].as_str().unwrap(),
"{name}"
);
assert_eq!(link.host_ref, want["host_ref"].as_str().unwrap(), "{name}");
let opt = |k: &str| want.get(k).and_then(|v| v.as_str()).map(str::to_string);
assert_eq!(link.fp, opt("fp"), "{name} fp");
assert_eq!(link.launch, opt("launch"), "{name} launch");
assert_eq!(link.profile, opt("profile"), "{name} profile");
assert_eq!(link.name, opt("name"), "{name} name");
let (addr, port) = match &link.host {
Some((a, p)) => (Some(a.clone()), Some(u64::from(*p))),
None => (None, None),
};
assert_eq!(addr, opt("host_addr"), "{name} host_addr");
assert_eq!(
port,
want.get("host_port").and_then(|v| v.as_u64()),
"{name} host_port"
);
if let Some(emit) = case.get("emit").and_then(|v| v.as_str()) {
assert_eq!(link.to_url(), emit, "{name} emit");
}
}
}
}
}
/// A `Result` from the parser is not enough on its own: the codes are the shared
/// vocabulary, so they must be exactly what the vector file (and the ports) name.
#[test]
fn refusals_are_specific() {
assert_eq!(parse("https://example.com/"), Err(ParseError::NotOurScheme));
assert_eq!(parse("punktfunk:/connect/x"), Err(ParseError::NotOurScheme));
assert_eq!(
parse(&format!("punktfunk://connect/{}", "a".repeat(MAX_URL_LEN))),
Err(ParseError::TooLong)
);
assert_eq!(parse("punktfunk://pair/1234"), Err(ParseError::PairRefused));
assert_eq!(
parse("punktfunk://teardown/host"),
Err(ParseError::UnknownRoute("teardown".into()))
);
assert_eq!(
parse("punktfunk://connect/"),
Err(ParseError::MissingHostRef)
);
assert_eq!(
parse(&format!(
"punktfunk://connect/{}",
"n".repeat(MAX_HOST_REF_LEN + 1)
)),
Err(ParseError::ParamTooLong("host-ref"))
);
}
/// The one-click contract in resolution form: an id beats a name beats an address, an
/// ambiguous name refuses, and a link whose record is gone still lands on the
/// confirmation sheet via `host=`+`fp=` instead of dying.
#[test]
fn host_resolution_order_and_recovery() {
let fp = "a".repeat(64);
let known = KnownHosts {
hosts: vec![
host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
),
host(
"Couch",
"192.168.1.60",
"66666666-7777-4888-8999-aaaaaaaaaaaa",
"",
),
host(
"Couch",
"192.168.1.61",
"bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
"",
),
],
};
let r = |url: &str| resolve_host(&parse(url).unwrap(), &known);
assert_eq!(
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
HostResolution::Known(0)
);
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Known(0));
assert_eq!(r("punktfunk://connect/couch"), HostResolution::Ambiguous);
assert_eq!(
r("punktfunk://connect/192.168.1.50"),
HostResolution::Known(0)
);
assert_eq!(
r("punktfunk://connect/192.168.1.50:9777"),
HostResolution::Known(0)
);
// A stale id with the recovery parameters: the address finds the record anyway.
assert_eq!(
r("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
HostResolution::Known(0)
);
// Nothing local matches: the sheet gets the address, the claimed name and the pin —
// which is what makes the first connect verified rather than blind TOFU.
assert_eq!(
r(&format!(
"punktfunk://connect/10.0.0.9:7000?name=Studio&fp={fp}"
)),
HostResolution::Unknown {
addr: "10.0.0.9".into(),
port: 7000,
name: Some("Studio".into()),
fp: Some(fp.clone()),
}
);
// An unmatched reference that could be an address (an mDNS/DNS name, a new IP) is
// offered as an unknown host — the sheet, never an auto-connect.
assert_eq!(
r("punktfunk://connect/nas.local"),
HostResolution::Unknown {
addr: "nas.local".into(),
port: DEFAULT_PORT,
name: None,
fp: None,
}
);
// But a stale record id is not a hostname: without `host=` there is nothing to dial,
// and dialing "11111111-…" would be a confusing dead end rather than a recovery.
assert_eq!(
r("punktfunk://connect/00000000-0000-4000-8000-000000000000"),
HostResolution::Unresolvable
);
// Neither is a display name that can't be an address.
assert_eq!(
r("punktfunk://connect/Basement%20PC"),
HostResolution::Unresolvable
);
// A pin that contradicts the stored one is the link lying — the caller hard-refuses.
let link = parse(&format!("punktfunk://connect/desk?fp={}", "b".repeat(64))).unwrap();
assert!(link.pin_conflict(&known.hosts[0]));
assert!(!parse(&format!("punktfunk://connect/desk?fp={fp}"))
.unwrap()
.pin_conflict(&known.hosts[0]));
// No pin stored (an address-only record) → nothing to contradict; the trust flow runs.
assert!(!link.pin_conflict(&known.hosts[1]));
}
/// Self-emitted links round-trip and carry all three references, so they survive both a
/// re-addressed host and a wiped store.
#[test]
fn self_emitted_links_round_trip() {
let fp = "c".repeat(64);
let mut h = host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
);
h.port = 7777;
let link = DeepLink::for_host(&h, Some("steam:570"), Some("aaaaaaaaaaaa"));
let url = link.to_url();
assert_eq!(
url,
"punktfunk://connect/11111111-2222-4333-8444-555555555555\
?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\
&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
);
assert_eq!(parse(&url).unwrap(), link);
// A record with no id yet (pre-migration store) still emits something resolvable.
let mut plain = h.clone();
plain.id = None;
assert_eq!(
DeepLink::for_host(&plain, None, None).host_ref,
"192.168.1.50:7777"
);
// Names with spaces and non-ASCII survive the round trip.
let link = DeepLink {
host_ref: "Wohnzimmer PC".into(),
name: Some("Büro · Mac".into()),
..Default::default()
};
assert!(link
.to_url()
.starts_with("punktfunk://connect/Wohnzimmer%20PC?"));
assert_eq!(parse(&link.to_url()).unwrap(), link);
}
/// `addr[:port]` parsing, including the bracketed IPv6 forms a link can carry.
#[test]
fn addr_port_forms() {
assert_eq!(
parse_addr_port("192.168.1.5"),
Some(("192.168.1.5".into(), 9777))
);
assert_eq!(
parse_addr_port("192.168.1.5:1234"),
Some(("192.168.1.5".into(), 1234))
);
assert_eq!(parse_addr_port("::1"), Some(("::1".into(), 9777)));
assert_eq!(parse_addr_port("[::1]"), Some(("::1".into(), 9777)));
assert_eq!(parse_addr_port("[::1]:1234"), Some(("::1".into(), 1234)));
assert_eq!(parse_addr_port("host:notaport"), None);
assert_eq!(parse_addr_port("[::1]junk"), None);
assert_eq!(parse_addr_port(""), None);
// An emitted IPv6 host parameter comes back bracketed so it parses again.
let link = DeepLink {
host_ref: "x".into(),
host: Some(("::1".into(), 1234)),
..Default::default()
};
assert_eq!(parse(&link.to_url()).unwrap().host, link.host);
}
}