Skip to main content

sdkt_audit/
plugin_store.rs

1//! Local, offline-first plugin store for `sdkt-audit` ().
2//!
3//! This module manages *plugin metadata and lifecycle only*. It never replaces
4//! or duplicates the existing plugin loaders ([`crate::plugin_loader`] for native
5//! `.so`/`.dylib`/`.dll`, [`crate::plugin_loader_wasm`] for `.wasm`). Loading a
6//! plugin into the [`crate::registry::RuleRegistry`] is still the job of those
7//! loaders; this store merely resolves a stable plugin `id` to an artifact path
8//! and validates install metadata.
9//!
10//! # Store layout
11//!
12//! ```text
13//! <store-root>/<plugin-id>/plugin.toml # metadata
14//! <store-root>/<plugin-id>/<artifact>    # the .so/.dylib/.dll/.wasm file
15//! ```
16//!
17//! # Root precedence (lowest → highest)
18//!
19//! 1. `<cwd>/.sdkt/plugins`
20//! 2. `<config-dir>/sdkt/plugins` (XDG/config per `dirs`)
21//! 3. `$SDKT_PLUGIN_DIR` (environment override)
22//!
23//! # Trust model
24//!
25//! Provenance-by-path: the user installed the artifact from a local file they
26//! obtained out-of-band. No third-party trust is assumed. Install validates the
27//! metadata, the `abi_major` constant, and the kind/extension match, and performs
28//! an optional dry-run load via the existing loader (when the corresponding
29//! feature is compiled in). `.sdktplugin` bundles add deterministic digest and
30//! optional signature verification without requiring a hosted registry.
31
32use 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/// Metadata stored in each plugin's `plugin.toml`.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct PluginMeta {
50    /// Stable, namespaced plugin id (e.g. `author/name`).
51    pub id: String,
52    /// Human-readable name.
53    pub name: String,
54    /// Semver version of the plugin.
55    pub version: String,
56    /// Plugin author / maintainer.
57    pub author: String,
58    /// Short description.
59    pub description: String,
60    /// Artifact kind: `native` (`.so`/`.dylib`/`.dll`) or `wasm` (`.wasm`).
61    pub kind: String,
62    /// Artifact filename inside the plugin directory.
63    pub artifact: String,
64    /// Plugin ABI major version. Must equal the host [`SDKT_AUDIT_ABI_MAJOR`].
65    pub abi_major: u32,
66    /// Plugin ABI minor version (informational).
67    pub abi_minor: u32,
68}
69
70impl PluginMeta {
71    /// Validate structural metadata invariants without checking ABI compatibility against the current host.
72    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    /// Validate the metadata invariants that do not require touching the
104    /// artifact: id non-empty, kind recognized, abi_major matches the host.
105    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    /// Expected artifact extension for this kind.
117    pub fn expected_ext(&self) -> &'static str {
118        match self.kind.as_str() {
119            "native" => "so", // validated loosely; see validate_kind_ext
120            "wasm" => "wasm",
121            _ => "",
122        }
123    }
124}
125
126/// Errors produced by the plugin store.
127#[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/// Result returned after a bundle has been verified. `signed` is false for a
154/// deliberately unsigned local bundle; callers should report that fact.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct BundleVerification {
157    pub metadata: PluginMeta,
158    pub signed: bool,
159}
160
161/// Validate that a relative path does not escape the parent directory (no path traversal).
162pub(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
169/// Compute hex-encoded SHA-256 digest of bytes.
170pub fn digest_hex(bytes: &[u8]) -> String {
171    Sha256::digest(bytes)
172        .iter()
173        .map(|byte| format!("{byte:02x}"))
174        .collect()
175}
176
177/// Create a stable TAR header with deterministic metadata.
178fn 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
187/// Pack one plugin artifact and its metadata into a deterministic tar-based
188/// `.sdktplugin` bundle. The archive contains `plugin.toml`, the artifact, and
189/// `manifest.sha256`; optional Ed25519 signature files are also included.
190pub 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
230/// Verify a bundle and extract it only after all metadata, digest, signature,
231/// and path-safety checks pass. `verifying_key` is optional only for unsigned
232/// bundles; signed bundles always verify against the embedded public key and,
233/// when supplied, the caller's key must match it.
234pub 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
356/// Verify a `.sdktplugin` bundle before installing its artifact into the local
357/// store. Unsigned bundles are accepted for local use, but the returned
358/// metadata lets callers report that the bundle was unsigned.
359pub fn install_bundle(bundle: &Path, opts: &InstallOpts) -> Result<BundleVerification, StoreError> {
360    install_bundle_with_key(bundle, opts, None)
361}
362
363/// Like [`install_bundle`], but when `verifying_key` is given the bundle must be
364/// signed by that key: an unsigned bundle, or a signed bundle whose embedded key
365/// differs, is rejected before anything is installed.
366pub 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        // A stripped signature must not bypass a key the caller asked for.
382        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
395/// Resolve the plugin store root using the documented precedence.
396///
397/// 1. `$SDKT_PLUGIN_DIR` (highest)
398/// 2. `<config-dir>/sdkt/plugins`
399/// 3. `<cwd>/.sdkt/plugins` (lowest)
400pub 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    // Fallback to cwd/.sdkt/plugins (always computable, may not exist yet).
413    std::env::current_dir()
414        .unwrap_or_else(|_| PathBuf::from("."))
415        .join(".sdkt")
416        .join("plugins")
417}
418
419/// Directory for a specific plugin id under the store root.
420pub(crate) fn plugin_dir(root: &Path, id: &str) -> PathBuf {
421    root.join(sanitize_id(id))
422}
423
424/// Prevent path traversal in plugin ids (they become directory names).
425pub(crate) fn sanitize_id(id: &str) -> String {
426    id.chars()
427        .map(|c| match c {
428            '/' | '\\' | '.' | ':' | ' ' => '_',
429            _ => c,
430        })
431        .collect()
432}
433
434/// Read and validate a plugin's metadata from its directory.
435///
436/// Validates basic structural invariants without rejecting incompatible
437/// ABI major versions so callers can inspect `meta.abi_major` and produce
438/// appropriate compatibility warnings rather than dropping the plugin.
439pub 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
448/// Parse `plugin.toml` content (exposed for unit testing).
449pub 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
455/// List all installed plugins (ids + metadata).
456pub fn list() -> Vec<PluginMeta> {
457    let root = resolve_store_root();
458    list_in(&root)
459}
460
461/// List installed plugins under a specific root (testable).
462pub 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    // Keep listing deterministic even if a malformed legacy store contains
479    // multiple entries with the same manifest id.
480    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
507/// Show a single plugin's metadata by id.
508pub fn show(id: &str) -> Option<PluginMeta> {
509    let root = resolve_store_root();
510    read_meta(&root, id).ok()
511}
512
513/// Resolve a plugin id to its artifact path, if installed.
514pub 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
526/// Validate that the artifact extension matches the declared kind.
527pub(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/// Options for [`install`].
548#[derive(Debug, Default, Clone)]
549pub struct InstallOpts {
550    /// Override the plugin id from metadata (rarely needed).
551    pub id: Option<String>,
552    /// Overwrite an existing install of the same id.
553    pub force: bool,
554}
555
556/// Install a plugin from a local artifact path.
557///
558/// Steps: parse+validate metadata (from a sibling/provided `plugin.toml` or the
559/// artifact's directory), check `abi_major`, check kind/extension, optionally
560/// dry-run load via the existing loader, then copy the artifact + manifest into
561/// the store. Nothing is committed to the store until all checks pass.
562pub 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    // Locate the plugin.toml: either next to the artifact, or the artifact
570    // itself is the manifest? No — artifact is the binary; manifest is separate.
571    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    // Optional dry-run load using the EXISTING loaders (feature-gated). This
595    // reuses, never replaces, the / loading paths.
596    #[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    // Capture the previously-managed artifact (if any) before committing, so a
608    // rename of the artifact filename during an update does not orphan the old
609    // file. Only the artifact referenced by this plugin's own metadata is ever
610    // considered; unrelated files are left untouched.
611    let previous_artifact = read_meta(&root, &id).ok().map(|old| old.artifact);
612
613    // Commit: create dir, copy artifact + the effective metadata. Persisting
614    // the override keeps list/show/remove/discovery aligned with the directory.
615    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    // Remove the stale artifact only after the new one is committed, and only
622    // when the filename actually changed. Guard against path traversal so we
623    // never delete outside the plugin directory.
624    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
635/// Remove an installed plugin by id. Idempotent: removing an absent plugin is a
636/// no-op success.
637pub 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(()); // idempotent
642    }
643    std::fs::remove_dir_all(&dir)?;
644    Ok(())
645}
646
647/// Update an installed plugin from a new local artifact (local-only).
648pub 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    // Reuse install with force semantics.
655    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        // A corrupted legacy store can contain duplicate manifest ids. The
685        // directory names run against version order, so directory order alone
686        // cannot produce the expected listing.
687        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}