guacamole-client 0.5.1

Rust client library for the Guacamole REST API
Documentation
use std::fmt;
use std::time::Duration;

use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{header, Client, StatusCode};
use serde::de::DeserializeOwned;

use crate::error::{Error, Result};
use crate::validation::{validate_data_source, validate_token};

/// Characters that must be percent-encoded in a URL query value.
const QUERY_VALUE_ENCODE: &AsciiSet = &CONTROLS
    .add(b' ')
    .add(b'#')
    .add(b'&')
    .add(b'+')
    .add(b'=')
    .add(b'?')
    .add(b'%');

/// Client for the Apache Guacamole REST API.
#[derive(Clone)]
pub struct GuacamoleClient {
    pub(crate) http: Client,
    pub(crate) base_url: String,
    pub(crate) auth_token: Option<String>,
    pub(crate) data_source: Option<String>,
}

impl fmt::Debug for GuacamoleClient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("GuacamoleClient")
            .field("base_url", &self.base_url)
            .field("auth_token", &"<redacted>")
            .field("data_source", &self.data_source)
            .field("http", &"<redacted>")
            .finish()
    }
}

impl GuacamoleClient {
    /// Creates a new client pointing at the given Guacamole base URL.
    ///
    /// No authentication is performed; call [`login`](Self::login) afterwards.
    #[must_use = "constructing a client is side-effect-free"]
    pub fn new(base_url: &str) -> Result<Self> {
        let http = Client::builder()
            .timeout(Duration::from_secs(30))
            .connect_timeout(Duration::from_secs(10))
            .build()?;

        Ok(Self {
            http,
            base_url: base_url.trim_end_matches('/').to_owned(),
            auth_token: None,
            data_source: None,
        })
    }

    /// Returns the stored auth token, or `NotAuthenticated` if absent.
    ///
    /// Also validates the token as defense-in-depth (primary validation is in `login()`).
    pub(crate) fn require_token(&self) -> Result<&str> {
        let token = self.auth_token.as_deref().ok_or(Error::NotAuthenticated)?;
        validate_token(token)?;
        Ok(token)
    }

    /// Returns the override data source if given, otherwise the stored default.
    pub(crate) fn resolve_data_source<'a>(
        &'a self,
        override_ds: Option<&'a str>,
    ) -> Result<&'a str> {
        if let Some(ds) = override_ds {
            validate_data_source(ds)?;
            Ok(ds)
        } else {
            let ds = self
                .data_source
                .as_deref()
                .ok_or(Error::NotAuthenticated)?;
            validate_data_source(ds)?;
            Ok(ds)
        }
    }

    /// Builds a full URL with the auth token appended as a query parameter.
    ///
    /// The token value is percent-encoded as defense in depth, even though
    /// [`validate_token`] already rejects most URL-special characters.
    pub(crate) fn url(&self, path: &str) -> Result<String> {
        let token = self.require_token()?;
        let encoded_token = utf8_percent_encode(token, QUERY_VALUE_ENCODE);
        let separator = if path.contains('?') { '&' } else { '?' };
        Ok(format!(
            "{}{path}{separator}token={encoded_token}",
            self.base_url
        ))
    }

    /// Builds a full URL without authentication (for login).
    pub(crate) fn url_unauth(&self, path: &str) -> String {
        format!("{}{path}", self.base_url)
    }

    /// Checks for HTTP errors and deserializes the response body.
    pub(crate) async fn parse_response<T: DeserializeOwned>(
        response: reqwest::Response,
        resource: &str,
    ) -> Result<T> {
        let response = Self::handle_error(response, resource).await?;
        Ok(response.json().await?)
    }

    /// Maps HTTP error status codes to typed errors.
    pub(crate) async fn handle_error(
        response: reqwest::Response,
        resource: &str,
    ) -> Result<reqwest::Response> {
        let status = response.status();

        if status.is_success() {
            return Ok(response);
        }

        let retry_after = response
            .headers()
            .get(header::RETRY_AFTER)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse::<u64>().ok())
            .map(Duration::from_secs);

        let mut body = response.text().await.unwrap_or_default();
        let max_len = 512;
        if body.len() > max_len {
            body.truncate(body.floor_char_boundary(max_len));
            body.push_str("...<truncated>");
        }

        match status {
            StatusCode::UNAUTHORIZED => Err(Error::Unauthorized { body }),
            StatusCode::FORBIDDEN => Err(Error::Forbidden { body }),
            StatusCode::NOT_FOUND => Err(Error::NotFound {
                resource: resource.to_owned(),
                body,
            }),
            StatusCode::TOO_MANY_REQUESTS => Err(Error::RateLimited { retry_after }),
            _ => Err(Error::Api { status, body }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn client_is_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<GuacamoleClient>();
    }

    #[test]
    fn client_is_clone() {
        fn assert_clone<T: Clone>() {}
        assert_clone::<GuacamoleClient>();
    }

    #[test]
    fn debug_does_not_leak_token() {
        let mut client = GuacamoleClient::new("http://localhost:8080/guacamole").unwrap();
        client.auth_token = Some("super-secret-token".to_string());
        let debug_output = format!("{client:?}");
        assert!(
            !debug_output.contains("super-secret-token"),
            "Debug output must not contain the auth token"
        );
        assert!(debug_output.contains("<redacted>"));
    }

    #[test]
    fn new_strips_trailing_slash() {
        let client = GuacamoleClient::new("http://localhost:8080/guacamole/").unwrap();
        assert_eq!(client.base_url, "http://localhost:8080/guacamole");
    }

    #[test]
    fn url_unauth_building() {
        let client = GuacamoleClient::new("http://localhost:8080/guacamole").unwrap();
        assert_eq!(
            client.url_unauth("/api/tokens"),
            "http://localhost:8080/guacamole/api/tokens"
        );
    }

    #[test]
    fn url_requires_token() {
        let client = GuacamoleClient::new("http://localhost:8080/guacamole").unwrap();
        assert!(matches!(client.url("/api/test"), Err(Error::NotAuthenticated)));
    }

    #[test]
    fn url_appends_token() {
        let mut client = GuacamoleClient::new("http://localhost:8080/guacamole").unwrap();
        client.auth_token = Some("abc123".to_string());
        let url = client.url("/api/session/data/mysql/users").unwrap();
        assert_eq!(
            url,
            "http://localhost:8080/guacamole/api/session/data/mysql/users?token=abc123"
        );
    }

    #[test]
    fn url_appends_token_with_existing_query() {
        let mut client = GuacamoleClient::new("http://localhost:8080/guacamole").unwrap();
        client.auth_token = Some("abc123".to_string());
        let url = client.url("/api/session/data/mysql/history/connections?contains=test").unwrap();
        assert_eq!(
            url,
            "http://localhost:8080/guacamole/api/session/data/mysql/history/connections?contains=test&token=abc123"
        );
    }

    #[test]
    fn require_token_returns_error_when_none() {
        let client = GuacamoleClient::new("http://localhost:8080").unwrap();
        assert!(matches!(client.require_token(), Err(Error::NotAuthenticated)));
    }

    #[test]
    fn require_token_returns_token_when_present() {
        let mut client = GuacamoleClient::new("http://localhost:8080").unwrap();
        client.auth_token = Some("tok".to_string());
        assert_eq!(client.require_token().unwrap(), "tok");
    }

    #[test]
    fn resolve_data_source_uses_override() {
        let client = GuacamoleClient::new("http://localhost:8080").unwrap();
        assert_eq!(
            client.resolve_data_source(Some("postgresql")).unwrap(),
            "postgresql"
        );
    }

    #[test]
    fn resolve_data_source_uses_stored() {
        let mut client = GuacamoleClient::new("http://localhost:8080").unwrap();
        client.data_source = Some("mysql".to_string());
        assert_eq!(client.resolve_data_source(None).unwrap(), "mysql");
    }

    #[test]
    fn resolve_data_source_validates_override() {
        let client = GuacamoleClient::new("http://localhost:8080").unwrap();
        assert!(matches!(
            client.resolve_data_source(Some("")),
            Err(Error::InvalidDataSource(_))
        ));
    }

    #[test]
    fn resolve_data_source_returns_error_when_no_default() {
        let client = GuacamoleClient::new("http://localhost:8080").unwrap();
        assert!(matches!(
            client.resolve_data_source(None),
            Err(Error::NotAuthenticated)
        ));
    }

    #[test]
    fn resolve_data_source_validates_stored() {
        let mut client = GuacamoleClient::new("http://localhost:8080").unwrap();
        client.data_source = Some("a/b".to_string());
        assert!(matches!(
            client.resolve_data_source(None),
            Err(Error::InvalidDataSource(_))
        ));
    }

    #[test]
    fn require_token_rejects_unsafe_token() {
        let mut client = GuacamoleClient::new("http://localhost:8080").unwrap();
        client.auth_token = Some("tok/traversal".to_string());
        assert!(matches!(
            client.require_token(),
            Err(Error::InvalidToken(_))
        ));
    }

    #[test]
    fn new_strips_multiple_trailing_slashes() {
        let client = GuacamoleClient::new("http://host///").unwrap();
        assert_eq!(client.base_url, "http://host");
    }

    #[test]
    fn new_accepts_empty_url() {
        let client = GuacamoleClient::new("").unwrap();
        assert_eq!(client.base_url, "");
    }

    #[test]
    fn url_token_special_chars_rejected() {
        let mut client = GuacamoleClient::new("http://host").unwrap();
        client.auth_token = Some("a&b=c".to_string());
        assert!(matches!(client.url("/api/test"), Err(Error::InvalidToken(_))));
    }

    #[test]
    fn url_with_multiple_query_params() {
        let mut client = GuacamoleClient::new("http://host").unwrap();
        client.auth_token = Some("tok".to_string());
        let url = client.url("/api/data?a=1&b=2").unwrap();
        assert_eq!(url, "http://host/api/data?a=1&b=2&token=tok");
    }
}