1use std::path::{Path, PathBuf};
33
34pub use ed25519_dalek;
35use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38use std::collections::BTreeMap;
39use std::io::Read;
40
41use crate::plugin_abi::SDKT_AUDIT_ABI_MAJOR;
42pub use crate::plugin_doctor::{
43 doctor, doctor_installed, doctor_installed_with_root, doctor_with_root, DoctorReport,
44 DoctorStage, DoctorStageStatus,
45};
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct PluginMeta {
50 pub id: String,
52 pub name: String,
54 pub version: String,
56 pub author: String,
58 pub description: String,
60 pub kind: String,
62 pub artifact: String,
64 pub abi_major: u32,
66 pub abi_minor: u32,
68}
69
70impl PluginMeta {
71 pub fn validate_basic(&self) -> Result<(), StoreError> {
73 if self.id.trim().is_empty() {
74 return Err(StoreError::InvalidMetadata("id must not be empty".into()));
75 }
76 if !matches!(self.kind.as_str(), "native" | "wasm") {
77 return Err(StoreError::InvalidMetadata(format!(
78 "kind must be 'native' or 'wasm', got '{}'",
79 self.kind
80 )));
81 }
82 if self.artifact.trim().is_empty() {
83 return Err(StoreError::InvalidMetadata(
84 "artifact must not be empty".into(),
85 ));
86 }
87 if !is_safe_relative_path(Path::new(&self.artifact)) {
88 return Err(StoreError::InvalidMetadata(
89 "artifact must be a safe relative path".into(),
90 ));
91 }
92 if matches!(
93 self.artifact.as_str(),
94 "plugin.toml" | "manifest.sha256" | "signature.ed25519" | "public_key.ed25519"
95 ) {
96 return Err(StoreError::InvalidMetadata(
97 "artifact uses a reserved bundle path".into(),
98 ));
99 }
100 Ok(())
101 }
102
103 pub fn validate(&self) -> Result<(), StoreError> {
106 self.validate_basic()?;
107 if self.abi_major != SDKT_AUDIT_ABI_MAJOR {
108 return Err(StoreError::AbiMismatch {
109 plugin_major: self.abi_major,
110 host_major: SDKT_AUDIT_ABI_MAJOR,
111 });
112 }
113 Ok(())
114 }
115
116 pub fn expected_ext(&self) -> &'static str {
118 match self.kind.as_str() {
119 "native" => "so", "wasm" => "wasm",
121 _ => "",
122 }
123 }
124}
125
126#[derive(Debug, thiserror::Error)]
128pub enum StoreError {
129 #[error("io error: {0}")]
130 Io(#[from] std::io::Error),
131 #[error("failed to parse plugin.toml: {0}")]
132 Toml(String),
133 #[error("invalid plugin metadata: {0}")]
134 InvalidMetadata(String),
135 #[error("plugin ABI mismatch (plugin v{plugin_major}.x, host v{host_major}.x)")]
136 AbiMismatch { plugin_major: u32, host_major: u32 },
137 #[error("artifact extension '{actual}' does not match plugin kind '{kind}'")]
138 KindExtMismatch { kind: String, actual: String },
139 #[error("plugin '{0}' is not installed")]
140 NotInstalled(String),
141 #[error("plugin '{0}' already installed (use --force to overwrite)")]
142 AlreadyInstalled(String),
143 #[error("dry-run load of plugin failed: {0}")]
144 DryRunLoad(String),
145 #[error("remote sources are not supported in M40 (local paths only)")]
146 RemoteUnsupported,
147 #[error("invalid plugin bundle: {0}")]
148 InvalidBundle(String),
149 #[error("plugin bundle signature verification failed")]
150 InvalidSignature,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct BundleVerification {
157 pub metadata: PluginMeta,
158 pub signed: bool,
159}
160
161pub(crate) fn is_safe_relative_path(path: &Path) -> bool {
163 !path.is_absolute()
164 && path
165 .components()
166 .all(|c| matches!(c, std::path::Component::Normal(_)))
167}
168
169pub fn digest_hex(bytes: &[u8]) -> String {
171 Sha256::digest(bytes)
172 .iter()
173 .map(|byte| format!("{byte:02x}"))
174 .collect()
175}
176
177fn stable_header(size: u64) -> tar::Header {
179 let mut h = tar::Header::new_gnu();
180 h.set_size(size);
181 h.set_mode(0o644);
182 h.set_mtime(0);
183 h.set_cksum();
184 h
185}
186
187pub fn pack_bundle(
191 output: &Path,
192 meta: &PluginMeta,
193 artifact: &Path,
194 signing_key: Option<&SigningKey>,
195) -> Result<(), StoreError> {
196 meta.validate()?;
197 validate_kind_ext(meta, artifact)?;
198 let artifact_bytes = std::fs::read(artifact)?;
199 let metadata = toml::to_string(meta).map_err(|e| StoreError::InvalidBundle(e.to_string()))?;
200 let mut manifest = BTreeMap::new();
201 manifest.insert("plugin.toml", digest_hex(metadata.as_bytes()));
202 manifest.insert(meta.artifact.as_str(), digest_hex(&artifact_bytes));
203 let manifest_bytes = manifest
204 .iter()
205 .map(|(p, d)| format!("{} {}\n", d, p))
206 .collect::<String>()
207 .into_bytes();
208 let mut file = std::fs::File::create(output)?;
209 let mut archive = tar::Builder::new(&mut file);
210 for (name, bytes) in [
211 ("plugin.toml", metadata.into_bytes()),
212 ("manifest.sha256", manifest_bytes.clone()),
213 (meta.artifact.as_str(), artifact_bytes),
214 ] {
215 let mut h = stable_header(bytes.len() as u64);
216 archive.append_data(&mut h, name, bytes.as_slice())?;
217 }
218 if let Some(key) = signing_key {
219 let sig = key.sign(&manifest_bytes).to_bytes().to_vec();
220 let pubkey = key.verifying_key().to_bytes().to_vec();
221 let mut h = stable_header(sig.len() as u64);
222 archive.append_data(&mut h, "signature.ed25519", sig.as_slice())?;
223 let mut h = stable_header(pubkey.len() as u64);
224 archive.append_data(&mut h, "public_key.ed25519", pubkey.as_slice())?;
225 }
226 archive.finish()?;
227 Ok(())
228}
229
230pub fn verify_bundle(
235 bundle: &Path,
236 destination: &Path,
237 verifying_key: Option<&VerifyingKey>,
238) -> Result<BundleVerification, StoreError> {
239 let file = std::fs::File::open(bundle)?;
240 let mut archive = tar::Archive::new(file);
241 let mut entries = BTreeMap::<String, Vec<u8>>::new();
242 for item in archive.entries()? {
243 let mut entry = item?;
244 if !entry.header().entry_type().is_file() {
245 return Err(StoreError::InvalidBundle(
246 "bundle contains a non-file entry".into(),
247 ));
248 }
249 let path = entry.path()?.to_path_buf();
250 if !is_safe_relative_path(&path) {
251 return Err(StoreError::InvalidBundle("path traversal rejected".into()));
252 }
253 let name = path.to_string_lossy().into_owned();
254 if entries.contains_key(&name) {
255 return Err(StoreError::InvalidBundle("duplicate archive entry".into()));
256 }
257 let mut bytes = Vec::new();
258 entry.read_to_end(&mut bytes)?;
259 entries.insert(name, bytes);
260 }
261 let raw_meta = entries
262 .get("plugin.toml")
263 .ok_or_else(|| StoreError::InvalidBundle("missing plugin.toml".into()))?;
264 let meta = parse_meta(
265 std::str::from_utf8(raw_meta)
266 .map_err(|_| StoreError::InvalidBundle("plugin.toml is not UTF-8".into()))?,
267 )?;
268 let manifest = entries
269 .get("manifest.sha256")
270 .ok_or_else(|| StoreError::InvalidBundle("missing manifest.sha256".into()))?;
271 let text = std::str::from_utf8(manifest)
272 .map_err(|_| StoreError::InvalidBundle("manifest is not UTF-8".into()))?;
273 let mut expected = BTreeMap::new();
274 for line in text.lines() {
275 let (digest, path) = line
276 .split_once(" ")
277 .ok_or_else(|| StoreError::InvalidBundle("invalid manifest line".into()))?;
278 if digest.len() != 64
279 || !digest.bytes().all(|b| b.is_ascii_hexdigit())
280 || !is_safe_relative_path(Path::new(path))
281 {
282 return Err(StoreError::InvalidBundle(
283 "invalid manifest path or digest".into(),
284 ));
285 }
286 if expected
287 .insert(path.to_string(), digest.to_string())
288 .is_some()
289 {
290 return Err(StoreError::InvalidBundle("duplicate manifest entry".into()));
291 }
292 }
293 if !expected.contains_key("plugin.toml") || !expected.contains_key(meta.artifact.as_str()) {
294 return Err(StoreError::InvalidBundle(
295 "manifest does not cover metadata and artifact".into(),
296 ));
297 }
298 for (path, digest) in &expected {
299 let bytes = entries
300 .get(path)
301 .ok_or_else(|| StoreError::InvalidBundle(format!("manifest entry missing: {path}")))?;
302 if digest != &digest_hex(bytes) {
303 return Err(StoreError::InvalidBundle(format!(
304 "digest mismatch: {path}"
305 )));
306 }
307 }
308 for name in entries.keys() {
309 if !expected.contains_key(name)
310 && !matches!(
311 name.as_str(),
312 "manifest.sha256" | "signature.ed25519" | "public_key.ed25519"
313 )
314 {
315 return Err(StoreError::InvalidBundle(format!(
316 "unlisted bundle entry: {name}"
317 )));
318 }
319 }
320 let signed = entries.contains_key("signature.ed25519");
321 if signed {
322 let sig = Signature::from_slice(entries.get("signature.ed25519").unwrap())
323 .map_err(|_| StoreError::InvalidSignature)?;
324 let embedded = VerifyingKey::from_bytes(
325 entries
326 .get("public_key.ed25519")
327 .ok_or(StoreError::InvalidSignature)?
328 .as_slice()
329 .try_into()
330 .map_err(|_| StoreError::InvalidSignature)?,
331 )
332 .map_err(|_| StoreError::InvalidSignature)?;
333 if let Some(key) = verifying_key {
334 if key != &embedded {
335 return Err(StoreError::InvalidSignature);
336 }
337 }
338 embedded
339 .verify(manifest, &sig)
340 .map_err(|_| StoreError::InvalidSignature)?;
341 }
342 std::fs::create_dir_all(destination)?;
343 for (name, bytes) in entries {
344 let path = destination.join(&name);
345 if let Some(parent) = path.parent() {
346 std::fs::create_dir_all(parent)?;
347 }
348 std::fs::write(path, bytes)?;
349 }
350 Ok(BundleVerification {
351 metadata: meta,
352 signed,
353 })
354}
355
356pub fn install_bundle(bundle: &Path, opts: &InstallOpts) -> Result<BundleVerification, StoreError> {
360 install_bundle_with_key(bundle, opts, None)
361}
362
363pub fn install_bundle_with_key(
367 bundle: &Path,
368 opts: &InstallOpts,
369 verifying_key: Option<&VerifyingKey>,
370) -> Result<BundleVerification, StoreError> {
371 let staging = std::env::temp_dir().join(format!(
372 "sdkt-plugin-{}-{}",
373 std::process::id(),
374 std::time::SystemTime::now()
375 .duration_since(std::time::UNIX_EPOCH)
376 .unwrap_or_default()
377 .as_nanos()
378 ));
379 let result = (|| {
380 let mut verified = verify_bundle(bundle, &staging, verifying_key)?;
381 if verifying_key.is_some() && !verified.signed {
383 return Err(StoreError::InvalidBundle(
384 "bundle is not signed but a public key was provided; refusing to install".into(),
385 ));
386 }
387 let source = staging.join(&verified.metadata.artifact);
388 verified.metadata = install(&source, opts)?;
389 Ok(verified)
390 })();
391 let _ = std::fs::remove_dir_all(&staging);
392 result
393}
394
395pub fn resolve_store_root() -> PathBuf {
401 if let Ok(env) = std::env::var("SDKT_PLUGIN_DIR") {
402 if !env.trim().is_empty() {
403 return PathBuf::from(env);
404 }
405 }
406 if let Some(config) = dirs::config_dir() {
407 let cfg = config.join("sdkt").join("plugins");
408 if cfg.exists() {
409 return cfg;
410 }
411 }
412 std::env::current_dir()
414 .unwrap_or_else(|_| PathBuf::from("."))
415 .join(".sdkt")
416 .join("plugins")
417}
418
419pub(crate) fn plugin_dir(root: &Path, id: &str) -> PathBuf {
421 root.join(sanitize_id(id))
422}
423
424pub(crate) fn sanitize_id(id: &str) -> String {
426 id.chars()
427 .map(|c| match c {
428 '/' | '\\' | '.' | ':' | ' ' => '_',
429 _ => c,
430 })
431 .collect()
432}
433
434pub fn read_meta(root: &Path, id: &str) -> Result<PluginMeta, StoreError> {
440 let toml_path = plugin_dir(root, id).join("plugin.toml");
441 let raw =
442 std::fs::read_to_string(&toml_path).map_err(|_| StoreError::NotInstalled(id.into()))?;
443 let meta: PluginMeta = toml::from_str(&raw).map_err(|e| StoreError::Toml(e.to_string()))?;
444 meta.validate_basic()?;
445 Ok(meta)
446}
447
448pub fn parse_meta(raw: &str) -> Result<PluginMeta, StoreError> {
450 let meta: PluginMeta = toml::from_str(raw).map_err(|e| StoreError::Toml(e.to_string()))?;
451 meta.validate()?;
452 Ok(meta)
453}
454
455pub fn list() -> Vec<PluginMeta> {
457 let root = resolve_store_root();
458 list_in(&root)
459}
460
461pub fn list_in(root: &Path) -> Vec<PluginMeta> {
463 let mut out = Vec::new();
464 if !root.is_dir() {
465 return out;
466 }
467 let Ok(entries) = std::fs::read_dir(root) else {
468 return out;
469 };
470 for e in entries.flatten() {
471 if !e.path().is_dir() {
472 continue;
473 }
474 if let Ok(meta) = read_meta(root, &e.file_name().to_string_lossy()) {
475 out.push(meta);
476 }
477 }
478 out.sort_by(|a, b| {
481 (
482 &a.id,
483 &a.version,
484 &a.name,
485 &a.author,
486 &a.description,
487 &a.kind,
488 &a.artifact,
489 a.abi_major,
490 a.abi_minor,
491 )
492 .cmp(&(
493 &b.id,
494 &b.version,
495 &b.name,
496 &b.author,
497 &b.description,
498 &b.kind,
499 &b.artifact,
500 b.abi_major,
501 b.abi_minor,
502 ))
503 });
504 out
505}
506
507pub fn show(id: &str) -> Option<PluginMeta> {
509 let root = resolve_store_root();
510 read_meta(&root, id).ok()
511}
512
513pub fn resolve(id: &str) -> Option<PathBuf> {
515 let root = resolve_store_root();
516 let dir = plugin_dir(&root, id);
517 let meta = read_meta(&root, id).ok()?;
518 let artifact = dir.join(&meta.artifact);
519 if artifact.exists() {
520 Some(artifact)
521 } else {
522 None
523 }
524}
525
526pub(crate) fn validate_kind_ext(meta: &PluginMeta, artifact_path: &Path) -> Result<(), StoreError> {
528 let ext = artifact_path
529 .extension()
530 .and_then(|e| e.to_str())
531 .map(|e| e.to_ascii_lowercase())
532 .unwrap_or_default();
533 let ok = match meta.kind.as_str() {
534 "native" => matches!(ext.as_str(), "so" | "dylib" | "dll"),
535 "wasm" => ext == "wasm",
536 _ => false,
537 };
538 if !ok {
539 return Err(StoreError::KindExtMismatch {
540 kind: meta.kind.clone(),
541 actual: ext,
542 });
543 }
544 Ok(())
545}
546
547#[derive(Debug, Default, Clone)]
549pub struct InstallOpts {
550 pub id: Option<String>,
552 pub force: bool,
554}
555
556pub fn install(local_source: &Path, opts: &InstallOpts) -> Result<PluginMeta, StoreError> {
563 if local_source.to_string_lossy().starts_with("http://")
564 || local_source.to_string_lossy().starts_with("https://")
565 {
566 return Err(StoreError::RemoteUnsupported);
567 }
568
569 let source_dir = local_source
572 .parent()
573 .map(|p| p.to_path_buf())
574 .unwrap_or_else(|| PathBuf::from("."));
575 let toml_path = source_dir.join("plugin.toml");
576 let raw = std::fs::read_to_string(&toml_path).map_err(|_| {
577 StoreError::InvalidMetadata("plugin.toml not found next to the artifact".into())
578 })?;
579 let mut meta = parse_meta(&raw)?;
580 if let Some(id) = &opts.id {
581 meta.id = id.clone();
582 meta.validate()?;
583 }
584
585 let id = meta.id.clone();
586 validate_kind_ext(&meta, local_source)?;
587
588 let root = resolve_store_root();
589 let dir = plugin_dir(&root, &id);
590 if dir.exists() && !opts.force {
591 return Err(StoreError::AlreadyInstalled(id));
592 }
593
594 #[cfg(feature = "plugins")]
597 if meta.kind == "native" {
598 crate::plugin_loader::PluginRule::load(local_source, "")
599 .map_err(|e| StoreError::DryRunLoad(e.to_string()))?;
600 }
601 #[cfg(feature = "wasm-plugins")]
602 if meta.kind == "wasm" {
603 crate::plugin_loader_wasm::WasmPluginRule::load(local_source, "")
604 .map_err(|e| StoreError::DryRunLoad(e.to_string()))?;
605 }
606
607 let previous_artifact = read_meta(&root, &id).ok().map(|old| old.artifact);
612
613 std::fs::create_dir_all(&dir)?;
616 let dest_artifact = dir.join(&meta.artifact);
617 std::fs::copy(local_source, &dest_artifact)?;
618 let stored_meta = toml::to_string(&meta).map_err(|e| StoreError::Toml(e.to_string()))?;
619 std::fs::write(dir.join("plugin.toml"), stored_meta)?;
620
621 if let Some(old_artifact) = previous_artifact {
625 if old_artifact != meta.artifact {
626 let old_path = dir.join(&old_artifact);
627 if old_path.is_file() && is_safe_relative_path(Path::new(&old_artifact)) {
628 std::fs::remove_file(&old_path)?;
629 }
630 }
631 }
632 Ok(meta)
633}
634
635pub fn remove(id: &str) -> Result<(), StoreError> {
638 let root = resolve_store_root();
639 let dir = plugin_dir(&root, id);
640 if !dir.exists() {
641 return Ok(()); }
643 std::fs::remove_dir_all(&dir)?;
644 Ok(())
645}
646
647pub fn update(id: &str, local_source: &Path) -> Result<PluginMeta, StoreError> {
649 if local_source.to_string_lossy().starts_with("http://")
650 || local_source.to_string_lossy().starts_with("https://")
651 {
652 return Err(StoreError::RemoteUnsupported);
653 }
654 let opts = InstallOpts {
656 id: Some(id.to_string()),
657 force: true,
658 };
659 install(local_source, &opts)
660}
661
662#[cfg(test)]
663mod list_tests {
664 use super::*;
665 use std::fs;
666
667 fn write_entry(root: &Path, dir: &str, id: &str, version: &str) {
668 let d = root.join(dir);
669 fs::create_dir_all(&d).unwrap();
670 fs::write(
671 d.join("plugin.toml"),
672 format!(
673 "id = \"{id}\"\nname = \"Rule\"\nversion = \"{version}\"\nauthor = \"Test\"\n\
674 description = \"d\"\nkind = \"wasm\"\nartifact = \"rule.wasm\"\n\
675 abi_major = {SDKT_AUDIT_ABI_MAJOR}\nabi_minor = 0\n"
676 ),
677 )
678 .unwrap();
679 }
680
681 #[test]
682 fn list_is_deterministic_when_ids_collide() {
683 let root = tempfile::tempdir().unwrap();
684 write_entry(root.path(), "a-alias", "same", "2.0.0");
688 write_entry(root.path(), "b-alias", "same", "1.0.0");
689 write_entry(root.path(), "0-first", "zeta", "1.0.0");
690
691 let listed: Vec<(String, String)> = list_in(root.path())
692 .into_iter()
693 .map(|m| (m.id, m.version))
694 .collect();
695 assert_eq!(
696 listed,
697 [("same", "1.0.0"), ("same", "2.0.0"), ("zeta", "1.0.0")]
698 .map(|(i, v)| (i.to_string(), v.to_string()))
699 );
700 }
701}
702
703#[cfg(test)]
704mod bundle_tests {
705 use super::*;
706 use std::fs;
707
708 fn meta() -> PluginMeta {
709 PluginMeta {
710 id: "example-rule".into(),
711 name: "Example Rule".into(),
712 version: "1.0.0".into(),
713 author: "SaboLabs".into(),
714 description: "test".into(),
715 kind: "wasm".into(),
716 artifact: "rule.wasm".into(),
717 abi_major: SDKT_AUDIT_ABI_MAJOR,
718 abi_minor: 0,
719 }
720 }
721
722 #[test]
723 fn bundle_is_reproducible_and_verifies() {
724 let dir = tempfile::tempdir().unwrap();
725 let artifact = dir.path().join("rule.wasm");
726 fs::write(&artifact, b"wasm").unwrap();
727 let a = dir.path().join("a.sdktplugin");
728 let b = dir.path().join("b.sdktplugin");
729 pack_bundle(&a, &meta(), &artifact, None).unwrap();
730 pack_bundle(&b, &meta(), &artifact, None).unwrap();
731 assert_eq!(fs::read(&a).unwrap(), fs::read(&b).unwrap());
732 let out = dir.path().join("out");
733 let verified = verify_bundle(&a, &out, None).unwrap();
734 assert!(!verified.signed);
735 assert_eq!(fs::read(out.join("rule.wasm")).unwrap(), b"wasm");
736 }
737
738 #[test]
739 fn tampered_bundle_fails_before_extraction() {
740 let dir = tempfile::tempdir().unwrap();
741 let artifact = dir.path().join("rule.wasm");
742 fs::write(&artifact, b"wasm").unwrap();
743 let bundle = dir.path().join("plugin.sdktplugin");
744 pack_bundle(&bundle, &meta(), &artifact, None).unwrap();
745 let mut bytes = fs::read(&bundle).unwrap();
746 let digest = digest_hex(b"wasm").into_bytes();
747 let offset = bytes
748 .windows(digest.len())
749 .position(|window| window == digest)
750 .unwrap();
751 bytes[offset] = if bytes[offset] == b'0' { b'1' } else { b'0' };
752 fs::write(&bundle, bytes).unwrap();
753 let out = dir.path().join("out");
754 assert!(verify_bundle(&bundle, &out, None).is_err());
755 assert!(!out.exists());
756 }
757
758 #[test]
759 fn signed_bundle_verifies_and_wrong_key_fails() {
760 let dir = tempfile::tempdir().unwrap();
761 let artifact = dir.path().join("rule.wasm");
762 fs::write(&artifact, b"wasm").unwrap();
763 let key = SigningKey::from_bytes(&[7u8; 32]);
764 let bundle = dir.path().join("plugin.sdktplugin");
765 pack_bundle(&bundle, &meta(), &artifact, Some(&key)).unwrap();
766 assert!(
767 verify_bundle(&bundle, &dir.path().join("out"), Some(&key.verifying_key()))
768 .unwrap()
769 .signed
770 );
771 let wrong = SigningKey::from_bytes(&[8u8; 32]);
772 assert!(matches!(
773 verify_bundle(
774 &bundle,
775 &dir.path().join("wrong"),
776 Some(&wrong.verifying_key())
777 ),
778 Err(StoreError::InvalidSignature)
779 ));
780 }
781
782 #[test]
783 fn install_bundle_with_wrong_key_fails_before_install() {
784 let dir = tempfile::tempdir().unwrap();
785 let artifact = dir.path().join("rule.wasm");
786 fs::write(&artifact, b"wasm").unwrap();
787 let key = SigningKey::from_bytes(&[7u8; 32]);
788 let bundle = dir.path().join("plugin.sdktplugin");
789 pack_bundle(&bundle, &meta(), &artifact, Some(&key)).unwrap();
790 let wrong = SigningKey::from_bytes(&[8u8; 32]);
791 assert!(matches!(
792 install_bundle_with_key(
793 &bundle,
794 &InstallOpts::default(),
795 Some(&wrong.verifying_key())
796 ),
797 Err(StoreError::InvalidSignature)
798 ));
799 }
800
801 #[test]
802 fn install_bundle_with_key_rejects_unsigned_bundle() {
803 let dir = tempfile::tempdir().unwrap();
804 let artifact = dir.path().join("rule.wasm");
805 fs::write(&artifact, b"wasm").unwrap();
806 let bundle = dir.path().join("plugin.sdktplugin");
807 pack_bundle(&bundle, &meta(), &artifact, None).unwrap();
808 let key = SigningKey::from_bytes(&[7u8; 32]);
809 let err =
810 install_bundle_with_key(&bundle, &InstallOpts::default(), Some(&key.verifying_key()))
811 .unwrap_err();
812 assert!(err.to_string().contains("bundle is not signed"), "{err}");
813 }
814}