diff options
| author | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
| commit | 07d4713f554b2ae2ccf4871f6be0590c129342b0 (patch) | |
| tree | e9675e5e8a7cfce544927339c8f93ee7f412a71b /src/config.rs | |
Implement mattertimesync: one-shot Matter time synchronization CLI
A standalone CLI Matter controller that joins Matter devices as a
secondary administrator (multi-admin) and sets their clocks via the
standard Time Synchronization cluster. Built on rs-matter 0.2.0, the
official CSA Rust Matter stack: its PASE/CASE initiators, Commissioner
flow, builtin mDNS, and generated cluster clients. One-shot runs from a
systemd timer; there is no daemon.
Hardware-validated end to end (commission, inspect, sync, decommission)
against an IKEA ALPSTUGA air quality monitor over Thread, commissioned
alongside Apple Home. Design and operation are documented in the
README. 34 unit tests, clippy clean.
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 264 |
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:?}"); + } + } +} |
