src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/config.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs264
1 files changed, 264 insertions, 0 deletions
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..3e53975
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,264 @@
+//! Validated JSON configuration, file-compatible with the TypeScript
+//! implementation (`/etc/mattertimesync/config.json`, camelCase keys).
+//!
+//! Unknown fields are rejected loudly via serde's `deny_unknown_fields`;
+//! device membership deliberately lives in controller storage, not here.
+
+use std::fmt;
+use std::path::PathBuf;
+
+use jiff::tz::TimeZone;
+use serde::Deserialize;
+
+pub const DEFAULT_CONFIG_PATH: &str = "/etc/mattertimesync/config.json";
+
+/// Matter FabricDescriptorStruct label limit.
+const FABRIC_LABEL_MAX_LENGTH: usize = 32;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ConfigError {
+ #[error("cannot read configuration file {path}: {source}")]
+ Unreadable {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ #[error("configuration is not valid: {0}")]
+ Invalid(#[from] serde_json::Error),
+ #[error("\"timezone\" must be a valid IANA time-zone name (got {0:?})")]
+ BadTimezone(String),
+ #[error(
+ "\"fabricLabel\" must be a non-empty string of at most {FABRIC_LABEL_MAX_LENGTH} characters"
+ )]
+ BadFabricLabel,
+ #[error(
+ "\"storagePath\" must be an absolute path (got {0:?}); JSON configs get no shell expansion"
+ )]
+ RelativeStoragePath(PathBuf),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LogLevel {
+ Debug,
+ #[default]
+ Info,
+ Warn,
+ Error,
+}
+
+impl From<LogLevel> for log::LevelFilter {
+ fn from(level: LogLevel) -> Self {
+ match level {
+ LogLevel::Debug => log::LevelFilter::Debug,
+ LogLevel::Info => log::LevelFilter::Info,
+ LogLevel::Warn => log::LevelFilter::Warn,
+ LogLevel::Error => log::LevelFilter::Error,
+ }
+ }
+}
+
+impl fmt::Display for LogLevel {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let name = match self {
+ LogLevel::Debug => "debug",
+ LogLevel::Info => "info",
+ LogLevel::Warn => "warn",
+ LogLevel::Error => "error",
+ };
+ f.write_str(name)
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Config {
+ /// Where this configuration was loaded from; not a config field.
+ #[serde(skip)]
+ pub source: PathBuf,
+ /// Directory holding persistent Matter fabric state and service state.
+ /// Contains the controller's private keys; mode 0700.
+ pub storage_path: PathBuf,
+ /// IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset.
+ #[serde(default = "default_timezone")]
+ pub timezone: String,
+ #[serde(default)]
+ pub log_level: LogLevel,
+ /// Fabric label other ecosystems display for this controller (e.g. the
+ /// Apple Home Connected Services subtitle). Must be unique per device.
+ #[serde(default = "default_fabric_label")]
+ pub fabric_label: String,
+}
+
+fn default_timezone() -> String {
+ "America/Chicago".into()
+}
+
+fn default_fabric_label() -> String {
+ "Matter Time Sync".into()
+}
+
+impl Config {
+ pub fn load(path: &std::path::Path) -> Result<Self, ConfigError> {
+ let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Unreadable {
+ path: path.to_owned(),
+ source,
+ })?;
+ let mut config = Self::parse(&raw)?;
+ config.source = path.to_owned();
+ Ok(config)
+ }
+
+ pub fn parse(raw: &str) -> Result<Self, ConfigError> {
+ let config: Config = serde_json::from_str(raw)?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ fn validate(&self) -> Result<(), ConfigError> {
+ // No shell expansion happens on a JSON file, so a "~/..." or relative
+ // path would silently land wherever the process happens to run.
+ if !self.storage_path.is_absolute() {
+ return Err(ConfigError::RelativeStoragePath(self.storage_path.clone()));
+ }
+ // Resolving through jiff's tzdb is the validation; a bare offset or
+ // invented name fails here rather than at 3am on a DST transition.
+ TimeZone::get(&self.timezone)
+ .map_err(|_| ConfigError::BadTimezone(self.timezone.clone()))?;
+ if self.fabric_label.trim().is_empty() || self.fabric_label.len() > FABRIC_LABEL_MAX_LENGTH
+ {
+ return Err(ConfigError::BadFabricLabel);
+ }
+ Ok(())
+ }
+
+ /// Warnings for path components that look like they expected shell
+ /// expansion: a component starting with `~` or containing `$`. Such
+ /// directories can legitimately exist, so these cannot be errors; but
+ /// far more often they mean the config was written expecting a shell to
+ /// expand it, and the data would land in a literal `~foo` directory.
+ pub fn path_warnings(&self) -> Vec<String> {
+ self.storage_path
+ .components()
+ .filter_map(|component| {
+ let text = component.as_os_str().to_string_lossy();
+ let looks_like = if text.starts_with('~') {
+ "a shell tilde"
+ } else if text.contains('$') {
+ "an unexpanded shell variable"
+ } else {
+ return None;
+ };
+ Some(format!(
+ "storagePath component {text:?} looks like {looks_like}; JSON configs get no \
+ shell expansion, so it will be used as a literal directory name"
+ ))
+ })
+ .collect()
+ }
+
+ /// The configured zone, resolved against the system tzdb.
+ pub fn time_zone(&self) -> TimeZone {
+ // Validated at load time; a tzdb that shrinks between then and now is
+ // not a scenario worth threading a Result through every caller for.
+ TimeZone::get(&self.timezone).expect("timezone was validated at config load")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const MINIMAL: &str = r#"{ "storagePath": "/var/lib/mattertimesync" }"#;
+
+ #[test]
+ fn minimal_config_applies_defaults() {
+ let config = Config::parse(MINIMAL).unwrap();
+ assert_eq!(
+ config.storage_path,
+ PathBuf::from("/var/lib/mattertimesync")
+ );
+ assert_eq!(config.timezone, "America/Chicago");
+ assert_eq!(config.log_level, LogLevel::Info);
+ assert_eq!(config.fabric_label, "Matter Time Sync");
+ }
+
+ #[test]
+ fn full_config_round_trips() {
+ let config = Config::parse(
+ r#"{
+ "storagePath": "/tmp/x",
+ "timezone": "Europe/Berlin",
+ "logLevel": "debug",
+ "fabricLabel": "Lakeside Time Sync"
+ }"#,
+ )
+ .unwrap();
+ assert_eq!(config.timezone, "Europe/Berlin");
+ assert_eq!(config.log_level, LogLevel::Debug);
+ assert_eq!(config.fabric_label, "Lakeside Time Sync");
+ }
+
+ #[test]
+ fn unknown_fields_are_rejected_loudly() {
+ // Fields from abandoned designs are rejected, not ignored.
+ for raw in [
+ r#"{ "storagePath": "/x", "nodeId": "1" }"#,
+ r#"{ "storagePath": "/x", "syncIntervalHours": 24 }"#,
+ r#"{ "storagePath": "/x", "unexpected": 1 }"#,
+ ] {
+ let error = Config::parse(raw).unwrap_err();
+ assert!(error.to_string().contains("unknown field"), "{error}");
+ }
+ }
+
+ #[test]
+ fn suspicious_path_components_warn_but_load() {
+ let config = Config::parse(r#"{ "storagePath": "/data/~backup" }"#).unwrap();
+ assert_eq!(config.path_warnings().len(), 1);
+ assert!(config.path_warnings()[0].contains("shell tilde"));
+
+ let config = Config::parse(r#"{ "storagePath": "/var/lib/$USER/mts" }"#).unwrap();
+ assert!(config.path_warnings()[0].contains("unexpanded shell variable"));
+
+ let config = Config::parse(r#"{ "storagePath": "/var/lib/mattertimesync" }"#).unwrap();
+ assert!(config.path_warnings().is_empty());
+ }
+
+ #[test]
+ fn non_absolute_storage_paths_are_rejected() {
+ for path in ["~/mts-storage", "data", "./data"] {
+ let raw = format!(r#"{{ "storagePath": {path:?} }}"#);
+ let error = Config::parse(&raw).unwrap_err();
+ assert!(
+ error.to_string().contains("absolute"),
+ "accepted {path:?}: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn storage_path_is_required() {
+ assert!(Config::parse(r#"{}"#).is_err());
+ }
+
+ #[test]
+ fn invalid_timezones_are_rejected() {
+ for tz in ["Central Time", "", "America/Springfield", "UTC-6"] {
+ let raw = format!(r#"{{ "storagePath": "/x", "timezone": {tz:?} }}"#);
+ assert!(Config::parse(&raw).is_err(), "accepted {tz:?}");
+ }
+ }
+
+ #[test]
+ fn invalid_log_levels_are_rejected() {
+ assert!(Config::parse(r#"{ "storagePath": "/x", "logLevel": "verbose" }"#).is_err());
+ }
+
+ #[test]
+ fn invalid_fabric_labels_are_rejected() {
+ for label in ["", " ", &"x".repeat(33)] {
+ let raw = format!(r#"{{ "storagePath": "/x", "fabricLabel": {label:?} }}"#);
+ assert!(Config::parse(&raw).is_err(), "accepted {label:?}");
+ }
+ }
+}