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