1use std::collections::BTreeSet;
35use std::path::{Path, PathBuf};
36
37use nexo_plugin_manifest::{validate_config, ConfigSchemaError, PluginManifest};
38use serde_yaml::Value;
39
40#[derive(Debug, Clone)]
45pub struct PluginConfig {
46 pub merged: Value,
50 pub schema_validated: bool,
53 pub source_files: Vec<PathBuf>,
57}
58
59impl PluginConfig {
60 fn empty() -> Self {
61 Self {
62 merged: Value::Mapping(serde_yaml::Mapping::new()),
63 schema_validated: false,
64 source_files: Vec::new(),
65 }
66 }
67}
68
69#[derive(Debug, thiserror::Error)]
73pub enum PluginConfigError {
74 #[error("read dir {path}: {error}", path = .path.display())]
75 DirRead { path: PathBuf, error: String },
76 #[error("read file {path}: {error}", path = .path.display())]
77 FileRead { path: PathBuf, error: String },
78 #[error("env-var resolve in {path}: {error}", path = .path.display())]
79 EnvResolve { path: PathBuf, error: String },
80 #[error("yaml parse {path}: {error}", path = .path.display())]
81 YamlParse { path: PathBuf, error: String },
82 #[error("read schema {path}: {error}", path = .path.display())]
83 SchemaRead { path: PathBuf, error: String },
84 #[error("parse schema {path}: {error}", path = .path.display())]
85 SchemaParse { path: PathBuf, error: String },
86 #[error(
87 "schema validation failed against {path} ({} error(s))", errors.len(),
88 path = .schema_path.display()
89 )]
90 SchemaValidation {
91 schema_path: PathBuf,
92 errors: Vec<ConfigSchemaError>,
93 },
94 #[error("symlink at {path} escapes the plugin config dir", path = .path.display())]
95 SymlinkEscape { path: PathBuf },
96}
97
98pub fn config_error_kind(e: &PluginConfigError) -> &'static str {
102 match e {
103 PluginConfigError::DirRead { .. } => "DirRead",
104 PluginConfigError::FileRead { .. } => "FileRead",
105 PluginConfigError::EnvResolve { .. } => "EnvResolve",
106 PluginConfigError::YamlParse { .. } => "YamlParse",
107 PluginConfigError::SchemaRead { .. } => "SchemaRead",
108 PluginConfigError::SchemaParse { .. } => "SchemaParse",
109 PluginConfigError::SchemaValidation { .. } => "SchemaValidation",
110 PluginConfigError::SymlinkEscape { .. } => "SymlinkEscape",
111 }
112}
113
114pub fn plugin_config_dir_for(config_dir: &Path, plugin_id: &str) -> PathBuf {
117 config_dir.join("plugins").join(plugin_id)
118}
119
120pub fn load_plugin_config(
126 plugin_root: &Path,
127 config_dir: &Path,
128 manifest: &PluginManifest,
129) -> Result<PluginConfig, PluginConfigError> {
130 let plugin_id = &manifest.plugin.id;
131 let dir = plugin_config_dir_for(config_dir, plugin_id);
132
133 if !dir.exists() {
134 return Ok(PluginConfig::empty());
135 }
136
137 let canonical_dir = std::fs::canonicalize(&dir).map_err(|e| PluginConfigError::DirRead {
138 path: dir.clone(),
139 error: e.to_string(),
140 })?;
141
142 let entries = std::fs::read_dir(&canonical_dir).map_err(|e| PluginConfigError::DirRead {
143 path: canonical_dir.clone(),
144 error: e.to_string(),
145 })?;
146
147 let mut yaml_files: BTreeSet<PathBuf> = BTreeSet::new();
150 for entry in entries.flatten() {
151 let path = entry.path();
152 let ft = match entry.file_type() {
153 Ok(ft) => ft,
154 Err(_) => continue,
155 };
156 if !ft.is_file() && !ft.is_symlink() {
157 continue;
158 }
159 let ext = path
160 .extension()
161 .and_then(|s| s.to_str())
162 .map(|s| s.to_ascii_lowercase());
163 if !matches!(ext.as_deref(), Some("yaml") | Some("yml")) {
164 continue;
165 }
166 yaml_files.insert(path);
167 }
168
169 let mut merged = Value::Mapping(serde_yaml::Mapping::new());
170 let mut source_files = Vec::with_capacity(yaml_files.len());
171
172 for path in &yaml_files {
173 let canonical = std::fs::canonicalize(path).map_err(|e| PluginConfigError::FileRead {
177 path: path.clone(),
178 error: e.to_string(),
179 })?;
180 if !canonical.starts_with(&canonical_dir) {
181 return Err(PluginConfigError::SymlinkEscape { path: path.clone() });
182 }
183
184 let raw = std::fs::read_to_string(&canonical).map_err(|e| PluginConfigError::FileRead {
185 path: canonical.clone(),
186 error: e.to_string(),
187 })?;
188
189 let resolved =
190 nexo_config::env::resolve_placeholders(&raw, &canonical.display().to_string())
191 .map_err(|e| PluginConfigError::EnvResolve {
192 path: canonical.clone(),
193 error: e.to_string(),
194 })?;
195
196 let value: Value =
197 serde_yaml::from_str(&resolved).map_err(|e| PluginConfigError::YamlParse {
198 path: canonical.clone(),
199 error: e.to_string(),
200 })?;
201
202 let value = if matches!(value, Value::Null) {
205 Value::Mapping(serde_yaml::Mapping::new())
206 } else {
207 value
208 };
209
210 merge_yaml(&mut merged, value);
211 source_files.push(canonical);
212 }
213
214 let schema_validated =
215 match manifest.plugin.config.schema_path.as_deref() {
216 None => false,
217 Some(rel) => {
218 let schema_path = plugin_root.join(rel);
219 let schema_bytes =
220 std::fs::read(&schema_path).map_err(|e| PluginConfigError::SchemaRead {
221 path: schema_path.clone(),
222 error: e.to_string(),
223 })?;
224 let schema_json: serde_json::Value = serde_json::from_slice(&schema_bytes)
225 .map_err(|e| PluginConfigError::SchemaParse {
226 path: schema_path.clone(),
227 error: e.to_string(),
228 })?;
229 let merged_json: serde_json::Value =
234 yaml_to_json(&merged).map_err(|e| PluginConfigError::YamlParse {
235 path: schema_path.clone(),
236 error: format!("merged yaml -> json: {e}"),
237 })?;
238 let errors = validate_config(&merged_json, &schema_json);
239 if !errors.is_empty() {
240 return Err(PluginConfigError::SchemaValidation {
241 schema_path,
242 errors,
243 });
244 }
245 true
246 }
247 };
248
249 Ok(PluginConfig {
250 merged,
251 schema_validated,
252 source_files,
253 })
254}
255
256fn merge_yaml(into: &mut Value, from: Value) {
260 match (into, from) {
261 (Value::Mapping(into_map), Value::Mapping(from_map)) => {
262 for (k, v) in from_map {
263 if let Some(existing) = into_map.get_mut(&k) {
264 merge_yaml(existing, v);
265 } else {
266 into_map.insert(k, v);
267 }
268 }
269 }
270 (slot, from) => {
271 *slot = from;
272 }
273 }
274}
275
276fn yaml_to_json(value: &Value) -> Result<serde_json::Value, String> {
281 let s = serde_yaml::to_string(value).map_err(|e| e.to_string())?;
282 let json: serde_json::Value =
283 serde_yaml::from_str(&s).map_err(|e| format!("yaml->json reparse: {e}"))?;
284 Ok(json)
285}
286
287#[cfg(test)]
290mod tests {
291 use super::*;
292 use nexo_plugin_manifest::PluginManifest;
293 use std::fs;
294 use tempfile::tempdir;
295
296 fn manifest(plugin_id: &str, schema_path: Option<&str>) -> PluginManifest {
297 let mut raw = format!(
298 "[plugin]\n\
299 id = \"{plugin_id}\"\n\
300 version = \"0.1.0\"\n\
301 name = \"{plugin_id}\"\n\
302 description = \"fixture\"\n\
303 min_nexo_version = \">=0.0.1\"\n",
304 );
305 if let Some(p) = schema_path {
306 raw.push_str(&format!("\n[plugin.config]\nschema_path = \"{p}\"\n"));
307 }
308 toml::from_str(&raw).unwrap()
309 }
310
311 fn write_file(dir: &Path, name: &str, body: &str) {
312 fs::create_dir_all(dir).unwrap();
313 fs::write(dir.join(name), body).unwrap();
314 }
315
316 #[test]
317 fn load_returns_empty_when_dir_missing() {
318 let cfg_dir = tempdir().unwrap();
319 let plugin_root = tempdir().unwrap();
320 let m = manifest("slack", None);
321 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
322 assert!(matches!(pc.merged, Value::Mapping(ref m) if m.is_empty()));
323 assert!(!pc.schema_validated);
324 assert!(pc.source_files.is_empty());
325 }
326
327 #[test]
328 fn load_returns_empty_when_dir_empty() {
329 let cfg_dir = tempdir().unwrap();
330 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
331 fs::create_dir_all(&plugin_dir).unwrap();
332 let plugin_root = tempdir().unwrap();
333 let m = manifest("slack", None);
334 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
335 assert!(matches!(pc.merged, Value::Mapping(ref m) if m.is_empty()));
336 assert!(pc.source_files.is_empty());
337 }
338
339 #[test]
340 fn load_skips_non_yaml_files() {
341 let cfg_dir = tempdir().unwrap();
342 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
343 write_file(&plugin_dir, "notes.txt", "ignore me");
344 write_file(&plugin_dir, "01-cred.yaml", "api_token: abc\n");
345 let plugin_root = tempdir().unwrap();
346 let m = manifest("slack", None);
347 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
348 assert_eq!(pc.source_files.len(), 1);
349 assert!(pc.source_files[0].ends_with("01-cred.yaml"));
350 assert_eq!(
351 pc.merged.get("api_token").and_then(Value::as_str),
352 Some("abc")
353 );
354 }
355
356 #[test]
357 fn load_merges_files_alphabetically_with_deep_merge() {
358 let cfg_dir = tempdir().unwrap();
359 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
360 write_file(&plugin_dir, "01-cred.yaml", "auth:\n token: t1\n");
361 write_file(&plugin_dir, "02-extra.yaml", "auth:\n workspace: w1\n");
362 let plugin_root = tempdir().unwrap();
363 let m = manifest("slack", None);
364 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
365 assert_eq!(pc.source_files.len(), 2);
366 let auth = pc.merged.get("auth").unwrap();
367 assert_eq!(auth.get("token").and_then(Value::as_str), Some("t1"));
368 assert_eq!(auth.get("workspace").and_then(Value::as_str), Some("w1"));
369 }
370
371 #[test]
372 fn load_later_files_overwrite_scalar_keys() {
373 let cfg_dir = tempdir().unwrap();
374 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
375 write_file(&plugin_dir, "01-base.yaml", "endpoint: prod\n");
376 write_file(&plugin_dir, "02-override.yaml", "endpoint: staging\n");
377 let plugin_root = tempdir().unwrap();
378 let m = manifest("slack", None);
379 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
380 assert_eq!(
381 pc.merged.get("endpoint").and_then(Value::as_str),
382 Some("staging")
383 );
384 }
385
386 #[test]
387 fn load_arrays_are_full_replace_not_concat() {
388 let cfg_dir = tempdir().unwrap();
389 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
390 write_file(&plugin_dir, "01-base.yaml", "channels:\n - a\n - b\n");
391 write_file(&plugin_dir, "02-override.yaml", "channels:\n - x\n");
392 let plugin_root = tempdir().unwrap();
393 let m = manifest("slack", None);
394 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
395 let channels = pc
396 .merged
397 .get("channels")
398 .and_then(Value::as_sequence)
399 .unwrap();
400 assert_eq!(channels.len(), 1);
401 assert_eq!(channels[0].as_str(), Some("x"));
402 }
403
404 #[test]
405 fn load_resolves_env_var_substitutions() {
406 std::env::set_var("NEXO_TEST_PCL_TOKEN_X", "secret-value");
408 let cfg_dir = tempdir().unwrap();
409 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
410 write_file(
411 &plugin_dir,
412 "01-cred.yaml",
413 "api_token: \"${NEXO_TEST_PCL_TOKEN_X}\"\n",
414 );
415 let plugin_root = tempdir().unwrap();
416 let m = manifest("slack", None);
417 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
418 assert_eq!(
419 pc.merged.get("api_token").and_then(Value::as_str),
420 Some("secret-value")
421 );
422 std::env::remove_var("NEXO_TEST_PCL_TOKEN_X");
423 }
424
425 #[test]
426 fn load_returns_yaml_parse_error_on_malformed_file() {
427 let cfg_dir = tempdir().unwrap();
428 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
429 write_file(&plugin_dir, "01-bad.yaml", "key: [unclosed\n");
430 let plugin_root = tempdir().unwrap();
431 let m = manifest("slack", None);
432 let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
433 assert!(matches!(err, PluginConfigError::YamlParse { .. }));
434 }
435
436 #[test]
437 fn load_validates_against_schema_when_present() {
438 let cfg_dir = tempdir().unwrap();
439 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
440 write_file(
441 &plugin_dir,
442 "01-cred.yaml",
443 "api_token: abc\nworkspace: ws\n",
444 );
445 let plugin_root = tempdir().unwrap();
446 fs::write(
447 plugin_root.path().join("config.schema.json"),
448 r#"{
449 "type": "object",
450 "required": ["api_token"],
451 "properties": {
452 "api_token": { "type": "string" },
453 "workspace": { "type": "string" }
454 }
455 }"#,
456 )
457 .unwrap();
458 let m = manifest("slack", Some("config.schema.json"));
459 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
460 assert!(pc.schema_validated);
461 }
462
463 #[test]
464 fn load_returns_schema_validation_errors_with_pointers() {
465 let cfg_dir = tempdir().unwrap();
466 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
467 write_file(&plugin_dir, "01-cred.yaml", "workspace: ws\n");
469 let plugin_root = tempdir().unwrap();
470 fs::write(
471 plugin_root.path().join("config.schema.json"),
472 r#"{
473 "type": "object",
474 "required": ["api_token"],
475 "properties": {
476 "api_token": { "type": "string" }
477 }
478 }"#,
479 )
480 .unwrap();
481 let m = manifest("slack", Some("config.schema.json"));
482 let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
483 match err {
484 PluginConfigError::SchemaValidation { errors, .. } => {
485 assert!(!errors.is_empty());
486 assert!(errors
489 .iter()
490 .any(|e| e.message.contains("required") || e.message.contains("api_token")));
491 }
492 other => panic!("expected SchemaValidation, got {other:?}"),
493 }
494 }
495
496 #[test]
497 fn load_skips_validation_when_schema_path_unset() {
498 let cfg_dir = tempdir().unwrap();
499 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
500 write_file(&plugin_dir, "01-cred.yaml", "anything_goes: 42\n");
501 let plugin_root = tempdir().unwrap();
502 let m = manifest("slack", None);
503 let pc = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap();
504 assert!(!pc.schema_validated);
505 }
506
507 #[test]
508 #[cfg(unix)]
509 fn load_rejects_symlink_escape() {
510 let cfg_dir = tempdir().unwrap();
511 let plugin_dir = cfg_dir.path().join("plugins").join("slack");
512 fs::create_dir_all(&plugin_dir).unwrap();
513 let outside = cfg_dir.path().join("outside.yaml");
515 fs::write(&outside, "evil: true\n").unwrap();
516 std::os::unix::fs::symlink(&outside, plugin_dir.join("01-evil.yaml")).unwrap();
517 let plugin_root = tempdir().unwrap();
518 let m = manifest("slack", None);
519 let err = load_plugin_config(plugin_root.path(), cfg_dir.path(), &m).unwrap_err();
520 assert!(matches!(err, PluginConfigError::SymlinkEscape { .. }));
521 }
522
523 #[test]
524 fn config_error_kind_maps_all_variants() {
525 let path = PathBuf::from("/x");
526 let cases: Vec<(PluginConfigError, &'static str)> = vec![
527 (
528 PluginConfigError::DirRead {
529 path: path.clone(),
530 error: "x".into(),
531 },
532 "DirRead",
533 ),
534 (
535 PluginConfigError::FileRead {
536 path: path.clone(),
537 error: "x".into(),
538 },
539 "FileRead",
540 ),
541 (
542 PluginConfigError::EnvResolve {
543 path: path.clone(),
544 error: "x".into(),
545 },
546 "EnvResolve",
547 ),
548 (
549 PluginConfigError::YamlParse {
550 path: path.clone(),
551 error: "x".into(),
552 },
553 "YamlParse",
554 ),
555 (
556 PluginConfigError::SchemaRead {
557 path: path.clone(),
558 error: "x".into(),
559 },
560 "SchemaRead",
561 ),
562 (
563 PluginConfigError::SchemaParse {
564 path: path.clone(),
565 error: "x".into(),
566 },
567 "SchemaParse",
568 ),
569 (
570 PluginConfigError::SchemaValidation {
571 schema_path: path.clone(),
572 errors: vec![],
573 },
574 "SchemaValidation",
575 ),
576 (
577 PluginConfigError::SymlinkEscape { path: path.clone() },
578 "SymlinkEscape",
579 ),
580 ];
581 for (e, want) in cases {
582 assert_eq!(config_error_kind(&e), want);
583 }
584 }
585}