statime-config 0.1.0

Full-featured implementation of NTP with NTS support
Documentation
use std::path::{Path, PathBuf};

use serde::de::DeserializeOwned;

use crate::{
    ConfigError,
    load::files::{Files, Filesystem},
    tree::{ConfigMerger, Configurable, Merge, PartialValue},
};

mod files;

/// Everything the loader needs of a partial configuration tree
pub trait PartialTree: DeserializeOwned + Default + Merge + PartialValue {}

impl<T> PartialTree for T where T: DeserializeOwned + Default + Merge + PartialValue {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemConfigSetting {
    Unset,
    None,
    DefaultPath(PathBuf),
    UserSpecifiedPath(PathBuf),
}

/// A configuration that a document can be loaded into.
///
/// Only the root of a configuration carries the setting that decides which
/// system configuration to layer underneath it.
pub trait RootConfig: Configurable<Partial: PartialTree<Resolved = Self>> {
    /// Whether, and from where, to read a system configuration, as the document
    /// said, or `None` if it said nothing. Generated by the derive from the
    /// field marked `#[config(use_system_config)]`.
    fn use_system_config(partial: &Self::Partial) -> SystemConfigSetting;

    /// Load this configuration from `path`
    fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError>
    where
        Self: Sized,
    {
        load_from(&Filesystem, path.as_ref())
    }
}

fn load_from<T: RootConfig>(files: &impl Files, main_path: &Path) -> Result<T, ConfigError> {
    let main = parse::<T::Partial>(files, main_path)?;

    let mut merger = ConfigMerger::new();

    for fragment in system_fragments(files, T::use_system_config(&main))? {
        let partial = parse::<T::Partial>(files, &fragment)?;

        // this setting is what sent the loader looking for fragments, so
        // the documents it found cannot be the ones to answer it
        if T::use_system_config(&partial) != SystemConfigSetting::Unset {
            return Err(ConfigError::DirectiveNotAllowed { path: fragment });
        }

        merger.add_system(fragment, partial)?;
    }

    merger.add_main(main_path.to_path_buf(), main)?;
    merger.apply_defaults();

    let (config, _provenance) = merger.finish()?;

    Ok(config)
}

fn parse<T: DeserializeOwned>(files: &impl Files, path: &Path) -> Result<T, ConfigError> {
    let contents = files
        .read(path)
        .map_err(|cause| ConfigError::CouldNotRead {
            path: path.to_path_buf(),
            cause,
        })?;

    toml::from_str(&contents).map_err(|cause| ConfigError::CouldNotParse {
        path: path.to_path_buf(),
        cause,
    })
}

/// The directory to read fragments from, and whether the configuration named
/// it: a directory that was asked for and is missing is a mistake, whereas the
/// default one can be missing.
fn fragment_directory(setting: SystemConfigSetting) -> Option<(PathBuf, bool)> {
    match setting {
        SystemConfigSetting::Unset => None,
        SystemConfigSetting::None => None,
        SystemConfigSetting::DefaultPath(path_buf) => Some((path_buf, false)),
        SystemConfigSetting::UserSpecifiedPath(path_buf) => Some((path_buf, true)),
    }
}

fn system_fragments(
    files: &impl Files,
    setting: SystemConfigSetting,
) -> Result<Vec<PathBuf>, ConfigError> {
    let Some((directory, named)) = fragment_directory(setting) else {
        // use-system-config is not set, or set explicitly to false
        return Ok(Vec::new());
    };

    let entries = match files.list(&directory) {
        Ok(entries) => entries,
        Err(cause) if cause.kind() == std::io::ErrorKind::NotFound && !named => {
            return Ok(Vec::new());
        }
        Err(cause) => {
            return Err(ConfigError::CouldNotRead {
                path: directory,
                cause,
            });
        }
    };

    // only read toml files in the directory
    let mut fragments: Vec<_> = entries
        .into_iter()
        .filter(|path| {
            path.extension()
                .is_some_and(|extension| extension == "toml")
        })
        .collect();

    // fragments don't need to be merged deterministically, but it helps for
    // error messages and debugging to ensure we do
    fragments.sort();

    Ok(fragments)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        ConfigError,
        fixture::{Fixture, Level, Server, Source, UseSystemConfig},
        load::files::Memory,
    };

    fn load(documents: Memory) -> Result<Fixture, ConfigError> {
        load_from::<Fixture>(&documents, Path::new("/etc/main.toml"))
    }

    #[test]
    fn fragments_contribute_to_the_configuration() {
        let documents = Memory::new()
            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
            .document("/etc/system.d/10-logging.toml", "logging.level = 'warn'")
            .document(
                "/etc/system.d/20-sources.toml",
                "[[sources]]\nkind = 'server'\naddress = 'example.com'",
            )
            // only TOML documents are read
            .document("/etc/system.d/README.md", "not a configuration");

        let config = load(documents).unwrap();

        assert_eq!(config.logging.level, Level::Warn);
        assert_eq!(
            config.sources,
            vec![Source::Server(Server {
                address: "example.com".to_owned(),
                version: 4,
            })]
        );
    }

    #[test]
    fn the_main_config_overrides_a_fragment() {
        let documents = Memory::new()
            .document(
                "/etc/main.toml",
                "use-system-config = '/etc/system.d'\nlogging.level = 'debug'",
            )
            .document("/etc/system.d/10-logging.toml", "logging.level = 'warn'");

        let config = load(documents).unwrap();

        assert_eq!(config.logging.level, Level::Debug);
    }

    #[test]
    fn a_fragment_may_not_steer_the_loader() {
        let documents = Memory::new()
            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
            .document("/etc/system.d/10-logging.toml", "use-system-config = true");

        let error = load(documents).unwrap_err();

        let ConfigError::DirectiveNotAllowed { path } = error else {
            panic!("expected a rejected directive, got {error:?}");
        };
        assert_eq!(path, PathBuf::from("/etc/system.d/10-logging.toml"));
    }

    #[test]
    fn the_default_directory_is_read_when_enabled() {
        let documents = Memory::new()
            .document("/etc/main.toml", "use-system-config = true")
            .document(
                "/usr/lib/ntpd-rs/system-config/10-logging.toml",
                "logging.level = 'warn'",
            );

        let config = load(documents).unwrap();

        assert_eq!(config.logging.level, Level::Warn);
    }

    #[test]
    fn an_unset_setting_means_no_system_configuration() {
        let documents = Memory::new()
            .document("/etc/main.toml", "")
            // the same fragment the setting would have reached
            .document(
                "/usr/lib/ntpd-rs/system-config/10-logging.toml",
                "logging.level = 'warn'",
            );

        let config = load(documents).unwrap();

        assert_eq!(config.logging.level, Level::Info);
    }

    #[test]
    fn the_system_config_setting_is_part_of_the_configuration() {
        let documents = Memory::new()
            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
            .directory("/etc/system.d");

        let config = load(documents).unwrap();

        assert_eq!(
            config.use_system_config,
            UseSystemConfig::Directory("/etc/system.d".into())
        );

        // and it takes its default like any other setting
        let unset = Memory::new().document("/etc/main.toml", "");
        assert_eq!(
            load(unset).unwrap().use_system_config,
            UseSystemConfig::Enabled(false)
        );
    }

    #[test]
    fn an_empty_fragment_directory_is_not_an_error() {
        let documents = Memory::new()
            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
            .directory("/etc/system.d");

        let config = load(documents).unwrap();

        assert_eq!(config.logging.level, Level::Info);
    }

    #[test]
    fn a_named_directory_that_is_missing_is_an_error() {
        let documents =
            Memory::new().document("/etc/main.toml", "use-system-config = '/etc/system.d'");

        let error = load(documents).unwrap_err();

        let ConfigError::CouldNotRead { path, .. } = error else {
            panic!("expected a read failure, got {error:?}");
        };
        assert_eq!(path, PathBuf::from("/etc/system.d"));
    }

    #[test]
    fn errors_describe_themselves() {
        let missing_value = Memory::new().document(
            "/etc/main.toml",
            "[[sources]]\nkind = 'server'\nversion = 5",
        );
        assert_eq!(
            load(missing_value).unwrap_err().to_string(),
            "`sources[0].address` is required, but was never set"
        );

        let unreadable = Memory::new();
        assert_eq!(
            load(unreadable).unwrap_err().to_string(),
            "could not read `/etc/main.toml`: no such document"
        );

        let set_in_fragment = Memory::new()
            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
            .document("/etc/system.d/10-logging.toml", "use-system-config = true");
        assert_eq!(
            load(set_in_fragment).unwrap_err().to_string(),
            "`/etc/system.d/10-logging.toml` sets `use-system-config`, \
             which only the main configuration may do"
        );
    }

    /// Assert that a document is rejected while parsing, by a complaint that
    /// points at `offender`.
    fn rejects(document: &str, offender: &str) {
        let documents = Memory::new().document("/etc/main.toml", document);
        let error = load(documents).unwrap_err();

        assert!(
            matches!(error, ConfigError::CouldNotParse { .. }),
            "expected {document:?} to be rejected while parsing, got {error:?}"
        );
        assert!(
            error.to_string().contains(offender),
            "the complaint about {document:?} does not mention `{offender}`: {error}"
        );
    }

    #[test]
    fn an_unknown_key_is_rejected_wherever_it_appears() {
        rejects("loggign = 'warn'", "loggign");
        rejects("[logging]\nlevle = 'warn'", "levle");
        rejects(
            "[[sources]]\nkind = 'server'\naddress = 'a'\naddres = 'b'",
            "addres",
        );
        rejects("[[sources]]\nkind = 'nonsense'", "nonsense");
    }

    #[test]
    fn the_default_directory_need_not_exist() {
        let documents = Memory::new().document("/etc/main.toml", "use-system-config = true");

        let config = load(documents).unwrap();

        assert_eq!(config.logging.level, Level::Info);
    }
}