1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::RwLock;
34
35use crate::config::{PluginConfig, PluginPhase};
36use crate::wasm::runner::PluginRunner;
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum PluginHealth {
41 Healthy,
43 Crashed { error: String, crash_count: u32 },
45 Disabled { reason: String },
47}
48
49#[derive(Clone, Debug)]
51pub struct LoadedPlugin {
52 pub config: PluginConfig,
53 pub checksum: String,
54 pub health: PluginHealth,
55}
56
57pub struct PluginLifecycle {
60 plugins: RwLock<HashMap<String, LoadedPlugin>>,
62 route_plugins: RwLock<HashMap<String, Vec<String>>>,
64 runner: RwLock<Option<PluginRunner>>,
66}
67
68impl PluginLifecycle {
69 pub fn new() -> Self {
71 Self {
72 plugins: RwLock::new(HashMap::new()),
73 route_plugins: RwLock::new(HashMap::new()),
74 runner: RwLock::new(None),
75 }
76 }
77
78 pub fn load(
92 &self,
93 configs: &[PluginConfig],
94 sources: &crate::wasm::source::SourceResolutions,
95 ) -> Result<(), LoadError> {
96 let mut plugins = HashMap::new();
97 let mut effective_paths: HashMap<String, PathBuf> = HashMap::new();
101
102 for config in configs {
103 let wasm_path: String = if let Some(p) = &config.wasm {
108 p.clone()
109 } else if let Some(_source) = &config.source {
110 match sources.get(&config.name) {
114 Some(Ok(path)) => {
115 effective_paths.insert(config.name.clone(), path.clone());
116 path.display().to_string()
117 }
118 Some(Err(e)) => {
119 let crash_count = self.next_crash_count(&config.name);
123 tracing::error!(
124 code = "plugin_source_failed",
125 plugin = %config.name,
126 "DW-165: registry source could not be resolved: {e}; \
127 routes referencing it fail closed (500)"
128 );
129 plugins.insert(
130 config.name.clone(),
131 LoadedPlugin {
132 config: config.clone(),
133 checksum: String::new(),
134 health: PluginHealth::Crashed {
135 error: format!("source resolution failed: {e}"),
136 crash_count,
137 },
138 },
139 );
140 continue;
141 }
142 None => {
143 let crash_count = self.next_crash_count(&config.name);
147 tracing::error!(
148 code = "plugin_source_failed",
149 plugin = %config.name,
150 "DW-165: registry source was not resolved (internal \
151 error: resolver skipped this plugin); routes referencing \
152 it fail closed (500)"
153 );
154 plugins.insert(
155 config.name.clone(),
156 LoadedPlugin {
157 config: config.clone(),
158 checksum: String::new(),
159 health: PluginHealth::Crashed {
160 error: "source was not resolved (internal resolver \
161 error)"
162 .to_string(),
163 crash_count,
164 },
165 },
166 );
167 continue;
168 }
169 }
170 } else {
171 continue;
172 };
173 let (checksum, health) = match std::fs::read(&wasm_path) {
178 Ok(wasm_bytes) => {
179 let checksum = sha256_hex(&wasm_bytes);
180 if let Some(src) = &config.source {
188 if checksum != src.digest.to_ascii_lowercase() {
189 let crash_count = self.next_crash_count(&config.name);
190 tracing::error!(
191 code = "plugin_source_digest_recheck_failed",
192 plugin = %config.name,
193 path = %wasm_path,
194 "DW-165: the artifact changed between verification \
195 and load (pinned digest does not match the loaded \
196 bytes); routes referencing it fail closed (500)"
197 );
198 (
199 String::new(),
200 PluginHealth::Crashed {
201 error: format!(
202 "digest re-check failed: source.digest pins \
203 {} but the loaded module hashes to {checksum} \
204 (the artifact changed between verification \
205 and load)",
206 src.digest
207 ),
208 crash_count,
209 },
210 )
211 } else {
212 let health = self.health_for_checksum(&config.name, &checksum);
213 (checksum, health)
214 }
215 } else {
216 let health = self.health_for_checksum(&config.name, &checksum);
217 (checksum, health)
218 }
219 }
220 Err(e) => {
221 let crash_count = self.next_crash_count(&config.name);
222 tracing::error!(
223 code = "plugin_load_failed",
224 plugin = %config.name,
225 path = %wasm_path,
226 error = %e,
227 "DW-157: plugin .wasm unreadable; routes referencing it fail closed (500)"
228 );
229 (
230 String::new(),
231 PluginHealth::Crashed {
232 error: format!("cannot read {wasm_path}: {e}"),
233 crash_count,
234 },
235 )
236 }
237 };
238
239 plugins.insert(
240 config.name.clone(),
241 LoadedPlugin {
242 config: config.clone(),
243 checksum,
244 health,
245 },
246 );
247 }
248
249 let runner = PluginRunner::new(configs, &effective_paths)
251 .map_err(|e| LoadError::Compile { error: e })?;
252
253 {
260 for (name, lp) in plugins.iter_mut() {
261 if (lp.config.wasm.is_some() || lp.config.source.is_some())
262 && !runner.has(name)
263 && matches!(lp.health, PluginHealth::Healthy)
264 {
265 tracing::error!(
266 code = "plugin_compile_failed",
267 plugin = %name,
268 "DW-157: plugin .wasm failed to compile; routes referencing it fail closed (500)"
269 );
270 lp.health = PluginHealth::Crashed {
271 error: "plugin failed to compile".to_string(),
272 crash_count: 1,
273 };
274 }
275 }
276 }
277
278 *self.plugins.write().unwrap() = plugins;
279 *self.runner.write().unwrap() = Some(runner);
280
281 Ok(())
282 }
283
284 pub fn set_route_plugins(&self, routes: HashMap<String, Vec<String>>) {
289 *self.route_plugins.write().unwrap() = routes;
290 }
291
292 pub fn register_route(&self, route_name: &str, plugin_names: &[String]) {
294 let mut route_plugins = self.route_plugins.write().unwrap();
295 route_plugins.insert(route_name.to_string(), plugin_names.to_vec());
296 }
297
298 fn next_crash_count(&self, name: &str) -> u32 {
304 let existing = self.plugins.read().unwrap();
305 match existing.get(name) {
306 Some(prev) => match &prev.health {
307 PluginHealth::Crashed { crash_count, .. } => crash_count + 1,
308 _ => 1,
309 },
310 None => 1,
311 }
312 }
313
314 fn health_for_checksum(&self, name: &str, checksum: &str) -> PluginHealth {
318 let existing = self.plugins.read().unwrap();
319 if let Some(prev) = existing.get(name) {
320 if prev.checksum == checksum {
321 return prev.health.clone();
323 }
324 }
325 PluginHealth::Healthy
326 }
327
328 pub fn mark_crashed(&self, plugin_name: &str, error: &str) {
330 let mut plugins = self.plugins.write().unwrap();
331 if let Some(plugin) = plugins.get_mut(plugin_name) {
332 let crash_count = match &plugin.health {
333 PluginHealth::Crashed { crash_count, .. } => crash_count + 1,
334 _ => 1,
335 };
336 plugin.health = PluginHealth::Crashed {
337 error: error.to_string(),
338 crash_count,
339 };
340 }
341 }
342
343 pub fn mark_healthy(&self, plugin_name: &str) {
345 let mut plugins = self.plugins.write().unwrap();
346 if let Some(plugin) = plugins.get_mut(plugin_name) {
347 plugin.health = PluginHealth::Healthy;
348 }
349 }
350
351 pub fn disable(&self, plugin_name: &str, reason: &str) {
353 let mut plugins = self.plugins.write().unwrap();
354 if let Some(plugin) = plugins.get_mut(plugin_name) {
355 plugin.health = PluginHealth::Disabled {
356 reason: reason.to_string(),
357 };
358 }
359 }
360
361 pub fn crashed_plugins_for_route(&self, route_name: &str) -> Vec<String> {
364 let route_plugins = self.route_plugins.read().unwrap();
365 let plugins = self.plugins.read().unwrap();
366
367 route_plugins
368 .get(route_name)
369 .map(|names| {
370 names
371 .iter()
372 .filter(|name| {
373 plugins
374 .get(*name)
375 .map(|p| matches!(p.health, PluginHealth::Crashed { .. }))
376 .unwrap_or(false)
377 })
378 .cloned()
379 .collect()
380 })
381 .unwrap_or_default()
382 }
383
384 pub fn route_should_500(&self, route_name: &str) -> bool {
386 !self.crashed_plugins_for_route(route_name).is_empty()
387 }
388
389 pub fn get_plugin(&self, name: &str) -> Option<LoadedPlugin> {
391 self.plugins.read().unwrap().get(name).cloned()
392 }
393
394 pub fn plugins(&self) -> Vec<LoadedPlugin> {
396 self.plugins.read().unwrap().values().cloned().collect()
397 }
398
399 pub fn runner(&self) -> Option<PluginRunner> {
401 self.runner.read().unwrap().clone()
402 }
403
404 pub fn plugin_count(&self) -> usize {
406 self.plugins.read().unwrap().len()
407 }
408
409 pub fn healthy_count(&self) -> usize {
411 self.plugins
412 .read()
413 .unwrap()
414 .values()
415 .filter(|p| matches!(p.health, PluginHealth::Healthy))
416 .count()
417 }
418
419 pub fn crashed_count(&self) -> usize {
421 self.plugins
422 .read()
423 .unwrap()
424 .values()
425 .filter(|p| matches!(p.health, PluginHealth::Crashed { .. }))
426 .count()
427 }
428
429 pub fn validate_config(config: &PluginConfig) -> Result<(), ValidationError> {
435 let wasm_path = match &config.wasm {
437 Some(p) => p,
438 None => return Ok(()),
439 };
440 if wasm_path.is_empty() {
441 return Err(ValidationError::EmptyWasmPath {
442 plugin: config.name.clone(),
443 });
444 }
445
446 if config.phases.is_empty() {
448 return Err(ValidationError::NoPhases {
449 plugin: config.name.clone(),
450 });
451 }
452
453 if let Some(limits) = &config.limits {
455 if let Some(fuel) = limits.fuel {
456 if fuel == 0 {
457 return Err(ValidationError::ZeroFuel {
458 plugin: config.name.clone(),
459 });
460 }
461 }
462 if let Some(memory_mb) = limits.memory_mb {
463 if memory_mb == 0 {
464 return Err(ValidationError::ZeroMemory {
465 plugin: config.name.clone(),
466 });
467 }
468 }
469 if let Some(timeout_ms) = limits.timeout_ms {
470 if timeout_ms == 0 {
471 return Err(ValidationError::ZeroTimeout {
472 plugin: config.name.clone(),
473 });
474 }
475 }
476 }
477
478 if !Path::new(wasm_path).exists() {
480 return Err(ValidationError::WasmNotFound {
481 plugin: config.name.clone(),
482 path: wasm_path.clone(),
483 });
484 }
485
486 Ok(())
487 }
488
489 pub fn phase_order(
492 plugin_names: &[String],
493 plugins: &HashMap<String, PluginConfig>,
494 ) -> Vec<(PluginPhase, Vec<String>)> {
495 let phases = [
496 PluginPhase::RequestHeaders,
497 PluginPhase::RequestBody,
498 PluginPhase::ResponseHeaders,
499 PluginPhase::ResponseBody,
500 ];
501
502 phases
503 .iter()
504 .map(|phase| {
505 let phase_plugins: Vec<String> = plugin_names
506 .iter()
507 .filter(|name| {
508 plugins
509 .get(*name)
510 .map(|p| p.phases.contains(phase))
511 .unwrap_or(false)
512 })
513 .cloned()
514 .collect();
515 (*phase, phase_plugins)
516 })
517 .filter(|(_, ps)| !ps.is_empty())
518 .collect()
519 }
520}
521
522impl Default for PluginLifecycle {
523 fn default() -> Self {
524 Self::new()
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
533pub enum LoadError {
534 Compile { error: String },
536}
537
538impl std::fmt::Display for LoadError {
539 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 match self {
541 LoadError::Compile { error } => {
542 write!(f, "plugin compile error: {error}")
543 }
544 }
545 }
546}
547
548impl std::error::Error for LoadError {}
549
550#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum ValidationError {
553 EmptyWasmPath { plugin: String },
555 WasmNotFound { plugin: String, path: String },
557 NoPhases { plugin: String },
559 ZeroFuel { plugin: String },
561 ZeroMemory { plugin: String },
563 ZeroTimeout { plugin: String },
565}
566
567impl std::fmt::Display for ValidationError {
568 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569 match self {
570 ValidationError::EmptyWasmPath { plugin } => {
571 write!(f, "plugin '{plugin}': wasm path is empty")
572 }
573 ValidationError::WasmNotFound { plugin, path } => {
574 write!(f, "plugin '{plugin}': wasm file not found: {path}")
575 }
576 ValidationError::NoPhases { plugin } => {
577 write!(f, "plugin '{plugin}': no phases declared")
578 }
579 ValidationError::ZeroFuel { plugin } => {
580 write!(f, "plugin '{plugin}': fuel limit is zero")
581 }
582 ValidationError::ZeroMemory { plugin } => {
583 write!(f, "plugin '{plugin}': memory limit is zero")
584 }
585 ValidationError::ZeroTimeout { plugin } => {
586 write!(f, "plugin '{plugin}': timeout limit is zero")
587 }
588 }
589 }
590}
591
592impl std::error::Error for ValidationError {}
593
594pub(crate) fn sha256_hex(data: &[u8]) -> String {
598 use sha2::{Digest, Sha256};
599
600 let mut hasher = Sha256::new();
608 hasher.update(data);
609 let digest = hasher.finalize();
610 let mut hex = String::with_capacity(64);
611 for byte in digest.iter() {
612 hex.push_str(&format!("{byte:02x}"));
613 }
614 hex
615}
616
617#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::config::{PluginConfig, PluginLimitsConfig, PluginPhase};
625
626 fn make_plugin_config(name: &str, wasm: &str) -> PluginConfig {
627 PluginConfig {
628 name: name.to_string(),
629 wasm: Some(wasm.to_string()),
630 source: None,
631 native: None,
632 phases: vec![PluginPhase::RequestHeaders],
633 config: None,
634 limits: None,
635 }
636 }
637
638 #[test]
639 fn lifecycle_new_is_empty() {
640 let lifecycle = PluginLifecycle::new();
641 assert_eq!(lifecycle.plugin_count(), 0);
642 assert_eq!(lifecycle.healthy_count(), 0);
643 assert_eq!(lifecycle.crashed_count(), 0);
644 assert!(lifecycle.runner().is_none());
645 }
646
647 #[test]
648 fn validate_empty_wasm_path() {
649 let config = PluginConfig {
650 name: "test".to_string(),
651 wasm: Some("".to_string()),
652 source: None,
653 native: None,
654 phases: vec![PluginPhase::RequestHeaders],
655 config: None,
656 limits: None,
657 };
658 let err = PluginLifecycle::validate_config(&config).unwrap_err();
659 assert!(matches!(err, ValidationError::EmptyWasmPath { .. }));
660 }
661
662 #[test]
663 fn validate_wasm_not_found() {
664 let config = PluginConfig {
665 name: "test".to_string(),
666 wasm: Some("/nonexistent/path.wasm".to_string()),
667 source: None,
668 native: None,
669 phases: vec![PluginPhase::RequestHeaders],
670 config: None,
671 limits: None,
672 };
673 let err = PluginLifecycle::validate_config(&config).unwrap_err();
674 assert!(matches!(err, ValidationError::WasmNotFound { .. }));
675 }
676
677 #[test]
678 fn validate_no_phases() {
679 let config = PluginConfig {
680 name: "test".to_string(),
681 wasm: Some("/tmp/test.wasm".to_string()),
682 source: None,
683 native: None,
684 phases: vec![],
685 config: None,
686 limits: None,
687 };
688 let err = PluginLifecycle::validate_config(&config).unwrap_err();
689 assert!(matches!(err, ValidationError::NoPhases { .. }));
690 }
691
692 #[test]
693 fn validate_zero_fuel() {
694 let config = PluginConfig {
695 name: "test".to_string(),
696 wasm: Some("/tmp/test.wasm".to_string()),
697 source: None,
698 native: None,
699 phases: vec![PluginPhase::RequestHeaders],
700 config: None,
701 limits: Some(PluginLimitsConfig {
702 fuel: Some(0),
703 memory_mb: None,
704 timeout_ms: None,
705 }),
706 };
707 let err = PluginLifecycle::validate_config(&config).unwrap_err();
708 assert!(matches!(err, ValidationError::ZeroFuel { .. }));
709 }
710
711 #[test]
712 fn validate_zero_memory() {
713 let config = PluginConfig {
714 name: "test".to_string(),
715 wasm: Some("/tmp/test.wasm".to_string()),
716 source: None,
717 native: None,
718 phases: vec![PluginPhase::RequestHeaders],
719 config: None,
720 limits: Some(PluginLimitsConfig {
721 fuel: None,
722 memory_mb: Some(0),
723 timeout_ms: None,
724 }),
725 };
726 let err = PluginLifecycle::validate_config(&config).unwrap_err();
727 assert!(matches!(err, ValidationError::ZeroMemory { .. }));
728 }
729
730 #[test]
731 fn validate_zero_timeout() {
732 let config = PluginConfig {
733 name: "test".to_string(),
734 wasm: Some("/tmp/test.wasm".to_string()),
735 source: None,
736 native: None,
737 phases: vec![PluginPhase::RequestHeaders],
738 config: None,
739 limits: Some(PluginLimitsConfig {
740 fuel: None,
741 memory_mb: None,
742 timeout_ms: Some(0),
743 }),
744 };
745 let err = PluginLifecycle::validate_config(&config).unwrap_err();
746 assert!(matches!(err, ValidationError::ZeroTimeout { .. }));
747 }
748
749 #[test]
750 fn mark_plugin_crashed() {
751 let lifecycle = PluginLifecycle::new();
752 {
754 let mut plugins = lifecycle.plugins.write().unwrap();
755 plugins.insert(
756 "test-plugin".to_string(),
757 LoadedPlugin {
758 config: make_plugin_config("test-plugin", "/tmp/test.wasm"),
759 checksum: "abc123".to_string(),
760 health: PluginHealth::Healthy,
761 },
762 );
763 }
764
765 assert_eq!(lifecycle.healthy_count(), 1);
766 assert_eq!(lifecycle.crashed_count(), 0);
767
768 lifecycle.mark_crashed("test-plugin", "out of fuel");
769 assert_eq!(lifecycle.healthy_count(), 0);
770 assert_eq!(lifecycle.crashed_count(), 1);
771
772 let plugin = lifecycle.get_plugin("test-plugin").unwrap();
773 match &plugin.health {
774 PluginHealth::Crashed { error, crash_count } => {
775 assert_eq!(error, "out of fuel");
776 assert_eq!(*crash_count, 1);
777 }
778 other => panic!("expected Crashed, got {other:?}"),
779 }
780 }
781
782 #[test]
783 fn mark_plugin_healthy_after_crash() {
784 let lifecycle = PluginLifecycle::new();
785 {
786 let mut plugins = lifecycle.plugins.write().unwrap();
787 plugins.insert(
788 "test-plugin".to_string(),
789 LoadedPlugin {
790 config: make_plugin_config("test-plugin", "/tmp/test.wasm"),
791 checksum: "abc123".to_string(),
792 health: PluginHealth::Crashed {
793 error: "out of fuel".to_string(),
794 crash_count: 2,
795 },
796 },
797 );
798 }
799
800 assert_eq!(lifecycle.crashed_count(), 1);
801 lifecycle.mark_healthy("test-plugin");
802 assert_eq!(lifecycle.healthy_count(), 1);
803 assert_eq!(lifecycle.crashed_count(), 0);
804 }
805
806 #[test]
807 fn disable_plugin() {
808 let lifecycle = PluginLifecycle::new();
809 {
810 let mut plugins = lifecycle.plugins.write().unwrap();
811 plugins.insert(
812 "test-plugin".to_string(),
813 LoadedPlugin {
814 config: make_plugin_config("test-plugin", "/tmp/test.wasm"),
815 checksum: "abc123".to_string(),
816 health: PluginHealth::Healthy,
817 },
818 );
819 }
820
821 lifecycle.disable("test-plugin", "circuit breaker");
822 let plugin = lifecycle.get_plugin("test-plugin").unwrap();
823 match &plugin.health {
824 PluginHealth::Disabled { reason } => {
825 assert_eq!(reason, "circuit breaker");
826 }
827 other => panic!("expected Disabled, got {other:?}"),
828 }
829 }
830
831 #[test]
832 fn failure_isolation_route_with_crashed_plugin() {
833 let lifecycle = PluginLifecycle::new();
834 {
835 let mut plugins = lifecycle.plugins.write().unwrap();
836 plugins.insert(
837 "broken-plugin".to_string(),
838 LoadedPlugin {
839 config: make_plugin_config("broken-plugin", "/tmp/test.wasm"),
840 checksum: "abc123".to_string(),
841 health: PluginHealth::Crashed {
842 error: "panic".to_string(),
843 crash_count: 1,
844 },
845 },
846 );
847 plugins.insert(
848 "good-plugin".to_string(),
849 LoadedPlugin {
850 config: make_plugin_config("good-plugin", "/tmp/test.wasm"),
851 checksum: "def456".to_string(),
852 health: PluginHealth::Healthy,
853 },
854 );
855 }
856
857 lifecycle.register_route("route-1", &["broken-plugin".to_string()]);
858 lifecycle.register_route("route-2", &["good-plugin".to_string()]);
859 lifecycle.register_route(
860 "route-3",
861 &["broken-plugin".to_string(), "good-plugin".to_string()],
862 );
863
864 assert!(lifecycle.route_should_500("route-1"));
866 assert_eq!(
867 lifecycle.crashed_plugins_for_route("route-1"),
868 vec!["broken-plugin"]
869 );
870
871 assert!(!lifecycle.route_should_500("route-2"));
873 assert!(lifecycle.crashed_plugins_for_route("route-2").is_empty());
874
875 assert!(lifecycle.route_should_500("route-3"));
877 }
878
879 #[test]
880 fn failure_isolation_unaffected_route() {
881 let lifecycle = PluginLifecycle::new();
882 {
883 let mut plugins = lifecycle.plugins.write().unwrap();
884 plugins.insert(
885 "broken-plugin".to_string(),
886 LoadedPlugin {
887 config: make_plugin_config("broken-plugin", "/tmp/test.wasm"),
888 checksum: "abc123".to_string(),
889 health: PluginHealth::Crashed {
890 error: "panic".to_string(),
891 crash_count: 1,
892 },
893 },
894 );
895 }
896
897 lifecycle.register_route("route-with-plugin", &["broken-plugin".to_string()]);
898 lifecycle.register_route("route-without-plugin", &[]);
899
900 assert!(lifecycle.route_should_500("route-with-plugin"));
902
903 assert!(!lifecycle.route_should_500("route-without-plugin"));
905 }
906
907 #[test]
908 fn crash_count_increments() {
909 let lifecycle = PluginLifecycle::new();
910 {
911 let mut plugins = lifecycle.plugins.write().unwrap();
912 plugins.insert(
913 "test-plugin".to_string(),
914 LoadedPlugin {
915 config: make_plugin_config("test-plugin", "/tmp/test.wasm"),
916 checksum: "abc123".to_string(),
917 health: PluginHealth::Healthy,
918 },
919 );
920 }
921
922 lifecycle.mark_crashed("test-plugin", "error 1");
923 lifecycle.mark_crashed("test-plugin", "error 2");
924 lifecycle.mark_crashed("test-plugin", "error 3");
925
926 let plugin = lifecycle.get_plugin("test-plugin").unwrap();
927 match &plugin.health {
928 PluginHealth::Crashed { crash_count, .. } => assert_eq!(*crash_count, 3),
929 other => panic!("expected Crashed, got {other:?}"),
930 }
931 }
932
933 #[test]
934 fn phase_order_deterministic() {
935 let mut plugins = HashMap::new();
936 plugins.insert(
937 "plugin-a".to_string(),
938 PluginConfig {
939 name: "plugin-a".to_string(),
940 wasm: Some("/tmp/a.wasm".to_string()),
941 source: None,
942 native: None,
943 phases: vec![PluginPhase::RequestHeaders, PluginPhase::ResponseBody],
944 config: None,
945 limits: None,
946 },
947 );
948 plugins.insert(
949 "plugin-b".to_string(),
950 PluginConfig {
951 name: "plugin-b".to_string(),
952 wasm: Some("/tmp/b.wasm".to_string()),
953 source: None,
954 native: None,
955 phases: vec![PluginPhase::RequestHeaders, PluginPhase::RequestBody],
956 config: None,
957 limits: None,
958 },
959 );
960
961 let order = PluginLifecycle::phase_order(
962 &["plugin-b".to_string(), "plugin-a".to_string()],
963 &plugins,
964 );
965
966 assert_eq!(order.len(), 3); assert_eq!(order[0].0, PluginPhase::RequestHeaders);
969 assert!(order[0].1.contains(&"plugin-a".to_string()));
970 assert!(order[0].1.contains(&"plugin-b".to_string()));
971 assert_eq!(order[1].0, PluginPhase::RequestBody);
972 assert_eq!(order[1].1, vec!["plugin-b"]);
973 assert_eq!(order[2].0, PluginPhase::ResponseBody);
974 assert_eq!(order[2].1, vec!["plugin-a"]);
975 }
976
977 #[test]
978 fn phase_order_skips_empty_phases() {
979 let mut plugins = HashMap::new();
980 plugins.insert(
981 "plugin-a".to_string(),
982 PluginConfig {
983 name: "plugin-a".to_string(),
984 wasm: Some("/tmp/a.wasm".to_string()),
985 source: None,
986 native: None,
987 phases: vec![PluginPhase::ResponseHeaders],
988 config: None,
989 limits: None,
990 },
991 );
992
993 let order = PluginLifecycle::phase_order(&["plugin-a".to_string()], &plugins);
994
995 assert_eq!(order.len(), 1);
997 assert_eq!(order[0].0, PluginPhase::ResponseHeaders);
998 }
999
1000 #[test]
1001 fn sha256_hex_is_deterministic() {
1002 let data = b"hello world";
1003 let hash1 = sha256_hex(data);
1004 let hash2 = sha256_hex(data);
1005 assert_eq!(hash1, hash2);
1006 }
1007
1008 #[test]
1009 fn sha256_hex_differs_for_different_data() {
1010 let hash1 = sha256_hex(b"hello");
1011 let hash2 = sha256_hex(b"world");
1012 assert_ne!(hash1, hash2);
1013 }
1014
1015 #[test]
1016 fn sha256_hex_is_real_sha256_full_width() {
1017 let digest = sha256_hex(b"hello world");
1021 assert_eq!(digest.len(), 64);
1022 assert_eq!(
1023 digest,
1024 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1025 );
1026 }
1027
1028 #[test]
1029 fn load_marks_unreadable_wasm_crashed_and_succeeds() {
1030 let lifecycle = PluginLifecycle::new();
1035 let configs = vec![make_plugin_config("broken", "/nonexistent/dwara-test.wasm")];
1036 lifecycle
1037 .load(&configs, &HashMap::new())
1038 .expect("load succeeds with per-plugin isolation");
1039 assert_eq!(lifecycle.plugin_count(), 1);
1040 assert_eq!(lifecycle.crashed_count(), 1);
1041 let plugin = lifecycle
1042 .get_plugin("broken")
1043 .expect("broken plugin tracked");
1044 match plugin.health {
1045 PluginHealth::Crashed { crash_count, .. } => assert_eq!(crash_count, 1),
1046 other => panic!("expected Crashed, got {other:?}"),
1047 }
1048 lifecycle
1050 .load(&configs, &HashMap::new())
1051 .expect("reload succeeds");
1052 let plugin = lifecycle.get_plugin("broken").unwrap();
1053 match plugin.health {
1054 PluginHealth::Crashed { crash_count, .. } => assert_eq!(crash_count, 2),
1055 other => panic!("expected Crashed, got {other:?}"),
1056 }
1057 }
1058
1059 #[test]
1060 fn set_route_plugins_replaces_wholesale() {
1061 let lifecycle = PluginLifecycle::new();
1062 {
1063 let mut plugins = lifecycle.plugins.write().unwrap();
1064 plugins.insert(
1065 "broken-plugin".to_string(),
1066 LoadedPlugin {
1067 config: make_plugin_config("broken-plugin", "/tmp/test.wasm"),
1068 checksum: "abc123".to_string(),
1069 health: PluginHealth::Crashed {
1070 error: "panic".to_string(),
1071 crash_count: 1,
1072 },
1073 },
1074 );
1075 }
1076 lifecycle.register_route("stale", &["broken-plugin".to_string()]);
1077 assert!(lifecycle.route_should_500("stale"));
1078 let mut next = HashMap::new();
1079 next.insert("fresh".to_string(), vec!["broken-plugin".to_string()]);
1080 lifecycle.set_route_plugins(next);
1081 assert!(
1082 !lifecycle.route_should_500("stale"),
1083 "the replaced mapping no longer exists"
1084 );
1085 assert!(lifecycle.route_should_500("fresh"));
1086 }
1087
1088 #[test]
1089 fn load_error_compile_display() {
1090 let err = LoadError::Compile {
1091 error: "wasmtime engine failed".to_string(),
1092 };
1093 let s = format!("{err}");
1094 assert!(s.contains("compile"));
1095 assert!(s.contains("wasmtime engine failed"));
1096 }
1097
1098 fn make_source_plugin(name: &str) -> PluginConfig {
1101 PluginConfig {
1102 name: name.to_string(),
1103 wasm: None,
1104 native: None,
1105 source: Some(crate::config::PluginSourceConfig {
1106 url: "https://registry.example.com/plugins/p.wasm".to_string(),
1107 digest: "a".repeat(64),
1108 signature: None,
1109 public_key: None,
1110 cache_path: None,
1111 }),
1112 phases: vec![PluginPhase::RequestHeaders],
1113 config: None,
1114 limits: None,
1115 }
1116 }
1117
1118 #[test]
1119 fn load_marks_unresolved_source_crashed_and_succeeds() {
1120 let lifecycle = PluginLifecycle::new();
1124 let configs = vec![make_source_plugin("remote")];
1125 let mut sources = HashMap::new();
1126 sources.insert(
1127 "remote".to_string(),
1128 Err(crate::wasm::source::SourceError::DigestMismatch {
1129 plugin: "remote".to_string(),
1130 origin: "downloaded",
1131 expected: "a".repeat(64),
1132 actual: "b".repeat(64),
1133 }),
1134 );
1135 lifecycle
1136 .load(&configs, &sources)
1137 .expect("load succeeds with per-plugin isolation");
1138 assert_eq!(lifecycle.crashed_count(), 1);
1139 let plugin = lifecycle.get_plugin("remote").unwrap();
1140 match &plugin.health {
1141 PluginHealth::Crashed { error, crash_count } => {
1142 assert_eq!(*crash_count, 1);
1143 assert!(
1144 error.contains("digest verification failed"),
1145 "error names the step: {error}"
1146 );
1147 assert!(error.contains("remote"), "error names the plugin: {error}");
1148 }
1149 other => panic!("expected Crashed, got {other:?}"),
1150 }
1151 assert_eq!(plugin.checksum, "");
1153 }
1154
1155 #[test]
1156 fn load_marks_unresolved_entry_source_crashed() {
1157 let lifecycle = PluginLifecycle::new();
1161 let configs = vec![make_source_plugin("remote")];
1162 lifecycle
1163 .load(&configs, &HashMap::new())
1164 .expect("load succeeds");
1165 let plugin = lifecycle.get_plugin("remote").unwrap();
1166 match &plugin.health {
1167 PluginHealth::Crashed { error, .. } => {
1168 assert!(error.contains("not resolved"), "got: {error}");
1169 }
1170 other => panic!("expected Crashed, got {other:?}"),
1171 }
1172 }
1173
1174 #[test]
1175 fn validation_error_display() {
1176 let err = ValidationError::NoPhases {
1177 plugin: "test".to_string(),
1178 };
1179 let s = format!("{err}");
1180 assert!(s.contains("test"));
1181 assert!(s.contains("no phases"));
1182 }
1183}