Skip to main content

openlogi_core/config/
file.rs

1//! Version-aware configuration loading and conflict-safe persistence.
2
3use std::{
4    collections::HashSet,
5    ffi::OsString,
6    fs, io,
7    path::{Path, PathBuf},
8    sync::{LazyLock, Mutex, PoisonError},
9};
10
11use atomic_write_file::AtomicWriteFile;
12use serde::Deserialize;
13use thiserror::Error;
14use toml_edit::{DocumentMut, Item, Table};
15
16use super::{Config, SCHEMA_VERSION};
17use crate::paths::{self, PathsError};
18
19const CONFIG_BACKUP_GENERATIONS: usize = 5;
20static BACKED_UP_CONFIGS: LazyLock<Mutex<HashSet<PathBuf>>> =
21    LazyLock::new(|| Mutex::new(HashSet::new()));
22
23/// Failure loading or persisting `config.toml`.
24#[derive(Debug, Error)]
25pub enum ConfigError {
26    /// The platform config directory could not be resolved.
27    #[error("could not resolve config path: {0}")]
28    Path(#[from] PathsError),
29    /// Reading the config file from disk failed.
30    #[error("could not read config at {path}: {source}")]
31    Read {
32        /// The config file the read targeted.
33        path: PathBuf,
34        /// The underlying I/O error.
35        #[source]
36        source: io::Error,
37    },
38    /// The file is not valid TOML for its declared schema.
39    #[error("could not parse config at {path}: {source}")]
40    Parse {
41        /// The config file that failed to parse.
42        path: PathBuf,
43        /// The underlying TOML deserialization error, including line/column.
44        #[source]
45        source: Box<toml::de::Error>,
46    },
47    /// A field from an older schema was used with a version where it is invalid.
48    #[error("config at {path} uses obsolete field {field} with schema_version {version}")]
49    ObsoleteField {
50        /// The config file containing the field.
51        path: PathBuf,
52        /// Dotted path to the obsolete field.
53        field: String,
54        /// Schema version declared by the file.
55        version: u32,
56    },
57    /// The file changed after it was loaded, so overwriting it would lose edits.
58    #[error("config at {path} changed on disk; restart OpenLogi to reload it")]
59    Conflict {
60        /// The concurrently modified config file.
61        path: PathBuf,
62    },
63    /// Writing the updated config back to disk failed.
64    #[error("could not write config at {path}: {source}")]
65    Write {
66        /// The config file the write targeted.
67        path: PathBuf,
68        /// The underlying I/O error.
69        #[source]
70        source: io::Error,
71    },
72    /// The in-memory config could not be serialized to TOML.
73    #[error("could not serialize config: {0}")]
74    Serialize(#[from] toml::ser::Error),
75    /// A generated or previously parsed TOML document could not be edited.
76    #[error("could not preserve config formatting at {path}: {source}")]
77    Edit {
78        /// The config file whose document was being updated.
79        path: PathBuf,
80        /// The TOML editing parser error.
81        #[source]
82        source: Box<toml_edit::TomlError>,
83    },
84    /// The file declares a schema outside the supported version range.
85    #[error("config at {path} has unsupported schema_version {found}")]
86    UnsupportedSchemaVersion {
87        /// The config file carrying the unsupported version.
88        path: PathBuf,
89        /// The schema version the file declared.
90        found: u32,
91    },
92}
93
94/// A loaded config file plus the exact source revision it came from.
95///
96/// Saving compares that source with the current file before writing and locks
97/// out other cooperating writers through a persistent sidecar. External editors
98/// that do not take the lock can still race with a save after its revision check.
99/// Existing comments and formatting are retained for keys that still exist.
100#[derive(Debug, Clone)]
101pub struct ConfigFile {
102    path: PathBuf,
103    source: Option<String>,
104    /// Text of the file as loaded, when it was written by an older schema.
105    /// Copied aside on the first save so a key-rewriting migration is
106    /// recoverable; cleared once that save lands, so only the first
107    /// *successful* one pays for it and a failed save still owes the copy.
108    migrated_from: Option<(u32, String)>,
109}
110
111#[derive(Deserialize)]
112struct ConfigHeader {
113    schema_version: u32,
114}
115
116impl ConfigFile {
117    /// Load the default user config, returning a writable default when the
118    /// file does not exist yet.
119    pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
120        Self::load_from_path(&paths::config_path()?)
121    }
122
123    /// Load `path`, retaining its source revision for conflict-safe saves.
124    pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
125        match fs::read_to_string(path) {
126            Ok(source) => {
127                let (config, loaded_version) = parse_config(path, &source)?;
128                let migrated_from =
129                    (loaded_version < SCHEMA_VERSION).then(|| (loaded_version, source.clone()));
130                Ok((
131                    config,
132                    Self {
133                        path: path.to_path_buf(),
134                        source: Some(source),
135                        migrated_from,
136                    },
137                ))
138            }
139            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
140                Config::default(),
141                Self {
142                    path: path.to_path_buf(),
143                    source: None,
144                    migrated_from: None,
145                },
146            )),
147            Err(source) => Err(ConfigError::Read {
148                path: path.to_path_buf(),
149                source,
150            }),
151        }
152    }
153
154    /// Save `config` only if the file still matches the loaded revision.
155    /// Returns a [`ConfigError::Write`] with [`io::ErrorKind::WouldBlock`] if
156    /// another writer holds the lock, rather than blocking the caller.
157    pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
158        // Atomic replacement must update the target, not replace its symlink.
159        // Resolve once so the lock, conflict check, backup and commit use the
160        // same target path even if the config link is repointed during a save.
161        let target = resolve_symlinks(&self.path).map_err(|source| ConfigError::Read {
162            path: self.path.clone(),
163            source,
164        })?;
165        if let Some(parent) = target.parent() {
166            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
167                path: target.clone(),
168                source,
169            })?;
170        }
171        // Lock a stable inode, not the config that atomic replacement swaps out.
172        // Place the sidecar beside the target so symlink aliases share the lock.
173        // Keep the sidecar on disk: unlinking it would let writers lock different
174        // inodes. The handle holds the lock through the check, backups and commit.
175        let lock_path = target.with_added_extension("lock");
176        let lock = fs::OpenOptions::new()
177            .read(true)
178            .write(true)
179            .create(true)
180            .truncate(false)
181            .open(&lock_path)
182            .map_err(|source| ConfigError::Write {
183                path: lock_path.clone(),
184                source,
185            })?;
186        lock.try_lock().map_err(|source| ConfigError::Write {
187            path: lock_path,
188            source: source.into(),
189        })?;
190        let current = match fs::read_to_string(&target) {
191            Ok(source) => Some(source),
192            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
193            Err(source) => {
194                return Err(ConfigError::Read {
195                    path: self.path.clone(),
196                    source,
197                });
198            }
199        };
200        if current != self.source {
201            return Err(ConfigError::Conflict {
202                path: self.path.clone(),
203            });
204        }
205
206        // Borrowed, not taken: a failed write must leave the recovery copy
207        // still owed. Taking here and then failing — a full disk, a
208        // read-only directory — would spend the only chance to keep the
209        // pre-migration file, and the next save that *did* succeed would
210        // overwrite it with migrated content and no backup anywhere.
211        if let Some((version, original)) = self.migrated_from.as_ref() {
212            let backup = migration_backup_path(&self.path, *version).map_err(|source| {
213                ConfigError::Write {
214                    path: self.path.clone(),
215                    source,
216                }
217            })?;
218            // Atomic like the config write itself: this is the only copy of
219            // the pre-migration file, so an interrupted write must not be
220            // able to leave a truncated one behind.
221            write_atomic(&backup, original.as_bytes()).map_err(|source| ConfigError::Write {
222                path: backup,
223                source,
224            })?;
225        }
226
227        let body = render_config(config, self.source.as_deref(), &self.path)?;
228        // Re-read at backup time so an edit landing after the conflict check
229        // is still recoverable, but from the resolved target rather than
230        // through the link, which may have been repointed since.
231        backup_config_once(&self.path, &target).map_err(|source| ConfigError::Write {
232            path: self.path.clone(),
233            source,
234        })?;
235        write_atomic(&target, body.as_bytes()).map_err(|source| ConfigError::Write {
236            path: target,
237            source,
238        })?;
239        // The migrated file is gone from disk now, and its copy is safely
240        // beside it — only here is the debt actually settled, so only here is
241        // it cleared. A second save from this `ConfigFile` must not rewrite
242        // the backup with content that is no longer pre-migration.
243        self.migrated_from = None;
244        self.source = Some(body);
245        Ok(())
246    }
247}
248
249impl Config {
250    /// Loads the config from the default user path, returning a default when
251    /// the file does not exist yet.
252    pub fn load_or_default() -> Result<Self, ConfigError> {
253        ConfigFile::load_or_default().map(|(config, _)| config)
254    }
255
256    /// Load from `path` without retaining a writable source revision.
257    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
258        ConfigFile::load_from_path(path).map(|(config, _)| config)
259    }
260
261    /// Atomically save to the default path. Long-lived writers should retain
262    /// and use [`ConfigFile`] so concurrent edits can be detected.
263    pub fn save_atomic(&self) -> Result<(), ConfigError> {
264        if self.ephemeral {
265            return Ok(());
266        }
267        self.save_to_path(&paths::config_path()?)
268    }
269
270    /// Atomically save to `path`, preserving comments in its current content.
271    /// Used by tests and one-shot tools; long-lived writers should use
272    /// [`ConfigFile::save`].
273    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
274        let (_, mut file) = ConfigFile::load_from_path(path)?;
275        file.save(self)
276    }
277}
278
279/// Parse `source`, applying every migration its declared `schema_version`
280/// needs. Returns the migrated config plus the version the file actually
281/// declared, so callers can tell a migrated load from an already-current one.
282fn parse_config(path: &Path, source: &str) -> Result<(Config, u32), ConfigError> {
283    let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
284        path: path.to_path_buf(),
285        source: Box::new(source),
286    })?;
287    if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
288        return Err(ConfigError::UnsupportedSchemaVersion {
289            path: path.to_path_buf(),
290            found: header.schema_version,
291        });
292    }
293    reject_obsolete_fields(path, source, header.schema_version)?;
294    let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
295        path: path.to_path_buf(),
296        source: Box::new(source),
297    })?;
298    if header.schema_version <= 3 {
299        config.migrate_owner_locked_gestures();
300    }
301    // v5 is the schema this change is part of, so every *released* schema —
302    // v4 and below — is what needs the rename. A file already declaring v5 was
303    // written by an unpublished build of this branch and is not something users
304    // have on disk.
305    if header.schema_version <= 4 {
306        config.migrate_transport_scoped_keys();
307    }
308    // Every released schema may contain an explicit copy of the pre-v7
309    // thumb-wheel defaults, either globally or in a per-app profile.
310    if header.schema_version <= 6 {
311        config.migrate_thumbwheel_native_direction();
312    }
313    config.repair_duplicate_routes();
314    config.schema_version = SCHEMA_VERSION;
315    Ok((config, header.schema_version))
316}
317
318fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
319    let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
320        path: path.to_path_buf(),
321        source: Box::new(source),
322    })?;
323    let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
324        return Ok(());
325    };
326    for (device_key, value) in devices {
327        let Some(device) = value.as_table() else {
328            continue;
329        };
330        for (field, last_version) in [
331            ("button_bindings", 1),
332            ("gesture_bindings", 1),
333            ("gesture_owner", 3),
334        ] {
335            if version > last_version && device.contains_key(field) {
336                return Err(ConfigError::ObsoleteField {
337                    path: path.to_path_buf(),
338                    field: format!("devices.{device_key}.{field}"),
339                    version,
340                });
341            }
342        }
343    }
344    Ok(())
345}
346
347fn render_config(
348    config: &Config,
349    original: Option<&str>,
350    path: &Path,
351) -> Result<String, ConfigError> {
352    let generated = toml::to_string_pretty(config)?;
353    let Some(original) = original else {
354        return Ok(generated);
355    };
356    let mut document = original
357        .parse::<DocumentMut>()
358        .map_err(|source| ConfigError::Edit {
359            path: path.to_path_buf(),
360            source: Box::new(source),
361        })?;
362    let generated = generated
363        .parse::<DocumentMut>()
364        .map_err(|source| ConfigError::Edit {
365            path: path.to_path_buf(),
366            source: Box::new(source),
367        })?;
368    reconcile_table(document.as_table_mut(), generated.as_table());
369    Ok(document.to_string())
370}
371
372fn reconcile_table(current: &mut Table, generated: &Table) {
373    let stale: Vec<String> = current
374        .iter()
375        .filter(|(key, _)| generated.get(key).is_none())
376        .map(|(key, _)| key.to_string())
377        .collect();
378    for key in stale {
379        current.remove(&key);
380    }
381    for (key, generated_item) in generated {
382        if let Some(current_item) = current.get_mut(key) {
383            reconcile_item(current_item, generated_item);
384        } else {
385            current.insert(key, generated_item.clone());
386        }
387    }
388}
389
390fn reconcile_item(current: &mut Item, generated: &Item) {
391    if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
392        reconcile_table(current, generated);
393        return;
394    }
395    let decor = current.as_value().map(|value| value.decor().clone());
396    *current = generated.clone();
397    if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
398        *value.decor_mut() = decor;
399    }
400}
401
402/// Back up the contents of `target` (the file `path` resolves to) beside
403/// `path` the first time this process saves it.
404fn backup_config_once(path: &Path, target: &Path) -> io::Result<()> {
405    let mut backed_up = BACKED_UP_CONFIGS
406        .lock()
407        .unwrap_or_else(PoisonError::into_inner);
408    if backed_up.contains(path) {
409        return Ok(());
410    }
411    match fs::read(target) {
412        Ok(current) => backup_existing_config(path, &current)?,
413        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
414        Err(error) => return Err(error),
415    }
416    backed_up.insert(path.to_path_buf());
417    Ok(())
418}
419
420/// Rotate the backups beside `path` and store `current` as the newest.
421pub(super) fn backup_existing_config(path: &Path, current: &[u8]) -> io::Result<()> {
422    for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
423        let source = config_backup_path(path, generation)?;
424        match fs::read(&source) {
425            Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
426            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
427            Err(error) => return Err(error),
428        }
429    }
430    write_atomic(&config_backup_path(path, 1)?, current)
431}
432
433/// Path of the pre-migration copy: the config's own name with
434/// `.v<version>.bak` appended, so `config.toml` yields
435/// `config.toml.v4.bak`. Appended, not substituted — `with_extension` would
436/// replace `.toml` and hand back `config.v4.bak`, which no longer names the
437/// file it is a copy of.
438pub(super) fn migration_backup_path(path: &Path, version: u32) -> io::Result<PathBuf> {
439    let Some(file_name) = path.file_name() else {
440        return Err(io::Error::new(
441            io::ErrorKind::InvalidInput,
442            "config path has no file name",
443        ));
444    };
445    let mut backup_name = OsString::from(file_name);
446    backup_name.push(format!(".v{version}.bak"));
447    Ok(path.with_file_name(backup_name))
448}
449
450pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
451    let Some(file_name) = path.file_name() else {
452        return Err(io::Error::new(
453            io::ErrorKind::InvalidInput,
454            "config path has no file name",
455        ));
456    };
457    let mut backup_name = OsString::from(file_name);
458    backup_name.push(format!(".backup.{generation}"));
459    Ok(path.with_file_name(backup_name))
460}
461
462/// Follow `path` through every symlink to the file it finally names, which
463/// need not exist yet (a dangling link is written through, not replaced).
464/// Relative link targets resolve against the link's own directory.
465pub(super) fn resolve_symlinks(path: &Path) -> io::Result<PathBuf> {
466    // Linux's loop limit (`MAXSYMLINKS`); macOS stops at 32. Inclusive, so
467    // the file reached by the last permitted hop is still examined.
468    const MAX_LINKS: usize = 40;
469    let mut current = path.to_path_buf();
470    for _ in 0..=MAX_LINKS {
471        match fs::symlink_metadata(&current) {
472            Ok(metadata) if metadata.file_type().is_symlink() => {
473                let link = fs::read_link(&current)?;
474                current = match current.parent() {
475                    Some(parent) => parent.join(link),
476                    None => link,
477                };
478            }
479            Ok(_) => return Ok(current),
480            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(current),
481            Err(error) => return Err(error),
482        }
483    }
484    Err(io::Error::new(
485        io::ErrorKind::InvalidInput,
486        "too many levels of symbolic links",
487    ))
488}
489
490fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
491    #[cfg_attr(
492        not(unix),
493        expect(unused_mut, reason = "only the unix path mutates the options")
494    )]
495    let mut options = AtomicWriteFile::options();
496    #[cfg(unix)]
497    {
498        use atomic_write_file::unix::OpenOptionsExt as _;
499        use std::os::unix::fs::OpenOptionsExt as _;
500        options.preserve_mode(false).mode(0o600);
501    }
502    let mut file = options.open(path)?;
503    io::Write::write_all(&mut file, bytes)?;
504    file.commit()
505}