use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::error::{Error, Result};
pub const DEFAULT_ENDPOINT: &str = "https://logs.autonomi.com";
pub const DEFAULT_INDEX_PREFIX: &str = "beta-nodes";
const CONFIG_FILENAME: &str = "log_forward.json";
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_uppercase().as_str() {
"TRACE" => Some(Self::Trace),
"DEBUG" => Some(Self::Debug),
"INFO" => Some(Self::Info),
"WARN" | "WARNING" => Some(Self::Warn),
"ERROR" => Some(Self::Error),
_ => None,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Trace => "TRACE",
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for LogLevel {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s).ok_or_else(|| {
Error::LogForward(format!(
"unknown log level '{s}' (expected one of: trace, debug, info, warn, error)"
))
})
}
}
const fn default_min_level() -> LogLevel {
LogLevel::Info
}
fn default_endpoint() -> String {
DEFAULT_ENDPOINT.to_string()
}
fn default_index_prefix() -> String {
DEFAULT_INDEX_PREFIX.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogForwardConfig {
pub enabled: bool,
#[serde(default)]
pub token: String,
#[serde(default = "default_endpoint")]
pub endpoint: String,
#[serde(default = "default_index_prefix")]
pub index_prefix: String,
#[serde(default = "default_min_level")]
pub min_level: LogLevel,
#[serde(default)]
pub installation_id: String,
}
impl Default for LogForwardConfig {
fn default() -> Self {
Self::disabled()
}
}
impl LogForwardConfig {
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
token: String::new(),
endpoint: default_endpoint(),
index_prefix: default_index_prefix(),
min_level: default_min_level(),
installation_id: String::new(),
}
}
pub fn ensure_installation_id(&mut self) {
if self.installation_id.is_empty() {
self.installation_id = generate_installation_id();
}
}
pub fn default_path() -> Result<PathBuf> {
Ok(config::config_dir()?.join(CONFIG_FILENAME))
}
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::disabled());
}
let contents = std::fs::read_to_string(path)?;
let config: Self = serde_json::from_str(&contents)?;
Ok(config)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)?;
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &contents)?;
restrict_to_owner(&tmp_path)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.installation_id.is_empty() {
return Err(Error::LogForward(
"internal: installation id was not generated before enabling".into(),
));
}
if self.token.trim().is_empty() {
return Err(Error::LogForward(
"a write token is required: ant node logs forward enable --token <token>".into(),
));
}
if !self.endpoint.starts_with("http://") && !self.endpoint.starts_with("https://") {
return Err(Error::LogForward(format!(
"endpoint must be an http(s) URL, got '{}'",
self.endpoint
)));
}
if self.index_prefix.trim().is_empty() {
return Err(Error::LogForward("index prefix must not be empty".into()));
}
Ok(())
}
#[must_use]
pub fn token_fingerprint(&self) -> Option<String> {
if self.token.trim().is_empty() {
return None;
}
let digest = blake3::hash(self.token.as_bytes());
Some(digest.to_hex()[..12].to_string())
}
#[must_use]
pub fn endpoint_base(&self) -> &str {
self.endpoint.trim_end_matches('/')
}
}
fn generate_installation_id() -> String {
use rand::Rng;
let bytes: [u8; 8] = rand::thread_rng().gen();
bytes.iter().fold(String::with_capacity(16), |mut acc, b| {
use std::fmt::Write;
let _ = write!(acc, "{b:02x}");
acc
})
}
#[cfg(unix)]
fn restrict_to_owner(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(not(unix))]
fn restrict_to_owner(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_config() -> LogForwardConfig {
LogForwardConfig {
enabled: true,
token: "test-api-key".to_string(),
installation_id: "0123456789abcdef".to_string(),
..LogForwardConfig::disabled()
}
}
#[test]
fn levels_order_by_severity() {
assert!(LogLevel::Trace < LogLevel::Debug);
assert!(LogLevel::Debug < LogLevel::Info);
assert!(LogLevel::Info < LogLevel::Warn);
assert!(LogLevel::Warn < LogLevel::Error);
}
#[test]
fn level_parses_both_log_formats_and_config_casing() {
assert_eq!(LogLevel::parse("INFO"), Some(LogLevel::Info));
assert_eq!(LogLevel::parse("info"), Some(LogLevel::Info));
assert_eq!(LogLevel::parse(" WARN "), Some(LogLevel::Warn));
assert_eq!(LogLevel::parse("WARNING"), Some(LogLevel::Warn));
assert_eq!(LogLevel::parse("nonsense"), None);
}
#[test]
fn level_from_str_reports_the_accepted_values() {
let err = "verbose".parse::<LogLevel>().unwrap_err().to_string();
assert!(err.contains("trace, debug, info, warn, error"), "{err}");
}
#[test]
fn missing_file_loads_as_disabled() {
let tmp = tempfile::tempdir().unwrap();
let config = LogForwardConfig::load(&tmp.path().join("absent.json")).unwrap();
assert_eq!(config, LogForwardConfig::disabled());
assert!(!config.enabled);
}
#[test]
fn corrupt_file_is_an_error_rather_than_a_silent_reset() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log_forward.json");
std::fs::write(&path, "{ not json").unwrap();
assert!(LogForwardConfig::load(&path).is_err());
}
#[test]
fn save_then_load_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("log_forward.json");
let config = enabled_config();
config.save(&path).unwrap();
assert_eq!(LogForwardConfig::load(&path).unwrap(), config);
}
#[test]
fn load_tolerates_a_config_missing_the_optional_fields() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log_forward.json");
std::fs::write(&path, r#"{"enabled":true,"token":"k"}"#).unwrap();
let config = LogForwardConfig::load(&path).unwrap();
assert!(config.enabled);
assert_eq!(config.endpoint, DEFAULT_ENDPOINT);
assert_eq!(config.index_prefix, DEFAULT_INDEX_PREFIX);
assert_eq!(config.min_level, LogLevel::Info);
}
#[cfg(unix)]
#[test]
fn saved_config_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log_forward.json");
enabled_config().save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"token file must not be group/world readable"
);
}
#[cfg(unix)]
#[test]
fn overwriting_an_existing_config_keeps_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log_forward.json");
std::fs::write(&path, "{}").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
enabled_config().save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn validate_rejects_a_missing_token() {
let config = LogForwardConfig {
token: " ".to_string(),
..enabled_config()
};
assert!(config.validate().unwrap_err().to_string().contains("token"));
}
#[test]
fn an_installation_id_is_generated_once_and_then_left_alone() {
let mut config = LogForwardConfig::disabled();
assert!(config.installation_id.is_empty());
config.ensure_installation_id();
let first = config.installation_id.clone();
assert_eq!(first.len(), 16, "64 bits rendered as hex");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
config.ensure_installation_id();
assert_eq!(config.installation_id, first);
}
#[test]
fn separate_installations_get_different_ids() {
let mut a = LogForwardConfig::disabled();
let mut b = LogForwardConfig::disabled();
a.ensure_installation_id();
b.ensure_installation_id();
assert_ne!(a.installation_id, b.installation_id);
}
#[test]
fn the_installation_id_survives_a_save_and_reload() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log_forward.json");
let config = enabled_config();
config.save(&path).unwrap();
assert_eq!(
LogForwardConfig::load(&path).unwrap().installation_id,
config.installation_id
);
}
#[test]
fn validate_rejects_a_non_http_endpoint() {
let config = LogForwardConfig {
endpoint: "logs.autonomi.com".to_string(),
..enabled_config()
};
assert!(config
.validate()
.unwrap_err()
.to_string()
.contains("http(s) URL"));
}
#[test]
fn validate_accepts_the_defaults_with_a_token() {
enabled_config().validate().unwrap();
}
#[test]
fn fingerprint_is_stable_absent_for_no_token_and_leaks_nothing() {
let config = enabled_config();
let fingerprint = config.token_fingerprint().unwrap();
assert_eq!(fingerprint.len(), 12);
assert_eq!(config.token_fingerprint().unwrap(), fingerprint);
assert!(!fingerprint.contains("test-api-key"));
let other = LogForwardConfig {
token: "a-different-key".to_string(),
..enabled_config()
};
assert_ne!(other.token_fingerprint().unwrap(), fingerprint);
assert_eq!(LogForwardConfig::disabled().token_fingerprint(), None);
}
#[test]
fn endpoint_base_strips_a_trailing_slash() {
let config = LogForwardConfig {
endpoint: "https://logs.autonomi.com/".to_string(),
..enabled_config()
};
assert_eq!(config.endpoint_base(), "https://logs.autonomi.com");
}
#[test]
fn fingerprint_rather_than_token_is_what_status_can_show() {
let config = enabled_config();
let fingerprint = config.token_fingerprint().unwrap();
assert!(!fingerprint.contains(&config.token));
}
}