Skip to main content

syncweb_core/storage/
config.rs

1use std::{
2    fs::OpenOptions,
3    io::Write,
4    path::{Path, PathBuf},
5    time::Duration,
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{Result, SyncwebError};
11use crate::net::RelayConfig;
12use crate::schedule::{ScheduleConfig, TimeWindow, parse_rate};
13
14#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
15#[non_exhaustive]
16pub struct Config {
17    #[serde(default)]
18    pub bep: BepConfig,
19    #[serde(default)]
20    pub schedule: ScheduleConfig,
21    #[serde(default)]
22    pub bandwidth: BandwidthConfig,
23    #[serde(default)]
24    pub parallel: ParallelConfig,
25    #[serde(default)]
26    pub cache: CacheConfig,
27    #[serde(default)]
28    pub advanced: AdvancedConfig,
29    #[serde(default)]
30    pub default_path: Option<PathBuf>,
31    #[serde(default = "default_sync_mode")]
32    pub default_sync_mode: String,
33}
34
35impl Config {
36    /// # Errors
37    ///
38    /// Returns an error if the config file cannot be read or parsed.
39    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
40        let path_ref = path.as_ref();
41        if !path_ref.exists() {
42            return Ok(Self::default());
43        }
44
45        let contents = std::fs::read_to_string(path_ref)
46            .map_err(|error| SyncwebError::operation("failed to read config", error))?;
47        toml::from_str(&contents).map_err(|error| SyncwebError::operation("failed to parse config", error))
48    }
49
50    /// # Errors
51    ///
52    /// Returns an error if the config cannot be serialized or written to disk.
53    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
54        let path_ref = path.as_ref();
55        let contents = toml::to_string_pretty(self)
56            .map_err(|error| SyncwebError::operation("failed to serialize config", error))?;
57        atomic_write(path_ref, contents.as_bytes())
58    }
59
60    #[must_use]
61    pub fn relay_config(&self) -> RelayConfig {
62        self.bep.relay_config()
63    }
64
65    /// # Errors
66    ///
67    /// Returns an error if the key is unknown or the value cannot be parsed.
68    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
69        match key {
70            "bep.enabled" => self.bep.enabled = parse_bool(key, value)?,
71            "bep.relay_urls" => self.bep.relay_urls = parse_string_list(key, value)?,
72            "bep.relay_timeout" => {
73                self.bep.relay_timeout = value.parse().map_err(|error| {
74                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
75                })?;
76            }
77            "bep.auto_fallback" => self.bep.auto_fallback = parse_bool(key, value)?,
78            "schedule.active_hours" => {
79                TimeWindow::parse(value)?;
80                value.clone_into(&mut self.schedule.active_hours);
81            }
82            "bandwidth.max_upload" => {
83                parse_rate(value)?;
84                value.clone_into(&mut self.bandwidth.max_upload);
85            }
86            "bandwidth.max_download" => {
87                parse_rate(value)?;
88                value.clone_into(&mut self.bandwidth.max_download);
89            }
90            "parallel.threads" => {
91                self.parallel.threads = value.parse().map_err(|error| {
92                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
93                })?;
94            }
95            "cache.max_cache_size" => {
96                self.cache.max_cache_size = value.parse().map_err(|error| {
97                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
98                })?;
99            }
100            "advanced.blob_cache_size_gb" => {
101                self.advanced.blob_cache_size_gb = value.parse().map_err(|error| {
102                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
103                })?;
104            }
105            "default_path" => self.default_path = Some(PathBuf::from(value)),
106            "default_sync_mode" => {
107                value.parse::<crate::folder::SyncMode>()?;
108                value.clone_into(&mut self.default_sync_mode);
109            }
110            _ => {
111                return Err(SyncwebError::InvalidConfig(format!(
112                    "unsupported config key {key:?}; supported keys: \
113                     bep.enabled, bep.relay_urls, bep.relay_timeout, bep.auto_fallback, schedule.active_hours, \
114                     bandwidth.max_upload, bandwidth.max_download, parallel.threads, cache.max_cache_size, \
115                     advanced.blob_cache_size_gb, default_path, default_sync_mode"
116                )));
117            }
118        }
119        Ok(())
120    }
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124#[non_exhaustive]
125pub struct BandwidthConfig {
126    #[serde(default)]
127    pub max_upload: String,
128    #[serde(default)]
129    pub max_download: String,
130}
131
132impl Default for BandwidthConfig {
133    fn default() -> Self {
134        Self {
135            max_upload: "0".to_owned(),
136            max_download: "0".to_owned(),
137        }
138    }
139}
140
141#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
142#[non_exhaustive]
143#[derive(Default)]
144pub struct ParallelConfig {
145    #[serde(default)]
146    pub threads: usize,
147}
148
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150#[non_exhaustive]
151pub struct CacheConfig {
152    #[serde(default = "default_cache_size")]
153    pub max_cache_size: usize,
154}
155
156impl Default for CacheConfig {
157    fn default() -> Self {
158        Self {
159            max_cache_size: default_cache_size(),
160        }
161    }
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165#[non_exhaustive]
166pub struct AdvancedConfig {
167    #[serde(default = "default_blob_cache_size")]
168    pub blob_cache_size_gb: u64,
169}
170
171impl Default for AdvancedConfig {
172    fn default() -> Self {
173        Self {
174            blob_cache_size_gb: default_blob_cache_size(),
175        }
176    }
177}
178
179fn default_sync_mode() -> String {
180    "sendreceive".to_owned()
181}
182
183const fn default_cache_size() -> usize {
184    5_000
185}
186
187const fn default_blob_cache_size() -> u64 {
188    2
189}
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192#[non_exhaustive]
193pub struct BepConfig {
194    #[serde(default)]
195    pub enabled: bool,
196    #[serde(default)]
197    pub relay_urls: Vec<String>,
198    #[serde(default = "default_relay_timeout")]
199    pub relay_timeout: u64,
200    #[serde(default = "default_auto_fallback")]
201    pub auto_fallback: bool,
202}
203
204impl Default for BepConfig {
205    fn default() -> Self {
206        Self {
207            enabled: false,
208            relay_urls: Vec::new(),
209            relay_timeout: default_relay_timeout(),
210            auto_fallback: default_auto_fallback(),
211        }
212    }
213}
214
215impl BepConfig {
216    #[must_use]
217    pub fn relay_config(&self) -> RelayConfig {
218        RelayConfig {
219            relay_urls: self.relay_urls.clone(),
220            timeout: Duration::from_secs(self.relay_timeout),
221            auto_fallback: self.enabled && self.auto_fallback,
222        }
223    }
224}
225
226const fn default_relay_timeout() -> u64 {
227    10
228}
229
230const fn default_auto_fallback() -> bool {
231    true
232}
233
234fn parse_bool(key: &str, value: &str) -> Result<bool> {
235    value
236        .parse()
237        .map_err(|error| SyncwebError::InvalidConfig(format!("{key} must be true or false: {error}")))
238}
239
240fn parse_string_list(key: &str, value: &str) -> Result<Vec<String>> {
241    let parsed: toml::Value = toml::from_str(&format!("value = {value}")).map_err(|error| {
242        SyncwebError::InvalidConfig(format!(
243            "{key} must be a TOML string array, for example [\"tcp://relay:22270\"]: {error}"
244        ))
245    })?;
246    parsed
247        .get("value")
248        .and_then(toml::Value::as_array)
249        .filter(|values| values.iter().all(toml::Value::is_str))
250        .map(|values| {
251            values
252                .iter()
253                .map(|v| v.as_str().unwrap_or_default().to_owned())
254                .collect()
255        })
256        .ok_or_else(|| {
257            SyncwebError::InvalidConfig(format!(
258                "{key} must be a TOML string array, for example [\"tcp://relay:22270\"]"
259            ))
260        })
261}
262
263fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
264    if let Some(parent) = path.parent()
265        && !parent.as_os_str().is_empty()
266    {
267        std::fs::create_dir_all(parent)
268            .map_err(|error| SyncwebError::operation("failed to create config directory", error))?;
269    }
270
271    let temporary_path = temporary_path(path);
272    let result = (|| -> Result<()> {
273        let mut options = OpenOptions::new();
274        options.write(true).create_new(true);
275        #[cfg(unix)]
276        {
277            use std::os::unix::fs::OpenOptionsExt;
278            options.mode(0o600);
279        }
280        let mut file = options
281            .open(&temporary_path)
282            .map_err(|error| SyncwebError::operation("failed to create temporary config", error))?;
283        file.write_all(contents)?;
284        file.sync_all()?;
285        std::fs::rename(&temporary_path, path)
286            .map_err(|error| SyncwebError::operation("failed to persist config", error))
287    })();
288
289    if result.is_err()
290        && let Err(error) = std::fs::remove_file(&temporary_path)
291    {
292        tracing::warn!(
293            path = %temporary_path.display(),
294            ?error,
295            "failed to clean up temporary config file"
296        );
297    }
298    result
299}
300
301fn temporary_path(path: &Path) -> PathBuf {
302    path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()))
303}