Skip to main content

smbcloud_network/
network.rs

1use {
2    log::{debug, error},
3    reqwest::{RequestBuilder, Response, StatusCode},
4    serde::de::DeserializeOwned,
5    smbcloud_model::{
6        account::SmbAuthorization,
7        error_codes::{ErrorCode, ErrorResponse},
8        login::AccountStatus,
9    },
10};
11
12/// The value of an `Authorization` header for a bearer token.
13///
14/// The login flow stores the whole header value (`Bearer <jwt>`), while deploy
15/// tokens are stored bare. Strips one leading `Bearer ` (any case) and adds
16/// exactly one, so callers never send `Bearer Bearer <jwt>`.
17pub fn bearer_value(token: &str) -> String {
18    let token = token.trim();
19    let bare = match token.get(..7) {
20        Some(prefix) if prefix.eq_ignore_ascii_case("bearer ") => token[7..].trim_start(),
21        _ => token,
22    };
23    format!("Bearer {bare}")
24}
25
26//use std::time::Duration;
27#[cfg(debug_assertions)]
28const LOG_RESPONSE_BODY: bool = true; // You know what to do here.
29#[cfg(not(debug_assertions))]
30const LOG_RESPONSE_BODY: bool = false;
31
32/// Check if there is an active internet connection.
33///
34/// Deprecated: this pings a third-party host, which fails behind an egress
35/// allowlist even when the smbCloud API is reachable. A request that cannot
36/// connect already returns `ErrorCode::NetworkError`.
37#[cfg(not(target_arch = "wasm32"))]
38#[deprecated(
39    since = "1.1.0",
40    note = "the request itself reports NetworkError; this pinged a third-party host"
41)]
42pub async fn check_internet_connection() -> bool {
43    debug!("Checking internet connection");
44    let client = reqwest::Client::builder()
45        //.timeout(Duration::from_secs(5)) Does not work on wasm32
46        .build();
47
48    if let Err(e) = client {
49        error!("Failed to create client for connectivity check: {:?}", e);
50        return false;
51    }
52
53    if let Ok(client) = client {
54        match client.get("https://dns.google").send().await {
55            Ok(response) => {
56                debug!(
57                    "Internet connection check successful: {}",
58                    response.status()
59                );
60                response.status().is_success()
61            }
62            Err(e) => {
63                error!("Internet connection check failed: {:?}", e);
64                false
65            }
66        }
67    } else {
68        false
69    }
70}
71
72#[cfg(target_arch = "wasm32")]
73#[deprecated(
74    since = "1.1.0",
75    note = "the request itself reports NetworkError; this pinged a third-party host"
76)]
77pub async fn check_internet_connection() -> bool {
78    true
79}
80
81/// Log why a request failed to send (connect, timeout, proxy or other), so
82/// `RUST_LOG=debug` explains a `NetworkError` without changing its message.
83pub fn log_send_error(e: &reqwest::Error) {
84    #[cfg(not(target_arch = "wasm32"))]
85    let connect = e.is_connect();
86    #[cfg(target_arch = "wasm32")]
87    let connect = false;
88    let kind = if e.is_timeout() {
89        "timeout"
90    } else if connect {
91        "connect"
92    } else if e.is_builder() {
93        "builder"
94    } else {
95        "other"
96    };
97    debug!("Request failed to send ({kind}): {e:?}");
98}
99
100pub async fn parse_error_response<T: DeserializeOwned>(
101    response: Response,
102) -> Result<T, ErrorResponse> {
103    let error_response_body = match response.text().await {
104        Ok(body) => body,
105        Err(e) => {
106            error!("Failed to get response body: {:?}", e);
107            return Err(ErrorResponse::Error {
108                error_code: ErrorCode::NetworkError,
109                message: ErrorCode::NetworkError.message(None).to_string(),
110            });
111        }
112    };
113
114    if LOG_RESPONSE_BODY {
115        debug!(
116            "Parse Error >>>> {:?}",
117            serde_json::to_string_pretty(&error_response_body)
118        );
119    }
120
121    let error_response = match serde_json::from_str(&error_response_body) {
122        Ok(error_response) => error_response,
123        Err(e) => {
124            error!("Failed to parse error response: {:?}", e);
125            return Err(ErrorResponse::Error {
126                error_code: ErrorCode::ParseError,
127                message: ErrorCode::ParseError.message(None).to_string(),
128            });
129        }
130    };
131    // The parsing itself is succeed.
132    // Why is this an ErrorResponse.
133    Err(error_response)
134}
135
136pub async fn request_login(builder: RequestBuilder) -> Result<AccountStatus, ErrorResponse> {
137    let response = builder.send().await;
138    let response = match response {
139        Ok(response) => response,
140        Err(e) => {
141            error!("request_login: Failed to get response: {:?}", e);
142            return Err(ErrorResponse::Error {
143                error_code: ErrorCode::NetworkError,
144                message: ErrorCode::NetworkError.message(None).to_string(),
145            });
146        }
147    };
148
149    if LOG_RESPONSE_BODY {
150        debug!("request_login: Parse >>>> {:?}", response.status());
151    }
152
153    match (response.status(), response.headers().get("Authorization")) {
154        (StatusCode::OK, Some(token)) => {
155            // Login successful, let's get the access token for real.
156            let access_token = match token.to_str() {
157                Ok(token) => token.to_string(),
158                Err(_) => {
159                    return Err(ErrorResponse::Error {
160                        error_code: ErrorCode::NetworkError,
161                        message: ErrorCode::NetworkError.message(None).to_string(),
162                    });
163                }
164            };
165            Ok(AccountStatus::Ready { access_token })
166        }
167        (StatusCode::OK, None) => {
168            // Silent login from oauth. Need improvement.
169            let error_response = match parse_error_response::<ErrorResponse>(response).await {
170                Ok(error) => error,
171                Err(_) => return Ok(AccountStatus::NotFound),
172            };
173            match error_response {
174                ErrorResponse::Error {
175                    error_code,
176                    message: _,
177                } => match error_code {
178                    ErrorCode::EmailNotVerified => Ok(AccountStatus::Incomplete {
179                        status: smbcloud_model::account::ErrorCode::EmailUnverified,
180                    }),
181                    ErrorCode::PasswordNotSet => Ok(AccountStatus::Incomplete {
182                        status: smbcloud_model::account::ErrorCode::PasswordNotSet,
183                    }),
184                    ErrorCode::Unknown => Ok(AccountStatus::Ready {
185                        access_token: "tokenization".to_string(),
186                    }),
187                    _ => Ok(AccountStatus::NotFound),
188                },
189            }
190        }
191        (StatusCode::NOT_FOUND, _) => {
192            // Account not found
193            Ok(AccountStatus::NotFound)
194        }
195        (StatusCode::UNPROCESSABLE_ENTITY, _) => {
196            let body_text = match response.text().await {
197                Ok(text) => text,
198                Err(_) => {
199                    return Err(ErrorResponse::Error {
200                        error_code: ErrorCode::NetworkError,
201                        message: ErrorCode::NetworkError.message(None).to_string(),
202                    });
203                }
204            };
205
206            let result: SmbAuthorization = match serde_json::from_str(&body_text) {
207                Ok(res) => res,
208                Err(_) => {
209                    // Try generic JSON for a useful message.
210                    let message = serde_json::from_str::<serde_json::Value>(&body_text)
211                        .ok()
212                        .and_then(|v| {
213                            v.get("message")
214                                .and_then(serde_json::Value::as_str)
215                                .map(ToOwned::to_owned)
216                        })
217                        .unwrap_or_else(|| ErrorCode::NetworkError.message(None).to_string());
218                    return Err(ErrorResponse::Error {
219                        error_code: ErrorCode::NetworkError,
220                        message,
221                    });
222                }
223            };
224
225            let error_code = match result.error_code {
226                Some(code) => code,
227                None => {
228                    // No error_code but we have a message — return it as Incomplete with a
229                    // generic unverified status so the client can display the message.
230                    return Err(ErrorResponse::Error {
231                        error_code: ErrorCode::NetworkError,
232                        message: result.message,
233                    });
234                }
235            };
236            Ok(AccountStatus::Incomplete { status: error_code })
237        }
238        (StatusCode::UNAUTHORIZED, _) => {
239            let body_text = match response.text().await {
240                Ok(text) => text,
241                Err(_) => {
242                    return Err(ErrorResponse::Error {
243                        error_code: ErrorCode::NetworkError,
244                        message: ErrorCode::NetworkError.message(None).to_string(),
245                    });
246                }
247            };
248
249            // Try parsing as SmbAuthorization first.
250            if let Ok(result) = serde_json::from_str::<SmbAuthorization>(&body_text) {
251                let error_code = match result.error_code {
252                    Some(code) => code,
253                    None => {
254                        // Response parsed but no error_code — surface the server's message.
255                        return Err(ErrorResponse::Error {
256                            error_code: ErrorCode::Unauthorized,
257                            message: result.message,
258                        });
259                    }
260                };
261                return Err(ErrorResponse::Error {
262                    error_code: ErrorCode::Unauthorized,
263                    message: error_code.to_string(),
264                });
265            }
266
267            // Fallback: try to extract message or error from a generic JSON object.
268            if let Ok(generic) = serde_json::from_str::<serde_json::Value>(&body_text) {
269                let extracted = generic
270                    .get("message")
271                    .or_else(|| generic.get("error"))
272                    .and_then(serde_json::Value::as_str)
273                    .unwrap_or("Sign in failed.");
274                return Err(ErrorResponse::Error {
275                    error_code: ErrorCode::Unauthorized,
276                    message: extracted.to_string(),
277                });
278            }
279
280            Err(ErrorResponse::Error {
281                error_code: ErrorCode::Unauthorized,
282                message: "Sign in failed.".to_string(),
283            })
284        }
285        (status, _) => parse_error_response(response)
286            .await
287            .map_err(|_| ErrorResponse::Error {
288                error_code: ErrorCode::NetworkError,
289                message: format!("Unexpected login response status: {}", status),
290            }),
291    }
292}
293
294pub async fn request<R: DeserializeOwned>(builder: RequestBuilder) -> Result<R, ErrorResponse> {
295    let response = builder.send().await;
296    let response = match response {
297        Ok(response) => response,
298        Err(e) => {
299            error!("Failed to get response: {:?}", e);
300            log_send_error(&e);
301            return Err(ErrorResponse::Error {
302                error_code: ErrorCode::NetworkError,
303                message: ErrorCode::NetworkError.message(None).to_string(),
304            });
305        }
306    };
307    let response = match response.status() {
308        reqwest::StatusCode::OK | reqwest::StatusCode::CREATED => response,
309        status => {
310            error!("Failed to get response: {:?}", status);
311            // This should handle parsing the error response.
312            return parse_error_response(response).await;
313        }
314    };
315
316    let response_body = match response.text().await {
317        Ok(body) => body,
318        Err(e) => {
319            error!("Failed to get response body: {:?}", e);
320            return Err(ErrorResponse::Error {
321                error_code: ErrorCode::NetworkError,
322                message: ErrorCode::NetworkError.message(None).to_string(),
323            });
324        }
325    };
326
327    if LOG_RESPONSE_BODY {
328        debug!(
329            "Parse >>>> {:?}",
330            serde_json::to_string_pretty(&response_body)
331        );
332    }
333
334    let response = match serde_json::from_str::<R>(&response_body) {
335        Ok(response) => response,
336        Err(e) => {
337            error!("Failed to parse response: {:?}", e);
338            return Err(ErrorResponse::Error {
339                error_code: ErrorCode::ParseError,
340                message: e.to_string(),
341            });
342        }
343    };
344
345    Ok(response)
346}
347
348#[cfg(all(test, not(target_arch = "wasm32")))]
349mod tests {
350    use super::*;
351    use serde_json::Value;
352    use std::time::{Duration, Instant};
353
354    #[tokio::test]
355    async fn unreachable_host_reports_network_error_quickly() {
356        let started = Instant::now();
357        let builder = reqwest::Client::new().get("http://127.0.0.1:1");
358        let result = request::<Value>(builder).await;
359        match result {
360            Err(ErrorResponse::Error { error_code, .. }) => {
361                assert!(matches!(error_code, ErrorCode::NetworkError));
362            }
363            other => panic!("expected NetworkError, got {other:?}"),
364        }
365        assert!(started.elapsed() < Duration::from_secs(5));
366    }
367
368    #[test]
369    fn workspace_does_not_call_the_connectivity_preflight() {
370        let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
371        let mut callers = Vec::new();
372        let mut stack = vec![crates];
373        while let Some(dir) = stack.pop() {
374            for entry in std::fs::read_dir(&dir).unwrap().flatten() {
375                let path = entry.path();
376                if path.is_dir() {
377                    stack.push(path);
378                } else if path.extension().is_some_and(|e| e == "rs")
379                    && !path.ends_with("smbcloud-network/src/network.rs")
380                    && std::fs::read_to_string(&path)
381                        .unwrap()
382                        .contains("check_internet_connection")
383                {
384                    callers.push(path);
385                }
386            }
387        }
388        assert!(
389            callers.is_empty(),
390            "preflight still called from {callers:?}"
391        );
392    }
393}
394
395#[cfg(test)]
396mod bearer_tests {
397    use super::bearer_value;
398
399    #[test]
400    fn adds_one_prefix() {
401        assert_eq!(bearer_value("Bearer x"), "Bearer x");
402        assert_eq!(bearer_value("bearer x"), "Bearer x");
403        assert_eq!(bearer_value("x"), "Bearer x");
404        assert_eq!(bearer_value("smb_deploy_abc"), "Bearer smb_deploy_abc");
405    }
406}