use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use nexo_plugin_manifest::{validate_config, ConfigSchemaError, PluginManifest};
use serde_yaml::Value;
#[derive(Debug, Clone)]
pub struct PluginConfig {
pub merged: Value,
pub schema_validated: bool,
pub source_files: Vec<PathBuf>,
}
impl PluginConfig {
fn empty() -> Self {
Self {
merged: Value::Mapping(serde_yaml::Mapping::new()),
schema_validated: false,
source_files: Vec::new(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginConfigError {
#[error("read dir {path}: {error}", path = .path.display())]
DirRead { path: PathBuf, error: String },
#[error("read file {path}: {error}", path = .path.display())]
FileRead { path: PathBuf, error: String },
#[error("env-var resolve in {path}: {error}", path = .path.display())]
EnvResolve { path: PathBuf, error: String },
#[error("yaml parse {path}: {error}", path = .path.display())]
YamlParse { path: PathBuf, error: String },
#[error("read schema {path}: {error}", path = .path.display())]
SchemaRead { path: PathBuf, error: String },
#[error("parse schema {path}: {error}", path = .path.display())]
SchemaParse { path: PathBuf, error: String },
#[error(
"schema validation failed against {path} ({} error(s))", errors.len(),
path = .schema_path.display()
)]
SchemaValidation {
schema_path: PathBuf,
errors: Vec<ConfigSchemaError>,
},
#[error("symlink at {path} escapes the plugin config dir", path = .path.display())]
SymlinkEscape { path: PathBuf },
}
pub fn config_error_kind(e: &PluginConfigError) -> &'static str {
match e {
PluginConfigError::DirRead { .. } => "DirRead",
PluginConfigError::FileRead { .. } => "FileRead",
PluginConfigError::EnvResolve { .. } => "EnvResolve",
PluginConfigError::YamlParse { .. } => "YamlParse",
PluginConfigError::SchemaRead { .. } => "SchemaRead",
PluginConfigError::SchemaParse { .. } => "SchemaParse",
PluginConfigError::SchemaValidation { .. } => "SchemaValidation",
PluginConfigError::SymlinkEscape { .. } => "SymlinkEscape",
}
}
pub fn plugin_config_dir_for(config_dir: &Path, plugin_id: &str) -> PathBuf {
config_dir.join("plugins").join(plugin_id)
}
pub fn load_plugin_config(
plugin_root: &Path,
config_dir: &Path,
manifest: &PluginManifest,
) -> Result<PluginConfig, PluginConfigError> {
let plugin_id = &manifest.plugin.id;
let dir = plugin_config_dir_for(config_dir, plugin_id);
if !dir.exists() {
return Ok(PluginConfig::empty());
}
let canonical_dir = std::fs::canonicalize(&dir).map_err(|e| PluginConfigError::DirRead {
path: dir.clone(),
error: e.to_string(),
})?;
let entries = std::fs::read_dir(&canonical_dir).map_err(|e| PluginConfigError::DirRead {
path: canonical_dir.clone(),
error: e.to_string(),
})?;
let mut yaml_files: BTreeSet<PathBuf> = BTreeSet::new();
for entry in entries.flatten() {
let path = entry.path();
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !ft.is_file() && !ft.is_symlink() {
continue;
}
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("yaml") | Some("yml")) {
continue;
}
yaml_files.insert(path);
}
let mut merged = Value::Mapping(serde_yaml::Mapping::new());
let mut source_files = Vec::with_capacity(yaml_files.len());
for path in &yaml_files {
let canonical = std::fs::canonicalize(path).map_err(|e| PluginConfigError::FileRead {
path: path.clone(),
error: e.to_string(),
})?;
if !canonical.starts_with(&canonical_dir) {
return Err(PluginConfigError::SymlinkEscape { path: path.clone() });
}
let raw = std::fs::read_to_string(&canonical).map_err(|e| PluginConfigError::FileRead {
path: canonical.clone(),
error: e.to_string(),
})?;
let resolved =
nexo_config::env::resolve_placeholders(&raw, &canonical.display().to_string())
.map_err(|e| PluginConfigError::EnvResolve {
path: canonical.clone(),
error: e.to_string(),
})?;
let value: Value =
serde_yaml::from_str(&resolved).map_err(|e| PluginConfigError::YamlParse {
path: canonical.clone(),
error: e.to_string(),
})?;
let value = if matches!(value, Value::Null) {
Value::Mapping(serde_yaml::Mapping::new())
} else {
value
};
merge_yaml(&mut merged, value);
source_files.push(canonical);
}
let schema_validated =
match manifest.plugin.config.schema_path.as_deref() {
None => false,
Some(rel) => {
let schema_path = plugin_root.join(rel);
let schema_bytes =
std::fs::read(&schema_path).map_err(|e| PluginConfigError::SchemaRead {
path: schema_path.clone(),
error: e.to_string(),
})?;
let schema_json: serde_json::Value = serde_json::from_slice(&schema_bytes)
.map_err(|e| PluginConfigError::SchemaParse {
path: schema_path.clone(),
error: e.to_string(),
})?;
let merged_json: serde_json::Value =
yaml_to_json(&merged).map_err(|e| PluginConfigError::YamlParse {
path: schema_path.clone(),
error: format!("merged yaml -> json: {e}"),
})?;
let errors = validate_config(&merged_json, &schema_json);
if !errors.is_empty() {
return Err(PluginConfigError::SchemaValidation {
schema_path,
errors,
});
}
true
}
};
Ok(PluginConfig {
merged,
schema_validated,
source_files,
})
}
fn merge_yaml(into: &mut Value, from: Value) {
match (into, from) {
(Value::Mapping(into_map), Value::Mapping(from_map)) => {
for (k, v) in from_map {
if let Some(existing) = into_map.get_mut(&k) {
merge_yaml(existing, v);
} else {
into_map.insert(k, v);
}
}
}
(slot, from) => {
*slot = from;
}
}
}
fn yaml_to_json(value: &Value) -> Result<serde_json::Value, String> {
let s = serde_yaml::to_string(value).map_err(|e| e.to_string())?;
let json: serde_json::Value =
serde_yaml::from_str(&s).map_err(|e| format!("yaml->json reparse: {e}"))?;
Ok(json)
}
#[cfg(test)]
mod tests {
use super::*;
use nexo_plugin_manifest::PluginManifest;
use std::fs;
use tempfile::tempdir;
fn manifest(plugin_id: &str, schema_path: Option<&str>) -> PluginManifest {
let mut raw = format!(
"[plugin]\n\
id = \"{plugin_id}\"\n\
version = \"0.1.0\"\n\
name = \"{plugin_id}\"\n\
description = \"fixture\"\n\
min_nexo_version = \">=0.0.1\"\n",
);
if let Some(p) = schema_path {
raw.push_str(&format!("\n[plugin.config]\nschema_path = \"{p}\"\n"));
}
toml::from_str(&raw).unwrap()
}
fn write_file(dir: &Path, name: &str, body: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(name), body).unwrap();
}
#[test]
fn load_returns_empty_when_dir_missing() {
let cfg_dir = tempdir().unwrap();
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert!(matches!(pc.merged, Value::Mapping(ref m) if m.is_empty()));
assert!(!pc.schema_validated);
assert!(pc.source_files.is_empty());
}
#[test]
fn load_returns_empty_when_dir_empty() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
fs::create_dir_all(&plugin_dir).unwrap();
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert!(matches!(pc.merged, Value::Mapping(ref m) if m.is_empty()));
assert!(pc.source_files.is_empty());
}
#[test]
fn load_skips_non_yaml_files() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "notes.txt", "ignore me");
write_file(&plugin_dir, "01-cred.yaml", "api_token: abc\n");
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert_eq!(pc.source_files.len(), 1);
assert!(pc.source_files[0].ends_with("01-cred.yaml"));
assert_eq!(
pc.merged.get("api_token").and_then(Value::as_str),
Some("abc")
);
}
#[test]
fn load_merges_files_alphabetically_with_deep_merge() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "01-cred.yaml", "auth:\n token: t1\n");
write_file(&plugin_dir, "02-extra.yaml", "auth:\n workspace: w1\n");
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert_eq!(pc.source_files.len(), 2);
let auth = pc.merged.get("auth").unwrap();
assert_eq!(auth.get("token").and_then(Value::as_str), Some("t1"));
assert_eq!(auth.get("workspace").and_then(Value::as_str), Some("w1"));
}
#[test]
fn load_later_files_overwrite_scalar_keys() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "01-base.yaml", "endpoint: prod\n");
write_file(&plugin_dir, "02-override.yaml", "endpoint: staging\n");
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert_eq!(
pc.merged.get("endpoint").and_then(Value::as_str),
Some("staging")
);
}
#[test]
fn load_arrays_are_full_replace_not_concat() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "01-base.yaml", "channels:\n - a\n - b\n");
write_file(&plugin_dir, "02-override.yaml", "channels:\n - x\n");
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
let channels = pc
.merged
.get("channels")
.and_then(Value::as_sequence)
.unwrap();
assert_eq!(channels.len(), 1);
assert_eq!(channels[0].as_str(), Some("x"));
}
#[test]
fn load_resolves_env_var_substitutions() {
std::env::set_var("NEXO_TEST_PCL_TOKEN_X", "secret-value");
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(
&plugin_dir,
"01-cred.yaml",
"api_token: \"${NEXO_TEST_PCL_TOKEN_X}\"\n",
);
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert_eq!(
pc.merged.get("api_token").and_then(Value::as_str),
Some("secret-value")
);
std::env::remove_var("NEXO_TEST_PCL_TOKEN_X");
}
#[test]
fn load_returns_yaml_parse_error_on_malformed_file() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "01-bad.yaml", "key: [unclosed\n");
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
assert!(matches!(err, PluginConfigError::YamlParse { .. }));
}
#[test]
fn load_validates_against_schema_when_present() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(
&plugin_dir,
"01-cred.yaml",
"api_token: abc\nworkspace: ws\n",
);
let plugin_root = tempdir().unwrap();
fs::write(
plugin_root.path().join("config.schema.json"),
r#"{
"type": "object",
"required": ["api_token"],
"properties": {
"api_token": { "type": "string" },
"workspace": { "type": "string" }
}
}"#,
)
.unwrap();
let m = manifest("slack", Some("config.schema.json"));
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert!(pc.schema_validated);
}
#[test]
fn load_returns_schema_validation_errors_with_pointers() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "01-cred.yaml", "workspace: ws\n");
let plugin_root = tempdir().unwrap();
fs::write(
plugin_root.path().join("config.schema.json"),
r#"{
"type": "object",
"required": ["api_token"],
"properties": {
"api_token": { "type": "string" }
}
}"#,
)
.unwrap();
let m = manifest("slack", Some("config.schema.json"));
let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
match err {
PluginConfigError::SchemaValidation { errors, .. } => {
assert!(!errors.is_empty());
assert!(errors
.iter()
.any(|e| e.message.contains("required") || e.message.contains("api_token")));
}
other => panic!("expected SchemaValidation, got {other:?}"),
}
}
#[test]
fn load_skips_validation_when_schema_path_unset() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
write_file(&plugin_dir, "01-cred.yaml", "anything_goes: 42\n");
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
assert!(!pc.schema_validated);
}
#[test]
#[cfg(unix)]
fn load_rejects_symlink_escape() {
let cfg_dir = tempdir().unwrap();
let plugin_dir = cfg_dir.path().join("plugins").join("slack");
fs::create_dir_all(&plugin_dir).unwrap();
let outside = cfg_dir.path().join("outside.yaml");
fs::write(&outside, "evil: true\n").unwrap();
std::os::unix::fs::symlink(&outside, plugin_dir.join("01-evil.yaml")).unwrap();
let plugin_root = tempdir().unwrap();
let m = manifest("slack", None);
let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
assert!(matches!(err, PluginConfigError::SymlinkEscape { .. }));
}
#[test]
fn config_error_kind_maps_all_variants() {
let path = PathBuf::from("/x");
let cases: Vec<(PluginConfigError, &'static str)> = vec![
(
PluginConfigError::DirRead {
path: path.clone(),
error: "x".into(),
},
"DirRead",
),
(
PluginConfigError::FileRead {
path: path.clone(),
error: "x".into(),
},
"FileRead",
),
(
PluginConfigError::EnvResolve {
path: path.clone(),
error: "x".into(),
},
"EnvResolve",
),
(
PluginConfigError::YamlParse {
path: path.clone(),
error: "x".into(),
},
"YamlParse",
),
(
PluginConfigError::SchemaRead {
path: path.clone(),
error: "x".into(),
},
"SchemaRead",
),
(
PluginConfigError::SchemaParse {
path: path.clone(),
error: "x".into(),
},
"SchemaParse",
),
(
PluginConfigError::SchemaValidation {
schema_path: path.clone(),
errors: vec![],
},
"SchemaValidation",
),
(
PluginConfigError::SymlinkEscape { path: path.clone() },
"SymlinkEscape",
),
];
for (e, want) in cases {
assert_eq!(config_error_kind(&e), want);
}
}
}