wekan-cli 0.11.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use std::{
    fmt,
    sync::atomic::{AtomicU64, Ordering},
    time::Duration,
};

use reqwest::{
    Body, Method, StatusCode, Url,
    header::{CONTENT_LENGTH, HeaderMap, HeaderValue},
};
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use tokio::io::AsyncRead;
use tokio_util::io::ReaderStream;

use super::{ClientError, ServerUrl, WekanClient, WekanClientFactory};

#[derive(Debug)]
pub(crate) enum RawApiAuthentication<'token> {
    Bearer(&'token SecretString),
    QueryToken(&'token SecretString),
    None,
}

pub(crate) enum RawApiRequestBody {
    Text(String),
    Reader(Box<dyn AsyncRead + Send + Unpin>),
}

impl RawApiRequestBody {
    fn into_reqwest_body(self) -> Body {
        match self {
            Self::Text(body) => Body::from(body),
            Self::Reader(reader) => Body::wrap_stream(ReaderStream::new(reader)),
        }
    }
}

pub(crate) struct RawApiRequest<'request> {
    pub(crate) method: Method,
    pub(crate) path: &'request str,
    pub(crate) query: &'request [(String, String)],
    pub(crate) headers: HeaderMap,
    pub(crate) body: Option<RawApiRequestBody>,
    pub(crate) content_length: Option<u64>,
    pub(crate) authentication: RawApiAuthentication<'request>,
    pub(crate) timeout: Option<Duration>,
}

#[derive(Debug, Error)]
pub(crate) enum RawApiRequestError {
    #[error("{0}")]
    InvalidInput(String),
    #[error(transparent)]
    Client(#[from] ClientError),
}

pub struct RawApiResponse {
    identity: u64,
    response: reqwest::Response,
}

impl fmt::Debug for RawApiResponse {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("RawApiResponse")
            .field("identity", &self.identity)
            .field("status", &self.status())
            .finish_non_exhaustive()
    }
}

impl PartialEq for RawApiResponse {
    fn eq(&self, other: &Self) -> bool {
        self.identity == other.identity
    }
}

impl Eq for RawApiResponse {}

impl RawApiResponse {
    pub fn status(&self) -> StatusCode {
        self.response.status()
    }

    pub fn headers(&self) -> &HeaderMap {
        self.response.headers()
    }

    pub(crate) async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>, ClientError> {
        let status = self.response.status();
        let retry_after_seconds = self
            .response
            .headers()
            .get(reqwest::header::RETRY_AFTER)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.parse::<u64>().ok());
        self.response
            .chunk()
            .await
            .map(|chunk| chunk.map(|bytes| bytes.to_vec()))
            .map_err(|source| ClientError::ResponseBody {
                status,
                retry_after_seconds,
                source: source.without_url(),
            })
    }
}

impl WekanClientFactory {
    pub(crate) fn validate_raw_api_request(
        &self,
        method: &Method,
        path: &str,
        query: &[(String, String)],
        query_token: bool,
    ) -> Result<(), RawApiRequestError> {
        raw_api_url(&self.server, method, path, query, query_token, None).map(|_| ())
    }
}

impl WekanClient {
    pub(crate) async fn execute_raw_api_request(
        &self,
        request: RawApiRequest<'_>,
    ) -> Result<RawApiResponse, RawApiRequestError> {
        let RawApiRequest {
            method,
            path,
            query,
            headers,
            body,
            content_length,
            authentication,
            timeout,
        } = request;
        let (query_token, bearer_token, query_token_value) = match authentication {
            RawApiAuthentication::Bearer(token) => (false, Some(token), None),
            RawApiAuthentication::QueryToken(token) => (true, None, Some(token.expose_secret())),
            RawApiAuthentication::None => (false, None, None),
        };
        let url = raw_api_url(
            &self.server,
            &method,
            path,
            query,
            query_token,
            query_token_value,
        )?;
        let mut headers = headers;
        if let Some(length) = content_length {
            headers.insert(CONTENT_LENGTH, HeaderValue::from(length));
        }
        let mut request = self.http.request(method, url).headers(headers);
        if let Some(token) = bearer_token {
            request = request.bearer_auth(token.expose_secret());
        }
        if let Some(body) = body {
            request = request.body(body.into_reqwest_body());
        }
        if let Some(timeout) = timeout {
            request = request.timeout(timeout);
        }

        request
            .send()
            .await
            .map(|response| RawApiResponse {
                identity: next_response_identity(),
                response,
            })
            .map_err(|error| {
                RawApiRequestError::Client(ClientError::Transport(error.without_url()))
            })
    }
}

fn raw_api_url(
    server: &ServerUrl,
    method: &Method,
    path: &str,
    query: &[(String, String)],
    query_token: bool,
    query_token_value: Option<&str>,
) -> Result<Url, RawApiRequestError> {
    if !path.starts_with('/') || path.starts_with("//") {
        return Err(RawApiRequestError::InvalidInput(
            "the raw API path must begin with exactly one slash".to_owned(),
        ));
    }
    if path.contains('#') {
        return Err(RawApiRequestError::InvalidInput(
            "the raw API path must not contain a URL fragment".to_owned(),
        ));
    }

    let path_only = path.split_once('?').map_or(path, |(path, _)| path);
    if path_only.contains('\\') {
        return Err(RawApiRequestError::InvalidInput(
            "the raw API path must use forward slashes and must not contain backslashes".to_owned(),
        ));
    }
    if path_only[1..].split('/').any(is_dot_segment) {
        return Err(RawApiRequestError::InvalidInput(
            "the raw API path must not contain dot-segment traversal".to_owned(),
        ));
    }
    if query_token && *method != Method::GET {
        return Err(RawApiRequestError::InvalidInput(
            "--auth-token-query is accepted only for GET requests".to_owned(),
        ));
    }

    let mut url = server.join(&path[1..]).map_err(|error| {
        RawApiRequestError::InvalidInput(format!("the raw API path is invalid: {error}"))
    })?;
    if url.origin() != server.0.origin() || !url.path().starts_with(server.0.path()) {
        return Err(RawApiRequestError::InvalidInput(
            "the raw API path must stay beneath the configured Wekan server base".to_owned(),
        ));
    }

    if url.query_pairs().any(|(name, _)| name == "authToken")
        || query.iter().any(|(name, _)| name == "authToken")
    {
        return Err(RawApiRequestError::InvalidInput(
            "authToken is managed by --auth-token-query and must not be supplied in the path or --query"
                .to_owned(),
        ));
    }
    if !query.is_empty() || query_token_value.is_some() {
        let mut pairs = url.query_pairs_mut();
        for (name, value) in query {
            pairs.append_pair(name, value);
        }
        if let Some(token) = query_token_value {
            pairs.append_pair("authToken", token);
        }
    }
    Ok(url)
}

fn is_dot_segment(segment: &str) -> bool {
    let mut decoded = segment.to_ascii_lowercase();
    while decoded.contains("%2e") {
        decoded = decoded.replace("%2e", ".");
    }
    matches!(decoded.as_str(), "." | "..")
}

fn next_response_identity() -> u64 {
    static NEXT_IDENTITY: AtomicU64 = AtomicU64::new(1);
    NEXT_IDENTITY.fetch_add(1, Ordering::Relaxed)
}

#[cfg(test)]
mod tests {
    use std::{net::TcpListener, time::Duration};

    use reqwest::{Method, header::HeaderMap};
    use secrecy::SecretString;
    use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path};

    use super::{RawApiAuthentication, RawApiRequest, RawApiRequestError};
    use crate::client::{ClientError, WekanClientFactory};

    #[tokio::test]
    async fn token_bearing_request_urls_are_absent_from_errors_and_response_debug() {
        let token = "managed+/token";
        let closed_listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let closed_address = closed_listener.local_addr().unwrap();
        drop(closed_listener);
        let factory = WekanClientFactory::for_profile(
            format!("http://{closed_address}/"),
            "default".to_owned(),
            false,
        );
        let client = factory.create().unwrap();
        let query_token = SecretString::from(token.to_owned());
        let error = client
            .execute_raw_api_request(RawApiRequest {
                method: Method::GET,
                path: "/api/query-auth-on-an-unmodeled-route",
                query: &[],
                headers: HeaderMap::new(),
                body: None,
                content_length: None,
                authentication: RawApiAuthentication::QueryToken(&query_token),
                timeout: Some(Duration::from_secs(1)),
            })
            .await
            .unwrap_err();
        assert!(matches!(
            error,
            RawApiRequestError::Client(ClientError::Transport(_))
        ));
        let rendered = format!("{error:?}");
        assert!(!rendered.contains(token));
        assert!(!rendered.contains("managed%2B%2Ftoken"));
        assert!(!rendered.contains("authToken"));

        let server = MockServer::start().await;
        Mock::given(path("/api/query-auth-on-an-unmodeled-route"))
            .respond_with(ResponseTemplate::new(200).insert_header("x-secret", token))
            .mount(&server)
            .await;
        let factory = WekanClientFactory::for_profile(
            format!("{}/", server.uri()),
            "default".to_owned(),
            false,
        );
        let client = factory.create().unwrap();
        let query_token = SecretString::from(token.to_owned());
        let response = client
            .execute_raw_api_request(RawApiRequest {
                method: Method::GET,
                path: "/api/query-auth-on-an-unmodeled-route",
                query: &[],
                headers: HeaderMap::new(),
                body: None,
                content_length: None,
                authentication: RawApiAuthentication::QueryToken(&query_token),
                timeout: None,
            })
            .await
            .unwrap();
        let rendered = format!("{response:?}");
        assert!(!rendered.contains(token));
        assert!(!rendered.contains("authToken"));
        assert!(!rendered.contains("x-secret"));
    }
}