use anyhow::{Context, Result, anyhow};
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use toml;
use crate::models::upstream::{AppConfig, CosignKeyConfig, MinisignKeyConfig};
use crate::services::trust::{CosignPublicKey, MinisignPublicKey};
use crate::utils::filesystem::atomic_ops::write_atomic;
pub struct ConfigStorage {
config: AppConfig,
config_file: PathBuf,
}
pub struct KeyMergeSummary {
pub imported: usize,
pub deduped: usize,
pub total: usize,
}
pub struct SignatureKeyMergeSummary {
pub minisign: KeyMergeSummary,
pub cosign: KeyMergeSummary,
}
impl ConfigStorage {
pub fn new(config_file: &Path) -> Result<Self> {
let mut storage = Self {
config: AppConfig::default(),
config_file: config_file.to_path_buf(),
};
storage.load_config()?;
Ok(storage)
}
pub fn load_config(&mut self) -> Result<()> {
if !self.config_file.exists() {
return Ok(());
}
let toml_str =
fs::read_to_string(&self.config_file).context("Failed to load config file")?;
self.config = toml::from_str(&toml_str).context("Tried to parse an invalid config")?;
Ok(())
}
pub fn save_config(&self) -> Result<()> {
let toml = toml::to_string_pretty(&self.config).context("Failed to serialize config")?;
write_atomic(&self.config_file, toml.as_bytes()).with_context(|| {
format!("Failed to save config to '{}'", self.config_file.display())
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&self.config_file, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn get_config(&self) -> &AppConfig {
&self.config
}
pub fn merge_trusted_minisign_keys(
&mut self,
keys: &[MinisignPublicKey],
) -> Result<KeyMergeSummary> {
let mut imported = 0_usize;
let mut deduped = 0_usize;
let total;
{
let existing = &mut self.config.trust.minisign_public_keys;
for key in keys {
let normalized = key.key.trim();
if normalized.is_empty() {
continue;
}
let duplicate = existing
.iter()
.any(|k| k.key.trim().eq_ignore_ascii_case(normalized));
if duplicate {
deduped += 1;
continue;
}
existing.push(MinisignKeyConfig {
id: key.id.clone(),
key: normalized.to_string(),
});
imported += 1;
}
total = existing.len();
}
self.save_config()?;
Ok(KeyMergeSummary {
imported,
deduped,
total,
})
}
pub fn merge_trusted_cosign_keys(
&mut self,
keys: &[CosignPublicKey],
) -> Result<KeyMergeSummary> {
let mut imported = 0_usize;
let mut deduped = 0_usize;
let total;
{
let existing = &mut self.config.trust.cosign_public_keys;
for key in keys {
let normalized = key.key.trim();
if normalized.is_empty() {
continue;
}
let duplicate = existing.iter().any(|k| k.key.trim() == normalized);
if duplicate {
deduped += 1;
continue;
}
existing.push(CosignKeyConfig {
id: key.id.clone(),
key: normalized.to_string(),
});
imported += 1;
}
total = existing.len();
}
self.save_config()?;
Ok(KeyMergeSummary {
imported,
deduped,
total,
})
}
pub fn try_set_value(&mut self, key_path: &str, value: &str) -> Result<()> {
if key_path.trim().is_empty() {
return Err(anyhow!("Key path cannot be empty"));
}
let mut root = toml::Value::try_from(&self.config).context("Failed to serialize config")?;
let keys: Vec<&str> = key_path.split('.').collect();
let (path, final_key) = keys.split_at(keys.len() - 1);
let mut current = root
.as_table_mut()
.ok_or_else(|| anyhow!("Config root is not a table"))?;
for key in path {
current = current
.get_mut(*key)
.and_then(toml::Value::as_table_mut)
.ok_or_else(|| anyhow!("Key path not found: {}", key_path))?;
}
let parsed_value = self.convert_value(value)?;
current.insert(final_key[0].to_string(), parsed_value);
self.config = root.try_into().context("Failed to update config")?;
self.save_config().context("Failed to save config")
}
pub fn try_get_value<T>(&self, key_path: &str) -> Result<T>
where
T: DeserializeOwned,
{
let value = self.get_value(key_path)?;
value
.clone()
.try_into()
.with_context(|| format!("Failed to deserialize '{}'", key_path))
}
fn get_value(&self, key_path: &str) -> Result<toml::Value> {
let root = toml::Value::try_from(&self.config).context("Failed to serialize config")?;
let mut current = &root;
for key in key_path.split('.') {
current = current
.get(key)
.ok_or_else(|| anyhow!("Key path not found: {}", key_path))?;
}
Ok(current.clone())
}
pub fn get_flattened_config(&self) -> HashMap<String, String> {
let root =
toml::Value::try_from(&self.config).unwrap_or(toml::Value::Table(Default::default()));
Self::flatten_value(&root, "", 10, 0)
}
pub fn reset_to_defaults(&mut self) -> Result<()> {
self.config = AppConfig::default();
self.save_config()
}
fn flatten_value(
value: &toml::Value,
prefix: &str,
max_depth: usize,
current_depth: usize,
) -> HashMap<String, String> {
let mut result = HashMap::new();
if current_depth >= max_depth {
return result;
}
match value {
toml::Value::String(s) => {
result.insert(prefix.to_string(), s.clone());
}
toml::Value::Integer(i) => {
result.insert(prefix.to_string(), i.to_string());
}
toml::Value::Float(f) => {
result.insert(prefix.to_string(), f.to_string());
}
toml::Value::Boolean(b) => {
result.insert(prefix.to_string(), b.to_string());
}
toml::Value::Table(table) => {
for (key, val) in table {
let new_prefix = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
result.extend(Self::flatten_value(
val,
&new_prefix,
max_depth,
current_depth + 1,
));
}
}
_ => {}
}
result
}
fn convert_value(&self, value: &str) -> Result<toml::Value> {
if let Ok(parsed) = value.parse::<toml::Value>() {
return Ok(parsed);
}
Ok(toml::Value::String(value.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::ConfigStorage;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{fs, io};
fn temp_config_file(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir()
.join(format!("upstream-config-test-{name}-{nanos}"))
.join("config.toml")
}
fn cleanup(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::remove_dir_all(parent)?;
}
Ok(())
}
#[test]
fn new_keeps_defaults_in_memory_when_file_missing() {
let path = temp_config_file("new-default-in-memory");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let storage = ConfigStorage::new(&path).expect("create storage");
assert!(!path.exists());
assert!(storage.get_config().github.api_token.is_none());
assert!(storage.get_config().gitlab.api_token.is_none());
cleanup(&path).expect("cleanup");
}
#[test]
fn set_and_get_nested_values_updates_config() {
let path = temp_config_file("set-get");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let mut storage = ConfigStorage::new(&path).expect("create storage");
storage
.try_set_value("github.api_token", "\"ghp_abc\"")
.expect("set github token");
storage
.try_set_value("gitlab.api_token", "\"abc\"")
.expect("set string literal");
let github_token: Option<String> = storage
.try_get_value("github.api_token")
.expect("read github token");
let token: Option<String> = storage
.try_get_value("gitlab.api_token")
.expect("read token");
assert_eq!(github_token.as_deref(), Some("ghp_abc"));
assert_eq!(token.as_deref(), Some("abc"));
cleanup(&path).expect("cleanup");
}
#[test]
fn flattened_config_contains_dot_notation_keys() {
let path = temp_config_file("flatten");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let storage = ConfigStorage::new(&path).expect("create storage");
let flat = storage.get_flattened_config();
assert!(!flat.contains_key("github.rate_limit"));
assert!(!flat.contains_key("gitlab.rate_limit"));
cleanup(&path).expect("cleanup");
}
#[test]
fn old_rate_limit_keys_are_ignored_when_loading_config() {
let path = temp_config_file("legacy-rate-limit");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(
&path,
r#"
[github]
api_token = "ghp_abc"
rate_limit = 1234
[gitlab]
rate_limit = 5678
"#,
)
.expect("write config");
let storage = ConfigStorage::new(&path).expect("load legacy config");
assert_eq!(
storage.get_config().github.api_token.as_deref(),
Some("ghp_abc")
);
assert!(storage.get_config().gitlab.api_token.is_none());
cleanup(&path).expect("cleanup");
}
#[test]
fn set_value_rejects_unknown_paths() {
let path = temp_config_file("bad-path");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let mut storage = ConfigStorage::new(&path).expect("create storage");
let err = storage
.try_set_value("github.missing.field", "1")
.expect_err("must reject unknown path");
assert!(err.to_string().contains("Key path not found"));
cleanup(&path).expect("cleanup");
}
#[test]
fn reset_to_defaults_restores_default_values() {
let path = temp_config_file("reset");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let mut storage = ConfigStorage::new(&path).expect("create storage");
storage
.try_set_value("github.api_token", "\"ghp_abc\"")
.expect("set override");
storage.reset_to_defaults().expect("reset defaults");
assert!(storage.get_config().github.api_token.is_none());
cleanup(&path).expect("cleanup");
}
}