chrony-confile 0.1.0

A full-featured Rust library for parsing, editing, validating, and serializing chrony configuration files
Documentation
//! Chrony configuration file parsing.
//!
//! This module provides both strict and lenient parsing modes:
//! - [`ChronyConfig::parse`] returns an error on the first invalid directive
//! - [`ChronyConfig::parse_lenient`] skips erroneous lines and returns warnings
//!
//! The [`lexer`] sub-module handles line normalization and tokenization, while [`grammar`]
//! implements the per-directive parsing logic. [`source_file`] provides lenient parsing
//! for `.sources` files.

// Parse functions return `Result<_, ParseError>` which triggers this lint for the large
// `ParseError` enum. Boxing would not improve ergonomics here.
#![allow(clippy::result_large_err)]

pub mod lexer;
pub mod grammar;
pub mod source_file;

use std::path::PathBuf;
use crate::ast::*;
use crate::error::{DirectiveError, ParseError};
use crate::span::Span;
use lexer::{LineType, MAX_LINE_LENGTH};

/// Parse a chrony configuration string in strict mode.
impl ChronyConfig {
    /// Parses a chrony configuration string, returning an error on the first invalid directive.
    ///
    /// This is the primary parsing entry point. It processes the entire input using chrony's
    /// line normalization logic (`CPS_NormalizeLine`), dispatches each directive to its
    /// type-specific parser, and returns the complete [`ChronyConfig`] AST.
    ///
    /// Comments and blank lines are preserved for lossless round-trip serialization.
    ///
    /// # Errors
    ///
    /// Returns [`ParseError`] on the first invalid directive, line exceeding maximum length,
    /// or any other parsing failure.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use chrony_confile::ChronyConfig;
    ///
    /// let config = ChronyConfig::parse("\
    /// server ntp.example.com iburst
    /// pool pool.ntp.org maxsources 6
    /// driftfile /var/lib/chrony/drift
    /// ")?;
    ///
    /// assert_eq!(config.directives().count(), 3);
    /// # Ok::<_, chrony_confile::ParseError>(())
    /// ```
    pub fn parse(input: &str) -> Result<Self, ParseError> {
        parse_internal(input, None, false).map(|c| c.0)
    }

    /// Parses a chrony configuration string in lenient mode.
    ///
    /// Unlike [`parse`](Self::parse), this method skips erroneous lines instead of failing.
    /// Any parse errors are returned as warnings alongside the partially-parsed configuration.
    /// This is useful for processing user-provided configs where a best-effort result is
    /// preferred over failing.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use chrony_confile::ChronyConfig;
    ///
    /// let (config, warnings) = ChronyConfig::parse_lenient("\
    /// server ntp.example.com iburst
    /// invalid_directive foo
    /// server ntp2.example.com
    /// ");
    ///
    /// // The valid directives are still parsed
    /// assert_eq!(config.directives().count(), 2);
    /// // The invalid directive is returned as a warning
    /// assert_eq!(warnings.len(), 1);
    /// ```
    pub fn parse_lenient(input: &str) -> (Self, Vec<ParseError>) {
        parse_internal(input, None, true).unwrap_or_else(|e| (ChronyConfig::default(), vec![e]))
    }
}

fn parse_internal(input: &str, file: Option<PathBuf>, lenient: bool) -> Result<(ChronyConfig, Vec<ParseError>), ParseError> {
    let mut nodes = Vec::new();
    let mut leading_comments: Vec<String> = Vec::new();
    let mut warnings = Vec::new();

    for (line_num, raw_line) in input.lines().enumerate() {
        let line_number = line_num + 1;

        if raw_line.len() > MAX_LINE_LENGTH {
            let err = ParseError::LineTooLong { file: file.clone(), line: line_number, len: raw_line.len() };
            if lenient { warnings.push(err); continue; }
            return Err(err);
        }

        match lexer::normalize_line(raw_line) {
            LineType::Blank => {
                flush_comments(&mut leading_comments, &mut nodes);
                nodes.push(ConfigNode::BlankLine);
            }
            LineType::Comment(comment) => {
                leading_comments.push(comment);
            }
            LineType::Directive { command, args } => {
                let span = Span::new(file.clone(), line_number, 1, raw_line.len());
                let trailing = extract_trailing_comment(raw_line);
                let clean_args = strip_inline_comment(&args);

                match dispatch(&command, clean_args, span.clone()) {
                    Ok(kind) => {
                        let mut directive = Box::new(Directive::new(kind, span));
                        directive.leading_comments = std::mem::take(&mut leading_comments);
                        directive.trailing_comment = trailing;
                        nodes.push(ConfigNode::Directive(directive));
                    }
                    Err(e) => {
                        if lenient {
                            leading_comments.clear();
                            warnings.push(ParseError::Parse { file: file.clone(), inner: e });
                            continue;
                        }
                        return Err(ParseError::Parse { file, inner: e });
                    }
                }
            }
        }
    }

    flush_comments(&mut leading_comments, &mut nodes);

    Ok((ChronyConfig { nodes }, warnings))
}

fn flush_comments(comments: &mut Vec<String>, nodes: &mut Vec<ConfigNode>) {
    for c in comments.drain(..) {
        nodes.push(ConfigNode::Comment(c));
    }
}

fn extract_trailing_comment(raw_line: &str) -> Option<String> {
    let trimmed = raw_line.trim_start();
    for (i, ch) in trimmed.char_indices() {
        if matches!(ch, '#' | ';') && i > 0 {
            let comment = trimmed[i+1..].trim();
            if !comment.is_empty() {
                return Some(comment.to_string());
            }
            break;
        }
    }
    None
}

/// Strip inline comments (starting with # or ; after the first character, preceded by a space)
/// from a normalized args string.
fn strip_inline_comment(args: &str) -> &str {
    for (i, ch) in args.char_indices() {
        if matches!(ch, '#' | ';') && i > 0
            && args.as_bytes().get(i - 1).copied() == Some(b' ')
        {
            return args[..i - 1].trim_end();
        }
    }
    args
}

fn dispatch(command: &str, args: &str, span: Span) -> Result<DirectiveKind, DirectiveError> {
    use grammar::*;

    match command.to_lowercase().as_str() {
        // Source
        "server" => parse_source_options(args, span, "server"),
        "pool" => parse_source_options(args, span, "pool"),
        "peer" => parse_source_options(args, span, "peer"),
        "initstepslew" => parse_initstepslew(args, span),
        "refclock" => parse_refclock(args, span),
        "manual" => parse_manual(args, span),
        "acquisitionport" => parse_acquisition_port(args, span),
        "bindacqaddress" => parse_bind_acq_address(args, span),
        "bindacqdevice" => parse_bind_acq_device(args, span),
        "dscp" => parse_dscp(args, span),
        "dumpdir" => parse_dumpdir(args, span),
        "maxsamples" => parse_max_samples(args, span),
        "minsamples" => parse_min_samples(args, span),
        "ntscachedir" | "ntsdumpdir" => parse_nts_dump_dir(args, span),
        "ntsrefresh" => parse_nts_refresh(args, span),
        "ntstrustedcerts" => parse_ntstrustedcerts(args, span),
        "nosystemcert" => parse_no_system_cert(args, span),
        "nocerttimecheck" => parse_no_cert_time_check(args, span),
        "refresh" => parse_refresh(args, span),

        // Selection
        "authselectmode" => parse_authselectmode(args, span),
        "combinelimit" => parse_combine_limit(args, span),
        "maxdistance" => parse_max_distance(args, span),
        "maxjitter" => parse_max_jitter(args, span),
        "minsources" => parse_minsources(args, span),
        "reselectdist" => parse_reselect_dist(args, span),
        "stratumweight" => parse_stratum_weight(args, span),

        // System clock
        "clockprecision" => parse_clock_precision(args, span),
        "corrtimeratio" => parse_corr_time_ratio(args, span),
        "driftfile" => parse_driftfile(args, span),
        "fallbackdrift" => parse_fallbackdrift(args, span),
        "leapsecmode" => parse_leapsecmode(args, span),
        "leapsectz" => parse_leap_sec_tz(args, span),
        "leapseclist" => parse_leap_sec_list(args, span),
        "makestep" => parse_makestep(args, span),
        "maxchange" => parse_maxchange(args, span),
        "maxclockerror" => parse_max_clock_error(args, span),
        "maxdrift" => parse_max_drift(args, span),
        "maxslewrate" => parse_max_slew_rate(args, span),
        "maxupdateskew" => parse_max_update_skew(args, span),
        "tempcomp" => parse_tempcomp(args, span),

        // NTP server
        "allow" => parse_allow(args, span),
        "deny" => parse_deny(args, span),
        "bindaddress" => parse_bind_address(args, span),
        "binddevice" => parse_bind_device(args, span),
        "broadcast" => parse_broadcast(args, span),
        "clientloglimit" => parse_client_log_limit(args, span),
        "noclientlog" => parse_no_client_log(args, span),
        "local" => parse_local(args, span),
        "ntpsigndsocket" => parse_ntp_signd_socket(args, span),
        "ntsport" => parse_nts_port(args, span),
        "ntsservercert" => parse_nts_server_cert(args, span),
        "ntsserverkey" => parse_nts_server_key(args, span),
        "ntsprocesses" => parse_nts_processes(args, span),
        "maxntsconnections" => parse_max_nts_connections(args, span),
        "ntsntpserver" => parse_nts_ntp_server(args, span),
        "ntsrotate" => parse_nts_rotate(args, span),
        "port" => parse_port(args, span),
        "ratelimit" => parse_ratelimit(args, span),
        "ntsratelimit" => parse_nts_ratelimit(args, span),
        "smoothtime" => parse_smoothtime(args, span),

        // Command access
        "bindcmdaddress" => parse_bind_cmd_address(args, span),
        "bindcmddevice" => parse_bind_cmd_device(args, span),
        "cmdallow" => parse_cmd_allow(args, span),
        "cmddeny" => parse_cmd_deny(args, span),
        "cmdport" => parse_cmd_port(args, span),
        "cmdratelimit" => parse_cmd_ratelimit(args, span),
        "opencommands" => parse_open_commands(args, span),

        // RTC
        "hwclockfile" => parse_hw_clock_file(args, span),
        "rtcautotrim" => parse_rtc_auto_trim(args, span),
        "rtcdevice" => parse_rtc_device(args, span),
        "rtcfile" => parse_rtc_file(args, span),
        "rtconutc" => parse_rtconutc(args, span),
        "rtcsync" => parse_rtcsync(args, span),

        // Log
        "log" => parse_log(args, span),
        "logbanner" => parse_log_banner(args, span),
        "logchange" => parse_log_change(args, span),
        "logdir" => parse_logdir(args, span),

        // Hardware timestamping
        "hwtimestamp" => parse_hwtimestamp(args, span),
        "hwtstimeout" => parse_hw_ts_timeout(args, span),
        "maxtxbuffers" => parse_max_tx_buffers(args, span),
        "ptpport" => parse_ptp_port(args, span),
        "ptpdomain" => parse_ptp_domain(args, span),

        // NTS
        "ntsaeads" => parse_nts_aeads(args, span),

        // Miscellaneous
        "confdir" => parse_confdir(args, span),
        "include" => parse_include(args, span),
        "sourcedir" => parse_source_dir(args, span),
        "lock_all" => parse_lock_all(args, span),
        "mailonchange" => parse_mailonchange(args, span),
        "pidfile" => parse_pidfile(args, span),
        "sched_priority" => parse_sched_priority(args, span),
        "user" => parse_user(args, span),
        "keyfile" => parse_keyfile(args, span),
        "dumponexit" => parse_dump_on_exit(args, span),

        // Deprecated (silently accept)
        "commandkey" | "generatecommandkey" | "linux_freq_scale" | "linux_hz" => {
            Ok(DirectiveKind::DumpOnExit)
        }

        _ => Err(DirectiveError::InvalidDirective { name: command.to_string(), span }),
    }
}