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 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 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 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}