Skip to main content

loonfs_client/
config.rs

1//! Client configuration: the TOML-loaded [`ClientConfig`] and its
2//! validation.
3
4use crate::{ClientError, Result};
5use http::Uri;
6use serde::Deserialize;
7use std::fs;
8use std::path::Path;
9
10/// Client configuration loaded from TOML or built by the caller.
11///
12/// Strict like every config struct in the workspace: an unknown key is a
13/// decode error, so a typo (`auth_tokn`) fails loudly instead of silently
14/// producing an unauthenticated client.
15#[derive(Debug, Clone, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct ClientConfig {
18    /// Base URL for the LoonFS server.
19    pub server_url: String,
20    /// Optional bearer token.
21    pub auth_token: Option<String>,
22    /// Optional overall per-request deadline in milliseconds. Unset means no
23    /// whole-request deadline: requests are bounded only by the built-in
24    /// 60-second socket inactivity timeouts, so slow-but-progressing large
25    /// transfers are not cut off while a stalled connection still fails.
26    #[serde(default)]
27    pub request_timeout_ms: Option<u64>,
28    /// Disables the bounded automatic retry of quick-clearing transient
29    /// failures: the retryable-unavailability codes (`server_busy`,
30    /// `commit_queue_full`, `shutting_down` — a draining process telling the
31    /// caller to retry against the next one) and network-level transport
32    /// errors (connect failures, timeouts, resets). Off by default. The retry
33    /// applies only to reads, commits (which carry a durable replay
34    /// identity), and operations whose repeat semantics are idempotent;
35    /// lifecycle mutations
36    /// and upload-session creation are always single-attempt.
37    #[serde(default)]
38    pub disable_transient_retry: bool,
39    /// PEM bundle of extra certificate authorities to trust for `https`
40    /// server URLs, for a server whose certificate a private CA issued.
41    /// Added to the platform trust store rather than replacing it, so a
42    /// client configured this way still reaches publicly-trusted servers.
43    #[serde(default)]
44    pub ca_cert_path: Option<String>,
45}
46
47impl ClientConfig {
48    /// Loads and validates a client config from TOML.
49    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
50        let bytes =
51            fs::read(path.as_ref()).map_err(|err| ClientError::ConfigIo(err.to_string()))?;
52        let config: Self = toml::from_str(
53            std::str::from_utf8(&bytes)
54                .map_err(|err| ClientError::ConfigDecode(err.to_string()))?,
55        )
56        .map_err(|err| ClientError::ConfigDecode(err.to_string()))?;
57        config.validate()?;
58        Ok(config)
59    }
60
61    /// Validates field invariants. [`Self::load`] and
62    /// [`Client::new`](crate::Client::new) both run this, so a file-loaded
63    /// config and a directly built one cannot diverge in what they accept.
64    pub fn validate(&self) -> Result<()> {
65        validate_absolute_http_url("server_url", &self.server_url)?;
66        if let Some(token) = &self.auth_token {
67            if token.trim().is_empty() {
68                return Err(ClientError::ConfigValidation {
69                    field: "auth_token",
70                    reason: "must not be empty".to_owned(),
71                });
72            }
73        }
74        if self.request_timeout_ms == Some(0) {
75            return Err(ClientError::ConfigValidation {
76                field: "request_timeout_ms",
77                reason: "must be greater than zero; omit it for no deadline".to_owned(),
78            });
79        }
80        if let Some(path) = &self.ca_cert_path {
81            if path.trim().is_empty() {
82                return Err(ClientError::ConfigValidation {
83                    field: "ca_cert_path",
84                    reason: "must not be empty; omit it to trust only the platform roots"
85                        .to_owned(),
86                });
87            }
88        }
89        Ok(())
90    }
91
92    /// Reads the configured CA bundle into the certificates reqwest adds to
93    /// the trust store. A path that cannot be read or does not hold PEM
94    /// certificates fails here, before any request: a client that silently
95    /// fell back to the platform roots would fail later and somewhere else.
96    pub(crate) fn extra_root_certificates(&self) -> Result<Vec<reqwest::Certificate>> {
97        let Some(path) = &self.ca_cert_path else {
98            return Ok(Vec::new());
99        };
100        let path = path.trim();
101        let pem = fs::read(path).map_err(|err| ClientError::ConfigValidation {
102            field: "ca_cert_path",
103            reason: format!("failed to read `{path}`: {err}"),
104        })?;
105        let certificates = reqwest::Certificate::from_pem_bundle(&pem).map_err(|err| {
106            ClientError::ConfigValidation {
107                field: "ca_cert_path",
108                reason: format!("`{path}` is not a PEM certificate bundle: {err}"),
109            }
110        })?;
111        if certificates.is_empty() {
112            return Err(ClientError::ConfigValidation {
113                field: "ca_cert_path",
114                reason: format!("`{path}` holds no CERTIFICATE section"),
115            });
116        }
117        Ok(certificates)
118    }
119}
120
121fn validate_absolute_http_url(field: &'static str, value: &str) -> Result<()> {
122    let trimmed = value.trim();
123    if trimmed.is_empty() {
124        return Err(ClientError::MissingConfigField { field });
125    }
126
127    let uri: Uri =
128        trimmed
129            .parse()
130            .map_err(|err: http::uri::InvalidUri| ClientError::ConfigValidation {
131                field,
132                reason: err.to_string(),
133            })?;
134
135    match uri.scheme_str() {
136        Some("http" | "https") => {}
137        Some(other) => {
138            return Err(ClientError::ConfigValidation {
139                field,
140                reason: format!("scheme must be http or https, got `{other}`"),
141            });
142        }
143        None => {
144            return Err(ClientError::ConfigValidation {
145                field,
146                reason: "must be an absolute http or https URL".to_owned(),
147            });
148        }
149    }
150
151    if uri.authority().is_none() {
152        return Err(ClientError::ConfigValidation {
153            field,
154            reason: "must be an absolute http or https URL".to_owned(),
155        });
156    }
157
158    Ok(())
159}