Skip to main content

wekan_cli/client/
raw.rs

1use std::{
2    fmt,
3    sync::atomic::{AtomicU64, Ordering},
4    time::Duration,
5};
6
7use reqwest::{
8    Body, Method, StatusCode, Url,
9    header::{CONTENT_LENGTH, HeaderMap, HeaderValue},
10};
11use secrecy::{ExposeSecret, SecretString};
12use thiserror::Error;
13use tokio::io::AsyncRead;
14use tokio_util::io::ReaderStream;
15
16use super::{ClientError, ServerUrl, WekanClient, WekanClientFactory};
17
18#[derive(Debug)]
19pub(crate) enum RawApiAuthentication<'token> {
20    Bearer(&'token SecretString),
21    QueryToken(&'token SecretString),
22    None,
23}
24
25pub(crate) enum RawApiRequestBody {
26    Text(String),
27    Reader(Box<dyn AsyncRead + Send + Unpin>),
28}
29
30impl RawApiRequestBody {
31    fn into_reqwest_body(self) -> Body {
32        match self {
33            Self::Text(body) => Body::from(body),
34            Self::Reader(reader) => Body::wrap_stream(ReaderStream::new(reader)),
35        }
36    }
37}
38
39pub(crate) struct RawApiRequest<'request> {
40    pub(crate) method: Method,
41    pub(crate) path: &'request str,
42    pub(crate) query: &'request [(String, String)],
43    pub(crate) headers: HeaderMap,
44    pub(crate) body: Option<RawApiRequestBody>,
45    pub(crate) content_length: Option<u64>,
46    pub(crate) authentication: RawApiAuthentication<'request>,
47    pub(crate) timeout: Option<Duration>,
48}
49
50#[derive(Debug, Error)]
51pub(crate) enum RawApiRequestError {
52    #[error("{0}")]
53    InvalidInput(String),
54    #[error(transparent)]
55    Client(#[from] ClientError),
56}
57
58pub struct RawApiResponse {
59    identity: u64,
60    response: reqwest::Response,
61}
62
63impl fmt::Debug for RawApiResponse {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        formatter
66            .debug_struct("RawApiResponse")
67            .field("identity", &self.identity)
68            .field("status", &self.status())
69            .finish_non_exhaustive()
70    }
71}
72
73impl PartialEq for RawApiResponse {
74    fn eq(&self, other: &Self) -> bool {
75        self.identity == other.identity
76    }
77}
78
79impl Eq for RawApiResponse {}
80
81impl RawApiResponse {
82    pub fn status(&self) -> StatusCode {
83        self.response.status()
84    }
85
86    pub fn headers(&self) -> &HeaderMap {
87        self.response.headers()
88    }
89
90    pub(crate) async fn next_chunk(&mut self) -> Result<Option<Vec<u8>>, ClientError> {
91        let status = self.response.status();
92        let retry_after_seconds = self
93            .response
94            .headers()
95            .get(reqwest::header::RETRY_AFTER)
96            .and_then(|value| value.to_str().ok())
97            .and_then(|value| value.parse::<u64>().ok());
98        self.response
99            .chunk()
100            .await
101            .map(|chunk| chunk.map(|bytes| bytes.to_vec()))
102            .map_err(|source| ClientError::ResponseBody {
103                status,
104                retry_after_seconds,
105                source: source.without_url(),
106            })
107    }
108}
109
110impl WekanClientFactory {
111    pub(crate) fn validate_raw_api_request(
112        &self,
113        method: &Method,
114        path: &str,
115        query: &[(String, String)],
116        query_token: bool,
117    ) -> Result<(), RawApiRequestError> {
118        raw_api_url(&self.server, method, path, query, query_token, None).map(|_| ())
119    }
120}
121
122impl WekanClient {
123    pub(crate) async fn execute_raw_api_request(
124        &self,
125        request: RawApiRequest<'_>,
126    ) -> Result<RawApiResponse, RawApiRequestError> {
127        let RawApiRequest {
128            method,
129            path,
130            query,
131            headers,
132            body,
133            content_length,
134            authentication,
135            timeout,
136        } = request;
137        let (query_token, bearer_token, query_token_value) = match authentication {
138            RawApiAuthentication::Bearer(token) => (false, Some(token), None),
139            RawApiAuthentication::QueryToken(token) => (true, None, Some(token.expose_secret())),
140            RawApiAuthentication::None => (false, None, None),
141        };
142        let url = raw_api_url(
143            &self.server,
144            &method,
145            path,
146            query,
147            query_token,
148            query_token_value,
149        )?;
150        let mut headers = headers;
151        if let Some(length) = content_length {
152            headers.insert(CONTENT_LENGTH, HeaderValue::from(length));
153        }
154        let mut request = self.http.request(method, url).headers(headers);
155        if let Some(token) = bearer_token {
156            request = request.bearer_auth(token.expose_secret());
157        }
158        if let Some(body) = body {
159            request = request.body(body.into_reqwest_body());
160        }
161        if let Some(timeout) = timeout {
162            request = request.timeout(timeout);
163        }
164
165        request
166            .send()
167            .await
168            .map(|response| RawApiResponse {
169                identity: next_response_identity(),
170                response,
171            })
172            .map_err(|error| {
173                RawApiRequestError::Client(ClientError::Transport(error.without_url()))
174            })
175    }
176}
177
178fn raw_api_url(
179    server: &ServerUrl,
180    method: &Method,
181    path: &str,
182    query: &[(String, String)],
183    query_token: bool,
184    query_token_value: Option<&str>,
185) -> Result<Url, RawApiRequestError> {
186    if !path.starts_with('/') || path.starts_with("//") {
187        return Err(RawApiRequestError::InvalidInput(
188            "the raw API path must begin with exactly one slash".to_owned(),
189        ));
190    }
191    if path.contains('#') {
192        return Err(RawApiRequestError::InvalidInput(
193            "the raw API path must not contain a URL fragment".to_owned(),
194        ));
195    }
196
197    let path_only = path.split_once('?').map_or(path, |(path, _)| path);
198    if path_only.contains('\\') {
199        return Err(RawApiRequestError::InvalidInput(
200            "the raw API path must use forward slashes and must not contain backslashes".to_owned(),
201        ));
202    }
203    if path_only[1..].split('/').any(is_dot_segment) {
204        return Err(RawApiRequestError::InvalidInput(
205            "the raw API path must not contain dot-segment traversal".to_owned(),
206        ));
207    }
208    if query_token && *method != Method::GET {
209        return Err(RawApiRequestError::InvalidInput(
210            "--auth-token-query is accepted only for GET requests".to_owned(),
211        ));
212    }
213
214    let mut url = server.join(&path[1..]).map_err(|error| {
215        RawApiRequestError::InvalidInput(format!("the raw API path is invalid: {error}"))
216    })?;
217    if url.origin() != server.0.origin() || !url.path().starts_with(server.0.path()) {
218        return Err(RawApiRequestError::InvalidInput(
219            "the raw API path must stay beneath the configured Wekan server base".to_owned(),
220        ));
221    }
222
223    if url.query_pairs().any(|(name, _)| name == "authToken")
224        || query.iter().any(|(name, _)| name == "authToken")
225    {
226        return Err(RawApiRequestError::InvalidInput(
227            "authToken is managed by --auth-token-query and must not be supplied in the path or --query"
228                .to_owned(),
229        ));
230    }
231    if !query.is_empty() || query_token_value.is_some() {
232        let mut pairs = url.query_pairs_mut();
233        for (name, value) in query {
234            pairs.append_pair(name, value);
235        }
236        if let Some(token) = query_token_value {
237            pairs.append_pair("authToken", token);
238        }
239    }
240    Ok(url)
241}
242
243fn is_dot_segment(segment: &str) -> bool {
244    let mut decoded = segment.to_ascii_lowercase();
245    while decoded.contains("%2e") {
246        decoded = decoded.replace("%2e", ".");
247    }
248    matches!(decoded.as_str(), "." | "..")
249}
250
251fn next_response_identity() -> u64 {
252    static NEXT_IDENTITY: AtomicU64 = AtomicU64::new(1);
253    NEXT_IDENTITY.fetch_add(1, Ordering::Relaxed)
254}
255
256#[cfg(test)]
257mod tests {
258    use std::{net::TcpListener, time::Duration};
259
260    use reqwest::{Method, header::HeaderMap};
261    use secrecy::SecretString;
262    use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path};
263
264    use super::{RawApiAuthentication, RawApiRequest, RawApiRequestError};
265    use crate::client::{ClientError, WekanClientFactory};
266
267    #[tokio::test]
268    async fn token_bearing_request_urls_are_absent_from_errors_and_response_debug() {
269        let token = "managed+/token";
270        let closed_listener = TcpListener::bind("127.0.0.1:0").unwrap();
271        let closed_address = closed_listener.local_addr().unwrap();
272        drop(closed_listener);
273        let factory = WekanClientFactory::for_profile(
274            format!("http://{closed_address}/"),
275            "default".to_owned(),
276            false,
277        );
278        let client = factory.create().unwrap();
279        let query_token = SecretString::from(token.to_owned());
280        let error = client
281            .execute_raw_api_request(RawApiRequest {
282                method: Method::GET,
283                path: "/api/query-auth-on-an-unmodeled-route",
284                query: &[],
285                headers: HeaderMap::new(),
286                body: None,
287                content_length: None,
288                authentication: RawApiAuthentication::QueryToken(&query_token),
289                timeout: Some(Duration::from_secs(1)),
290            })
291            .await
292            .unwrap_err();
293        assert!(matches!(
294            error,
295            RawApiRequestError::Client(ClientError::Transport(_))
296        ));
297        let rendered = format!("{error:?}");
298        assert!(!rendered.contains(token));
299        assert!(!rendered.contains("managed%2B%2Ftoken"));
300        assert!(!rendered.contains("authToken"));
301
302        let server = MockServer::start().await;
303        Mock::given(path("/api/query-auth-on-an-unmodeled-route"))
304            .respond_with(ResponseTemplate::new(200).insert_header("x-secret", token))
305            .mount(&server)
306            .await;
307        let factory = WekanClientFactory::for_profile(
308            format!("{}/", server.uri()),
309            "default".to_owned(),
310            false,
311        );
312        let client = factory.create().unwrap();
313        let query_token = SecretString::from(token.to_owned());
314        let response = client
315            .execute_raw_api_request(RawApiRequest {
316                method: Method::GET,
317                path: "/api/query-auth-on-an-unmodeled-route",
318                query: &[],
319                headers: HeaderMap::new(),
320                body: None,
321                content_length: None,
322                authentication: RawApiAuthentication::QueryToken(&query_token),
323                timeout: None,
324            })
325            .await
326            .unwrap();
327        let rendered = format!("{response:?}");
328        assert!(!rendered.contains(token));
329        assert!(!rendered.contains("authToken"));
330        assert!(!rendered.contains("x-secret"));
331    }
332}