Skip to main content

sdkt_audit/
plugin_doctor.rs

1//! End-to-end diagnostic and self-check runner for `sdkt-audit` plugins.
2//!
3//! Orchestrates the multi-stage validation lifecycle:
4//! 1. Metadata: `plugin.toml` presence, valid TOML, basic invariant checks.
5//! 2. Artifact: file existence, kind-extension match.
6//! 3. Integrity: bundle digest / manifest verification, signature checks.
7//! 4. Compatibility: ABI major version comparison against the host.
8//! 5. Load: loader availability and symbol verification.
9//! 6. Self-check: controlled dry-run analysis on an embedded sample contract.
10
11use std::collections::BTreeMap;
12use std::io::Read;
13use std::path::{Path, PathBuf};
14
15use ed25519_dalek::{Signature, Verifier, VerifyingKey};
16use serde::{Deserialize, Serialize};
17
18use crate::audit::{AuditContext, AuditRule};
19use crate::plugin_abi::SDKT_AUDIT_ABI_MAJOR;
20#[cfg(feature = "wasm-plugins")]
21use crate::plugin_abi_wasm::SDKT_AUDIT_WASM_ABI_MAJOR;
22#[cfg(not(feature = "wasm-plugins"))]
23const SDKT_AUDIT_WASM_ABI_MAJOR: u32 = 1;
24
25use crate::plugin_store::{self, PluginMeta};
26
27/// Embedded sample contract source used for Stage 6 self-check.
28pub const DOCTOR_SAMPLE_CONTRACT: &str = "pub fn sdkt_example_trigger_admin() {}\n";
29
30/// Status of an individual doctor diagnostic stage.
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "lowercase")]
33pub enum DoctorStageStatus {
34    /// Stage completed successfully.
35    Passed,
36    /// Stage failed; subsequent stages were aborted.
37    Failed,
38}
39
40/// A single stage in the doctor execution pipeline.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct DoctorStage {
43    /// Stage identifier: "metadata", "artifact", "integrity", "compatibility", "load", "self-check".
44    pub name: String,
45    /// Status: "passed" or "failed".
46    pub status: DoctorStageStatus,
47    /// Human-readable detail or error explanation.
48    pub detail: String,
49}
50
51/// Structured diagnostic report returned by `sdkt plugin doctor`.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct DoctorReport {
54    /// The target name or path evaluated.
55    pub target: String,
56    /// Whether all executed stages passed.
57    pub healthy: bool,
58    /// Ordered list of stages executed up to the first failure (or all stages if healthy).
59    pub stages: Vec<DoctorStage>,
60}
61
62impl DoctorReport {
63    /// Create a new report for `target`.
64    pub fn new(target: impl Into<String>) -> Self {
65        Self {
66            target: target.into(),
67            healthy: true,
68            stages: Vec::new(),
69        }
70    }
71
72    /// Record a stage result. If `status` is `Failed`, marks `healthy` as false.
73    pub fn add_stage(
74        &mut self,
75        name: impl Into<String>,
76        status: DoctorStageStatus,
77        detail: impl Into<String>,
78    ) {
79        if status == DoctorStageStatus::Failed {
80            self.healthy = false;
81        }
82        self.stages.push(DoctorStage {
83            name: name.into(),
84            status,
85            detail: detail.into(),
86        });
87    }
88
89    /// The first failing stage, if any.
90    pub fn failed_stage(&self) -> Option<&DoctorStage> {
91        self.stages
92            .iter()
93            .find(|s| s.status == DoctorStageStatus::Failed)
94    }
95
96    /// Exit code: 0 if healthy, or the 1-based index of the first failing stage.
97    pub fn exit_code(&self) -> i32 {
98        if self.healthy {
99            0
100        } else {
101            for (idx, stage) in self.stages.iter().enumerate() {
102                if stage.status == DoctorStageStatus::Failed {
103                    return (idx + 1) as i32;
104                }
105            }
106            1
107        }
108    }
109}
110
111/// RAII guard to clean up a temporary directory upon drop.
112struct TempDirGuard(PathBuf);
113impl Drop for TempDirGuard {
114    fn drop(&mut self) {
115        let _ = std::fs::remove_dir_all(&self.0);
116    }
117}
118
119/// Run plugin doctor diagnostics for `target` using default store resolution.
120pub fn doctor(target: &str) -> DoctorReport {
121    let root = plugin_store::resolve_store_root();
122    doctor_with_root(&root, target)
123}
124
125/// Run plugin doctor diagnostics for `target` with an explicit store `root`.
126pub fn doctor_with_root(root: &Path, target: &str) -> DoctorReport {
127    let mut report = DoctorReport::new(target);
128    let target_path = Path::new(target);
129
130    // Resolve target to either a bundle file or a directory
131    let is_local_file = target_path.is_file();
132    let is_local_dir = target_path.is_dir();
133
134    if is_local_file {
135        run_bundle_doctor(&mut report, target_path);
136    } else if is_local_dir {
137        run_directory_doctor(&mut report, target_path);
138    } else {
139        // Not a direct local path; resolve via store root
140        let store_dir = plugin_store::plugin_dir(root, target);
141        if store_dir.exists() {
142            run_directory_doctor(&mut report, &store_dir);
143        } else {
144            report.add_stage(
145                "metadata",
146                DoctorStageStatus::Failed,
147                format!(
148                    "target '{}' not found: not a valid file/directory and not installed in store ({})",
149                    target,
150                    root.display()
151                ),
152            );
153        }
154    }
155
156    report
157}
158
159/// Run plugin doctor diagnostics strictly for an installed plugin `id` in the default store.
160pub fn doctor_installed(id: &str) -> DoctorReport {
161    let root = plugin_store::resolve_store_root();
162    doctor_installed_with_root(&root, id)
163}
164
165/// Run plugin doctor diagnostics strictly for an installed plugin `id` with an explicit store `root`.
166pub fn doctor_installed_with_root(root: &Path, id: &str) -> DoctorReport {
167    let mut report = DoctorReport::new(id);
168    let store_dir = plugin_store::plugin_dir(root, id);
169    if store_dir.exists() {
170        run_directory_doctor(&mut report, &store_dir);
171    } else {
172        report.add_stage(
173            "metadata",
174            DoctorStageStatus::Failed,
175            format!("plugin '{}' not found in store ({})", id, root.display()),
176        );
177    }
178    report
179}
180
181/// Execute doctor stages against a packed `.sdktplugin` bundle file.
182fn run_bundle_doctor(report: &mut DoctorReport, bundle_path: &Path) {
183    let file = match std::fs::File::open(bundle_path) {
184        Ok(f) => f,
185        Err(e) => {
186            report.add_stage(
187                "metadata",
188                DoctorStageStatus::Failed,
189                format!("failed to open bundle: {e}"),
190            );
191            return;
192        }
193    };
194
195    let mut archive = tar::Archive::new(file);
196    let mut entries = BTreeMap::<String, Vec<u8>>::new();
197    let read_res = (|| -> Result<(), String> {
198        let items = archive.entries().map_err(|e| e.to_string())?;
199        for item in items {
200            let mut entry = item.map_err(|e| e.to_string())?;
201            if entry.header().entry_type().is_file() {
202                let path = entry
203                    .path()
204                    .map_err(|e| e.to_string())?
205                    .to_string_lossy()
206                    .into_owned();
207                let mut bytes = Vec::new();
208                entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
209                entries.insert(path, bytes);
210            }
211        }
212        Ok(())
213    })();
214
215    if let Err(e) = read_res {
216        report.add_stage(
217            "metadata",
218            DoctorStageStatus::Failed,
219            format!("invalid bundle archive: {e}"),
220        );
221        return;
222    }
223
224    // Stage 1: metadata
225    let raw_meta = match entries.get("plugin.toml") {
226        Some(b) => b,
227        None => {
228            report.add_stage(
229                "metadata",
230                DoctorStageStatus::Failed,
231                "missing plugin.toml in bundle",
232            );
233            return;
234        }
235    };
236    let meta_str = match std::str::from_utf8(raw_meta) {
237        Ok(s) => s,
238        Err(_) => {
239            report.add_stage(
240                "metadata",
241                DoctorStageStatus::Failed,
242                "plugin.toml is not valid UTF-8",
243            );
244            return;
245        }
246    };
247    let meta: PluginMeta = match toml::from_str(meta_str) {
248        Ok(m) => m,
249        Err(e) => {
250            report.add_stage(
251                "metadata",
252                DoctorStageStatus::Failed,
253                format!("failed to parse plugin.toml: {e}"),
254            );
255            return;
256        }
257    };
258    if let Err(e) = meta.validate_basic() {
259        report.add_stage(
260            "metadata",
261            DoctorStageStatus::Failed,
262            format!("invalid plugin metadata: {e}"),
263        );
264        return;
265    }
266    report.add_stage(
267        "metadata",
268        DoctorStageStatus::Passed,
269        format!(
270            "valid plugin metadata for '{}' (v{}, {})",
271            meta.id, meta.version, meta.kind
272        ),
273    );
274
275    // Stage 2: artifact
276    if !entries.contains_key(&meta.artifact) {
277        report.add_stage(
278            "artifact",
279            DoctorStageStatus::Failed,
280            format!(
281                "artifact '{}' declared in plugin.toml not found in bundle",
282                meta.artifact
283            ),
284        );
285        return;
286    }
287    if let Err(e) = plugin_store::validate_kind_ext(&meta, Path::new(&meta.artifact)) {
288        report.add_stage("artifact", DoctorStageStatus::Failed, e.to_string());
289        return;
290    }
291    report.add_stage(
292        "artifact",
293        DoctorStageStatus::Passed,
294        format!(
295            "artifact '{}' present and matches kind '{}'",
296            meta.artifact, meta.kind
297        ),
298    );
299
300    // Stage 3: integrity (reuse verify_bundle)
301    let staging = std::env::temp_dir().join(format!(
302        "sdkt-doctor-{}-{}",
303        std::process::id(),
304        std::time::SystemTime::now()
305            .duration_since(std::time::UNIX_EPOCH)
306            .unwrap_or_default()
307            .as_nanos()
308    ));
309    let _guard = TempDirGuard(staging.clone());
310
311    match plugin_store::verify_bundle(bundle_path, &staging, None) {
312        Ok(verification) => {
313            let detail = if verification.signed {
314                "bundle integrity verified (signed)"
315            } else {
316                "bundle integrity verified (unsigned)"
317            };
318            report.add_stage("integrity", DoctorStageStatus::Passed, detail);
319        }
320        Err(e) => {
321            report.add_stage("integrity", DoctorStageStatus::Failed, e.to_string());
322            return;
323        }
324    }
325
326    let artifact_path = staging.join(&meta.artifact);
327
328    // Stages 4, 5, 6
329    run_compatibility_load_and_check(report, &meta, &artifact_path);
330}
331
332/// Execute doctor stages against an unpacked plugin directory.
333fn run_directory_doctor(report: &mut DoctorReport, dir: &Path) {
334    // Stage 1: metadata
335    let toml_path = dir.join("plugin.toml");
336    let raw_meta = match std::fs::read_to_string(&toml_path) {
337        Ok(s) => s,
338        Err(e) => {
339            report.add_stage(
340                "metadata",
341                DoctorStageStatus::Failed,
342                format!("plugin.toml not found at '{}': {e}", toml_path.display()),
343            );
344            return;
345        }
346    };
347    let meta: PluginMeta = match toml::from_str(&raw_meta) {
348        Ok(m) => m,
349        Err(e) => {
350            report.add_stage(
351                "metadata",
352                DoctorStageStatus::Failed,
353                format!("failed to parse plugin.toml: {e}"),
354            );
355            return;
356        }
357    };
358    if let Err(e) = meta.validate_basic() {
359        report.add_stage(
360            "metadata",
361            DoctorStageStatus::Failed,
362            format!("invalid plugin metadata: {e}"),
363        );
364        return;
365    }
366    report.add_stage(
367        "metadata",
368        DoctorStageStatus::Passed,
369        format!(
370            "valid plugin metadata for '{}' (v{}, {})",
371            meta.id, meta.version, meta.kind
372        ),
373    );
374
375    // Stage 2: artifact
376    let artifact_path = dir.join(&meta.artifact);
377    if !artifact_path.exists() {
378        report.add_stage(
379            "artifact",
380            DoctorStageStatus::Failed,
381            format!("artifact not found at '{}'", artifact_path.display()),
382        );
383        return;
384    }
385    if let Err(e) = plugin_store::validate_kind_ext(&meta, &artifact_path) {
386        report.add_stage("artifact", DoctorStageStatus::Failed, e.to_string());
387        return;
388    }
389    report.add_stage(
390        "artifact",
391        DoctorStageStatus::Passed,
392        format!("artifact found at '{}'", artifact_path.display()),
393    );
394
395    // Stage 3: integrity
396    let manifest_path = dir.join("manifest.sha256");
397    if manifest_path.exists() {
398        match verify_dir_manifest(dir, &manifest_path, &meta.artifact) {
399            Ok(signed) => {
400                let detail = if signed {
401                    "manifest verified (signed)"
402                } else {
403                    "manifest verified (unsigned)"
404                };
405                report.add_stage("integrity", DoctorStageStatus::Passed, detail);
406            }
407            Err(e) => {
408                report.add_stage("integrity", DoctorStageStatus::Failed, e);
409                return;
410            }
411        }
412    } else {
413        report.add_stage(
414            "integrity",
415            DoctorStageStatus::Passed,
416            "directory target (unbundled)",
417        );
418    }
419
420    // Stages 4, 5, 6
421    run_compatibility_load_and_check(report, &meta, &artifact_path);
422}
423
424/// Verify directory integrity against `manifest.sha256`, checking digest matches,
425/// preventing path traversal, ensuring required files (`plugin.toml` and artifact) are covered,
426/// and validating signatures if present.
427fn verify_dir_manifest(dir: &Path, manifest_path: &Path, artifact: &str) -> Result<bool, String> {
428    let manifest_bytes = std::fs::read(manifest_path).map_err(|e| e.to_string())?;
429    let manifest_str = std::str::from_utf8(&manifest_bytes)
430        .map_err(|_| "manifest.sha256 is not UTF-8".to_string())?;
431    let mut covered = std::collections::BTreeSet::new();
432    for line in manifest_str.lines() {
433        if line.trim().is_empty() {
434            continue;
435        }
436        let (expected_digest, rel_path) = if let Some((d, p)) = line.split_once("  ") {
437            (d, p)
438        } else if let Some((d, p)) = line.split_once(' ') {
439            (d, p.trim_start())
440        } else {
441            return Err("invalid manifest line".to_string());
442        };
443        if !plugin_store::is_safe_relative_path(Path::new(rel_path)) {
444            return Err(format!("unsafe manifest path: {rel_path}"));
445        }
446        let target_file = dir.join(rel_path);
447        let bytes =
448            std::fs::read(&target_file).map_err(|e| format!("cannot read {rel_path}: {e}"))?;
449        let actual_digest = plugin_store::digest_hex(&bytes);
450        if actual_digest != expected_digest {
451            return Err(format!("digest mismatch: {rel_path}"));
452        }
453        covered.insert(rel_path.to_string());
454    }
455    for required in ["plugin.toml", artifact] {
456        if !covered.contains(required) {
457            return Err(format!("manifest does not cover '{required}'"));
458        }
459    }
460    let sig_path = dir.join("signature.ed25519");
461    let pubkey_path = dir.join("public_key.ed25519");
462    if sig_path.exists() != pubkey_path.exists() {
463        return Err("incomplete signature: signature and public key must both be present".into());
464    }
465    if sig_path.exists() && pubkey_path.exists() {
466        let sig_bytes = std::fs::read(sig_path).map_err(|e| e.to_string())?;
467        let pubkey_bytes = std::fs::read(pubkey_path).map_err(|e| e.to_string())?;
468        let sig = Signature::from_slice(&sig_bytes)
469            .map_err(|_| "invalid signature format".to_string())?;
470        let arr: [u8; 32] = pubkey_bytes
471            .as_slice()
472            .try_into()
473            .map_err(|_| "invalid public key format".to_string())?;
474        let pubkey =
475            VerifyingKey::from_bytes(&arr).map_err(|_| "invalid public key format".to_string())?;
476        pubkey
477            .verify(&manifest_bytes, &sig)
478            .map_err(|_| "signature verification failed".to_string())?;
479        Ok(true)
480    } else {
481        Ok(false)
482    }
483}
484
485/// Run Stages 4 (compatibility), 5 (load), and 6 (self-check) on an artifact.
486#[allow(unused_variables)]
487fn run_compatibility_load_and_check(
488    report: &mut DoctorReport,
489    meta: &PluginMeta,
490    artifact_path: &Path,
491) {
492    // Stage 4: compatibility
493    let host_major = match meta.kind.as_str() {
494        "native" => SDKT_AUDIT_ABI_MAJOR,
495        "wasm" => SDKT_AUDIT_WASM_ABI_MAJOR,
496        _ => SDKT_AUDIT_ABI_MAJOR,
497    };
498
499    if meta.abi_major != host_major {
500        report.add_stage(
501            "compatibility",
502            DoctorStageStatus::Failed,
503            format!(
504                "plugin ABI mismatch (plugin v{}.x, host v{}.x)",
505                meta.abi_major, host_major
506            ),
507        );
508        return;
509    }
510
511    // Optionally probe the artifact's exported ABI version if available
512    #[allow(unused_mut)]
513    let mut artifact_abi_mismatch: Option<String> = None;
514    #[cfg(feature = "plugins")]
515    if meta.kind == "native" {
516        if let Ok(lib) = unsafe { libloading::Library::new(artifact_path) } {
517            if let Ok(abi_fn) =
518                unsafe { lib.get::<unsafe extern "C" fn() -> u32>(b"sdkt_plugin_abi_version\0") }
519            {
520                let version = unsafe { abi_fn() };
521                let plugin_major = crate::plugin_abi::abi_major(version);
522                if plugin_major != host_major {
523                    artifact_abi_mismatch = Some(format!(
524                        "plugin ABI mismatch (plugin v{plugin_major}.x, host v{host_major}.x)"
525                    ));
526                }
527            }
528        }
529    }
530    #[cfg(feature = "wasm-plugins")]
531    if meta.kind == "wasm" {
532        if let Ok(wasm_bytes) = std::fs::read(artifact_path) {
533            let manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)])
534                .with_timeout(std::time::Duration::from_millis(5000));
535            if let Ok(mut plugin) = extism::Plugin::new(&manifest, [], true) {
536                if let Ok(abi_version_raw) = plugin.call::<(), i64>("sdkt_plugin_abi_version", ()) {
537                    let plugin_major = abi_version_raw.unsigned_abs() as u32;
538                    if plugin_major != host_major {
539                        artifact_abi_mismatch = Some(format!(
540                            "plugin ABI mismatch (plugin v{plugin_major}.x, host v{host_major}.x)"
541                        ));
542                    }
543                }
544            }
545        }
546    }
547
548    if let Some(err) = artifact_abi_mismatch {
549        report.add_stage("compatibility", DoctorStageStatus::Failed, err);
550        return;
551    }
552
553    report.add_stage(
554        "compatibility",
555        DoctorStageStatus::Passed,
556        format!(
557            "ABI compatible (host v{}.x, plugin v{}.x)",
558            host_major, meta.abi_major
559        ),
560    );
561
562    // Stage 5: load and Stage 6: self-check
563    if meta.kind == "native" {
564        #[cfg(feature = "plugins")]
565        {
566            match crate::plugin_loader::PluginRule::load(artifact_path, DOCTOR_SAMPLE_CONTRACT) {
567                Ok(rule) => {
568                    report.add_stage(
569                        "load",
570                        DoctorStageStatus::Passed,
571                        format!("plugin loaded successfully (id: '{}')", rule.id()),
572                    );
573                    run_self_check(&rule, report);
574                }
575                Err(e) => {
576                    report.add_stage("load", DoctorStageStatus::Failed, e.to_string());
577                }
578            }
579        }
580        #[cfg(not(feature = "plugins"))]
581        {
582            report.add_stage(
583                "load",
584                DoctorStageStatus::Failed,
585                "loader unavailable (build compiled without `plugins` feature)",
586            );
587        }
588    } else if meta.kind == "wasm" {
589        #[cfg(feature = "wasm-plugins")]
590        {
591            match crate::plugin_loader_wasm::WasmPluginRule::load(
592                artifact_path,
593                DOCTOR_SAMPLE_CONTRACT,
594            ) {
595                Ok(rule) => {
596                    report.add_stage(
597                        "load",
598                        DoctorStageStatus::Passed,
599                        format!("plugin loaded successfully (id: '{}')", rule.id()),
600                    );
601                    run_self_check(&rule, report);
602                }
603                Err(e) => {
604                    report.add_stage("load", DoctorStageStatus::Failed, e.to_string());
605                }
606            }
607        }
608        #[cfg(not(feature = "wasm-plugins"))]
609        {
610            report.add_stage(
611                "load",
612                DoctorStageStatus::Failed,
613                "loader unavailable (build compiled without `wasm-plugins` feature)",
614            );
615        }
616    } else {
617        report.add_stage(
618            "load",
619            DoctorStageStatus::Failed,
620            format!("unsupported plugin kind '{}'", meta.kind),
621        );
622    }
623}
624
625/// Run Stage 6 self-check against the sample contract source.
626#[allow(dead_code)]
627fn run_self_check(rule: &dyn AuditRule, report: &mut DoctorReport) {
628    let scans = crate::audit::scan_all_functions_str(DOCTOR_SAMPLE_CONTRACT).unwrap_or_default();
629    let ctx = AuditContext { spec: None };
630    let mut audit_report = crate::types::AuditReport::default();
631
632    let check_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
633        rule.check(&scans, &ctx, &mut audit_report);
634    }));
635
636    match check_res {
637        Ok(()) => {
638            let trap_finding = audit_report.findings.iter().find(|f| {
639                f.message.contains("wasm plugin trap") || f.message.contains("mutex poisoned")
640            });
641            if let Some(f) = trap_finding {
642                report.add_stage(
643                    "self-check",
644                    DoctorStageStatus::Failed,
645                    format!("self-check caught error: {}", f.message),
646                );
647            } else {
648                report.add_stage(
649                    "self-check",
650                    DoctorStageStatus::Passed,
651                    format!(
652                        "self-check completed ({} finding(s) emitted)",
653                        audit_report.findings.len()
654                    ),
655                );
656            }
657        }
658        Err(_) => {
659            report.add_stage(
660                "self-check",
661                DoctorStageStatus::Failed,
662                "plugin panicked during execution",
663            );
664        }
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use std::fs;
672    use tempfile::TempDir;
673
674    /// Helper to create dummy plugin directories for testing.
675    fn write_plugin(dir: &Path, kind: &str, artifact: &str, abi_major: u32, create_artifact: bool) {
676        fs::create_dir_all(dir).unwrap();
677        let toml_content = format!(
678            r#"id = "test-plugin"
679name = "Test Plugin"
680version = "1.0.0"
681author = "SaboLabs"
682description = "A test rule"
683kind = "{kind}"
684artifact = "{artifact}"
685abi_major = {abi_major}
686abi_minor = 0
687"#
688        );
689        fs::write(dir.join("plugin.toml"), toml_content).unwrap();
690        if create_artifact {
691            fs::write(dir.join(artifact), b"dummy-bytes").unwrap();
692        }
693    }
694
695    #[test]
696    fn test_target_not_found() {
697        let store_root = TempDir::new().unwrap();
698        let report = doctor_with_root(store_root.path(), "non-existent-plugin");
699        assert!(!report.healthy);
700        assert_eq!(report.exit_code(), 1);
701        assert_eq!(report.stages.len(), 1);
702        assert_eq!(report.stages[0].name, "metadata");
703        assert_eq!(report.stages[0].status, DoctorStageStatus::Failed);
704    }
705
706    #[test]
707    fn test_invalid_metadata_bad_kind() {
708        let store_root = TempDir::new().unwrap();
709        let plugin_dir = store_root.path().join("test-plugin");
710        write_plugin(&plugin_dir, "invalid-kind", "rule.so", 1, true);
711
712        let report = doctor_with_root(store_root.path(), "test-plugin");
713        assert!(!report.healthy);
714        assert_eq!(report.exit_code(), 1);
715        assert_eq!(report.stages.len(), 1);
716        assert_eq!(report.stages[0].name, "metadata");
717        assert_eq!(report.stages[0].status, DoctorStageStatus::Failed);
718        assert!(report.stages[0]
719            .detail
720            .contains("kind must be 'native' or 'wasm'"));
721    }
722
723    #[test]
724    fn test_invalid_metadata_reserved_path() {
725        let store_root = TempDir::new().unwrap();
726        let plugin_dir = store_root.path().join("test-plugin");
727        write_plugin(&plugin_dir, "native", "manifest.sha256", 1, true);
728
729        let report = doctor_with_root(store_root.path(), "test-plugin");
730        assert!(!report.healthy);
731        assert_eq!(report.exit_code(), 1);
732        assert_eq!(report.stages.len(), 1);
733        assert_eq!(report.stages[0].name, "metadata");
734        assert_eq!(report.stages[0].status, DoctorStageStatus::Failed);
735        assert!(report.stages[0].detail.contains("reserved bundle path"));
736    }
737
738    #[test]
739    fn test_missing_artifact_fails_at_artifact_stage() {
740        let store_root = TempDir::new().unwrap();
741        let plugin_dir = store_root.path().join("test-plugin");
742        write_plugin(&plugin_dir, "native", "rule.so", 1, false);
743
744        let report = doctor_with_root(store_root.path(), "test-plugin");
745        assert!(!report.healthy);
746        assert_eq!(report.exit_code(), 2);
747        assert_eq!(report.stages.len(), 2);
748        assert_eq!(report.stages[0].name, "metadata");
749        assert_eq!(report.stages[0].status, DoctorStageStatus::Passed);
750        assert_eq!(report.stages[1].name, "artifact");
751        assert_eq!(report.stages[1].status, DoctorStageStatus::Failed);
752        assert!(report.stages[1].detail.contains("rule.so"));
753    }
754
755    #[test]
756    fn test_abi_mismatch_fails_compatibility_stage() {
757        let store_root = TempDir::new().unwrap();
758        let plugin_dir = store_root.path().join("test-plugin");
759        write_plugin(&plugin_dir, "native", "rule.so", 99, true);
760
761        let report = doctor_with_root(store_root.path(), "test-plugin");
762        assert!(!report.healthy);
763        assert_eq!(report.exit_code(), 4);
764        assert_eq!(report.stages.len(), 4);
765        assert_eq!(report.stages[0].name, "metadata");
766        assert_eq!(report.stages[0].status, DoctorStageStatus::Passed);
767        assert_eq!(report.stages[1].name, "artifact");
768        assert_eq!(report.stages[1].status, DoctorStageStatus::Passed);
769        assert_eq!(report.stages[2].name, "integrity");
770        assert_eq!(report.stages[2].status, DoctorStageStatus::Passed);
771        assert_eq!(report.stages[3].name, "compatibility");
772        assert_eq!(report.stages[3].status, DoctorStageStatus::Failed);
773        assert!(report.stages[3].detail.contains("plugin v99.x, host v1.x"));
774    }
775
776    #[test]
777    fn test_tampered_bundle_fails_integrity_stage() {
778        let tmp = TempDir::new().unwrap();
779        let src_dir = tmp.path().join("src");
780        write_plugin(&src_dir, "wasm", "rule.wasm", 1, true);
781
782        let meta: PluginMeta =
783            toml::from_str(&fs::read_to_string(src_dir.join("plugin.toml")).unwrap()).unwrap();
784        let bundle_path = tmp.path().join("bundle.sdktplugin");
785        plugin_store::pack_bundle(&bundle_path, &meta, &src_dir.join("rule.wasm"), None).unwrap();
786
787        // Doctor on healthy bundle up to Stage 3 or 5
788        let report_before = doctor_with_root(tmp.path(), bundle_path.to_str().unwrap());
789        assert_eq!(report_before.stages[2].name, "integrity");
790        assert_eq!(report_before.stages[2].status, DoctorStageStatus::Passed);
791
792        // Tamper with the manifest digest inside the bundle
793        let mut bundle_bytes = fs::read(&bundle_path).unwrap();
794        let digest = plugin_store::digest_hex(b"dummy-bytes").into_bytes();
795        let offset = bundle_bytes
796            .windows(digest.len())
797            .position(|window| window == digest)
798            .unwrap();
799        bundle_bytes[offset] = if bundle_bytes[offset] == b'0' {
800            b'1'
801        } else {
802            b'0'
803        };
804        fs::write(&bundle_path, bundle_bytes).unwrap();
805
806        let report_after = doctor_with_root(tmp.path(), bundle_path.to_str().unwrap());
807        assert!(!report_after.healthy);
808        assert_eq!(report_after.exit_code(), 3);
809        assert_eq!(report_after.stages.len(), 3);
810        assert_eq!(report_after.stages[0].name, "metadata");
811        assert_eq!(report_after.stages[0].status, DoctorStageStatus::Passed);
812        assert_eq!(report_after.stages[1].name, "artifact");
813        assert_eq!(report_after.stages[1].status, DoctorStageStatus::Passed);
814        assert_eq!(report_after.stages[2].name, "integrity");
815        assert_eq!(report_after.stages[2].status, DoctorStageStatus::Failed);
816        assert!(report_after.stages[2].detail.contains("digest mismatch"));
817    }
818
819    #[test]
820    fn test_dir_manifest_path_traversal_rejected() {
821        let tmp = TempDir::new().unwrap();
822        let dir = tmp.path().join("plugin");
823        write_plugin(&dir, "wasm", "rule.wasm", 1, true);
824
825        // Write a manifest with path traversal entry
826        let manifest =
827            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  ../outside.txt\n";
828        fs::write(dir.join("manifest.sha256"), manifest).unwrap();
829
830        let report = doctor_with_root(tmp.path(), dir.to_str().unwrap());
831        assert!(!report.healthy);
832        assert_eq!(report.stages[2].name, "integrity");
833        assert_eq!(report.stages[2].status, DoctorStageStatus::Failed);
834        assert!(report.stages[2].detail.contains("unsafe manifest path"));
835    }
836
837    #[test]
838    fn test_dir_manifest_missing_artifact_coverage_fails() {
839        let tmp = TempDir::new().unwrap();
840        let dir = tmp.path().join("plugin");
841        write_plugin(&dir, "wasm", "rule.wasm", 1, true);
842
843        // Manifest only covers plugin.toml, not rule.wasm
844        let toml_bytes = fs::read(dir.join("plugin.toml")).unwrap();
845        let toml_digest = plugin_store::digest_hex(&toml_bytes);
846        let manifest = format!("{toml_digest}  plugin.toml\n");
847        fs::write(dir.join("manifest.sha256"), manifest).unwrap();
848
849        let report = doctor_with_root(tmp.path(), dir.to_str().unwrap());
850        assert!(!report.healthy);
851        assert_eq!(report.stages[2].name, "integrity");
852        assert_eq!(report.stages[2].status, DoctorStageStatus::Failed);
853        assert!(report.stages[2]
854            .detail
855            .contains("manifest does not cover 'rule.wasm'"));
856    }
857
858    #[test]
859    fn test_dir_manifest_mismatched_signature_files_fails() {
860        let tmp = TempDir::new().unwrap();
861        let dir = tmp.path().join("plugin");
862        write_plugin(&dir, "wasm", "rule.wasm", 1, true);
863
864        let toml_bytes = fs::read(dir.join("plugin.toml")).unwrap();
865        let toml_digest = plugin_store::digest_hex(&toml_bytes);
866        let wasm_bytes = fs::read(dir.join("rule.wasm")).unwrap();
867        let wasm_digest = plugin_store::digest_hex(&wasm_bytes);
868        let manifest = format!("{toml_digest}  plugin.toml\n{wasm_digest}  rule.wasm\n");
869        fs::write(dir.join("manifest.sha256"), manifest).unwrap();
870
871        // Only write signature.ed25519 without public_key.ed25519
872        fs::write(dir.join("signature.ed25519"), b"fake-signature").unwrap();
873
874        let report = doctor_with_root(tmp.path(), dir.to_str().unwrap());
875        assert!(!report.healthy);
876        assert_eq!(report.stages[2].name, "integrity");
877        assert_eq!(report.stages[2].status, DoctorStageStatus::Failed);
878        assert!(report.stages[2].detail.contains("incomplete signature"));
879    }
880}