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};
const QUERY_VALUE_ENCODE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'#')
.add(b'&')
.add(b'+')
.add(b'=')
.add(b'?')
.add(b'%');
#[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 {
#[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,
})
}
pub(crate) fn require_token(&self) -> Result<&str> {
let token = self.auth_token.as_deref().ok_or(Error::NotAuthenticated)?;
validate_token(token)?;
Ok(token)
}
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)
}
}
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
))
}
pub(crate) fn url_unauth(&self, path: &str) -> String {
format!("{}{path}", self.base_url)
}
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?)
}
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");
}
}