zerodds_config/load.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Loading order: a base file, sorted drop-in directories, nested includes.
4
5extern crate alloc;
6
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use std::path::{Path, PathBuf};
10
11use crate::{Config, ConfigError, Node};
12
13/// How deep `include:` may nest before the loader stops.
14///
15/// A bound and not a guess: cycle detection already refuses a chain that
16/// returns to a file it is reading, so this catches the other shape — a
17/// generator that emits `a` including `b` including `c` for ever without
18/// repeating a name. Sixteen is past any hand-written layout.
19pub const MAX_INCLUDE_DEPTH: usize = 16;
20
21/// Builds a [`Config`] out of a base file and drop-in directories.
22///
23/// ```no_run
24/// # use zerodds_config::Loader;
25/// let config = Loader::new()
26/// .base("/etc/zerodds/zerodds.yaml")
27/// .drop_in_dir("/etc/zerodds/zerodds.d")
28/// .drop_in_dir("/run/zerodds/zerodds.d")
29/// .load()?;
30/// // Where did this come from?
31/// println!("{:?}", config.origin_of("transport.mtu"));
32/// # Ok::<(), zerodds_config::ConfigError>(())
33/// ```
34#[derive(Debug, Default, Clone)]
35pub struct Loader {
36 base: Option<PathBuf>,
37 drop_in_dirs: Vec<PathBuf>,
38 extension: Option<String>,
39 missing_base_is_error: bool,
40}
41
42impl Loader {
43 /// A loader with nothing in it yet.
44 #[must_use]
45 pub fn new() -> Self {
46 Self {
47 extension: Some("yaml".to_string()),
48 ..Self::default()
49 }
50 }
51
52 /// The base file, read before every drop-in.
53 ///
54 /// Absent by default: a package can ship only drop-ins, and a site can
55 /// run with only a base.
56 #[must_use]
57 pub fn base(mut self, path: impl AsRef<Path>) -> Self {
58 self.base = Some(path.as_ref().to_path_buf());
59 self
60 }
61
62 /// Fail rather than continue when the base file is not there.
63 ///
64 /// Off by default, because "no configuration" is a legitimate state and a
65 /// daemon that starts on its built-in defaults is what an empty
66 /// `/etc/zerodds` should produce. On for a caller who passed `--config`
67 /// and means it.
68 #[must_use]
69 pub fn require_base(mut self) -> Self {
70 self.missing_base_is_error = true;
71 self
72 }
73
74 /// Adds a drop-in directory, after the ones already added.
75 ///
76 /// Directories are read in the order they are added — a package
77 /// directory, then a site one, then a runtime one — and inside each
78 /// directory the files are read sorted by name. `10-net.yaml` before
79 /// `20-net.yaml` before `99-local.yaml`, which is the ordering every
80 /// systemd drop-in already follows and therefore the one an operator will
81 /// assume without being told.
82 ///
83 /// A directory that does not exist is not an error. Half the point of
84 /// drop-ins is that `/run/...` exists only sometimes.
85 #[must_use]
86 pub fn drop_in_dir(mut self, path: impl AsRef<Path>) -> Self {
87 self.drop_in_dirs.push(path.as_ref().to_path_buf());
88 self
89 }
90
91 /// Only files with this extension are read from drop-in directories.
92 ///
93 /// `yaml` by default. `None` reads every file, which is what a directory
94 /// of `.conf` fragments needs. Either way `.rpmnew`, `.dpkg-dist` and
95 /// editor backups stay out when the default is kept — a package upgrade
96 /// leaving `zerodds.yaml.rpmnew` behind must not quietly change what runs.
97 #[must_use]
98 pub fn extension(mut self, ext: Option<&str>) -> Self {
99 self.extension = ext.map(ToString::to_string);
100 self
101 }
102
103 /// Reads everything and merges it.
104 ///
105 /// # Errors
106 /// [`ConfigError::Io`] for an unreadable file (and for a missing base
107 /// after [`Self::require_base`]), [`ConfigError::Syntax`] for text outside
108 /// the subset, [`ConfigError::IncludeCycle`] and
109 /// [`ConfigError::IncludeTooDeep`] for include chains that do not end.
110 pub fn load(&self) -> Result<Config, ConfigError> {
111 self.load_onto(Config::default())
112 }
113
114 /// Reads everything and merges it over `config`, which keeps its sources
115 /// and loses every value a file sets again. The environment layer goes in
116 /// here, so that the files win over it and a drop-in's `+name` appends to
117 /// what it set.
118 ///
119 /// # Errors
120 /// As [`Self::load`].
121 pub fn load_onto(&self, mut config: Config) -> Result<Config, ConfigError> {
122 if let Some(base) = &self.base {
123 if base
124 .try_exists()
125 .map_err(|error| ConfigError::Io(base.display().to_string(), error.to_string()))?
126 {
127 self.read_into(&mut config, base, &mut Vec::new(), 0)?;
128 } else if self.missing_base_is_error {
129 return Err(ConfigError::Io(
130 base.display().to_string(),
131 "no such file".to_string(),
132 ));
133 }
134 }
135 for dir in &self.drop_in_dirs {
136 for file in self.sorted_files(dir)? {
137 self.read_into(&mut config, &file, &mut Vec::new(), 0)?;
138 }
139 }
140 Ok(config)
141 }
142
143 /// The files of one drop-in directory, sorted by name.
144 ///
145 /// Sorted by the file name and not by what the filesystem happens to
146 /// return: `read_dir` gives no order, and a configuration whose result
147 /// depends on inode layout is not a configuration.
148 fn sorted_files(&self, dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
149 let entries = match std::fs::read_dir(dir) {
150 Ok(e) => e,
151 // A drop-in directory that is not there contributes nothing.
152 Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
153 Err(e) => return Err(ConfigError::Io(dir.display().to_string(), e.to_string())),
154 };
155 let mut files: Vec<PathBuf> = Vec::new();
156 for entry in entries {
157 let entry =
158 entry.map_err(|e| ConfigError::Io(dir.display().to_string(), e.to_string()))?;
159 let path = entry.path();
160 if let Some(want) = &self.extension {
161 if path.extension().and_then(|e| e.to_str()) != Some(want.as_str()) {
162 continue;
163 }
164 }
165 let metadata = std::fs::metadata(&path)
166 .map_err(|e| ConfigError::Io(path.display().to_string(), e.to_string()))?;
167 if !metadata.is_file() {
168 continue;
169 }
170 files.push(path);
171 }
172 files.sort();
173 Ok(files)
174 }
175
176 /// Reads one file or explicitly included directory, resolving includes
177 /// before the including file's own keys. Directory entries are not
178 /// recursively discovered: further directories require another include.
179 ///
180 /// zerodds-lint: recursion-depth 16
181 fn read_into(
182 &self,
183 config: &mut Config,
184 path: &Path,
185 chain: &mut Vec<String>,
186 depth: usize,
187 ) -> Result<(), ConfigError> {
188 if depth > MAX_INCLUDE_DEPTH {
189 return Err(ConfigError::IncludeTooDeep(MAX_INCLUDE_DEPTH));
190 }
191 let shown = path.display().to_string();
192 // Canonicalise for the comparison only: two names for one file are
193 // still a cycle, and the operator wants to read the names they wrote.
194 let identity = std::fs::canonicalize(path)
195 .map(|p| p.display().to_string())
196 .unwrap_or_else(|_| shown.clone());
197 if chain.contains(&identity) {
198 let mut names = chain.clone();
199 names.push(identity);
200 return Err(ConfigError::IncludeCycle(names));
201 }
202 chain.push(identity);
203
204 // Unlike optional top-level drop-ins, explicitly included paths must
205 // exist. Resolve metadata before sorted_files, whose API deliberately
206 // treats a missing top-level directory as empty.
207 let metadata =
208 std::fs::metadata(path).map_err(|e| ConfigError::Io(shown.clone(), e.to_string()))?;
209 if metadata.is_dir() {
210 for file in self.sorted_files(path)? {
211 self.read_into(config, &file, chain, depth + 1)?;
212 }
213 chain.pop();
214 return Ok(());
215 }
216
217 let text = std::fs::read_to_string(path)
218 .map_err(|e| ConfigError::Io(shown.clone(), e.to_string()))?;
219 let mut node = crate::parse_str(&text, &shown)?;
220
221 // Includes are defaults for this file, regardless of where the
222 // include key appears in its text. Their sequence order is retained.
223 let includes = take_includes(&mut node, &shown)?;
224 for relative in includes {
225 let resolved = resolve_include(path, &relative);
226 self.read_into(config, &resolved, chain, depth + 1)?;
227 }
228 config.push(node, shown)?;
229 chain.pop();
230 Ok(())
231 }
232}
233
234/// Removes the `include:` key and returns what it named.
235fn take_includes(node: &mut Node, path: &str) -> Result<Vec<String>, ConfigError> {
236 let Node::Map(map) = node else {
237 return Ok(Vec::new());
238 };
239 match map.remove("include") {
240 None => Ok(Vec::new()),
241 Some(Node::Seq(items)) => items
242 .into_iter()
243 .map(|item| include_path(item, path))
244 .collect(),
245 Some(one) => Ok(alloc::vec![include_path(one, path)?]),
246 }
247}
248
249/// An invalid directive cannot silently remove a requested configuration
250/// layer. Containers have no single stored source line, so diagnostics for
251/// those point at the file, line 1; scalar diagnostics retain their line.
252fn include_path(node: Node, path: &str) -> Result<String, ConfigError> {
253 let line = match node.origin() {
254 Some(crate::Origin::File { line, .. }) => *line,
255 _ => 1,
256 };
257 if let Node::Scalar(value, _) = node {
258 if !value.trim().is_empty() {
259 return Ok(value);
260 }
261 }
262 Err(ConfigError::Syntax {
263 path: path.to_string(),
264 line,
265 message: "include expects a nonempty path or a sequence of nonempty paths".to_string(),
266 })
267}
268
269/// An include is relative to the file that wrote it, absolute stays absolute.
270fn resolve_include(from: &Path, relative: &str) -> PathBuf {
271 let candidate = Path::new(relative);
272 if candidate.is_absolute() {
273 return candidate.to_path_buf();
274 }
275 from.parent()
276 .unwrap_or_else(|| Path::new("."))
277 .join(candidate)
278}
279
280/// Every path in a tree, dotted, with the origin of its scalar.
281///
282/// The operator-facing view: one line per value, saying where it came from.
283#[must_use]
284pub fn origins(config: &Config) -> Vec<(String, crate::Origin)> {
285 let mut out = Vec::new();
286 if let Some(root) = config.root() {
287 walk(root, &mut String::new(), &mut out);
288 }
289 out
290}
291
292/// zerodds-lint: recursion-depth 32
293///
294/// Bounded by construction: the tree it walks came out of `parse_str`, which
295/// refuses past [`crate::MAX_NESTING_DEPTH`]. The guard is here anyway, since
296/// a caller may hand in a tree it built itself.
297fn walk(node: &Node, prefix: &mut String, out: &mut Vec<(String, crate::Origin)>) {
298 walk_at(node, prefix, out, 0);
299}
300
301/// zerodds-lint: recursion-depth 32
302fn walk_at(node: &Node, prefix: &mut String, out: &mut Vec<(String, crate::Origin)>, depth: usize) {
303 if depth > crate::MAX_NESTING_DEPTH {
304 return;
305 }
306 match node {
307 Node::Scalar(_, origin) => out.push((prefix.clone(), origin.clone())),
308 Node::Map(map) => {
309 for (key, value) in map {
310 let mark = prefix.len();
311 if !prefix.is_empty() {
312 prefix.push('.');
313 }
314 prefix.push_str(key);
315 walk_at(value, prefix, out, depth + 1);
316 prefix.truncate(mark);
317 }
318 }
319 Node::Seq(items) => {
320 for (i, item) in items.iter().enumerate() {
321 let mark = prefix.len();
322 prefix.push_str(&alloc::format!("[{i}]"));
323 walk_at(item, prefix, out, depth + 1);
324 prefix.truncate(mark);
325 }
326 }
327 }
328}