1use 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
27pub const DOCTOR_SAMPLE_CONTRACT: &str = "pub fn sdkt_example_trigger_admin() {}\n";
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "lowercase")]
33pub enum DoctorStageStatus {
34 Passed,
36 Failed,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct DoctorStage {
43 pub name: String,
45 pub status: DoctorStageStatus,
47 pub detail: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct DoctorReport {
54 pub target: String,
56 pub healthy: bool,
58 pub stages: Vec<DoctorStage>,
60}
61
62impl DoctorReport {
63 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 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 pub fn failed_stage(&self) -> Option<&DoctorStage> {
91 self.stages
92 .iter()
93 .find(|s| s.status == DoctorStageStatus::Failed)
94 }
95
96 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
111struct TempDirGuard(PathBuf);
113impl Drop for TempDirGuard {
114 fn drop(&mut self) {
115 let _ = std::fs::remove_dir_all(&self.0);
116 }
117}
118
119pub fn doctor(target: &str) -> DoctorReport {
121 let root = plugin_store::resolve_store_root();
122 doctor_with_root(&root, target)
123}
124
125pub 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 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 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
159pub fn doctor_installed(id: &str) -> DoctorReport {
161 let root = plugin_store::resolve_store_root();
162 doctor_installed_with_root(&root, id)
163}
164
165pub 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
181fn 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 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 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 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 run_compatibility_load_and_check(report, &meta, &artifact_path);
330}
331
332fn run_directory_doctor(report: &mut DoctorReport, dir: &Path) {
334 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 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 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 run_compatibility_load_and_check(report, &meta, &artifact_path);
422}
423
424fn 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#[allow(unused_variables)]
487fn run_compatibility_load_and_check(
488 report: &mut DoctorReport,
489 meta: &PluginMeta,
490 artifact_path: &Path,
491) {
492 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 #[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 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#[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 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 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 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 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 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 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}