use hush_core::PublicKey;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
const DEFAULT_CURATOR_KEYS_HEX: &[&str] = &[
"b51f6b9b8b2fcf77fb365f8a191579483c92af88ed914d6f79f08784699411ed",
];
const ENV_VAR: &str = "CLAWDSTRIKE_TRUSTED_CURATORS";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CuratorConfigFile {
#[serde(default)]
pub trusted_keys: Vec<String>,
}
impl CuratorConfigFile {
pub fn load(path: impl AsRef<Path>) -> Result<Option<Self>> {
let path = path.as_ref();
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path).map_err(|e| {
Error::ConfigError(format!(
"Failed to read curator config {}: {}",
path.display(),
e
))
})?;
let config: Self = toml::from_str(&contents).map_err(|e| {
Error::ConfigError(format!(
"Failed to parse curator config {}: {}",
path.display(),
e
))
})?;
Ok(Some(config))
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
Error::ConfigError(format!(
"Failed to create config directory {}: {}",
parent.display(),
e
))
})?;
}
let contents = toml::to_string_pretty(self)
.map_err(|e| Error::ConfigError(format!("Failed to serialize curator config: {e}")))?;
std::fs::write(path, contents).map_err(|e| {
Error::ConfigError(format!(
"Failed to write curator config {}: {}",
path.display(),
e
))
})?;
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct CuratorTrustSet {
keys: Vec<PublicKey>,
}
impl CuratorTrustSet {
pub fn defaults() -> Result<Self> {
let keys = parse_hex_keys(DEFAULT_CURATOR_KEYS_HEX.iter().copied())?;
Ok(Self { keys })
}
pub fn load(config_path: Option<&Path>) -> Result<Self> {
let mut hex_keys: Vec<String> = DEFAULT_CURATOR_KEYS_HEX
.iter()
.map(|s| (*s).to_string())
.collect();
if let Some(path) = config_path {
if let Some(file_config) = CuratorConfigFile::load(path)? {
hex_keys.extend(file_config.trusted_keys);
}
}
if let Ok(env_val) = std::env::var(ENV_VAR) {
let mut env_keys = Vec::new();
for chunk in env_val.split(',') {
let trimmed = chunk.trim();
if !trimmed.is_empty() {
env_keys.push(trimmed.to_string());
}
}
if !env_keys.is_empty() {
tracing::warn!(
count = env_keys.len(),
"loaded curator trust keys from CLAWDSTRIKE_TRUSTED_CURATORS env var"
);
hex_keys.extend(env_keys);
}
}
hex_keys.sort();
hex_keys.dedup();
let keys = parse_hex_keys(hex_keys.iter().map(|s| s.as_str()))?;
Ok(Self { keys })
}
pub fn keys(&self) -> &[PublicKey] {
&self.keys
}
pub fn contains(&self, key: &PublicKey) -> bool {
self.keys.iter().any(|k| k == key)
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
fn parse_hex_keys<'a>(hex_iter: impl Iterator<Item = &'a str>) -> Result<Vec<PublicKey>> {
let mut keys = Vec::new();
for hex in hex_iter {
let hex = hex.strip_prefix("0x").unwrap_or(hex);
let pk = PublicKey::from_hex(hex)
.map_err(|e| Error::ConfigError(format!("Invalid curator public key '{hex}': {e}")))?;
keys.push(pk);
}
Ok(keys)
}
pub fn default_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".clawdstrike").join("trusted_curators.toml"))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrustLevel {
Full,
AuditOnly,
}
fn default_trust_level() -> TrustLevel {
TrustLevel::AuditOnly
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CuratorEntry {
pub name: String,
pub public_key: String,
#[serde(default = "default_trust_level")]
pub trust_level: TrustLevel,
#[serde(default)]
pub feed_ids: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RichCuratorConfigFile {
#[serde(default)]
pub curator: Vec<CuratorEntry>,
}
#[derive(Clone, Debug)]
pub struct ValidatedCurator {
pub name: String,
pub public_key: PublicKey,
pub trust_level: TrustLevel,
pub feed_ids: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct CuratorConfig {
entries: Vec<ValidatedCurator>,
}
impl CuratorConfig {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
Error::ConfigError(format!(
"Failed to read curator config at {}: {}",
path.display(),
e
))
})?;
Self::parse(&content)
}
pub fn load_default() -> Result<Self> {
if let Some(path) = default_config_path() {
if path.exists() {
return Self::load(&path);
}
}
Ok(Self {
entries: Vec::new(),
})
}
pub fn parse(toml_str: &str) -> Result<Self> {
let file: RichCuratorConfigFile = toml::from_str(toml_str)
.map_err(|e| Error::ConfigError(format!("Invalid curator config TOML: {e}")))?;
let mut entries = Vec::with_capacity(file.curator.len());
for entry in file.curator {
let hex = entry
.public_key
.strip_prefix("0x")
.unwrap_or(&entry.public_key);
let pk = PublicKey::from_hex(hex).map_err(|e| {
Error::ConfigError(format!(
"Invalid public key for curator '{}': {}",
entry.name, e
))
})?;
entries.push(ValidatedCurator {
name: entry.name,
public_key: pk,
trust_level: entry.trust_level,
feed_ids: entry.feed_ids,
});
}
Ok(Self { entries })
}
pub fn public_keys(&self) -> Vec<PublicKey> {
self.entries.iter().map(|e| e.public_key.clone()).collect()
}
pub fn public_keys_for_feed(&self, feed_id: &str) -> Vec<PublicKey> {
self.entries
.iter()
.filter(|e| e.feed_ids.is_empty() || e.feed_ids.iter().any(|f| f == feed_id))
.map(|e| e.public_key.clone())
.collect()
}
pub fn find_curator(&self, public_key: &PublicKey) -> Option<&ValidatedCurator> {
self.entries.iter().find(|e| &e.public_key == public_key)
}
pub fn curators(&self) -> &[ValidatedCurator] {
&self.entries
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_loads_embedded_key() {
let trust = CuratorTrustSet::defaults().expect("should load defaults");
assert_eq!(trust.len(), 1);
assert_eq!(
trust.keys()[0].to_hex(),
"b51f6b9b8b2fcf77fb365f8a191579483c92af88ed914d6f79f08784699411ed"
);
}
#[test]
fn config_file_roundtrip() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("curators.toml");
let keypair = hush_core::Keypair::generate();
let hex = keypair.public_key().to_hex();
let config = CuratorConfigFile {
trusted_keys: vec![hex.clone()],
};
config.save(&path).expect("save");
let loaded = CuratorConfigFile::load(&path)
.expect("load")
.expect("should exist");
assert_eq!(loaded.trusted_keys, vec![hex]);
}
#[test]
fn load_merges_defaults_and_config_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("curators.toml");
let keypair = hush_core::Keypair::generate();
let hex = keypair.public_key().to_hex();
let config = CuratorConfigFile {
trusted_keys: vec![hex.clone()],
};
config.save(&path).expect("save");
let trust = CuratorTrustSet::load(Some(&path)).expect("load");
assert!(trust.len() >= 2);
assert!(trust.contains(&keypair.public_key()));
}
#[test]
fn missing_config_file_uses_defaults() {
let path = Path::new("/nonexistent/curators.toml");
let trust = CuratorTrustSet::load(Some(path)).expect("should use defaults");
assert_eq!(trust.len(), 1);
}
#[test]
fn deduplicates_keys() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("curators.toml");
let config = CuratorConfigFile {
trusted_keys: vec![
"b51f6b9b8b2fcf77fb365f8a191579483c92af88ed914d6f79f08784699411ed".to_string(),
],
};
config.save(&path).expect("save");
let trust = CuratorTrustSet::load(Some(&path)).expect("load");
assert_eq!(trust.len(), 1);
}
#[test]
fn parse_valid_config() {
let kp = hush_core::Keypair::generate();
let toml_str = format!(
r#"
[[curator]]
name = "test-curator"
public_key = "{}"
trust_level = "full"
feed_ids = ["test-feed"]
"#,
kp.public_key().to_hex()
);
let config = CuratorConfig::parse(&toml_str).unwrap();
assert_eq!(config.curators().len(), 1);
assert_eq!(config.curators()[0].name, "test-curator");
assert_eq!(config.curators()[0].trust_level, TrustLevel::Full);
assert_eq!(config.curators()[0].feed_ids, vec!["test-feed"]);
}
#[test]
fn parse_config_with_0x_prefix() {
let kp = hush_core::Keypair::generate();
let toml_str = format!(
r#"
[[curator]]
name = "prefixed"
public_key = "0x{}"
trust_level = "full"
"#,
kp.public_key().to_hex()
);
let config = CuratorConfig::parse(&toml_str).unwrap();
assert_eq!(config.curators()[0].public_key, kp.public_key());
}
#[test]
fn empty_config_returns_no_curators() {
let config = CuratorConfig::parse("").unwrap();
assert!(config.curators().is_empty());
assert!(config.public_keys().is_empty());
}
#[test]
fn feed_id_filtering() {
let kp1 = hush_core::Keypair::generate();
let kp2 = hush_core::Keypair::generate();
let toml_str = format!(
r#"
[[curator]]
name = "specific"
public_key = "{}"
trust_level = "full"
feed_ids = ["feed-a"]
[[curator]]
name = "wildcard"
public_key = "{}"
trust_level = "full"
feed_ids = []
"#,
kp1.public_key().to_hex(),
kp2.public_key().to_hex()
);
let config = CuratorConfig::parse(&toml_str).unwrap();
assert_eq!(config.public_keys_for_feed("feed-a").len(), 2);
assert_eq!(config.public_keys_for_feed("feed-b").len(), 1);
}
#[test]
fn invalid_public_key_errors() {
let toml_str = r#"
[[curator]]
name = "bad"
public_key = "not-a-valid-key"
trust_level = "full"
"#;
let err = CuratorConfig::parse(toml_str).unwrap_err();
assert!(err.to_string().contains("Invalid public key"));
}
#[test]
fn default_trust_level_is_audit_only() {
let kp = hush_core::Keypair::generate();
let toml_str = format!(
r#"
[[curator]]
name = "no-level"
public_key = "{}"
"#,
kp.public_key().to_hex()
);
let config = CuratorConfig::parse(&toml_str).unwrap();
assert_eq!(config.curators()[0].trust_level, TrustLevel::AuditOnly);
}
#[test]
fn find_curator_by_public_key() {
let kp1 = hush_core::Keypair::generate();
let kp2 = hush_core::Keypair::generate();
let toml_str = format!(
r#"
[[curator]]
name = "alice"
public_key = "{}"
trust_level = "full"
[[curator]]
name = "bob"
public_key = "{}"
trust_level = "audit-only"
"#,
kp1.public_key().to_hex(),
kp2.public_key().to_hex()
);
let config = CuratorConfig::parse(&toml_str).unwrap();
let alice = config.find_curator(&kp1.public_key()).unwrap();
assert_eq!(alice.name, "alice");
assert_eq!(alice.trust_level, TrustLevel::Full);
let bob = config.find_curator(&kp2.public_key()).unwrap();
assert_eq!(bob.name, "bob");
assert_eq!(bob.trust_level, TrustLevel::AuditOnly);
let unknown = hush_core::Keypair::generate();
assert!(config.find_curator(&unknown.public_key()).is_none());
}
#[test]
fn unknown_fields_rejected() {
let kp = hush_core::Keypair::generate();
let toml_str = format!(
r#"
[[curator]]
name = "test"
public_key = "{}"
trust_level = "full"
bogus_field = true
"#,
kp.public_key().to_hex()
);
let err = CuratorConfig::parse(&toml_str).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
#[test]
fn load_from_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("curators.toml");
let kp = hush_core::Keypair::generate();
let content = format!(
r#"
[[curator]]
name = "file-curator"
public_key = "{}"
trust_level = "full"
"#,
kp.public_key().to_hex()
);
std::fs::write(&path, content).expect("write");
let config = CuratorConfig::load(&path).unwrap();
assert_eq!(config.curators().len(), 1);
assert_eq!(config.curators()[0].name, "file-curator");
}
#[test]
fn load_default_returns_empty_when_no_file() {
let config = CuratorConfig::load_default().unwrap();
let _ = config.curators();
}
}