voa_config/file/
loader.rs

1//! The configuration file loader.
2
3use std::{
4    collections::BTreeMap,
5    ffi::{OsStr, OsString},
6    os::linux::fs::MetadataExt,
7    path::{Path, PathBuf},
8};
9
10use directories::ProjectDirs;
11use log::{debug, error, info, trace};
12use voa_core::identifiers::Os;
13use xdg::BaseDirectories;
14
15use crate::{CONFIG_DIRS_SYSTEM_MODE, CONFIG_FILE_EXTENSION, file::ConfigFile};
16
17/// The XDG qualifier name of the application.
18const QUALIFIER_NAME: &str = "voa";
19
20/// Collects all configuration directories relevant for a real user.
21fn collect_user_mode_dirs() -> Vec<PathBuf> {
22    // 1. Add the system-wide default location.
23    let mut config_dirs = CONFIG_DIRS_SYSTEM_MODE
24        .iter()
25        .map(PathBuf::from)
26        .collect::<Vec<_>>();
27
28    // Look into the XDG Base Directory Specification with qualifier "voa",
29    // organization "VOA", and application "VOA".
30    //
31    // Return early with system-wide default location if the user has no home.
32    let Some(project_dirs) = ProjectDirs::from(QUALIFIER_NAME, "VOA", "VOA") else {
33        error!("Unable to retrieve XDG dirs for current user as it has no home");
34        return config_dirs;
35    };
36    // Get XDG base directory information.
37    let xdg = BaseDirectories::with_prefix(QUALIFIER_NAME);
38
39    // 2. Collect all entries in $XDG_CONFIG_DIRS or set its default value.
40    {
41        let mut xdg_config_dirs = xdg.get_config_dirs();
42        xdg_config_dirs.reverse();
43
44        if xdg_config_dirs.is_empty() {
45            xdg_config_dirs.push(PathBuf::from("/etc/xdg/voa"));
46        }
47
48        config_dirs.append(&mut xdg_config_dirs);
49    }
50
51    // 3. $XDG_CONFIG_HOME/voa/
52    config_dirs.push(project_dirs.config_dir().to_path_buf());
53
54    config_dirs
55}
56
57/// The mode in which configuration files are loaded.
58#[derive(Clone, Copy, Debug)]
59pub enum LoadMode {
60    /// System mode.
61    ///
62    /// Only load from system-wide locations.
63    System,
64
65    /// User mode.
66    ///
67    /// Load from system-wide locations and user-specific locations (in accordance with XDG Base
68    /// Directory specification).
69    User,
70}
71
72/// Loader for all valid default and drop-in configuration files.
73#[derive(Debug)]
74pub struct ConfigLoader {
75    defaults: BTreeMap<Os, ConfigFile>,
76    drop_ins: BTreeMap<Os, Vec<ConfigFile>>,
77}
78
79impl ConfigLoader {
80    /// Creates a new [`ConfigLoader`] using a [`LoadMode`].
81    ///
82    /// If `mode` is [`LoadMode::System`] only system-wide locations are considered for the loading
83    /// of default and drop-in configuration files.
84    /// If `mode` is [`LoadMode::User`], system-wide and locations of the calling user are
85    /// considered for the loading of default and drop-in configuration files.
86    pub fn load(mode: LoadMode) -> Self {
87        let (defaults, drop_ins) = match mode {
88            LoadMode::System => (
89                collect_default_config_files(CONFIG_DIRS_SYSTEM_MODE),
90                collect_drop_in_config_files(CONFIG_DIRS_SYSTEM_MODE),
91            ),
92            LoadMode::User => {
93                let dirs = collect_user_mode_dirs();
94                (
95                    collect_default_config_files(&dirs),
96                    collect_drop_in_config_files(&dirs),
97                )
98            }
99        };
100
101        Self { defaults, drop_ins }
102    }
103
104    /// Returns the map of OS default configuration files.
105    pub fn defaults(&self) -> &BTreeMap<Os, ConfigFile> {
106        &self.defaults
107    }
108
109    /// Returns the map of OS drop-in configuration files.
110    pub fn drop_ins(&self) -> &BTreeMap<Os, Vec<ConfigFile>> {
111        &self.drop_ins
112    }
113}
114
115/// Checks whether a path is masked.
116///
117/// Paths are considered masked if:
118///
119/// - they are a symlink to /dev/null
120/// - they are empty regular files.
121fn is_masked(path: impl AsRef<Path>) -> bool {
122    let path = path.as_ref();
123
124    let metadata = match path.symlink_metadata() {
125        Ok(metadata) => metadata,
126        Err(error) => {
127            error!("Unable to retrieve metadata for config candidate {path:?}: {error}");
128            return false;
129        }
130    };
131
132    let Some(file_name) = path.file_name() else {
133        error!("The config candidate {path:?} has no file name. Skipping...");
134        return false;
135    };
136
137    if metadata.is_symlink() {
138        let target_path = match path.read_link() {
139            Ok(path) => path,
140            Err(error) => {
141                error!("Unable to read link target of symlink {path:?}: {error}");
142                return false;
143            }
144        };
145        if target_path.as_path() == Path::new("/dev/null") {
146            info!(
147                "The config candidate {path:?} is masked. Skipping all configs with the same file name: {file_name:?}"
148            );
149            return true;
150        }
151    }
152
153    if metadata.is_file() && metadata.st_size() == 0 {
154        info!(
155            "The config candidate {path:?} is empty (masked). Skipping all configs with the same file name: {file_name:?}"
156        );
157        return true;
158    }
159
160    false
161}
162
163/// Collects all valid default config files from a list of input `dirs`.
164///
165/// Load logic follows the [Configuration Files specification].
166/// Creates a map of target OSes and relevant file path locations.
167/// All masked config files are ignored.
168/// Default config files are sorted and read according to the UAPI config file specification.
169///
170/// [Configuration Files specification]: https://uapi-group.org/specifications/specs/configuration_files_specification/
171fn collect_default_config_files(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, ConfigFile> {
172    let config_paths = {
173        let extension_check = Some(OsStr::new(CONFIG_FILE_EXTENSION));
174        let mut config_paths: BTreeMap<Os, Vec<PathBuf>> = BTreeMap::new();
175
176        for config_dir in dirs {
177            let config_dir = config_dir.as_ref();
178            let read_dir = match config_dir.read_dir() {
179                Ok(read_dir) => read_dir,
180                Err(error) => {
181                    debug!("Unable to read directory {config_dir:?}: {error}");
182                    continue;
183                }
184            };
185
186            for dir_entry in read_dir {
187                let dir_entry = match dir_entry {
188                    Ok(dir_entry) => dir_entry,
189                    Err(error) => {
190                        debug!("Unable to read file in {config_dir:?}: {error}");
191                        continue;
192                    }
193                };
194
195                let config_path = dir_entry.path();
196
197                // Skip files that don't use the correct extension.
198                if config_path.extension() != extension_check {
199                    trace!(
200                        "The drop-in config path {config_path:?} does not use the {CONFIG_FILE_EXTENSION} extension. Skipping..."
201                    );
202                    continue;
203                }
204
205                // Remove the .yaml extension and use the file name to construct an OS identifier.
206                let os = {
207                    let without_yaml = config_path.with_extension("");
208                    let Some(file_name) = without_yaml.file_name() else {
209                        trace!(
210                            "Path {config_path:?} has no file name after removing the .yaml extension. Skipping..."
211                        );
212                        continue;
213                    };
214
215                    match Os::try_from(file_name) {
216                        Ok(os) => os,
217                        Err(error) => {
218                            error!(
219                                "Invalid OS identifier in file name of path {config_path:?}: {error}"
220                            );
221                            continue;
222                        }
223                    }
224                };
225
226                if let Some(path_list) = config_paths.get_mut(&os) {
227                    path_list.push(config_path);
228                } else {
229                    config_paths.insert(os, vec![config_path]);
230                }
231            }
232        }
233
234        config_paths
235    };
236
237    // For each OS, add the config file with the highest priority.
238    let mut output: BTreeMap<Os, ConfigFile> = BTreeMap::new();
239    for (os, path_list) in config_paths.into_iter() {
240        // Skip all configuration files for the OS, if it is masked.
241        // Relevant messages are emitted by the `is_masked` function.
242        if path_list.as_slice().iter().any(is_masked) {
243            continue;
244        }
245
246        let config_file_list = {
247            let mut config_file_list: Vec<ConfigFile> = Vec::new();
248            for path in path_list.iter() {
249                // We are only interested in regular files.
250                if !path.is_file() {
251                    error!("The path {path:?} is not a file. Skipping...");
252                    continue;
253                }
254
255                match ConfigFile::from_yaml_file(path, super::ConfigFileType::Config) {
256                    Ok(config_file) => config_file_list.push(config_file),
257                    Err(error) => {
258                        error!("Failed to read config file from file path {path:?}: {error}")
259                    }
260                }
261            }
262            config_file_list
263        };
264
265        let Some(config_file) = config_file_list.into_iter().last() else {
266            error!("No valid config file found for OS {os}");
267            continue;
268        };
269
270        output.insert(os, config_file);
271    }
272
273    output
274}
275
276/// Collects drop-in directories from a list of `dirs`.
277///
278/// Ignores any paths or path entries in `dirs` that cannot be read.
279/// Paths for drop-in configuration files are only valid if they
280///
281/// - are directories
282/// - have the ".yaml.d" extension
283fn collect_drop_in_dirs(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, Vec<PathBuf>> {
284    let mut drop_in_dirs: BTreeMap<Os, Vec<PathBuf>> = BTreeMap::new();
285
286    for config_dir in dirs {
287        let config_dir = config_dir.as_ref();
288        let read_dir = match config_dir.read_dir() {
289            Ok(read_dir) => read_dir,
290            Err(error) => {
291                debug!("Unable to read directory {config_dir:?}: {error}");
292                continue;
293            }
294        };
295
296        for dir_entry in read_dir {
297            let dir_entry = match dir_entry {
298                Ok(dir_entry) => dir_entry,
299                Err(error) => {
300                    debug!("Unable to read entry in {config_dir:?}: {error}");
301                    continue;
302                }
303            };
304
305            let path = dir_entry.path();
306
307            // Skip all non-directory paths.
308            if !path.is_dir() {
309                trace!("Non-directory path {path:?} cannot be a drop-in directory. Skipping...");
310                continue;
311            }
312
313            // Skip all dirs without the ".yaml.d" extension.
314            if path.extension() != Some(OsStr::new("d"))
315                || path.with_extension("").extension() != Some(OsStr::new(CONFIG_FILE_EXTENSION))
316            {
317                trace!("Directory {path:?} has no .yaml.d extension. Skipping...");
318                continue;
319            }
320
321            // Remove the .yaml.d extension and use the file name to construct an OS identifier.
322            let os = {
323                let without_d = path.with_extension("");
324                let without_yaml = without_d.with_extension("");
325                let Some(file_name) = without_yaml.file_name() else {
326                    trace!(
327                        "Directory {path:?} has no file name after removing the .yaml.d extension. Skipping..."
328                    );
329                    continue;
330                };
331
332                match Os::try_from(file_name) {
333                    Ok(os) => os,
334                    Err(error) => {
335                        error!("Invalid OS identifier in drop-in directory path {path:?}: {error}");
336                        continue;
337                    }
338                }
339            };
340
341            if let Some(paths) = drop_in_dirs.get_mut(&os) {
342                paths.push(path);
343            } else {
344                drop_in_dirs.insert(os, vec![path]);
345            }
346        }
347    }
348
349    drop_in_dirs
350}
351
352/// Collects all valid [drop-in] config files from a list of input `dirs`.
353///
354/// First uses `collect_drop_in_dirs` to collect all drop-in directories from the top-level `dirs`.
355/// Then creates a map of target OSes and relevant file names (with specific file path locations).
356/// All masked drop-ins are ignored.
357/// Drop-in config files are sorted and read according to the UAPI config file specification.
358///
359/// [drop-in]: https://uapi-group.org/specifications/specs/configuration_files_specification/#drop-ins
360fn collect_drop_in_config_files(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, Vec<ConfigFile>> {
361    let drop_in_dirs = collect_drop_in_dirs(dirs);
362    let extension_check = Some(OsStr::new(CONFIG_FILE_EXTENSION));
363
364    let mut os_drop_ins: BTreeMap<Os, BTreeMap<OsString, Vec<PathBuf>>> = BTreeMap::new();
365    // Create a map of target OSes and their respective drop-in configuration file paths.
366    // Configuration file paths are tracked based on their file name, mapped to lists of config
367    // paths in increasing priority.
368    for (os, drop_in_dir_list) in drop_in_dirs.iter() {
369        for drop_in_dir in drop_in_dir_list.iter() {
370            let read_dir = match drop_in_dir.read_dir() {
371                Ok(read_dir) => read_dir,
372                Err(error) => {
373                    error!("Unable to read drop-in directory {drop_in_dir:?}: {error}");
374                    continue;
375                }
376            };
377
378            for dir_entry in read_dir {
379                let dir_entry = match dir_entry {
380                    Ok(dir_entry) => dir_entry,
381                    Err(error) => {
382                        error!("Unable to read entry in {drop_in_dir:?}: {error}");
383                        continue;
384                    }
385                };
386
387                let config_path = dir_entry.path();
388
389                // Skip files that don't use the correct extension.
390                if config_path.extension() != extension_check {
391                    trace!(
392                        "The drop-in config path {config_path:?} does not use the {CONFIG_FILE_EXTENSION} extension. Skipping..."
393                    );
394                    continue;
395                }
396
397                // Extract the file name and skip if there isn't any.
398                let Some(file_name) = config_path.file_name().map(ToOwned::to_owned) else {
399                    error!(
400                        "The drop-in config file path {config_path:?} has no file name. Skipping..."
401                    );
402                    continue;
403                };
404
405                if let Some(file_name_paths) = os_drop_ins.get_mut(os) {
406                    if let Some(path_list) = file_name_paths.get_mut(&file_name) {
407                        path_list.push(config_path.to_path_buf());
408                    } else {
409                        file_name_paths.insert(file_name, vec![config_path.to_path_buf()]);
410                    }
411                } else {
412                    os_drop_ins.insert(
413                        os.clone(),
414                        BTreeMap::from_iter([(file_name, vec![config_path.to_path_buf()])]),
415                    );
416                }
417            }
418        }
419    }
420
421    let mut output: BTreeMap<Os, Vec<ConfigFile>> = BTreeMap::new();
422    // For each OS, go over the file names and associated configuration file paths.
423    for (os, file_name_paths) in os_drop_ins.into_iter() {
424        // For each file name, collect the file path with the highest priority.
425        for (file_name, path_list) in file_name_paths.into_iter() {
426            debug!(
427                "Precedence for {file_name:?}: {}",
428                path_list
429                    .iter()
430                    .map(|path| path.to_string_lossy().to_string())
431                    .collect::<Vec<_>>()
432                    .join(" < ")
433            );
434            // Skip all drop-in config file locations of an OS that use the same file name, if one
435            // is masked.
436            // Relevant messages are emitted by the `is_masked` function.
437            if path_list.as_slice().iter().any(is_masked) {
438                continue;
439            }
440
441            let config_file_list = {
442                let mut config_file_list: Vec<ConfigFile> = Vec::new();
443                for path in path_list.iter() {
444                    // We are only interested in regular files.
445                    if !path.is_file() {
446                        error!("The path {path:?} is not a file. Skipping...");
447                        continue;
448                    }
449
450                    match ConfigFile::from_yaml_file(path, crate::file::ConfigFileType::DropIn) {
451                        Ok(config_file) => config_file_list.push(config_file),
452                        Err(error) => {
453                            error!("Failed to read config file from file path {path:?}: {error}")
454                        }
455                    }
456                }
457                config_file_list
458            };
459
460            let Some(config_file) = config_file_list.into_iter().last() else {
461                error!("No valid config file with file name {file_name:?} found for OS {os}");
462                continue;
463            };
464
465            info!(
466                "Using {} as drop-in config file path with highest priority for file name {file_name:?}",
467                config_file
468                    .origins()
469                    .iter()
470                    .map(ToString::to_string)
471                    .collect::<Vec<_>>()
472                    .join(", ")
473            );
474
475            if let Some(config_file_list) = output.get_mut(&os) {
476                config_file_list.push(config_file);
477            } else {
478                output.insert(os.clone(), vec![config_file]);
479            }
480        }
481    }
482
483    output
484}