use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use serde::Deserialize;
use tracing::{debug, warn};
use super::provider::DnsProviderError;
#[derive(Debug, Default)]
pub struct CredentialLoader;
impl CredentialLoader {
pub fn load_from_file(path: &Path) -> Result<Credentials, DnsProviderError> {
#[cfg(unix)]
{
let metadata = fs::metadata(path).map_err(|e| {
DnsProviderError::Credentials(format!(
"Failed to read credentials file '{}': {}",
path.display(),
e
))
})?;
let mode = metadata.permissions().mode();
let file_mode = mode & 0o777;
if file_mode & 0o077 != 0 {
warn!(
path = %path.display(),
mode = format!("{:o}", file_mode),
"Credentials file has overly permissive permissions (should be 0600 or 0400)"
);
}
}
let content = fs::read_to_string(path).map_err(|e| {
DnsProviderError::Credentials(format!(
"Failed to read credentials file '{}': {}",
path.display(),
e
))
})?;
Self::parse_credentials(&content, path)
}
pub fn load_from_env(var_name: &str) -> Result<Credentials, DnsProviderError> {
let value = std::env::var(var_name).map_err(|_| {
DnsProviderError::Credentials(format!("Environment variable '{}' not set", var_name))
})?;
if value.trim().starts_with('{') {
Self::parse_json_credentials(&value)
} else {
Ok(Credentials::Token(value.trim().to_string()))
}
}
fn parse_credentials(content: &str, path: &Path) -> Result<Credentials, DnsProviderError> {
let trimmed = content.trim();
if trimmed.starts_with('{') {
return Self::parse_json_credentials(trimmed);
}
if trimmed.is_empty() {
return Err(DnsProviderError::Credentials(format!(
"Credentials file '{}' is empty",
path.display()
)));
}
debug!(path = %path.display(), "Loaded credentials as plain text token");
Ok(Credentials::Token(trimmed.to_string()))
}
fn parse_json_credentials(json: &str) -> Result<Credentials, DnsProviderError> {
#[derive(Deserialize)]
struct TokenFormat {
token: Option<String>,
api_token: Option<String>,
}
#[derive(Deserialize)]
struct KeySecretFormat {
api_key: String,
api_secret: String,
}
#[derive(Deserialize)]
struct ApiKeyOnlyFormat {
api_key: Option<String>,
}
if let Ok(parsed) = serde_json::from_str::<KeySecretFormat>(json) {
debug!("Loaded credentials as JSON key+secret");
return Ok(Credentials::KeySecret {
key: parsed.api_key,
secret: parsed.api_secret,
});
}
if let Ok(parsed) = serde_json::from_str::<TokenFormat>(json) {
if let Some(token) = parsed.token.or(parsed.api_token) {
debug!("Loaded credentials as JSON token");
return Ok(Credentials::Token(token));
}
}
if let Ok(parsed) = serde_json::from_str::<ApiKeyOnlyFormat>(json) {
if let Some(key) = parsed.api_key {
debug!("Loaded credentials as JSON api_key token");
return Ok(Credentials::Token(key));
}
}
Err(DnsProviderError::Credentials(
"Invalid JSON credentials format. Expected {\"token\": \"...\"} or {\"api_key\": \"...\", \"api_secret\": \"...\"}".to_string()
))
}
}
#[derive(Debug, Clone)]
pub enum Credentials {
Token(String),
KeySecret { key: String, secret: String },
}
impl Credentials {
pub fn token(&self) -> Option<&str> {
match self {
Credentials::Token(t) => Some(t),
Credentials::KeySecret { .. } => None,
}
}
pub fn key(&self) -> Option<&str> {
match self {
Credentials::KeySecret { key, .. } => Some(key),
Credentials::Token(_) => None,
}
}
pub fn secret(&self) -> Option<&str> {
match self {
Credentials::KeySecret { secret, .. } => Some(secret),
Credentials::Token(_) => None,
}
}
pub fn as_bearer_token(&self) -> &str {
match self {
Credentials::Token(t) => t,
Credentials::KeySecret { key, .. } => key,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_load_json_token() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"token": "test-token-123"}}"#).unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.token(), Some("test-token-123"));
}
#[test]
fn test_load_json_api_token() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"api_token": "api-token-456"}}"#).unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.token(), Some("api-token-456"));
}
#[test]
fn test_load_json_key_secret() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"{{"api_key": "key123", "api_secret": "secret456"}}"#
)
.unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.key(), Some("key123"));
assert_eq!(creds.secret(), Some("secret456"));
}
#[test]
fn test_load_plain_text() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "plain-text-token").unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.token(), Some("plain-text-token"));
}
#[test]
fn test_load_plain_text_with_whitespace() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, " token-with-spaces \n").unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.token(), Some("token-with-spaces"));
}
#[test]
fn test_empty_file_error() {
let file = NamedTempFile::new().unwrap();
let result = CredentialLoader::load_from_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_invalid_json_error() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"invalid": "format"}}"#).unwrap();
let result = CredentialLoader::load_from_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_load_json_api_key_only() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"api_key": "just-a-key"}}"#).unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.token(), Some("just-a-key"));
}
#[test]
fn test_load_json_with_extra_fields() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"{{"token": "my-token", "extra": "field", "another": 123}}"#
)
.unwrap();
let creds = CredentialLoader::load_from_file(file.path()).unwrap();
assert_eq!(creds.token(), Some("my-token"));
}
#[test]
fn test_credentials_as_bearer_token() {
let token_creds = Credentials::Token("my-token".to_string());
assert_eq!(token_creds.as_bearer_token(), "my-token");
let key_secret_creds = Credentials::KeySecret {
key: "my-key".to_string(),
secret: "my-secret".to_string(),
};
assert_eq!(key_secret_creds.as_bearer_token(), "my-key");
}
#[test]
fn test_credentials_accessors() {
let token_creds = Credentials::Token("my-token".to_string());
assert_eq!(token_creds.token(), Some("my-token"));
assert_eq!(token_creds.key(), None);
assert_eq!(token_creds.secret(), None);
let key_secret_creds = Credentials::KeySecret {
key: "my-key".to_string(),
secret: "my-secret".to_string(),
};
assert_eq!(key_secret_creds.token(), None);
assert_eq!(key_secret_creds.key(), Some("my-key"));
assert_eq!(key_secret_creds.secret(), Some("my-secret"));
}
#[test]
fn test_load_from_env() {
std::env::set_var("TEST_DNS_TOKEN_12345", "env-token-value");
let creds = CredentialLoader::load_from_env("TEST_DNS_TOKEN_12345").unwrap();
assert_eq!(creds.token(), Some("env-token-value"));
std::env::remove_var("TEST_DNS_TOKEN_12345");
}
#[test]
fn test_load_from_env_json() {
std::env::set_var("TEST_DNS_JSON_12345", r#"{"token": "json-env-token"}"#);
let creds = CredentialLoader::load_from_env("TEST_DNS_JSON_12345").unwrap();
assert_eq!(creds.token(), Some("json-env-token"));
std::env::remove_var("TEST_DNS_JSON_12345");
}
#[test]
fn test_load_from_env_not_set() {
let result = CredentialLoader::load_from_env("NONEXISTENT_VAR_12345");
assert!(result.is_err());
}
#[test]
fn test_nonexistent_file() {
let result = CredentialLoader::load_from_file(std::path::Path::new(
"/nonexistent/path/to/creds.json",
));
assert!(result.is_err());
}
#[test]
fn test_whitespace_only_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, " \n\t \n").unwrap();
let result = CredentialLoader::load_from_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_malformed_json() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"token": "unclosed"#).unwrap();
let result = CredentialLoader::load_from_file(file.path());
assert!(result.is_err());
}
}