dwara_core/wasm/
lifecycle.rs

1//! Plugin lifecycle (DW-056).
2//!
3//! Load from config (path/registry ref, checksums); hot-swap on
4//! reload; plugin config schema validation; failure isolation.
5//!
6//! Attachment: config selects plugins per route/service/global,
7//! applied in phase order per the DW-055 phase contract (section 9.3)
8//! -- a route's plugin chain is deterministic from its config, not
9//! load-order-dependent.
10//!
11//! ## Failure isolation
12//!
13//! A crashed plugin returns 500 on affected routes only, never
14//! gateway-wide. The plugin lifecycle manager tracks which plugins
15//! are healthy and which routes use them. When a plugin crashes, only
16//! the routes that reference that plugin are affected. The dataplane
17//! reloads plugins with every config generation (DW-157): a plugin
18//! whose .wasm fails to read or compile is marked Crashed HERE, at
19//! load time, so routes referencing it fail closed from the first
20//! request of the new generation.
21//!
22//! ## Hot-swap on reload
23//!
24//! When the config is reloaded, the lifecycle manager recompiles
25//! plugins that changed (by checksum) and swaps them in atomically.
26//! Plugins that did not change are reused (no recompilation).
27//!
28//! This module compiles unconditionally in the OSS build — there are
29//! no cargo features for the plugin runtime.
30
31use 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/// A plugin's health status.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum PluginHealth {
41    /// The plugin is healthy and ready to serve.
42    Healthy,
43    /// The plugin has crashed; routes referencing it get 500.
44    Crashed { error: String, crash_count: u32 },
45    /// The plugin is disabled (manually or by circuit breaker).
46    Disabled { reason: String },
47}
48
49/// A loaded plugin: its config, checksum, and health.
50#[derive(Clone, Debug)]
51pub struct LoadedPlugin {
52    pub config: PluginConfig,
53    pub checksum: String,
54    pub health: PluginHealth,
55}
56
57/// The plugin lifecycle manager: tracks loaded plugins, their health,
58/// and which routes use them.
59pub struct PluginLifecycle {
60    /// Loaded plugins, keyed by name.
61    plugins: RwLock<HashMap<String, LoadedPlugin>>,
62    /// Route -> plugin names mapping (for failure isolation).
63    route_plugins: RwLock<HashMap<String, Vec<String>>>,
64    /// The plugin runner (compiled modules).
65    runner: RwLock<Option<PluginRunner>>,
66}
67
68impl PluginLifecycle {
69    /// Create a new plugin lifecycle manager.
70    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    /// Load plugins from config. Compiles each plugin's .wasm module
79    /// and stores it. `sources` carries the DW-165 registry-source
80    /// resolutions (built by [`crate::wasm::source::resolve_sources`]):
81    /// a resolved `source:` plugin loads from its verified local
82    /// artifact exactly like a `wasm:` plugin; an unresolved one is
83    /// marked Crashed with the step-named resolution error. Failure
84    /// isolation is per plugin (DW-157): a .wasm that cannot be read
85    /// or compiled — or a `source:` that could not be resolved or
86    /// verified — marks THAT plugin Crashed (logged, visible to the
87    /// operator at publish time) instead of failing the whole publish
88    /// — routes referencing a crashed plugin answer 500 fail-closed,
89    /// every other plugin keeps serving. The only whole-load failure
90    /// is engine construction ([`LoadError::Compile`]).
91    pub fn load(
92        &self,
93        configs: &[PluginConfig],
94        sources: &crate::wasm::source::SourceResolutions,
95    ) -> Result<(), LoadError> {
96        let mut plugins = HashMap::new();
97        // DW-165: effective local artifact paths for the runner (a
98        // `source:` plugin has no `wasm` field; it loads from its
99        // resolved cache path).
100        let mut effective_paths: HashMap<String, PathBuf> = HashMap::new();
101
102        for config in configs {
103            // DW-119: a plugin is either `wasm:` or `native:`. The
104            // lifecycle manager only loads WASM plugins here; native
105            // filters are registered with the NativeRegistry and
106            // dispatched by the unified plugin chain (plugins domain).
107            let wasm_path: String = if let Some(p) = &config.wasm {
108                p.clone()
109            } else if let Some(_source) = &config.source {
110                // DW-165: registry-sourced plugin. Resolution ran
111                // before this step; its outcome decides whether there
112                // are verified bytes to load at all.
113                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                        // Fail-closed (DW-157/165): never a partial
120                        // load. The error names the exact resolution
121                        // step (digest mismatch, download, signature).
122                        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                        // Defensive: the resolver covers every source
144                        // plugin; absence means an internal wiring bug,
145                        // and the safe answer is still fail-closed.
146                        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            // Read the .wasm file and compute checksum. A read failure
174            // is recorded with an EMPTY checksum (never produced by a
175            // successful read) so a persistently broken file keeps
176            // incrementing its crash count across reloads.
177            let (checksum, health) = match std::fs::read(&wasm_path) {
178                Ok(wasm_bytes) => {
179                    let checksum = sha256_hex(&wasm_bytes);
180                    // DW-165 review parity re-check: the resolver
181                    // verified the bytes IT read; this load re-reads
182                    // the file (and an async reload may hand in a
183                    // resolution computed a moment earlier). If the
184                    // artifact changed in that window, the checksum
185                    // diverges from the pin: fail closed rather than
186                    // load unverified bytes.
187                    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        // Build the plugin runner.
250        let runner = PluginRunner::new(configs, &effective_paths)
251            .map_err(|e| LoadError::Compile { error: e })?;
252
253        // A WASM plugin (local `wasm:` or resolved registry `source:`,
254        // DW-165) with no compiled module (read failed above, or the
255        // module failed to compile inside the runner) must read as
256        // Crashed, not Healthy: a Healthy entry with no module would
257        // otherwise pass the request-path health gate and then silently
258        // skip dispatch. Fail closed (DW-157).
259        {
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    /// Replace the route -> plugins mapping wholesale (DW-157). The
285    /// dataplane calls this on every generation swap so routes removed
286    /// from the config stop mapping to plugin names (a per-route
287    /// `register_route` insert-only API would leak stale routes).
288    pub fn set_route_plugins(&self, routes: HashMap<String, Vec<String>>) {
289        *self.route_plugins.write().unwrap() = routes;
290    }
291
292    /// Register which plugins a route uses (for failure isolation).
293    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    /// Next crash count for `name`: the previous Crashed count + 1, or
299    /// 1 for a plugin that was healthy or absent (crash counts grow
300    /// across reloads of the same broken plugin — pinned by
301    /// `load_marks_unreadable_wasm_crashed_and_succeeds`). Extracted
302    /// from the load paths that each duplicated the bookkeeping.
303    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    /// Hot-swap health for a successfully read module (DW-157):
315    /// unchanged bytes keep the previous health, changed bytes reset
316    /// to Healthy (the recompile-on-change contract).
317    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                // Unchanged: keep the previous health.
322                return prev.health.clone();
323            }
324        }
325        PluginHealth::Healthy
326    }
327
328    /// Mark a plugin as crashed.
329    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    /// Mark a plugin as healthy (after recovery).
344    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    /// Disable a plugin (manually or by circuit breaker).
352    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    /// Check if a route is affected by a crashed plugin.
362    /// Returns the list of crashed plugins on this route.
363    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    /// Whether a route should get a 500 (has crashed plugins).
385    pub fn route_should_500(&self, route_name: &str) -> bool {
386        !self.crashed_plugins_for_route(route_name).is_empty()
387    }
388
389    /// Get a loaded plugin.
390    pub fn get_plugin(&self, name: &str) -> Option<LoadedPlugin> {
391        self.plugins.read().unwrap().get(name).cloned()
392    }
393
394    /// Get all loaded plugins.
395    pub fn plugins(&self) -> Vec<LoadedPlugin> {
396        self.plugins.read().unwrap().values().cloned().collect()
397    }
398
399    /// Get the plugin runner.
400    pub fn runner(&self) -> Option<PluginRunner> {
401        self.runner.read().unwrap().clone()
402    }
403
404    /// The number of loaded plugins.
405    pub fn plugin_count(&self) -> usize {
406        self.plugins.read().unwrap().len()
407    }
408
409    /// The number of healthy plugins.
410    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    /// The number of crashed plugins.
420    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    /// Validate a plugin config: check that the .wasm file exists,
430    /// phases are non-empty, and limits are within bounds. Only
431    /// validates WASM plugins (those with `wasm` set); native filters
432    /// are validated by the snapshot pipeline (exactly-one-of
433    /// wasm/native, phases non-empty) and the registry at startup.
434    pub fn validate_config(config: &PluginConfig) -> Result<(), ValidationError> {
435        // Check .wasm path is non-empty (only for WASM plugins).
436        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        // Check phases are non-empty.
447        if config.phases.is_empty() {
448            return Err(ValidationError::NoPhases {
449                plugin: config.name.clone(),
450            });
451        }
452
453        // Check limits are within bounds.
454        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        // Check .wasm file exists (last -- the other checks are cheaper).
479        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    /// Get the phase order for a set of plugins (deterministic, not
490    /// load-order-dependent).
491    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/// An error loading plugins. Per-plugin failures (an unreadable or
529/// uncompilable .wasm) do NOT appear here — they mark that plugin
530/// Crashed and the load succeeds (DW-157); only a failure that takes
531/// the whole plugin runtime down (wasmtime engine construction) does.
532#[derive(Debug, Clone, PartialEq, Eq)]
533pub enum LoadError {
534    /// The plugin runtime could not be constructed (engine failure).
535    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/// A validation error for a plugin config.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum ValidationError {
553    /// The .wasm path is empty.
554    EmptyWasmPath { plugin: String },
555    /// The .wasm file does not exist.
556    WasmNotFound { plugin: String, path: String },
557    /// The plugin has no phases declared.
558    NoPhases { plugin: String },
559    /// The fuel limit is zero.
560    ZeroFuel { plugin: String },
561    /// The memory limit is zero.
562    ZeroMemory { plugin: String },
563    /// The timeout limit is zero.
564    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
594/// Compute a SHA-256 hex digest of a byte slice. pub(crate): the
595/// DW-165 source resolver reuses it for artifact digest verification
596/// (one digest spelling across the plugin surface).
597pub(crate) fn sha256_hex(data: &[u8]) -> String {
598    use sha2::{Digest, Sha256};
599
600    // Real SHA-256 (DW-158): the digest is exposed on the plugin
601    // status surface (`GET /plugins`, `dwara-cli status`), where an
602    // operator compares digests across a fleet and against a registry
603    // pin's sha256 — a private hash would not survive that comparison.
604    // The value is also the hot-swap change-detection key (two loads of
605    // the same bytes always produce the same digest, which is all that
606    // comparison needs).
607    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// White-box tests staying in src/ per AGENTS.md: these tests directly
618// manipulate the private `plugins` map and call the private `sha256_hex`
619// helper, which the public API (`load`, `register_route`) does not
620// expose without a real WASM runtime.
621#[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        // Manually insert a plugin for testing.
753        {
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        // Route 1 has a crashed plugin -> should 500.
865        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        // Route 2 has only healthy plugins -> should not 500.
872        assert!(!lifecycle.route_should_500("route-2"));
873        assert!(lifecycle.crashed_plugins_for_route("route-2").is_empty());
874
875        // Route 3 has a crashed plugin -> should 500.
876        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        // Route with the crashed plugin -> 500.
901        assert!(lifecycle.route_should_500("route-with-plugin"));
902
903        // Route without any plugins -> not 500 (failure isolation).
904        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        // Phase order should be deterministic (not load-order-dependent).
967        assert_eq!(order.len(), 3); // RequestHeaders, RequestBody, ResponseBody
968        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        // Only ResponseHeaders should appear (the other 3 phases are empty).
996        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        // DW-158: the digest is exposed on the plugin status surface and
1018        // compared against registry pins, so it must be a true SHA-256
1019        // (64 lowercase hex chars, the standard "hello world" vector).
1020        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        // DW-157: a per-plugin failure must not fail the whole load —
1031        // the plugin reads as Crashed (fail-closed for routes
1032        // referencing it) and the crash count keeps growing across
1033        // reloads of the same broken file.
1034        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        // Reload the same broken file: still Ok, crash count grows.
1049        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    // --- DW-165: registry source plugins ---------------------------------
1099
1100    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        // DW-165: a source plugin whose resolution FAILED is Crashed
1121        // with the step-named error — never a partial load — and the
1122        // load (and publish) still succeeds for every other plugin.
1123        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        // An empty checksum means the artifact never loaded.
1152        assert_eq!(plugin.checksum, "");
1153    }
1154
1155    #[test]
1156    fn load_marks_unresolved_entry_source_crashed() {
1157        // Defensive branch: a source plugin missing from the resolver
1158        // output entirely still fails closed (internal wiring bug, not
1159        // a silent skip).
1160        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}