Skip to main content

syncweb_core/storage/
config.rs

1use std::{
2    collections::BTreeMap,
3    path::{Path, PathBuf},
4    time::Duration,
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::{Result, SyncwebError};
10use crate::net::RelayConfig;
11use crate::schedule::{ScheduleConfig, TimeWindow, parse_rate};
12
13#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
14#[non_exhaustive]
15pub struct Config {
16    #[serde(default)]
17    pub bep: BepConfig,
18    #[serde(default)]
19    pub schedule: ScheduleConfig,
20    #[serde(default)]
21    pub bandwidth: BandwidthConfig,
22    #[serde(default)]
23    pub parallel: ParallelConfig,
24    #[serde(default)]
25    pub cache: CacheConfig,
26    #[serde(default)]
27    pub advanced: AdvancedConfig,
28    #[serde(default)]
29    pub discovery: DiscoveryConfig,
30    #[serde(default)]
31    pub default_path: Option<PathBuf>,
32    #[serde(default = "default_sync_mode")]
33    pub default_sync_mode: String,
34    #[serde(default)]
35    pub subscribe: SubscribeConfig,
36}
37
38impl Config {
39    /// # Errors
40    ///
41    /// Returns an error if the config file cannot be read or parsed.
42    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
43        let path_ref = path.as_ref();
44        if !path_ref.exists() {
45            return Ok(Self::default());
46        }
47
48        let contents = std::fs::read_to_string(path_ref)
49            .map_err(|error| SyncwebError::operation("failed to read config", error))?;
50        toml::from_str(&contents).map_err(|error| SyncwebError::operation("failed to parse config", error))
51    }
52
53    /// # Errors
54    ///
55    /// Returns an error if the config cannot be serialized or written to disk.
56    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
57        let path_ref = path.as_ref();
58        let contents = toml::to_string_pretty(self)
59            .map_err(|error| SyncwebError::operation("failed to serialize config", error))?;
60        crate::fs::atomic::atomic_write(path_ref, contents.as_bytes())
61    }
62
63    #[must_use]
64    pub fn relay_config(&self) -> RelayConfig {
65        self.bep.relay_config()
66    }
67
68    /// # Errors
69    ///
70    /// Returns an error if the key is unknown or the value cannot be parsed.
71    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
72        match key {
73            "bep.enabled" => self.bep.enabled = parse_bool(key, value)?,
74            "bep.relay_urls" => self.bep.relay_urls = parse_string_list(key, value)?,
75            "bep.relay_timeout" => {
76                self.bep.relay_timeout = value.parse().map_err(|error| {
77                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
78                })?;
79            }
80            "bep.auto_fallback" => self.bep.auto_fallback = parse_bool(key, value)?,
81            "schedule.active_hours" => {
82                TimeWindow::parse(value)?;
83                value.clone_into(&mut self.schedule.active_hours);
84            }
85            "bandwidth.max_upload" => {
86                parse_rate(value)?;
87                value.clone_into(&mut self.bandwidth.max_upload);
88            }
89            "bandwidth.max_download" => {
90                parse_rate(value)?;
91                value.clone_into(&mut self.bandwidth.max_download);
92            }
93            "parallel.threads" => {
94                self.parallel.threads = value.parse().map_err(|error| {
95                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
96                })?;
97            }
98            "cache.max_cache_size" => {
99                self.cache.max_cache_size = value.parse().map_err(|error| {
100                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
101                })?;
102            }
103            "advanced.blob_cache_size_gb" => {
104                self.advanced.blob_cache_size_gb = value.parse().map_err(|error| {
105                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
106                })?;
107            }
108            "discovery.mdns" => self.discovery.mdns = parse_bool(key, value)?,
109            "discovery.beacon" => self.discovery.beacon = parse_bool(key, value)?,
110            "discovery.beacon_base_port" => {
111                self.discovery.beacon_base_port = value.parse().map_err(|error| {
112                    SyncwebError::InvalidConfig(format!("{key} must be a valid port number: {error}"))
113                })?;
114            }
115            "discovery.beacon_interval_ms" => {
116                self.discovery.beacon_interval_ms = value.parse().map_err(|error| {
117                    SyncwebError::InvalidConfig(format!("{key} must be a non-negative integer: {error}"))
118                })?;
119            }
120            "discovery.interface" => self.discovery.interface = Some(value.to_owned()),
121            "default_path" => self.default_path = Some(PathBuf::from(value)),
122            "default_sync_mode" => {
123                value.parse::<crate::folder::SyncMode>()?;
124                value.clone_into(&mut self.default_sync_mode);
125            }
126            _ if key.ends_with(".subscribe") => {
127                if let Some(namespace) = key.strip_suffix(".subscribe") {
128                    let namespace_id = namespace.parse::<iroh_docs::NamespaceId>().map_err(|error| {
129                        SyncwebError::InvalidConfig(format!("invalid namespace {namespace:?} in {key}: {error}"))
130                    })?;
131                    let enabled = parse_bool(key, value)?;
132                    if enabled {
133                        let entry = self.subscribe.folders.entry(namespace_id.to_string()).or_default();
134                        entry.enabled = true;
135                    } else {
136                        self.subscribe.folders.remove(namespace_id.to_string().as_str());
137                    }
138                }
139            }
140            _ => {
141                return Err(SyncwebError::InvalidConfig(format!(
142                    "unsupported config key {key:?}; supported keys: \
143                     bep.enabled, bep.relay_urls, bep.relay_timeout, bep.auto_fallback, schedule.active_hours, \
144                     bandwidth.max_upload, bandwidth.max_download, parallel.threads, cache.max_cache_size, \
145                     advanced.blob_cache_size_gb, discovery.mdns, discovery.beacon, discovery.beacon_base_port, \
146                     discovery.beacon_interval_ms, discovery.interface, default_path, default_sync_mode, \
147                     <namespace>.subscribe"
148                )));
149            }
150        }
151        Ok(())
152    }
153
154    /// Set the live-syncing (`subscribe-changes`) state and filters for a folder.
155    ///
156    /// The folder's entry is created on first use, so a plain `join` writes
157    /// `enabled: false` and a later `join --subscribe` flips it to `true`
158    /// without dropping the persisted filters.
159    pub fn set_subscribe(&mut self, namespace: &str, enabled: bool, filters: &SubscribeFilters) {
160        let entry = self.subscribe.folders.entry(namespace.to_owned()).or_default();
161        entry.enabled = enabled;
162        if !filters.is_default() {
163            entry.filters = filters.clone();
164        }
165    }
166
167    /// Remove the per-folder `subscribe-changes` entry, e.g. when leaving.
168    pub fn remove_subscribe(&mut self, namespace: &str) -> bool {
169        self.subscribe.folders.remove(namespace).is_some()
170    }
171}
172
173/// Per-folder live-sync (`subscribe-changes`) settings.
174#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
175#[non_exhaustive]
176pub struct SubscribeConfig {
177    /// Map of folder namespace to its live-sync setting.
178    #[serde(default)]
179    pub folders: BTreeMap<String, SubscribeFolderConfig>,
180}
181
182/// The live-sync state and filters for a single folder.
183#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
184#[non_exhaustive]
185pub struct SubscribeFolderConfig {
186    /// Whether the daemon auto-supervises this folder (default: false, D7).
187    #[serde(default)]
188    pub enabled: bool,
189    /// Subscription filters applied when the daemon supervises the folder.
190    #[serde(default)]
191    pub filters: SubscribeFilters,
192}
193
194/// Persisted subscription filters, mirroring the `join --subscribe` flags.
195#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
196#[non_exhaustive]
197pub struct SubscribeFilters {
198    /// Only deliver entries ingested after live syncing is enabled.
199    #[serde(default)]
200    pub ingest_only: bool,
201    /// Ignore this device's own writes.
202    #[serde(default)]
203    pub ignore_self: bool,
204    /// Area prefix filter for subscription entries.
205    #[serde(default)]
206    pub sync_prefix: Option<PathBuf>,
207    /// Glob filter for subscription entries.
208    #[serde(default)]
209    pub glob: Option<String>,
210    /// Maximum number of entries to deliver.
211    #[serde(default)]
212    pub max_count: Option<u64>,
213    /// Maximum total bytes to deliver.
214    #[serde(default)]
215    pub max_size: Option<u64>,
216}
217
218impl SubscribeFilters {
219    /// Construct filters from the `join --subscribe` filter flags.
220    #[must_use]
221    pub const fn new(
222        ingest_only: bool,
223        ignore_self: bool,
224        sync_prefix: Option<PathBuf>,
225        glob: Option<String>,
226        max_count: Option<u64>,
227        max_size: Option<u64>,
228    ) -> Self {
229        Self {
230            ingest_only,
231            ignore_self,
232            sync_prefix,
233            glob,
234            max_count,
235            max_size,
236        }
237    }
238
239    #[must_use]
240    pub fn is_default(&self) -> bool {
241        *self == Self::default()
242    }
243}
244
245#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
246#[non_exhaustive]
247pub struct BandwidthConfig {
248    #[serde(default)]
249    pub max_upload: String,
250    #[serde(default)]
251    pub max_download: String,
252}
253impl Default for BandwidthConfig {
254    fn default() -> Self {
255        Self {
256            max_upload: "0".to_owned(),
257            max_download: "0".to_owned(),
258        }
259    }
260}
261
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
263#[non_exhaustive]
264#[derive(Default)]
265pub struct ParallelConfig {
266    /// Thread count for parallel operations. `0` (the default) means
267    /// auto-detect the number of available CPU cores.
268    #[serde(default)]
269    pub threads: usize,
270}
271
272/// Controls the in-memory peer availability cache used during blob resolution.
273/// This is **not** a blob data cache — it limits how many peer entries are tracked
274/// for which peers serve which blobs.
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276#[non_exhaustive]
277pub struct CacheConfig {
278    /// Maximum number of entries in the peer availability tracker.
279    #[serde(default = "default_cache_size")]
280    pub max_cache_size: usize,
281}
282
283impl Default for CacheConfig {
284    fn default() -> Self {
285        Self {
286            max_cache_size: default_cache_size(),
287        }
288    }
289}
290
291/// Advanced / experimental configuration options.
292#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
293#[non_exhaustive]
294pub struct AdvancedConfig {
295    /// Maximum Iroh blob cache size in GiB (default: 2). Controls how much
296    /// local disk space is used for caching synced blob data.
297    #[serde(default = "default_blob_cache_size")]
298    pub blob_cache_size_gb: u64,
299}
300
301impl Default for AdvancedConfig {
302    fn default() -> Self {
303        Self {
304            blob_cache_size_gb: default_blob_cache_size(),
305        }
306    }
307}
308
309/// Local discovery settings. `interface` only applies to the UDP beacon; the
310/// mDNS lookup always uses the system's default multicast interface.
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
312#[non_exhaustive]
313pub struct DiscoveryConfig {
314    /// Whether the mDNS address lookup is registered (default: true).
315    #[serde(default = "default_discovery_enabled")]
316    pub mdns: bool,
317    /// Whether the UDP beacon address lookup is registered (default: true).
318    #[serde(default = "default_discovery_enabled")]
319    pub beacon: bool,
320    /// Base UDP port the beacon spreads network scopes over (default: 15200).
321    #[serde(default = "default_beacon_base_port")]
322    pub beacon_base_port: u16,
323    /// How often the beacon re-broadcasts, in milliseconds (default: 1000).
324    #[serde(default = "default_beacon_interval_ms")]
325    pub beacon_interval_ms: u64,
326    /// Restrict the beacon to a single network interface by name, if any.
327    #[serde(default)]
328    pub interface: Option<String>,
329}
330
331impl Default for DiscoveryConfig {
332    fn default() -> Self {
333        Self {
334            mdns: default_discovery_enabled(),
335            beacon: default_discovery_enabled(),
336            beacon_base_port: default_beacon_base_port(),
337            beacon_interval_ms: default_beacon_interval_ms(),
338            interface: None,
339        }
340    }
341}
342
343const fn default_discovery_enabled() -> bool {
344    true
345}
346
347const fn default_beacon_base_port() -> u16 {
348    crate::node::beacon_lookup::DEFAULT_BEACON_PORT
349}
350
351const fn default_beacon_interval_ms() -> u64 {
352    1_000
353}
354
355fn default_sync_mode() -> String {
356    "sendreceive".to_owned()
357}
358
359const fn default_cache_size() -> usize {
360    5_000
361}
362
363const fn default_blob_cache_size() -> u64 {
364    2
365}
366
367#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
368#[non_exhaustive]
369pub struct BepConfig {
370    #[serde(default)]
371    pub enabled: bool,
372    #[serde(default)]
373    pub relay_urls: Vec<String>,
374    #[serde(default = "default_relay_timeout")]
375    pub relay_timeout: u64,
376    #[serde(default = "default_auto_fallback")]
377    pub auto_fallback: bool,
378}
379
380impl Default for BepConfig {
381    fn default() -> Self {
382        Self {
383            enabled: false,
384            relay_urls: Vec::new(),
385            relay_timeout: default_relay_timeout(),
386            auto_fallback: default_auto_fallback(),
387        }
388    }
389}
390
391impl BepConfig {
392    #[must_use]
393    pub fn relay_config(&self) -> RelayConfig {
394        RelayConfig {
395            relay_urls: self.relay_urls.clone(),
396            timeout: Duration::from_secs(self.relay_timeout),
397            auto_fallback: self.enabled && self.auto_fallback,
398        }
399    }
400}
401
402const fn default_relay_timeout() -> u64 {
403    10
404}
405
406const fn default_auto_fallback() -> bool {
407    true
408}
409
410fn parse_bool(key: &str, value: &str) -> Result<bool> {
411    value
412        .parse()
413        .map_err(|error| SyncwebError::InvalidConfig(format!("{key} must be true or false: {error}")))
414}
415
416fn parse_string_list(key: &str, value: &str) -> Result<Vec<String>> {
417    let parsed: toml::Value = toml::from_str(&format!("value = {value}")).map_err(|error| {
418        SyncwebError::InvalidConfig(format!(
419            "{key} must be a TOML string array, for example [\"tcp://relay:22270\"]: {error}"
420        ))
421    })?;
422    parsed
423        .get("value")
424        .and_then(toml::Value::as_array)
425        .filter(|values| values.iter().all(toml::Value::is_str))
426        .map(|values| {
427            values
428                .iter()
429                .map(|v| v.as_str().unwrap_or_default().to_owned())
430                .collect()
431        })
432        .ok_or_else(|| {
433            SyncwebError::InvalidConfig(format!(
434                "{key} must be a TOML string array, for example [\"tcp://relay:22270\"]"
435            ))
436        })
437}