use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use aviso::auth::AuthProvider;
use serde::Deserialize;
use serde_norway as yaml;
use crate::auth as cli_auth;
use crate::exit::usage_error;
use crate::paths;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ConfigFile {
#[serde(default, rename = "base_url")]
pub(crate) _base_url: Option<serde::de::IgnoredAny>,
#[serde(default, rename = "auth")]
pub(crate) _auth: Option<serde::de::IgnoredAny>,
#[serde(default, rename = "timeout")]
pub(crate) _timeout: Option<serde::de::IgnoredAny>,
#[serde(default, rename = "heartbeat_interval")]
pub(crate) _heartbeat_interval: Option<serde::de::IgnoredAny>,
#[serde(default, rename = "tls")]
pub(crate) tls: Option<serde::de::IgnoredAny>,
#[serde(default)]
pub(crate) state_file: Option<PathBuf>,
#[serde(default)]
pub(crate) listeners: Vec<ListenerSpec>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ListenerSpec {
#[serde(default)]
pub(crate) name: Option<String>,
pub(crate) event: String,
#[serde(default)]
pub(crate) identifiers: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub(crate) from_id: Option<u64>,
#[serde(default)]
pub(crate) from_date: Option<String>,
#[serde(default)]
pub(crate) triggers: Vec<aviso::watch::TriggerConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Source {
Flag,
Env,
File,
Default,
}
#[derive(Debug, Clone)]
pub(crate) struct Sourced<T> {
pub(crate) value: T,
pub(crate) source: Source,
}
#[derive(Debug, Clone)]
pub(crate) struct Resolved {
pub(crate) config_path: Sourced<PathBuf>,
pub(crate) state_path: Sourced<PathBuf>,
pub(crate) base_url: Option<Sourced<String>>,
pub(crate) timeout: Option<Sourced<Duration>>,
pub(crate) heartbeat_interval: Option<Sourced<Duration>>,
pub(crate) tls_ca_bundle_paths: Sourced<Vec<PathBuf>>,
pub(crate) tls_danger_accept_invalid_certs: Sourced<bool>,
pub(crate) auth_provider: Option<Arc<dyn AuthProvider>>,
pub(crate) auth_source: Option<&'static str>,
pub(crate) listeners: Vec<ListenerSpec>,
pub(crate) force_json: bool,
pub(crate) verbose: u8,
}
#[allow(
clippy::too_many_arguments,
reason = "the resolver takes one argument per layered field; bundling them into a struct would only add a one-off type with no further consumers"
)]
pub(crate) fn resolve(
cli_config: Option<&PathBuf>,
cli_state_file: Option<&PathBuf>,
cli_base_url: Option<&str>,
cli_token: Option<&str>,
cli_username: Option<&str>,
cli_password: Option<&str>,
cli_ca_bundle: &[PathBuf],
cli_danger_accept_invalid_certs: bool,
cli_force_json: bool,
cli_verbose: u8,
) -> Result<Resolved> {
let env_config_path = read_env("AVISO_CLIENT_CONFIG_FILE")?;
let env_state_path = read_env("AVISO_STATE_FILE")?;
let env_base_url = read_env("AVISO_BASE_URL")?;
let config_path = {
let value = paths::resolve_config_path(cli_config, env_config_path.as_deref())?;
let source = if cli_config.is_some() {
Source::Flag
} else if env_config_path.is_some() {
Source::Env
} else {
Source::Default
};
Sourced { value, source }
};
let loaded = load_optional(&config_path.value)
.with_context(|| format!("at: {}", config_path.value.display()))?;
let file = loaded.parsed;
let settings = loaded.settings;
let state_path = if let Some(p) = cli_state_file {
Sourced {
value: paths::resolve_state_path(Some(p), None)?,
source: Source::Flag,
}
} else if let Some(s) = env_state_path.as_deref() {
Sourced {
value: paths::resolve_state_path(None, Some(s))?,
source: Source::Env,
}
} else if let Some(p) = file.state_file.as_ref() {
Sourced {
value: paths::resolve_state_path(Some(p), None)?,
source: Source::File,
}
} else {
Sourced {
value: paths::resolve_state_path(None, None)?,
source: Source::Default,
}
};
let base_url = cli_base_url
.map(|s| Sourced {
value: s.to_string(),
source: Source::Flag,
})
.or_else(|| {
env_base_url.clone().map(|s| Sourced {
value: s,
source: Source::Env,
})
})
.or_else(|| {
settings.base_url.clone().map(|s| Sourced {
value: s,
source: Source::File,
})
});
let timeout = settings.timeout.map(|v| Sourced {
value: v,
source: Source::File,
});
let heartbeat_interval = settings.heartbeat_interval.map(|v| Sourced {
value: v,
source: Source::File,
});
let (tls_ca_bundle_paths, tls_danger_accept_invalid_certs) = resolve_tls(
cli_ca_bundle,
cli_danger_accept_invalid_certs,
file.tls.is_some().then_some(&settings),
)?;
let flag_provider = cli_auth::provider_from_flags(cli_token, cli_username, cli_password)?;
let (auth_provider, auth_source) =
cli_auth::resolve_provider(flag_provider, &config_path.value, loaded.content)?;
Ok(Resolved {
config_path,
state_path,
base_url,
timeout,
heartbeat_interval,
tls_ca_bundle_paths,
tls_danger_accept_invalid_certs,
auth_provider,
auth_source,
listeners: file.listeners,
force_json: cli_force_json,
verbose: cli_verbose,
})
}
fn resolve_tls(
cli_ca_bundle: &[PathBuf],
cli_danger: bool,
file_tls: Option<&aviso::ClientSettings>,
) -> Result<(Sourced<Vec<PathBuf>>, Sourced<bool>)> {
let ca_bundle = if !cli_ca_bundle.is_empty() {
Sourced {
value: absolutize_all(cli_ca_bundle)?,
source: Source::Flag,
}
} else if let Some(settings) = file_tls {
Sourced {
value: settings.ca_bundle.clone(),
source: Source::File,
}
} else {
Sourced {
value: Vec::new(),
source: Source::Default,
}
};
let danger = if cli_danger {
Sourced {
value: true,
source: Source::Flag,
}
} else if let Some(settings) = file_tls {
Sourced {
value: settings.danger_accept_invalid_certs,
source: Source::File,
}
} else {
Sourced {
value: false,
source: Source::Default,
}
};
Ok((ca_bundle, danger))
}
fn absolutize_all(paths_in: &[PathBuf]) -> Result<Vec<PathBuf>> {
paths_in.iter().map(|p| paths::absolutize(p)).collect()
}
fn read_env(name: &str) -> Result<Option<String>> {
match std::env::var(name) {
Ok(v) if !v.is_empty() => Ok(Some(v)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(raw)) => Err(usage_error(format!(
"env var {name} is set but its value is not valid UTF-8 ({}); set a UTF-8 value or unset the variable",
raw.display()
))),
}
}
pub(crate) struct LoadedConfig {
pub(crate) parsed: ConfigFile,
pub(crate) settings: aviso::ClientSettings,
pub(crate) content: Option<String>,
}
pub(crate) fn load_optional(path: &Path) -> Result<LoadedConfig> {
match std::fs::symlink_metadata(path) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadedConfig {
parsed: ConfigFile::default(),
settings: aviso::ClientSettings::default(),
content: None,
});
}
Err(e) => {
return Err(e).with_context(|| format!("read config file: {}", path.display()));
}
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("read config file: {}", path.display()))?;
let parsed: ConfigFile = yaml::from_str(&content)
.with_context(|| format!("parse config file: {}", path.display()))?;
let settings = aviso::ClientSettings::parse(&content, path)?;
Ok(LoadedConfig {
parsed,
settings,
content: Some(content),
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code: unwrap/expect on yaml round-trip is the expected diagnostic"
)]
mod tests {
use super::*;
fn parse(yaml_text: &str) -> ConfigFile {
yaml::from_str(yaml_text).expect("test YAML should parse")
}
fn settings(yaml_text: &str) -> aviso::ClientSettings {
aviso::ClientSettings::parse(yaml_text, Path::new("/etc/aviso/config.yaml"))
.expect("test YAML should parse as client settings")
}
#[test]
fn parse_empty_yaml_yields_defaults() {
let cfg = parse("");
assert!(cfg.state_file.is_none());
assert!(cfg.listeners.is_empty());
assert_eq!(settings(""), aviso::ClientSettings::default());
}
#[test]
fn parse_full_config_round_trip() {
let yaml_text = r#"
base_url: "https://aviso.example.org"
auth:
bearer_token: "secret"
timeout: 30s
heartbeat_interval: 30s
state_file: /var/lib/aviso/state.json
tls:
danger_accept_invalid_certs: false
listeners:
- name: mars-od
event: mars
identifiers:
class: od
stream: oper
triggers:
- type: echo
"#;
let cfg = parse(yaml_text);
let s = settings(yaml_text);
assert_eq!(s.base_url.as_deref(), Some("https://aviso.example.org"));
assert_eq!(s.timeout, Some(Duration::from_secs(30)));
assert_eq!(s.heartbeat_interval, Some(Duration::from_secs(30)));
assert_eq!(
cfg.state_file,
Some(PathBuf::from("/var/lib/aviso/state.json"))
);
assert!(cfg.tls.is_some(), "tls block present");
assert!(!s.danger_accept_invalid_certs);
assert!(s.ca_bundle.is_empty());
assert_eq!(cfg.listeners.len(), 1);
let listener = &cfg.listeners[0];
assert_eq!(listener.name.as_deref(), Some("mars-od"));
assert_eq!(listener.event, "mars");
assert_eq!(listener.identifiers.len(), 2);
assert_eq!(listener.triggers.len(), 1);
}
#[test]
fn parse_accepts_a_nested_auth_block() {
let cfg = parse("auth:\n basic:\n username: alice\n password: hunter2\n");
assert!(cfg.listeners.is_empty());
}
#[test]
fn parse_rejects_unknown_top_level_field() {
let err = yaml::from_str::<ConfigFile>("bogus_key: 1").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("bogus_key") || msg.contains("unknown field"),
"error should name the bad field: {msg}"
);
}
#[test]
fn debug_output_never_carries_the_auth_block() {
let cfg = parse("auth:\n bearer_token: super-secret-value\n");
let rendered = format!("{cfg:?}");
assert!(
!rendered.contains("super-secret-value"),
"the parsed config must not retain credential material: {rendered}"
);
}
#[test]
fn parse_accepts_an_auth_block_it_does_not_interpret() {
let cfg = parse("auth:\n bogus_key: 1\n");
assert!(cfg.listeners.is_empty());
}
#[test]
fn load_optional_returns_default_when_file_absent() {
let loaded = load_optional(Path::new("/tmp/this-path-does-not-exist-aviso-test")).unwrap();
assert!(loaded.settings.base_url.is_none());
assert!(loaded.content.is_none());
assert!(loaded.parsed.listeners.is_empty());
}
#[test]
fn listeners_list_with_identifiers_field_name() {
let yaml_text = r"
listeners:
- event: mars
identifiers:
class: od
";
let cfg = parse(yaml_text);
assert_eq!(cfg.listeners.len(), 1);
let l = &cfg.listeners[0];
assert_eq!(l.event, "mars");
assert_eq!(l.identifiers.len(), 1);
}
#[test]
fn resolve_tls_absolutizes_relative_cli_ca_bundle_paths() {
let rel = PathBuf::from("aviso-test-relative-flag-ca.pem");
let (bundle, _) = resolve_tls(std::slice::from_ref(&rel), false, None).unwrap();
assert_eq!(bundle.source, Source::Flag);
assert_eq!(bundle.value.len(), 1);
assert!(
bundle.value[0].is_absolute(),
"CA bundle path supplied via flag should be absolutized so error messages quote absolute paths; got {}",
bundle.value[0].display()
);
assert!(
bundle.value[0].ends_with("aviso-test-relative-flag-ca.pem"),
"file name should be preserved; got {}",
bundle.value[0].display()
);
}
#[test]
fn resolve_tls_takes_file_ca_bundle_paths_resolved_against_the_file() {
let s = settings("tls:\n ca_bundle: [aviso-test-relative-file-ca.pem]\n");
let (bundle, _) = resolve_tls(&[], false, Some(&s)).unwrap();
assert_eq!(bundle.source, Source::File);
assert_eq!(
bundle.value,
vec![PathBuf::from("/etc/aviso/aviso-test-relative-file-ca.pem")]
);
}
#[test]
fn resolve_tls_attributes_an_empty_tls_block_to_the_file() {
let s = settings("tls: {}\n");
let (bundle, danger) = resolve_tls(&[], false, Some(&s)).unwrap();
assert_eq!(bundle.source, Source::File);
assert!(bundle.value.is_empty());
assert_eq!(danger.source, Source::File);
}
#[test]
fn resolve_tls_passes_absolute_ca_bundle_paths_through_unchanged() {
let abs = PathBuf::from("/tmp/aviso-test-already-absolute.pem");
let (bundle, _) = resolve_tls(std::slice::from_ref(&abs), false, None).unwrap();
assert_eq!(bundle.value, vec![abs]);
}
}