Skip to main content

liboverdrop/
lib.rs

1//! Simple library to handle configuration fragments.
2//!
3//! This crate provides helpers to scan configuration fragments on disk.
4//! The goal is to help in writing Linux services which are shipped as part of a [Reproducible OS][reproducible].
5//! Its name derives from **over**lays and **drop**ins (base directories and configuration fragments).
6//!
7//! The main entrypoint is [`scan`](fn.scan.html). It scans
8//! for configuration fragments across multiple directories (with increasing priority),
9//! following these rules:
10//!
11//!  * fragments are identified by unique filenames, lexicographically (e.g. `50-default-limits.conf`).
12//!  * in case of name duplication, last directory wins (e.g. `/etc/svc/custom.conf` can override `/usr/lib/svc/custom.conf`).
13//!  * a fragment symlinked to `/dev/null` is used to ignore any previous fragment with the same filename.
14//!
15//! [reproducible]: http://0pointer.net/blog/projects/stateless.html
16//!
17//! # Example
18//!
19//! ```rust,no_run
20//! # use liboverdrop;
21//! // Scan for fragments under:
22//! //  * /usr/lib/my-crate/config.d/*.toml
23//! //  * /run/my-crate/config.d/*.toml
24//! //  * /etc/my-crate/config.d/*.toml
25//!
26//! let base_dirs = [
27//!     "/usr/lib",
28//!     "/run",
29//!     "/etc",
30//! ];
31//! let fragments = liboverdrop::scan(&base_dirs, "my-crate/config.d", &["toml"], false);
32//!
33//! for (filename, filepath) in fragments {
34//!     println!("fragment '{}' located at '{}'", filename.to_string_lossy(), filepath.display());
35//! }
36//! ```
37//!
38//! # Migrating from liboverdrop 0.0.x
39//!
40//! The signature changed from
41//! ```rust,compile_fail
42//! # use liboverdrop::FragmentScanner;
43//! let base_dirs: Vec<String> = vec![/**/];
44//! let shared_path: &str = "config.d";
45//! let allowed_extensions: Vec<String> = vec![/**/];
46//! for (basename, filepath) in FragmentScanner::new(
47//!                                 base_dirs, shared_path, false, allowed_extensions).scan() {
48//!     // basename: String
49//!     // filepath: PathBuf
50//! }
51//! ```
52//! to
53//! ```rust,no_run
54//! # use liboverdrop;
55//! # /*
56//! let base_dirs: IntoIterator<Item = AsRef<Path>> = /* could be anything */;
57//! let shared_path: AsRef<Path> = /* ... */;
58//! let allowed_extensions: &[AsRef<OsStr>] = &[/* ... */];
59//! # */
60//! # let base_dirs = [""];
61//! # let shared_path = "";
62//! # let allowed_extensions = &[""];
63//! for (basename, filepath) in liboverdrop::scan(
64//!                                 base_dirs, shared_path, allowed_extensions, false) {
65//!     // basename: OsString
66//!     // filepath: PathBuf
67//! }
68//! ```
69//!
70//! When updating, re-consider if you need to allocate any argument now,
71//! since they can all be literals or borrowed.
72
73use log::trace;
74use std::collections::BTreeMap;
75use std::ffi::{OsStr, OsString};
76use std::fs;
77use std::path::{Path, PathBuf};
78
79/// The well-known path to the null device used for overrides.
80const DEVNULL: &str = "/dev/null";
81
82/// Scan unique configuration fragments from the configuration directories specified.
83///
84/// # Arguments
85///
86/// * `base_dirs` - Base components of directories where configuration fragments are located.
87/// * `shared_path` - Common relative path from each entry in `base_dirs` to the directory
88///                   holding configuration fragments.
89/// * `allowed_extensions` - Only scan files that have an extension listed in `allowed_extensions`.
90///                          If an empty slice is passed, then all extensions are allowed.
91/// * `ignore_dotfiles` - Whether to ignore dotfiles (hidden files with name prefixed with '.').
92///
93/// `shared_path` is joined onto each entry in `base_dirs` to form the directory paths to scan.
94///
95/// Returns a `BTreeMap` indexed by configuration fragment filename,
96/// holding the path where the unique configuration fragment is located.
97///
98/// Configuration fragments are stored in the `BTreeMap` in alphanumeric order by filename.
99/// Configuration fragments existing in directories that are scanned later override fragments
100/// of the same filename in directories that are scanned earlier.
101pub fn scan<BdS: AsRef<Path>, BdI: IntoIterator<Item = BdS>, Sp: AsRef<Path>, As: AsRef<OsStr>>(
102    base_dirs: BdI,
103    shared_path: Sp,
104    allowed_extensions: &[As],
105    ignore_dotfiles: bool,
106) -> BTreeMap<OsString, PathBuf> {
107    let shared_path = shared_path.as_ref();
108
109    let mut files_map = BTreeMap::new();
110    for dir in base_dirs {
111        let dir = dir.as_ref().join(shared_path);
112        trace!("Scanning directory '{}'", dir.display());
113
114        let dir_iter = match fs::read_dir(dir) {
115            Ok(iter) => iter,
116            _ => continue,
117        };
118        for entry in dir_iter.flatten() {
119            let fpath = entry.path();
120            let fname = entry.file_name();
121
122            // If hidden files not allowed, ignore dotfiles.
123            // Rust RFC 900 &c.: there's no way to check if a Path/OsStr starts with a prefix;
124            // instead, we check via to_string_lossy(), which will only allocate if the basename wasn't UTF-8,
125            // and the lossiness doesn't bother us; https://github.com/rust-lang/rfcs/issues/900
126            if ignore_dotfiles && fname.to_string_lossy().starts_with('.') {
127                continue;
128            }
129
130            // If extensions are specified, proceed only if filename has one of the allowed
131            // extensions.
132            if !allowed_extensions.is_empty() {
133                if let Some(extension) = fpath.extension() {
134                    if !allowed_extensions.iter().any(|ae| ae.as_ref() == extension) {
135                        continue;
136                    }
137                } else {
138                    continue;
139                }
140            }
141
142            // Check filetype, ignore non-file.
143            let meta = match entry.metadata() {
144                Ok(m) => m,
145                _ => continue,
146            };
147            if !meta.file_type().is_file() {
148                if let Ok(target) = fs::read_link(&fpath) {
149                    // A devnull symlink is a special case to ignore previous file-names.
150                    if target == Path::new(DEVNULL) {
151                        trace!("Nulled config file '{}'", fpath.display());
152                        files_map.remove(&fname);
153                    }
154                }
155                continue;
156            }
157
158            trace!(
159                "Found config file '{}' at '{}'",
160                Path::new(&fname).display(),
161                fpath.display()
162            );
163            files_map.insert(fname, fpath);
164        }
165    }
166
167    files_map
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn assert_fragments_match(
175        fragments: &BTreeMap<OsString, PathBuf>,
176        filename: &OsStr,
177        filepath: &Path,
178    ) {
179        assert_eq!(fragments.get(filename).unwrap(), filepath);
180    }
181
182    fn assert_fragments_hit<T: AsRef<OsStr>>(fragments: &BTreeMap<OsString, PathBuf>, filename: T) {
183        assert!(fragments.get(filename.as_ref()).is_some());
184    }
185
186    fn assert_fragments_miss<T: AsRef<OsStr>>(
187        fragments: &BTreeMap<OsString, PathBuf>,
188        filename: T,
189    ) {
190        assert!(fragments.get(filename.as_ref()).is_none());
191    }
192
193    #[test]
194    fn basic_override() {
195        let treedir = "tests/fixtures/tree-basic";
196        let dirs = [
197            format!("{}/{}", treedir, "usr/lib"),
198            format!("{}/{}", treedir, "run"),
199            format!("{}/{}", treedir, "etc"),
200        ];
201
202        let expected_fragments = [
203            (
204                OsString::from("01-config-a.toml"),
205                Path::new(treedir).join("etc/liboverdrop.d/01-config-a.toml"),
206            ),
207            (
208                OsString::from("02-config-b.toml"),
209                Path::new(treedir).join("run/liboverdrop.d/02-config-b.toml"),
210            ),
211            (
212                OsString::from("03-config-c.toml"),
213                Path::new(treedir).join("etc/liboverdrop.d/03-config-c.toml"),
214            ),
215            (
216                OsString::from("04-config-d.toml"),
217                Path::new(treedir).join("usr/lib/liboverdrop.d/04-config-d.toml"),
218            ),
219            (
220                OsString::from("05-config-e.toml"),
221                Path::new(treedir).join("etc/liboverdrop.d/05-config-e.toml"),
222            ),
223            (
224                OsString::from("06-config-f.toml"),
225                Path::new(treedir).join("run/liboverdrop.d/06-config-f.toml"),
226            ),
227            (
228                OsString::from("07-config-g.toml"),
229                Path::new(treedir).join("etc/liboverdrop.d/07-config-g.toml"),
230            ),
231        ];
232
233        let fragments = scan(&dirs, "liboverdrop.d", &["toml"], false);
234
235        for (name, path) in &expected_fragments {
236            assert_fragments_match(&fragments, &name, &path);
237        }
238
239        // Check keys are stored in the correct order.
240        let expected_keys: Vec<_> = expected_fragments
241            .into_iter()
242            .map(|(name, _)| name)
243            .collect();
244        let fragments_keys: Vec<_> = fragments.keys().cloned().collect();
245        assert_eq!(fragments_keys, expected_keys);
246    }
247
248    #[test]
249    fn basic_override_restrict_extensions() {
250        let treedir = "tests/fixtures/tree-basic";
251        let dirs = [format!("{}/{}", treedir, "etc")];
252
253        let fragments = scan(&dirs, "liboverdrop.d", &["toml"], false);
254
255        assert_fragments_hit(&fragments, "01-config-a.toml");
256        assert_fragments_miss(&fragments, "08-config-h.conf");
257        assert_fragments_miss(&fragments, "noextension");
258    }
259
260    #[test]
261    fn basic_override_allow_all_extensions() {
262        let treedir = "tests/fixtures/tree-basic";
263        let dirs = [format!("{}/{}", treedir, "etc")];
264
265        let fragments = scan::<_, _, _, &str>(&dirs, "liboverdrop.d", &[], false);
266
267        assert_fragments_hit(&fragments, "01-config-a.toml");
268        assert_fragments_hit(&fragments, "config.conf");
269        assert_fragments_hit(&fragments, "noextension");
270    }
271
272    #[test]
273    fn basic_override_ignore_hidden() {
274        let treedir = "tests/fixtures/tree-basic";
275        let dirs = [format!("{}/{}", treedir, "etc")];
276
277        let fragments = scan::<_, _, _, &str>(&dirs, "liboverdrop.d", &[], true);
278
279        assert_fragments_hit(&fragments, "config.conf");
280        assert_fragments_miss(&fragments, ".hidden.conf");
281    }
282
283    #[test]
284    fn basic_override_allow_hidden() {
285        let treedir = "tests/fixtures/tree-basic";
286        let dirs = [format!("{}/{}", treedir, "etc")];
287
288        let fragments = scan::<_, _, _, &OsStr>(&dirs, "liboverdrop.d", &[], false);
289
290        assert_fragments_hit(&fragments, "config.conf");
291        assert_fragments_hit(&fragments, ".hidden.conf");
292    }
293}