//! What a Rust client needs to reach one enrolled server: host, port and //! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s //! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T` //! deep link -- the exact link `wg-app-link`'s `enroll` module mints and //! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from //! the same text a phone would scan as a QR, with no second format //! invented for it (RUST.md's E4). //! //! What this type deliberately does not decide: where it is persisted, and //! under what file permissions. A phone seals its token in the Android //! Keystore; a desktop client has its own `$XDG_CONFIG_HOME//` //! directory and its own file-mode conventions (MACHINE.md: owner-only, //! never in the repo). Both are caller-specific, so they stay out of this //! crate per the code rules' "ask for the least you need" -- see //! `iris/desktop-app/src/config.rs` for the desktop instance. use serde::{Deserialize, Serialize}; /// One enrolled server: reachable at `https://{host}:{port}`, authenticated /// with `token` as a bearer header. Does not carry the pinned CA -- that is /// a public certificate rather than a secret, and where to find it differs /// by caller (a phone pins the one its APK was built against; a desktop /// client is told a path). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EnrolledServer { pub host: String, pub port: u16, pub token: String, } impl EnrolledServer { /// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not /// matter; unrecognised keys are ignored). `token` is percent-decoded, /// since `ui-sandbox.sh` encodes it precisely because a raw token can /// contain `+`, which turns into a space if left to a naive splitter. pub fn parse_link(link: &str) -> Result { let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| { format!( "'{link}' has no query string (expected \ aiapp://enroll?host=...&port=...&token=...)" ) })?; let mut host = None; let mut port = None; let mut token = None; for pair in query.split('&') { let Some((key, value)) = pair.split_once('=') else { continue; }; let value = percent_decode(value); match key { "host" => host = Some(value), "port" => port = Some(value), "token" => token = Some(value), _ => {} } } let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?; let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?; let port: u16 = port_str .parse() .map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?; let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?; Ok(Self { host, port, token }) } /// Where a `client_core::api::UreqTransport` reaches this server. pub fn base_url(&self) -> String { format!("https://{}:{}", self.host, self.port) } } fn percent_decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() && let Ok(byte) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) { out.push(byte); i += 3; continue; } out.push(bytes[i]); i += 1; } String::from_utf8_lossy(&out).into_owned() } #[cfg(test)] mod tests { use super::*; #[test] fn parses_host_port_and_token() { let server = EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123") .unwrap(); assert_eq!( server, EnrolledServer { host: "127.0.0.1".to_string(), port: 8547, token: "abcDEF123".to_string(), } ); assert_eq!(server.base_url(), "https://127.0.0.1:8547"); } #[test] fn field_order_does_not_matter() { let server = EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com") .unwrap(); assert_eq!(server.host, "example.com"); assert_eq!(server.port, 443); assert_eq!(server.token, "tok"); } #[test] fn a_percent_encoded_token_is_decoded() { // ui-sandbox.sh's own reason for encoding: a raw '+' would // otherwise arrive as a space. let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap(); assert_eq!(server.token, "a+b/c"); } #[test] fn a_missing_field_is_named_in_the_error() { let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err(); assert!( err.contains("token"), "error should name the missing field: {err}" ); } #[test] fn a_non_numeric_port_is_named_in_the_error() { let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err(); assert!( err.contains("port"), "error should name the offending field: {err}" ); } }