diff --git a/client-core/src/config.rs b/client-core/src/config.rs new file mode 100644 index 0000000..99e420c --- /dev/null +++ b/client-core/src/config.rs @@ -0,0 +1,152 @@ +//! 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() { + if 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}" + ); + } +} diff --git a/client-core/src/lib.rs b/client-core/src/lib.rs index 0e4d8d6..80f65f0 100644 --- a/client-core/src/lib.rs +++ b/client-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod ansi; pub mod api; +pub mod config; pub mod event_stream; pub mod highlight; pub mod notifications;