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