chrony-confile 0.1.0

A full-featured Rust library for parsing, editing, validating, and serializing chrony configuration files
Documentation
//! Include/confdir expansion for chrony configuration files.
//!
//! This module provides the [`ExpandOptions`] struct and `expand` methods on [`ChronyConfig`]
//! for resolving `include` and `confdir` directives by reading the referenced files and
//! inlining their contents. Expansion is recursive up to a configurable maximum depth.

// Expand functions return `Result<_, ParseError>` which triggers this lint.
#![allow(clippy::result_large_err)]

use std::path::{Path, PathBuf};
use crate::ast::*;
use crate::error::ParseError;

/// Options for controlling configuration expansion.
#[derive(Debug, Clone)]
pub struct ExpandOptions {
    /// Maximum include nesting depth (default: 10).
    pub max_include_level: usize,
    /// Whether to follow `include` directives (default: true).
    pub follow_includes: bool,
    /// Whether to follow `confdir` directives (default: true).
    pub follow_confdirs: bool,
}

impl Default for ExpandOptions {
    fn default() -> Self {
        Self {
            max_include_level: 10,
            follow_includes: true,
            follow_confdirs: true,
        }
    }
}

impl ChronyConfig {
    /// Expand `include` and `confdir` directives by reading the referenced files
    /// and inlining their contents.
    ///
    /// Uses default [`ExpandOptions`]: max 10 levels of nesting, following both
    /// `include` and `confdir` directives.
    pub fn expand(&self, base_dir: &Path) -> Result<ChronyConfig, ParseError> {
        let opts = ExpandOptions::default();
        let mut expanded = Vec::new();
        expand_nodes(&self.nodes, base_dir, 0, &opts, &mut expanded)?;
        Ok(ChronyConfig { nodes: expanded })
    }

    /// Expand `include` and `confdir` directives with custom options.
    ///
    /// Allows fine-grained control over which directives to follow and the maximum
    /// nesting depth via [`ExpandOptions`].
    pub fn expand_with_opts(&self, base_dir: &Path, opts: &ExpandOptions) -> Result<ChronyConfig, ParseError> {
        let mut expanded = Vec::new();
        expand_nodes(&self.nodes, base_dir, 0, opts, &mut expanded)?;
        Ok(ChronyConfig { nodes: expanded })
    }
}

fn expand_nodes(
    nodes: &[ConfigNode],
    base_dir: &Path,
    depth: usize,
    opts: &ExpandOptions,
    out: &mut Vec<ConfigNode>,
) -> Result<(), ParseError> {
    for node in nodes {
        match node {
            ConfigNode::Directive(d) => match &d.kind {
                DirectiveKind::Include(c) if opts.follow_includes => {
                    if depth >= opts.max_include_level {
                        return Err(ParseError::IncludeLevelExceeded {
                            file: base_dir.to_path_buf(),
                            line: d.span.line_start,
                        });
                    }
                    let pattern = base_dir.join(&c.pattern);
                    let path = PathBuf::from(pattern.to_string_lossy().as_ref());
                    if path.exists() {
                        let content = std::fs::read_to_string(&path)
                            .map_err(ParseError::Io)?;
                        let sub = ChronyConfig::parse(&content)
                            .map_err(|e| adapt_error(e, Some(path.clone())))?;
                        let sub_dir = path.parent().unwrap_or(base_dir);
                        expand_nodes(&sub.nodes, sub_dir, depth + 1, opts, out)?;
                    }
                }
                DirectiveKind::ConfDir(c) if opts.follow_confdirs => {
                    let dir = base_dir.join(&c.directory);
                    if dir.is_dir() {
                        let mut entries: Vec<_> = std::fs::read_dir(&dir)
                            .map_err(ParseError::Io)?
                            .filter_map(|e| e.ok())
                            .filter(|e| {
                                e.path().extension().is_some_and(|ext| ext == "conf")
                            })
                            .collect();
                        entries.sort_by_key(|e| e.file_name());
                        for entry in entries {
                            let path = entry.path();
                            let content = std::fs::read_to_string(&path)
                                .map_err(ParseError::Io)?;
                            let sub = ChronyConfig::parse(&content)
                                .map_err(|e| adapt_error(e, Some(path.clone())))?;
                            expand_nodes(
                                &sub.nodes,
                                path.parent().unwrap_or(base_dir),
                                depth + 1,
                                opts,
                                out,
                            )?;
                        }
                    }
                }
                _ => out.push(node.clone()),
            },
            _ => out.push(node.clone()),
        }
    }
    Ok(())
}

fn adapt_error(e: ParseError, file: Option<PathBuf>) -> ParseError {
    match e {
        ParseError::Parse { file: _, inner } => ParseError::Parse { file, inner },
        other => other,
    }
}