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}