1pub mod control_behavior;
2
3use self::control_behavior::PluginControlBehavior;
4
5use crate::PluginConfigEntry;
6use crate::diagnostic::DiagnosticResult;
7use crate::model::ConfigPath;
8use crate::validate::{
9 ConfigDiagnostic, ConfigDiagnosticCode, ConfigDiagnosticSchemaSource, ConfigDiagnosticSeverity,
10 ConfigDiagnosticSource,
11};
12use crate::validation_support::validation_diagnostic;
13use std::collections::{BTreeMap, BTreeSet};
14use toml::Value;
15
16pub const SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION: u32 = 1;
17
18#[derive(Clone, Debug, PartialEq)]
19pub enum PluginSchemaAvailability {
20 Available(PluginConfigSchema),
21 NotInstalled,
22 MissingSchema,
23 UnsupportedVersion { version: u32 },
24}
25
26#[derive(Clone, Debug, PartialEq)]
27pub struct PluginConfigSchema {
28 pub plugin_name: String,
29 pub schema_version: u32,
30 pub allow_unvalidated_config: bool,
31 pub settings: Vec<PluginSettingSchema>,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct PluginSettingSchema {
36 pub key: String,
37 pub value_schema: PluginValueSchema,
38 pub required: bool,
39 pub default_json: Option<String>,
40 pub constraints: Vec<PluginSettingConstraint>,
41 pub description: Option<String>,
42 pub control_behavior: Option<PluginControlBehavior>,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct PluginValueSchema {
47 pub kind: PluginValueKind,
48 pub enum_values: Vec<String>,
49 pub items: Option<Box<PluginValueSchema>>,
50 pub object_properties: Vec<PluginObjectPropertySchema>,
51 pub allow_additional_properties: bool,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct PluginObjectPropertySchema {
56 pub key: String,
57 pub value_schema: PluginValueSchema,
58 pub required: bool,
59 pub description: Option<String>,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PluginValueKind {
64 Boolean,
65 Integer,
66 Float,
67 String,
68 Path,
69 Url,
70 Enum,
71 Array,
72 Object,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum PluginSettingConstraint {
77 NonEmpty,
78 Positive,
79 Range {
80 min: Option<String>,
81 max: Option<String>,
82 },
83 AllowedValues {
84 values: Vec<String>,
85 },
86 Requires {
87 key: String,
88 },
89}
90
91pub(crate) fn validate_plugin_entries(entries: &[PluginConfigEntry]) -> DiagnosticResult {
92 for (index, entry) in entries.iter().enumerate() {
93 validate_plugin_startup(entry, index)?;
94 }
95 Ok(())
96}
97
98pub(crate) fn validate_plugin_entries_strict<F>(
99 entries: &[PluginConfigEntry],
100 raw_toml: Option<&str>,
101 mut schema_for_plugin: F,
102) -> Vec<ConfigDiagnostic>
103where
104 F: FnMut(&str) -> PluginSchemaAvailability,
105{
106 let mut diagnostics = plugin_misplaced_key_diagnostics(raw_toml);
107
108 for entry in entries {
109 let has_custom_settings = !entry.settings.is_empty();
110 let settings_path = plugin_settings_path(&entry.name);
111 match schema_for_plugin(&entry.name) {
112 PluginSchemaAvailability::Available(schema) => {
113 if schema.schema_version != SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION {
114 diagnostics.push(plugin_diagnostic(
115 ConfigDiagnosticCode::UnsupportedSchemaVersion,
116 ConfigDiagnosticSeverity::Error,
117 settings_path,
118 format!(
119 "plugin '{}' declares unsupported config schema_version {}; expected {}",
120 entry.name, schema.schema_version, SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION
121 ),
122 ));
123 continue;
124 }
125 if schema.allow_unvalidated_config {
126 if has_custom_settings {
127 diagnostics.push(plugin_diagnostic(
128 ConfigDiagnosticCode::LegacyUnvalidatedConfig,
129 ConfigDiagnosticSeverity::Warning,
130 settings_path,
131 format!(
132 "plugin '{}' allows legacy unvalidated config; unknown custom settings are accepted, but declared settings are still schema-validated",
133 entry.name
134 ),
135 ));
136 }
137 diagnostics.extend(validate_plugin_settings_against_schema(
138 entry, &schema, true,
139 ));
140 continue;
141 }
142 diagnostics.extend(validate_plugin_settings_against_schema(
143 entry, &schema, false,
144 ));
145 }
146 PluginSchemaAvailability::NotInstalled => {
147 if has_custom_settings {
148 diagnostics.push(plugin_diagnostic(
149 ConfigDiagnosticCode::SchemaUnavailable,
150 ConfigDiagnosticSeverity::Error,
151 settings_path,
152 format!(
153 "plugin '{}' is not installed, so custom settings cannot be validated in strict mode",
154 entry.name
155 ),
156 ));
157 }
158 }
159 PluginSchemaAvailability::MissingSchema => {
160 if has_custom_settings {
161 diagnostics.push(plugin_diagnostic(
162 ConfigDiagnosticCode::SchemaUnavailable,
163 ConfigDiagnosticSeverity::Error,
164 settings_path,
165 format!(
166 "plugin '{}' does not expose install-time config schema metadata, so custom settings cannot be validated in strict mode",
167 entry.name
168 ),
169 ));
170 }
171 }
172 PluginSchemaAvailability::UnsupportedVersion { version } => {
173 diagnostics.push(plugin_diagnostic(
174 ConfigDiagnosticCode::UnsupportedSchemaVersion,
175 ConfigDiagnosticSeverity::Error,
176 settings_path,
177 format!(
178 "plugin '{}' declares unsupported config schema_version {}; expected {}",
179 entry.name, version, SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION
180 ),
181 ));
182 }
183 }
184 }
185
186 diagnostics
187}
188
189fn validate_plugin_startup(entry: &PluginConfigEntry, index: usize) -> DiagnosticResult {
190 if matches!(entry.startup.connect_timeout_secs, Some(0)) {
191 return Err(validation_diagnostic(
192 &format!("plugin[{index}].startup.connect_timeout_secs"),
193 format!("plugin[{index}].startup.connect_timeout_secs must be at least 1 when set"),
194 ));
195 }
196 if matches!(entry.startup.init_timeout_secs, Some(0)) {
197 return Err(validation_diagnostic(
198 &format!("plugin[{index}].startup.init_timeout_secs"),
199 format!("plugin[{index}].startup.init_timeout_secs must be at least 1 when set"),
200 ));
201 }
202 Ok(())
203}
204
205fn validate_plugin_settings_against_schema(
206 entry: &PluginConfigEntry,
207 schema: &PluginConfigSchema,
208 allow_unknown_settings: bool,
209) -> Vec<ConfigDiagnostic> {
210 let mut diagnostics = Vec::new();
211 let schema_by_key = schema
212 .settings
213 .iter()
214 .map(|setting| (setting.key.as_str(), setting))
215 .collect::<BTreeMap<_, _>>();
216
217 for key in entry.settings.keys() {
218 if !allow_unknown_settings && !schema_by_key.contains_key(key.as_str()) {
219 diagnostics.push(plugin_diagnostic(
220 ConfigDiagnosticCode::UnknownField,
221 ConfigDiagnosticSeverity::Error,
222 plugin_setting_path(&entry.name, [key.as_str()]),
223 format!(
224 "plugin '{}' does not declare custom setting '{}' in [[plugin]].settings",
225 entry.name, key
226 ),
227 ));
228 }
229 }
230
231 for setting in &schema.settings {
232 let Some(value) = entry.settings.get(&setting.key) else {
233 if setting.required {
234 diagnostics.push(plugin_diagnostic(
235 ConfigDiagnosticCode::MissingRequiredValue,
236 ConfigDiagnosticSeverity::Error,
237 plugin_setting_path(&entry.name, [setting.key.as_str()]),
238 format!(
239 "plugin '{}' requires [[plugin]].settings.{} to be set",
240 entry.name, setting.key
241 ),
242 ));
243 }
244 continue;
245 };
246
247 validate_plugin_value(
248 &entry.name,
249 &[setting.key.as_str()],
250 value,
251 &setting.value_schema,
252 &setting.constraints,
253 &entry.settings,
254 &mut diagnostics,
255 );
256 }
257
258 diagnostics
259}
260
261fn validate_plugin_value(
262 plugin_name: &str,
263 path_segments: &[&str],
264 value: &Value,
265 schema: &PluginValueSchema,
266 constraints: &[PluginSettingConstraint],
267 root_settings: &BTreeMap<String, Value>,
268 diagnostics: &mut Vec<ConfigDiagnostic>,
269) {
270 if let Err(message) = validate_plugin_value_kind(value, schema) {
271 diagnostics.push(plugin_diagnostic(
272 ConfigDiagnosticCode::InvalidValue,
273 ConfigDiagnosticSeverity::Error,
274 plugin_setting_path(plugin_name, path_segments.iter().copied()),
275 message,
276 ));
277 return;
278 }
279
280 for constraint in constraints {
281 if let Err(message) = validate_plugin_constraint(value, constraint, root_settings) {
282 diagnostics.push(plugin_diagnostic(
283 ConfigDiagnosticCode::InvalidValue,
284 ConfigDiagnosticSeverity::Error,
285 plugin_setting_path(plugin_name, path_segments.iter().copied()),
286 message,
287 ));
288 }
289 }
290
291 match (&schema.kind, value) {
292 (PluginValueKind::Array, Value::Array(items)) => {
293 if let Some(item_schema) = schema.items.as_deref() {
294 for (index, item) in items.iter().enumerate() {
295 let index_segment = index.to_string();
296 let mut nested = path_segments.to_vec();
297 nested.push(index_segment.as_str());
298 validate_plugin_value(
299 plugin_name,
300 &nested,
301 item,
302 item_schema,
303 &[],
304 root_settings,
305 diagnostics,
306 );
307 }
308 }
309 }
310 (PluginValueKind::Object, Value::Table(table)) => {
311 let object_schema = schema
312 .object_properties
313 .iter()
314 .map(|property| (property.key.as_str(), property))
315 .collect::<BTreeMap<_, _>>();
316
317 for key in table.keys() {
318 if !schema.allow_additional_properties && !object_schema.contains_key(key.as_str())
319 {
320 diagnostics.push(plugin_diagnostic(
321 ConfigDiagnosticCode::UnknownField,
322 ConfigDiagnosticSeverity::Error,
323 plugin_setting_path(
324 plugin_name,
325 path_segments.iter().copied().chain([key.as_str()]),
326 ),
327 format!(
328 "plugin '{}' does not allow object property '{}' here",
329 plugin_name, key
330 ),
331 ));
332 }
333 }
334
335 for property in &schema.object_properties {
336 let Some(property_value) = table.get(&property.key) else {
337 if property.required {
338 diagnostics.push(plugin_diagnostic(
339 ConfigDiagnosticCode::MissingRequiredValue,
340 ConfigDiagnosticSeverity::Error,
341 plugin_setting_path(
342 plugin_name,
343 path_segments.iter().copied().chain([property.key.as_str()]),
344 ),
345 format!(
346 "plugin '{}' requires object property '{}' here",
347 plugin_name, property.key
348 ),
349 ));
350 }
351 continue;
352 };
353
354 let mut nested = path_segments.to_vec();
355 nested.push(property.key.as_str());
356 validate_plugin_value(
357 plugin_name,
358 &nested,
359 property_value,
360 &property.value_schema,
361 &[],
362 root_settings,
363 diagnostics,
364 );
365 }
366 }
367 _ => {}
368 }
369}
370
371fn validate_plugin_value_kind(value: &Value, schema: &PluginValueSchema) -> Result<(), String> {
372 match schema.kind {
373 PluginValueKind::Boolean if value.is_bool() => Ok(()),
374 PluginValueKind::Integer if value.as_integer().is_some() => Ok(()),
375 PluginValueKind::Float if numeric_value(value).is_some() => Ok(()),
376 PluginValueKind::String | PluginValueKind::Path if value.as_str().is_some() => Ok(()),
377 PluginValueKind::Url => {
378 let Some(raw) = value.as_str() else {
379 return Err("expected URL string".into());
380 };
381 if raw.contains("://") {
382 Ok(())
383 } else {
384 Err(format!("expected valid URL, got {raw:?}"))
385 }
386 }
387 PluginValueKind::Enum => {
388 let Some(raw) = value.as_str() else {
389 return Err("expected enum string".into());
390 };
391 if schema.enum_values.iter().any(|candidate| candidate == raw) {
392 Ok(())
393 } else {
394 Err(format!(
395 "expected one of: {}",
396 schema.enum_values.join(", ")
397 ))
398 }
399 }
400 PluginValueKind::Array if value.as_array().is_some() => Ok(()),
401 PluginValueKind::Object if value.as_table().is_some() => Ok(()),
402 PluginValueKind::Boolean => Err("expected boolean".into()),
403 PluginValueKind::Integer => Err("expected integer".into()),
404 PluginValueKind::Float => Err("expected number".into()),
405 PluginValueKind::String => Err("expected string".into()),
406 PluginValueKind::Path => Err("expected path string".into()),
407 PluginValueKind::Array => Err("expected array".into()),
408 PluginValueKind::Object => Err("expected object/table".into()),
409 }
410}
411
412fn validate_plugin_constraint(
413 value: &Value,
414 constraint: &PluginSettingConstraint,
415 root_settings: &BTreeMap<String, Value>,
416) -> Result<(), String> {
417 match constraint {
418 PluginSettingConstraint::NonEmpty => {
419 let valid = match value {
420 Value::String(inner) => !inner.trim().is_empty(),
421 Value::Array(inner) => !inner.is_empty(),
422 Value::Table(inner) => !inner.is_empty(),
423 _ => true,
424 };
425 if valid {
426 Ok(())
427 } else {
428 Err("must not be empty".into())
429 }
430 }
431 PluginSettingConstraint::Positive => {
432 let Some(number) = numeric_value(value) else {
433 return Err("must be numeric to apply positive constraint".into());
434 };
435 if number > 0.0 {
436 Ok(())
437 } else {
438 Err("must be greater than 0".into())
439 }
440 }
441 PluginSettingConstraint::Range { min, max } => {
442 let Some(number) = numeric_value(value) else {
443 return Err("must be numeric to apply range constraint".into());
444 };
445 if let Some(min) = parse_optional_constraint_number("min", min.as_deref())?
446 && number < min
447 {
448 return Err(format!("must be at least {}", render_number(min)));
449 }
450 if let Some(max) = parse_optional_constraint_number("max", max.as_deref())?
451 && number > max
452 {
453 return Err(format!("must be at most {}", render_number(max)));
454 }
455 Ok(())
456 }
457 PluginSettingConstraint::AllowedValues { values } => {
458 let Some(raw) = value.as_str() else {
459 return Err("must be string-like to apply allowed-values constraint".into());
460 };
461 if values.iter().any(|candidate| candidate == raw) {
462 Ok(())
463 } else {
464 Err(format!("expected one of: {}", values.join(", ")))
465 }
466 }
467 PluginSettingConstraint::Requires { key } => {
468 if root_settings.contains_key(key) {
469 Ok(())
470 } else {
471 Err(format!("requires [[plugin]].settings.{key} to also be set"))
472 }
473 }
474 }
475}
476
477fn plugin_misplaced_key_diagnostics(raw_toml: Option<&str>) -> Vec<ConfigDiagnostic> {
478 let Some(raw_toml) = raw_toml else {
479 return Vec::new();
480 };
481 let Ok(parsed) = toml::from_str::<Value>(raw_toml) else {
482 return Vec::new();
483 };
484 let Some(plugin_entries) = parsed.get("plugin").and_then(Value::as_array) else {
485 return Vec::new();
486 };
487
488 let allowed_top_level = BTreeSet::from([
489 "name",
490 "enabled",
491 "web_ui_enabled",
492 "command",
493 "args",
494 "url",
495 "startup",
496 "settings",
497 ]);
498 let allowed_startup = BTreeSet::from([
499 "connect_timeout_secs",
500 "init_timeout_secs",
501 "optional",
502 "lazy_start",
503 ]);
504
505 let mut diagnostics = Vec::new();
506 for (index, item) in plugin_entries.iter().enumerate() {
507 let Some(table) = item.as_table() else {
508 continue;
509 };
510 let plugin_name = table
511 .get("name")
512 .and_then(Value::as_str)
513 .unwrap_or("<plugin>")
514 .to_string();
515
516 for key in table.keys() {
517 if allowed_top_level.contains(key.as_str()) {
518 continue;
519 }
520 diagnostics.push(
521 plugin_diagnostic(
522 ConfigDiagnosticCode::MisplacedField,
523 ConfigDiagnosticSeverity::Error,
524 plugin_setting_path(&plugin_name, [key.as_str()]),
525 format!(
526 "plugin[{index}].{key} is a custom plugin setting in a host-owned location; move it under [[plugin]].settings.{key}"
527 ),
528 )
529 .at_path(ConfigPath::parse_rendered(&format!("plugin[{index}].{key}")).unwrap_or_default()),
530 );
531 }
532
533 if let Some(startup) = table.get("startup").and_then(Value::as_table) {
534 for key in startup.keys() {
535 if allowed_startup.contains(key.as_str()) {
536 continue;
537 }
538 diagnostics.push(
539 plugin_diagnostic(
540 ConfigDiagnosticCode::MisplacedField,
541 ConfigDiagnosticSeverity::Error,
542 plugin_setting_path(&plugin_name, [key.as_str()]),
543 format!(
544 "plugin[{index}].startup.{key} is not a host-owned startup key; plugin custom settings must live under [[plugin]].settings.{key}"
545 ),
546 )
547 .at_path(
548 ConfigPath::parse_rendered(&format!("plugin[{index}].startup.{key}"))
549 .unwrap_or_default(),
550 ),
551 );
552 }
553 }
554 }
555
556 diagnostics
557}
558
559fn plugin_diagnostic(
560 code: ConfigDiagnosticCode,
561 severity: ConfigDiagnosticSeverity,
562 path: ConfigPath,
563 message: impl Into<String>,
564) -> ConfigDiagnostic {
565 ConfigDiagnostic::new(code, severity, ConfigDiagnosticSource::Plugin, message)
566 .with_schema_source(ConfigDiagnosticSchemaSource::Plugin)
567 .at_path(path.clone())
568 .with_canonical_path(path)
569}
570
571fn plugin_settings_path(plugin_name: &str) -> ConfigPath {
572 ConfigPath::from_fields(["plugin", plugin_name, "settings"])
573}
574
575fn plugin_setting_path<'a>(
576 plugin_name: &str,
577 segments: impl IntoIterator<Item = &'a str>,
578) -> ConfigPath {
579 let mut path = plugin_settings_path(plugin_name);
580 for segment in segments {
581 path.push_field(segment);
582 }
583 path
584}
585
586fn numeric_value(value: &Value) -> Option<f64> {
587 value
588 .as_float()
589 .or_else(|| value.as_integer().map(|integer| integer as f64))
590}
591
592fn parse_optional_constraint_number(
593 bound_name: &str,
594 raw: Option<&str>,
595) -> Result<Option<f64>, String> {
596 let Some(raw) = raw else {
597 return Ok(None);
598 };
599 raw.parse::<f64>()
600 .map(Some)
601 .map_err(|_| format!("range constraint {bound_name} bound must be numeric, got {raw:?}"))
602}
603
604fn render_number(value: f64) -> String {
605 if value.fract() == 0.0 {
606 format!("{value:.0}")
607 } else {
608 value.to_string()
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use crate::PluginWebUiPreference;
616
617 fn schema() -> PluginConfigSchema {
618 PluginConfigSchema {
619 plugin_name: "blackboard".into(),
620 schema_version: SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION,
621 allow_unvalidated_config: false,
622 settings: vec![
623 PluginSettingSchema {
624 key: "retention_days".into(),
625 value_schema: PluginValueSchema {
626 kind: PluginValueKind::Integer,
627 enum_values: Vec::new(),
628 items: None,
629 object_properties: Vec::new(),
630 allow_additional_properties: false,
631 },
632 required: true,
633 default_json: Some("14".into()),
634 constraints: vec![PluginSettingConstraint::Range {
635 min: Some("1".into()),
636 max: Some("365".into()),
637 }],
638 description: None,
639 control_behavior: None,
640 },
641 PluginSettingSchema {
642 key: "mode".into(),
643 value_schema: PluginValueSchema {
644 kind: PluginValueKind::Enum,
645 enum_values: vec!["strict".into(), "relaxed".into()],
646 items: None,
647 object_properties: Vec::new(),
648 allow_additional_properties: false,
649 },
650 required: false,
651 default_json: Some("\"strict\"".into()),
652 constraints: Vec::new(),
653 description: None,
654 control_behavior: None,
655 },
656 ],
657 }
658 }
659
660 #[test]
661 fn strict_plugin_validation_reports_misplaced_and_unknown_keys() {
662 let config: crate::MeshConfig = toml::from_str(
663 r#"
664[[plugin]]
665name = "blackboard"
666retention_days = 14
667
668[plugin.settings]
669mode = "strict"
670unknown = true
671"#,
672 )
673 .unwrap();
674
675 let diagnostics = validate_plugin_entries_strict(
676 &config.plugins,
677 Some(
678 r#"
679[[plugin]]
680name = "blackboard"
681retention_days = 14
682
683[plugin.settings]
684mode = "strict"
685unknown = true
686"#,
687 ),
688 |_| PluginSchemaAvailability::Available(schema()),
689 );
690
691 assert!(
692 diagnostics
693 .iter()
694 .any(|diagnostic| diagnostic.code == ConfigDiagnosticCode::MisplacedField)
695 );
696 assert!(
697 diagnostics
698 .iter()
699 .any(|diagnostic| diagnostic.code == ConfigDiagnosticCode::UnknownField)
700 );
701 }
702
703 #[test]
704 fn plugin_web_ui_enabled_is_host_owned_and_not_a_custom_setting() {
705 let raw = r#"
706[[plugin]]
707name = "blackboard"
708web_ui_enabled = false
709
710[plugin.settings]
711retention_days = 14
712"#;
713 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
714
715 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
716 PluginSchemaAvailability::Available(schema())
717 });
718
719 assert!(
720 !diagnostics
721 .iter()
722 .any(|diagnostic| diagnostic.code == ConfigDiagnosticCode::MisplacedField)
723 );
724 }
725
726 #[test]
727 fn plugin_web_ui_preference_resolves_declared_absent_and_explicit_choices() {
728 let absent = crate::PluginConfigEntry {
729 name: "blackboard".to_string(),
730 enabled: Some(false),
731 web_ui_enabled: None,
732 command: None,
733 args: Vec::new(),
734 url: None,
735 settings: BTreeMap::new(),
736 startup: Default::default(),
737 };
738 let disabled = crate::PluginConfigEntry {
739 web_ui_enabled: Some(false),
740 ..absent.clone()
741 };
742 let enabled = crate::PluginConfigEntry {
743 web_ui_enabled: Some(true),
744 ..absent.clone()
745 };
746
747 assert_eq!(
748 absent.web_ui_preference(true),
749 PluginWebUiPreference::Enabled
750 );
751 assert_eq!(
752 disabled.web_ui_preference(true),
753 PluginWebUiPreference::Disabled
754 );
755 assert_eq!(
756 enabled.web_ui_preference(true),
757 PluginWebUiPreference::Enabled
758 );
759 assert_eq!(
760 disabled.web_ui_preference(false),
761 PluginWebUiPreference::None
762 );
763 }
764
765 #[test]
766 fn strict_plugin_validation_rejects_required_settings_when_settings_table_is_absent() {
767 let raw = r#"
768[[plugin]]
769name = "blackboard"
770"#;
771 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
772
773 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
774 PluginSchemaAvailability::Available(schema())
775 });
776
777 assert!(diagnostics.iter().any(|diagnostic| {
778 diagnostic.code == ConfigDiagnosticCode::MissingRequiredValue
779 && diagnostic
780 .canonical_path
781 .as_ref()
782 .map(ConfigPath::render)
783 .as_deref()
784 == Some("plugin.blackboard.settings.retention_days")
785 }));
786 }
787
788 #[test]
789 fn strict_plugin_validation_rejects_malformed_range_bound() {
790 let raw = r#"
791[[plugin]]
792name = "blackboard"
793
794[plugin.settings]
795retention_days = 14
796"#;
797 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
798 let mut malformed_schema = schema();
799 malformed_schema.settings[0].constraints = vec![PluginSettingConstraint::Range {
800 min: Some("low".into()),
801 max: Some("365".into()),
802 }];
803
804 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
805 PluginSchemaAvailability::Available(malformed_schema.clone())
806 });
807
808 assert_eq!(diagnostics.len(), 1);
809 assert_eq!(diagnostics[0].code, ConfigDiagnosticCode::InvalidValue);
810 assert_eq!(diagnostics[0].severity, ConfigDiagnosticSeverity::Error);
811 assert_eq!(
812 diagnostics[0]
813 .canonical_path
814 .as_ref()
815 .map(ConfigPath::render),
816 Some("plugin.blackboard.settings.retention_days".to_string())
817 );
818 assert!(
819 diagnostics[0]
820 .message
821 .contains("range constraint min bound must be numeric")
822 );
823 }
824
825 #[test]
826 fn strict_plugin_validation_rejects_missing_install_time_schema_metadata() {
827 let raw = r#"
828[[plugin]]
829name = "blackboard"
830
831[plugin.settings]
832retention_days = 14
833"#;
834 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
835
836 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
837 PluginSchemaAvailability::MissingSchema
838 });
839
840 assert_eq!(diagnostics.len(), 1);
841 assert_eq!(diagnostics[0].code, ConfigDiagnosticCode::SchemaUnavailable);
842 assert_eq!(diagnostics[0].severity, ConfigDiagnosticSeverity::Error);
843 assert_eq!(
844 diagnostics[0]
845 .canonical_path
846 .as_ref()
847 .map(ConfigPath::render),
848 Some("plugin.blackboard.settings".to_string())
849 );
850 }
851
852 #[test]
853 fn strict_plugin_validation_rejects_uninstalled_plugins_with_custom_settings() {
854 let raw = r#"
855[[plugin]]
856name = "blackboard"
857
858[plugin.settings]
859retention_days = 14
860"#;
861 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
862
863 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
864 PluginSchemaAvailability::NotInstalled
865 });
866
867 assert_eq!(diagnostics.len(), 1);
868 assert_eq!(diagnostics[0].code, ConfigDiagnosticCode::SchemaUnavailable);
869 assert!(
870 diagnostics[0]
871 .message
872 .contains("custom settings cannot be validated in strict mode")
873 );
874 }
875
876 #[test]
877 fn strict_plugin_validation_only_allows_unbounded_settings_via_legacy_escape_hatch() {
878 let raw = r#"
879[[plugin]]
880name = "blackboard"
881
882[plugin.settings]
883retention_days = 14
884unknown = true
885"#;
886 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
887 let mut legacy_schema = schema();
888 legacy_schema.allow_unvalidated_config = true;
889
890 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
891 PluginSchemaAvailability::Available(legacy_schema.clone())
892 });
893
894 assert_eq!(diagnostics.len(), 1);
895 assert_eq!(
896 diagnostics[0].code,
897 ConfigDiagnosticCode::LegacyUnvalidatedConfig
898 );
899 assert_eq!(diagnostics[0].severity, ConfigDiagnosticSeverity::Warning);
900 assert!(
901 diagnostics[0]
902 .message
903 .contains("allows legacy unvalidated config")
904 );
905 }
906
907 #[test]
908 fn strict_plugin_validation_legacy_escape_hatch_still_validates_known_settings() {
909 let raw = r#"
910[[plugin]]
911name = "blackboard"
912
913[plugin.settings]
914retention_days = 0
915mode = "mystery"
916unknown = true
917"#;
918 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
919 let mut legacy_schema = schema();
920 legacy_schema.allow_unvalidated_config = true;
921
922 let diagnostics = validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
923 PluginSchemaAvailability::Available(legacy_schema.clone())
924 });
925
926 assert!(diagnostics.iter().any(|diagnostic| {
927 diagnostic.code == ConfigDiagnosticCode::LegacyUnvalidatedConfig
928 && diagnostic.severity == ConfigDiagnosticSeverity::Warning
929 }));
930 assert!(diagnostics.iter().any(|diagnostic| {
931 diagnostic.code == ConfigDiagnosticCode::InvalidValue
932 && diagnostic
933 .canonical_path
934 .as_ref()
935 .map(ConfigPath::render)
936 .as_deref()
937 == Some("plugin.blackboard.settings.retention_days")
938 }));
939 assert!(diagnostics.iter().any(|diagnostic| {
940 diagnostic.code == ConfigDiagnosticCode::InvalidValue
941 && diagnostic
942 .canonical_path
943 .as_ref()
944 .map(ConfigPath::render)
945 .as_deref()
946 == Some("plugin.blackboard.settings.mode")
947 }));
948 assert!(!diagnostics.iter().any(|diagnostic| {
949 diagnostic.code == ConfigDiagnosticCode::UnknownField
950 && diagnostic
951 .canonical_path
952 .as_ref()
953 .map(ConfigPath::render)
954 .as_deref()
955 == Some("plugin.blackboard.settings.unknown")
956 }));
957 }
958
959 #[test]
960 fn strict_plugin_validation_rejects_unsupported_schema_version_boundaries() {
961 let raw = r#"
962[[plugin]]
963name = "blackboard"
964
965[plugin.settings]
966retention_days = 14
967"#;
968 let config: crate::MeshConfig = toml::from_str(raw).unwrap();
969
970 let mut mismatched_schema = schema();
971 mismatched_schema.schema_version = SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION + 1;
972 let available_diagnostics =
973 validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
974 PluginSchemaAvailability::Available(mismatched_schema.clone())
975 });
976 let unavailable_diagnostics =
977 validate_plugin_entries_strict(&config.plugins, Some(raw), |_| {
978 PluginSchemaAvailability::UnsupportedVersion {
979 version: SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION + 2,
980 }
981 });
982
983 assert_eq!(
984 available_diagnostics[0].code,
985 ConfigDiagnosticCode::UnsupportedSchemaVersion
986 );
987 assert!(
988 available_diagnostics[0]
989 .message
990 .contains("unsupported config schema_version")
991 );
992 assert_eq!(
993 unavailable_diagnostics[0].code,
994 ConfigDiagnosticCode::UnsupportedSchemaVersion
995 );
996 assert!(
997 unavailable_diagnostics[0]
998 .message
999 .contains(&format!("{}", SUPPORTED_PLUGIN_CONFIG_SCHEMA_VERSION + 2))
1000 );
1001 }
1002}