use std::path::Path;
use serde::Deserialize;
use crate::error::SynqroError;
const SENTINEL_REPLACE_ME: &str = "REPLACE_ME";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceConfig {
pub provider: String,
pub owner: String,
pub repo: String,
pub branch: String,
pub manifest_path: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
pub token_source: String,
pub env_var: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CryptoConfig {
pub release_signing_pubkey: String,
pub github_api_cert_pin: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfig {
pub check_interval_seconds: u64,
pub max_retries: u32,
pub retry_backoff_base_seconds: u64,
pub download_timeout_seconds: u64,
pub max_payload_size_bytes: u64,
pub staging_dir: String,
pub backup_dir: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RollbackConfig {
pub enabled: bool,
pub health_check_timeout_seconds: u64,
pub max_backup_versions: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReportingConfig {
pub enabled: bool,
pub telegram_token_source: String,
pub telegram_token_env_var: String,
pub telegram_chat_id: String,
pub scrub_patterns: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoggingConfig {
pub level: String,
pub structured: bool,
pub audit_log_path: String,
pub audit_hmac_enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SynqroConfig {
pub version: String,
pub installation_id: String,
pub source: SourceConfig,
pub auth: AuthConfig,
pub crypto: CryptoConfig,
pub update: UpdateConfig,
pub rollback: RollbackConfig,
pub reporting: ReportingConfig,
pub logging: LoggingConfig,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SynqroConfigFile {
#[serde(rename = "axiomota")]
axiomota: SynqroConfig,
}
fn validate_config(cfg: &SynqroConfig) -> Result<(), SynqroError> {
if cfg.update.check_interval_seconds < 60 {
return Err(SynqroError::Config(format!(
"update.check_interval_seconds is {} but must be >= 60",
cfg.update.check_interval_seconds
)));
}
if cfg.update.download_timeout_seconds == 0 {
return Err(SynqroError::Config(
"update.download_timeout_seconds must be > 0".into(),
));
}
if cfg.update.max_payload_size_bytes == 0 {
return Err(SynqroError::Config(
"update.max_payload_size_bytes must be > 0".into(),
));
}
if cfg.update.staging_dir.is_empty() {
return Err(SynqroError::Config(
"update.staging_dir must not be empty".into(),
));
}
if cfg.update.backup_dir.is_empty() {
return Err(SynqroError::Config(
"update.backup_dir must not be empty".into(),
));
}
if cfg.crypto.release_signing_pubkey.trim() == SENTINEL_REPLACE_ME
|| cfg.crypto.release_signing_pubkey.trim().is_empty()
{
return Err(SynqroError::Config(
"crypto.release_signing_pubkey must be set to a real Ed25519 public key; \
remove the REPLACE_ME placeholder before deployment"
.into(),
));
}
if cfg.crypto.github_api_cert_pin.trim() == SENTINEL_REPLACE_ME
|| cfg.crypto.github_api_cert_pin.trim().is_empty()
{
return Err(SynqroError::Config(
"crypto.github_api_cert_pin must be set to a real SHA-256 fingerprint; \
remove the REPLACE_ME placeholder before deployment"
.into(),
));
}
if cfg.auth.env_var.trim() == SENTINEL_REPLACE_ME || cfg.auth.env_var.trim().is_empty() {
return Err(SynqroError::Config(
"auth.env_var must not be empty or a REPLACE_ME sentinel".into(),
));
}
if cfg.installation_id.trim().is_empty() || cfg.installation_id.trim() == SENTINEL_REPLACE_ME {
return Err(SynqroError::Config(
"installation_id must be set to a unique identifier".into(),
));
}
if cfg.source.provider.is_empty() {
return Err(SynqroError::Config(
"source.provider must not be empty".into(),
));
}
if cfg.source.owner.is_empty() {
return Err(SynqroError::Config("source.owner must not be empty".into()));
}
if cfg.source.repo.is_empty() {
return Err(SynqroError::Config("source.repo must not be empty".into()));
}
if cfg.source.branch.is_empty() {
return Err(SynqroError::Config(
"source.branch must not be empty".into(),
));
}
if cfg.source.manifest_path.is_empty() {
return Err(SynqroError::Config(
"source.manifest_path must not be empty".into(),
));
}
if cfg.version.is_empty() {
return Err(SynqroError::Config("version must not be empty".into()));
}
if cfg.rollback.enabled && cfg.rollback.health_check_timeout_seconds == 0 {
return Err(SynqroError::Config(
"rollback.health_check_timeout_seconds must be > 0 when rollback is enabled".into(),
));
}
if cfg.reporting.enabled {
if cfg.reporting.telegram_token_env_var.trim().is_empty()
|| cfg.reporting.telegram_token_env_var.trim() == SENTINEL_REPLACE_ME
{
return Err(SynqroError::Config(
"reporting.telegram_token_env_var must be configured when reporting is enabled"
.into(),
));
}
if cfg.reporting.telegram_chat_id.trim().is_empty() {
return Err(SynqroError::Config(
"reporting.telegram_chat_id must be set when reporting is enabled".into(),
));
}
}
const VALID_LOG_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
if !VALID_LOG_LEVELS.contains(&cfg.logging.level.to_lowercase().as_str()) {
return Err(SynqroError::Config(format!(
"logging.level '{}' is not valid; expected one of: trace, debug, info, warn, error",
cfg.logging.level
)));
}
if cfg.logging.audit_log_path.is_empty() {
return Err(SynqroError::Config(
"logging.audit_log_path must not be empty".into(),
));
}
Ok(())
}
pub fn load_config(path: &Path) -> Result<SynqroConfig, SynqroError> {
let raw = std::fs::read_to_string(path)?;
let file: SynqroConfigFile = serde_yaml::from_str(&raw)?;
let cfg = file.axiomota;
validate_config(&cfg)?;
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_valid_yaml() -> &'static str {
r#"
axiomota:
version: "1.0"
installation_id: "550e8400-e29b-41d4-a716-446655440000"
source:
provider: "github"
owner: "example-org"
repo: "my-service"
branch: "main"
manifest_path: "releases/synqro_manifest.json"
auth:
token_source: "env"
env_var: "SYNQRO_GITHUB_TOKEN"
crypto:
release_signing_pubkey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
github_api_cert_pin: "abc123def456abc123def456abc123def456abc123def456abc123def456ab12"
update:
check_interval_seconds: 300
max_retries: 3
retry_backoff_base_seconds: 5
download_timeout_seconds: 60
max_payload_size_bytes: 104857600
staging_dir: ".synqro_cache/staging"
backup_dir: ".synqro_cache/backup"
rollback:
enabled: true
health_check_timeout_seconds: 30
max_backup_versions: 3
reporting:
enabled: false
telegram_token_source: "env"
telegram_token_env_var: "SYNQRO_TELEGRAM_TOKEN"
telegram_chat_id: ""
scrub_patterns: []
logging:
level: "info"
structured: true
audit_log_path: "/var/log/synqro/audit.log"
audit_hmac_enabled: true
"#
}
fn parse_inline(yaml: &str) -> Result<SynqroConfig, SynqroError> {
let file: SynqroConfigFile = serde_yaml::from_str(yaml)?;
let cfg = file.axiomota;
validate_config(&cfg)?;
Ok(cfg)
}
#[test]
fn valid_config_parses_ok() {
assert!(parse_inline(minimal_valid_yaml()).is_ok());
}
#[test]
fn rejects_check_interval_below_60() {
let yaml = minimal_valid_yaml()
.replace("check_interval_seconds: 300", "check_interval_seconds: 59");
let err = parse_inline(&yaml).unwrap_err();
assert!(matches!(err, SynqroError::Config(_)), "{:?}", err);
}
#[test]
fn rejects_sentinel_pubkey() {
let yaml = minimal_valid_yaml()
.replace("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "REPLACE_ME");
let err = parse_inline(&yaml).unwrap_err();
assert!(matches!(err, SynqroError::Config(_)), "{:?}", err);
}
#[test]
fn rejects_empty_pubkey() {
let yaml = minimal_valid_yaml().replace(
"release_signing_pubkey: \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"",
"release_signing_pubkey: \"\"",
);
let err = parse_inline(&yaml).unwrap_err();
assert!(matches!(err, SynqroError::Config(_)), "{:?}", err);
}
#[test]
fn rejects_invalid_log_level() {
let yaml = minimal_valid_yaml().replace("level: \"info\"", "level: \"verbose\"");
let err = parse_inline(&yaml).unwrap_err();
assert!(matches!(err, SynqroError::Config(_)), "{:?}", err);
}
#[test]
fn rejects_rollback_with_zero_timeout() {
let yaml = minimal_valid_yaml().replace(
"health_check_timeout_seconds: 30",
"health_check_timeout_seconds: 0",
);
let err = parse_inline(&yaml).unwrap_err();
assert!(matches!(err, SynqroError::Config(_)), "{:?}", err);
}
#[test]
fn config_values_are_accessible() {
let cfg = parse_inline(minimal_valid_yaml()).expect("valid config");
assert_eq!(cfg.version, "1.0");
assert_eq!(cfg.source.provider, "github");
assert_eq!(cfg.update.check_interval_seconds, 300);
assert!(cfg.rollback.enabled);
assert!(!cfg.reporting.enabled);
assert!(cfg.logging.audit_hmac_enabled);
}
}