Skip to main content

osp_cli/plugin/
discovery.rs

1use super::conversion::to_command_spec;
2use super::manager::{DiscoveredPlugin, PluginManager, PluginSource};
3use crate::completion::CommandSpec;
4use crate::config::{default_cache_root_dir, default_config_root_dir};
5use crate::core::plugin::DescribeV1;
6use anyhow::{Context, Result, anyhow};
7use semver::Version;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::{HashMap, HashSet};
11use std::fmt::Write as FmtWrite;
12use std::io::{BufReader, Read};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{Duration, UNIX_EPOCH};
16
17const PLUGIN_EXECUTABLE_PREFIX: &str = "osp-";
18const BUNDLED_MANIFEST_FILE: &str = "manifest.toml";
19
20#[derive(Debug, Clone)]
21pub(super) struct SearchRoot {
22    pub(super) path: PathBuf,
23    pub(super) source: PluginSource,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub(super) struct BundledManifest {
28    protocol_version: u32,
29    #[serde(default)]
30    plugin: Vec<ManifestPlugin>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub(super) struct ManifestPlugin {
35    pub(super) id: String,
36    pub(super) exe: String,
37    pub(super) version: String,
38    #[serde(default = "default_true")]
39    pub(super) enabled_by_default: bool,
40    pub(super) checksum_sha256: Option<String>,
41    #[serde(default)]
42    pub(super) commands: Vec<String>,
43}
44
45#[derive(Debug, Clone)]
46pub(super) struct ValidatedBundledManifest {
47    pub(super) by_exe: HashMap<String, ManifestPlugin>,
48}
49
50pub(super) enum ManifestState {
51    NotBundled,
52    Missing,
53    Invalid(String),
54    Valid(ValidatedBundledManifest),
55}
56
57enum DescribeEligibility {
58    Allowed,
59    Skip,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum DiscoveryMode {
64    Passive,
65    Dispatch,
66}
67
68struct DescribeCacheState<'a> {
69    file: &'a mut DescribeCacheFile,
70    seen_paths: &'a mut HashSet<String>,
71    dirty: &'a mut bool,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub(super) struct DescribeCacheFile {
76    #[serde(default)]
77    pub(super) entries: Vec<DescribeCacheEntry>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub(super) struct DescribeCacheEntry {
82    pub(super) path: String,
83    pub(super) size: u64,
84    pub(super) mtime_secs: u64,
85    pub(super) mtime_nanos: u32,
86    pub(super) describe: DescribeV1,
87}
88
89impl PluginManager {
90    /// Clears both passive and dispatch discovery caches.
91    ///
92    /// Call this after changing plugin search roots or the filesystem state
93    /// they point at so later browse or dispatch calls rescan discovery
94    /// inputs. This does not clear in-memory command preferences such as
95    /// provider selections.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use osp_cli::plugin::PluginManager;
101    ///
102    /// let manager = PluginManager::new(Vec::new());
103    /// manager.refresh();
104    ///
105    /// assert!(manager.list_plugins().is_empty());
106    /// ```
107    pub fn refresh(&self) {
108        let mut guard = self
109            .discovered_cache
110            .write()
111            .unwrap_or_else(|err| err.into_inner());
112        *guard = None;
113        let mut dispatch_guard = self
114            .dispatch_discovered_cache
115            .write()
116            .unwrap_or_else(|err| err.into_inner());
117        *dispatch_guard = None;
118    }
119
120    pub(super) fn discover(&self) -> Arc<[DiscoveredPlugin]> {
121        self.discover_with_mode(DiscoveryMode::Passive)
122    }
123
124    pub(super) fn discover_for_dispatch(&self) -> Arc<[DiscoveredPlugin]> {
125        self.discover_with_mode(DiscoveryMode::Dispatch)
126    }
127
128    fn discover_with_mode(&self, mode: DiscoveryMode) -> Arc<[DiscoveredPlugin]> {
129        let cache = match mode {
130            DiscoveryMode::Passive => &self.discovered_cache,
131            DiscoveryMode::Dispatch => &self.dispatch_discovered_cache,
132        };
133        if let Some(cached) = cache.read().unwrap_or_else(|err| err.into_inner()).clone() {
134            return cached;
135        }
136
137        let mut guard = cache.write().unwrap_or_else(|err| err.into_inner());
138        if let Some(cached) = guard.clone() {
139            return cached;
140        }
141        let discovered = self.discover_uncached(mode);
142        let shared = Arc::<[DiscoveredPlugin]>::from(discovered);
143        *guard = Some(shared.clone());
144        shared
145    }
146
147    fn discover_uncached(&self, mode: DiscoveryMode) -> Vec<DiscoveredPlugin> {
148        let roots = self.search_roots();
149        let mut plugins: Vec<DiscoveredPlugin> = Vec::new();
150        let mut seen_paths: HashSet<PathBuf> = HashSet::new();
151        let mut describe_cache = self.load_describe_cache_or_warn();
152        let mut seen_describe_paths: HashSet<String> = HashSet::new();
153        let mut cache_dirty = false;
154        let mut describe_cache_state = DescribeCacheState {
155            file: &mut describe_cache,
156            seen_paths: &mut seen_describe_paths,
157            dirty: &mut cache_dirty,
158        };
159
160        for root in &roots {
161            plugins.extend(discover_plugins_in_root(
162                root,
163                &mut seen_paths,
164                &mut describe_cache_state,
165                self.process_timeout,
166                mode,
167            ));
168        }
169
170        mark_duplicate_plugin_ids(&mut plugins);
171
172        cache_dirty |=
173            prune_stale_describe_cache_entries(&mut describe_cache, &seen_describe_paths);
174        if cache_dirty {
175            self.save_describe_cache_or_warn(&describe_cache);
176        }
177
178        tracing::debug!(
179            discovered_plugins = plugins.len(),
180            unhealthy_plugins = plugins
181                .iter()
182                .filter(|plugin| plugin.issue.is_some())
183                .count(),
184            search_roots = roots.len(),
185            "completed plugin discovery"
186        );
187
188        plugins
189    }
190
191    fn search_roots(&self) -> Vec<SearchRoot> {
192        let ordered = self.ordered_search_roots();
193        let roots = existing_unique_search_roots(ordered);
194        tracing::debug!(search_roots = roots.len(), "resolved plugin search roots");
195        roots
196    }
197
198    fn ordered_search_roots(&self) -> Vec<SearchRoot> {
199        let mut ordered = Vec::new();
200
201        ordered.extend(self.explicit_dirs.iter().cloned().map(|path| SearchRoot {
202            path,
203            source: PluginSource::Explicit,
204        }));
205
206        if let Ok(raw) = std::env::var("OSP_PLUGIN_PATH") {
207            ordered.extend(std::env::split_paths(&raw).map(|path| SearchRoot {
208                path,
209                source: PluginSource::Env,
210            }));
211        }
212
213        ordered.extend(bundled_plugin_dirs().into_iter().map(|path| SearchRoot {
214            path,
215            source: PluginSource::Bundled,
216        }));
217
218        if let Some(user_dir) = self.user_plugin_dir() {
219            ordered.push(SearchRoot {
220                path: user_dir,
221                source: PluginSource::UserConfig,
222            });
223        }
224
225        if self.allow_path_discovery
226            && let Ok(raw) = std::env::var("PATH")
227        {
228            ordered.extend(std::env::split_paths(&raw).map(|path| SearchRoot {
229                path,
230                source: PluginSource::Path,
231            }));
232        }
233
234        tracing::trace!(
235            search_roots = ordered.len(),
236            "assembled ordered plugin search roots"
237        );
238        ordered
239    }
240
241    fn load_describe_cache(&self) -> Result<DescribeCacheFile> {
242        let Some(path) = self.describe_cache_path() else {
243            tracing::debug!("describe cache path unavailable; using empty cache");
244            return Ok(DescribeCacheFile::default());
245        };
246        if !path.exists() {
247            tracing::debug!(path = %path.display(), "describe cache missing; using empty cache");
248            return Ok(DescribeCacheFile::default());
249        }
250
251        let raw = std::fs::read_to_string(&path)
252            .with_context(|| format!("failed to read describe cache {}", path.display()))?;
253        let cache = serde_json::from_str::<DescribeCacheFile>(&raw)
254            .with_context(|| format!("failed to parse describe cache {}", path.display()))?;
255        tracing::debug!(
256            path = %path.display(),
257            entries = cache.entries.len(),
258            "loaded describe cache"
259        );
260        Ok(cache)
261    }
262
263    fn load_describe_cache_or_warn(&self) -> DescribeCacheFile {
264        match self.load_describe_cache() {
265            Ok(cache) => cache,
266            Err(err) => {
267                warn_nonfatal_cache_error("load", self.describe_cache_path().as_deref(), &err);
268                DescribeCacheFile::default()
269            }
270        }
271    }
272
273    fn save_describe_cache(&self, cache: &DescribeCacheFile) -> Result<()> {
274        let Some(path) = self.describe_cache_path() else {
275            return Ok(());
276        };
277        if let Some(parent) = path.parent() {
278            std::fs::create_dir_all(parent).with_context(|| {
279                format!("failed to create describe cache dir {}", parent.display())
280            })?;
281        }
282
283        let payload = serde_json::to_string_pretty(cache)
284            .context("failed to serialize describe cache to JSON")?;
285        super::state::write_text_atomic(&path, &payload)
286            .with_context(|| format!("failed to write describe cache {}", path.display()))
287    }
288
289    fn save_describe_cache_or_warn(&self, cache: &DescribeCacheFile) {
290        if let Err(err) = self.save_describe_cache(cache) {
291            warn_nonfatal_cache_error("write", self.describe_cache_path().as_deref(), &err);
292        }
293    }
294
295    fn user_plugin_dir(&self) -> Option<PathBuf> {
296        let mut path = self.config_root.clone().or_else(|| {
297            self.allow_default_roots
298                .then(default_config_root_dir)
299                .flatten()
300        })?;
301        path.push("plugins");
302        Some(path)
303    }
304
305    fn describe_cache_path(&self) -> Option<PathBuf> {
306        let mut path = self.cache_root.clone().or_else(|| {
307            self.allow_default_roots
308                .then(default_cache_root_dir)
309                .flatten()
310        })?;
311        path.push("describe-v1.json");
312        Some(path)
313    }
314}
315
316fn warn_nonfatal_cache_error(action: &str, path: Option<&Path>, err: &anyhow::Error) {
317    match path {
318        Some(path) => tracing::warn!(
319            action,
320            path = %path.display(),
321            error = %err,
322            "non-fatal describe cache error; continuing without cache"
323        ),
324        None => tracing::warn!(
325            action,
326            error = %err,
327            "non-fatal describe cache error; continuing without cache"
328        ),
329    }
330}
331
332pub(super) fn bundled_manifest_path(root: &SearchRoot) -> Option<PathBuf> {
333    (root.source == PluginSource::Bundled).then(|| root.path.join(BUNDLED_MANIFEST_FILE))
334}
335
336pub(super) fn load_manifest_state(root: &SearchRoot) -> ManifestState {
337    let Some(path) = bundled_manifest_path(root) else {
338        return ManifestState::NotBundled;
339    };
340    if !path.exists() {
341        return ManifestState::Missing;
342    }
343    load_manifest_state_from_path(&path)
344}
345
346pub(super) fn load_manifest_state_from_path(path: &Path) -> ManifestState {
347    match load_and_validate_manifest(path) {
348        Ok(manifest) => ManifestState::Valid(manifest),
349        Err(err) => ManifestState::Invalid(err.to_string()),
350    }
351}
352
353pub(super) fn existing_unique_search_roots(ordered: Vec<SearchRoot>) -> Vec<SearchRoot> {
354    let mut deduped_paths: HashSet<PathBuf> = HashSet::new();
355    ordered
356        .into_iter()
357        .filter(|root| {
358            if !root.path.is_dir() {
359                return false;
360            }
361            let canonical = root
362                .path
363                .canonicalize()
364                .unwrap_or_else(|_| root.path.clone());
365            deduped_paths.insert(canonical)
366        })
367        .collect()
368}
369
370pub(super) fn discover_root_executables(root: &Path) -> Vec<PathBuf> {
371    let Ok(entries) = std::fs::read_dir(root) else {
372        return Vec::new();
373    };
374
375    let mut executables = entries
376        .filter_map(|entry| entry.ok())
377        .map(|entry| entry.path())
378        .filter(|path| is_plugin_executable(path))
379        .collect::<Vec<PathBuf>>();
380    executables.sort();
381    executables
382}
383
384fn discover_plugins_in_root(
385    root: &SearchRoot,
386    seen_paths: &mut HashSet<PathBuf>,
387    describe_cache: &mut DescribeCacheState<'_>,
388    process_timeout: Duration,
389    mode: DiscoveryMode,
390) -> Vec<DiscoveredPlugin> {
391    let manifest_state = load_manifest_state(root);
392    let plugins = discover_root_executables(&root.path)
393        .into_iter()
394        .filter(|path| seen_paths.insert(path.clone()))
395        .map(|executable| {
396            assemble_discovered_plugin_with_mode(
397                root.source,
398                executable,
399                &manifest_state,
400                describe_cache,
401                process_timeout,
402                mode,
403            )
404        })
405        .collect::<Vec<_>>();
406
407    tracing::debug!(
408        root = %root.path.display(),
409        source = %root.source,
410        discovered_plugins = plugins.len(),
411        unhealthy_plugins = plugins.iter().filter(|plugin| plugin.issue.is_some()).count(),
412        "scanned plugin search root"
413    );
414
415    plugins
416}
417
418pub(super) fn mark_duplicate_plugin_ids(plugins: &mut [DiscoveredPlugin]) {
419    let mut by_id: HashMap<String, Vec<usize>> = HashMap::new();
420    for (index, plugin) in plugins.iter().enumerate() {
421        by_id
422            .entry(plugin.plugin_id.clone())
423            .or_default()
424            .push(index);
425    }
426
427    for (plugin_id, indexes) in by_id {
428        if indexes.len() < 2 {
429            continue;
430        }
431
432        // Duplicate provider IDs cannot be disambiguated by users because
433        // selection and persisted preferences key off `plugin_id`, not an
434        // executable-specific identity. Picking one winner preserves a working
435        // provider and reports the rest as shadowed; erroring the whole group
436        // turns one duplicate binary into a denial of service. A richer
437        // provider identity would be a larger, separate design change.
438        let winner = indexes
439            .iter()
440            .copied()
441            .find(|index| plugins[*index].issue.is_none())
442            .unwrap_or(indexes[0]);
443        let winner_path = plugins[winner].executable.display().to_string();
444        let providers = indexes
445            .iter()
446            .map(|index| plugins[*index].executable.display().to_string())
447            .collect::<Vec<_>>();
448        tracing::warn!(
449            plugin_id = %plugin_id,
450            winner = %winner_path,
451            providers = providers.join(", "),
452            "duplicate plugin id discovered"
453        );
454        for index in indexes {
455            if index == winner {
456                continue;
457            }
458            let issue = format!(
459                "duplicate plugin id `{plugin_id}` shadowed by {}",
460                winner_path
461            );
462            super::state::merge_issue(&mut plugins[index].issue, issue);
463        }
464    }
465}
466
467#[cfg(test)]
468pub(super) fn assemble_discovered_plugin(
469    source: PluginSource,
470    executable: PathBuf,
471    manifest_state: &ManifestState,
472    describe_cache: &mut DescribeCacheFile,
473    seen_describe_paths: &mut HashSet<String>,
474    cache_dirty: &mut bool,
475    process_timeout: Duration,
476) -> DiscoveredPlugin {
477    let mut describe_cache_state = DescribeCacheState {
478        file: describe_cache,
479        seen_paths: seen_describe_paths,
480        dirty: cache_dirty,
481    };
482    assemble_discovered_plugin_with_mode(
483        source,
484        executable,
485        manifest_state,
486        &mut describe_cache_state,
487        process_timeout,
488        DiscoveryMode::Passive,
489    )
490}
491
492fn assemble_discovered_plugin_with_mode(
493    source: PluginSource,
494    executable: PathBuf,
495    manifest_state: &ManifestState,
496    describe_cache: &mut DescribeCacheState<'_>,
497    process_timeout: Duration,
498    mode: DiscoveryMode,
499) -> DiscoveredPlugin {
500    let file_name = executable
501        .file_name()
502        .and_then(|name| name.to_str())
503        .unwrap_or_default()
504        .to_string();
505    let manifest_entry = manifest_entry_for_executable(manifest_state, &file_name);
506    let mut plugin =
507        seeded_discovered_plugin(source, executable.clone(), &file_name, &manifest_entry);
508
509    apply_manifest_discovery_issue(&mut plugin.issue, manifest_state, manifest_entry.as_ref());
510
511    match describe_eligibility(source, manifest_state, manifest_entry.as_ref(), &executable) {
512        Ok(DescribeEligibility::Allowed) => {
513            match describe_with_cache(&executable, source, mode, describe_cache, process_timeout) {
514                Ok(describe) => {
515                    apply_describe_metadata(&mut plugin, &describe, manifest_entry.as_ref())
516                }
517                Err(err) => super::state::merge_issue(&mut plugin.issue, err.to_string()),
518            }
519        }
520        Ok(DescribeEligibility::Skip) => {}
521        Err(err) => super::state::merge_issue(&mut plugin.issue, err.to_string()),
522    }
523
524    tracing::debug!(
525        plugin_id = %plugin.plugin_id,
526        source = %plugin.source,
527        executable = %plugin.executable.display(),
528        healthy = plugin.issue.is_none(),
529        issue = ?plugin.issue,
530        command_count = plugin.commands.len(),
531        "assembled discovered plugin"
532    );
533
534    plugin
535}
536
537fn manifest_entry_for_executable(
538    manifest_state: &ManifestState,
539    file_name: &str,
540) -> Option<ManifestPlugin> {
541    match manifest_state {
542        ManifestState::Valid(manifest) => manifest.by_exe.get(file_name).cloned(),
543        ManifestState::NotBundled | ManifestState::Missing | ManifestState::Invalid(_) => None,
544    }
545}
546
547fn seeded_discovered_plugin(
548    source: PluginSource,
549    executable: PathBuf,
550    file_name: &str,
551    manifest_entry: &Option<ManifestPlugin>,
552) -> DiscoveredPlugin {
553    let fallback_id = file_name
554        .strip_prefix(PLUGIN_EXECUTABLE_PREFIX)
555        .unwrap_or("unknown")
556        .to_string();
557    let commands = manifest_entry
558        .as_ref()
559        .map(|entry| entry.commands.clone())
560        .unwrap_or_default();
561
562    DiscoveredPlugin {
563        plugin_id: manifest_entry
564            .as_ref()
565            .map(|entry| entry.id.clone())
566            .unwrap_or(fallback_id),
567        plugin_version: manifest_entry.as_ref().map(|entry| entry.version.clone()),
568        executable,
569        source,
570        describe_commands: Vec::new(),
571        command_specs: commands
572            .iter()
573            .map(|name| CommandSpec::new(name.clone()))
574            .collect(),
575        commands,
576        issue: None,
577        default_enabled: manifest_entry
578            .as_ref()
579            .map(|entry| entry.enabled_by_default)
580            .unwrap_or(true),
581    }
582}
583
584fn apply_manifest_discovery_issue(
585    issue: &mut Option<String>,
586    manifest_state: &ManifestState,
587    manifest_entry: Option<&ManifestPlugin>,
588) {
589    if let Some(message) = manifest_discovery_issue(manifest_state, manifest_entry) {
590        super::state::merge_issue(issue, message);
591    }
592}
593
594fn describe_eligibility(
595    source: PluginSource,
596    manifest_state: &ManifestState,
597    manifest_entry: Option<&ManifestPlugin>,
598    executable: &Path,
599) -> Result<DescribeEligibility> {
600    if source != PluginSource::Bundled {
601        return Ok(DescribeEligibility::Allowed);
602    }
603
604    match manifest_state {
605        ManifestState::Missing | ManifestState::Invalid(_) => return Ok(DescribeEligibility::Skip),
606        ManifestState::Valid(_) if manifest_entry.is_none() => {
607            return Ok(DescribeEligibility::Skip);
608        }
609        ManifestState::NotBundled | ManifestState::Valid(_) => {}
610    }
611
612    if let Some(entry) = manifest_entry {
613        validate_manifest_checksum(entry, executable)?;
614    }
615
616    Ok(DescribeEligibility::Allowed)
617}
618
619fn manifest_discovery_issue(
620    manifest_state: &ManifestState,
621    manifest_entry: Option<&ManifestPlugin>,
622) -> Option<String> {
623    match manifest_state {
624        ManifestState::Missing => Some(format!("bundled {} not found", BUNDLED_MANIFEST_FILE)),
625        ManifestState::Invalid(err) => Some(format!("bundled manifest invalid: {err}")),
626        ManifestState::Valid(_) if manifest_entry.is_none() => {
627            Some("plugin executable not present in bundled manifest".to_string())
628        }
629        ManifestState::NotBundled | ManifestState::Valid(_) => None,
630    }
631}
632
633fn apply_describe_metadata(
634    plugin: &mut DiscoveredPlugin,
635    describe: &DescribeV1,
636    manifest_entry: Option<&ManifestPlugin>,
637) {
638    if let Some(entry) = manifest_entry {
639        plugin.default_enabled = entry.enabled_by_default;
640        if let Err(err) = validate_manifest_describe(entry, describe) {
641            super::state::merge_issue(&mut plugin.issue, err.to_string());
642            return;
643        }
644    }
645
646    plugin.plugin_id = describe.plugin_id.clone();
647    plugin.plugin_version = Some(describe.plugin_version.clone());
648    plugin.commands = describe
649        .commands
650        .iter()
651        .map(|cmd| cmd.name.clone())
652        .collect::<Vec<String>>();
653    plugin.describe_commands = describe.commands.clone();
654    plugin.command_specs = describe
655        .commands
656        .iter()
657        .map(to_command_spec)
658        .collect::<Vec<CommandSpec>>();
659
660    if let Some(issue) = min_osp_version_issue(describe) {
661        super::state::merge_issue(&mut plugin.issue, issue);
662    }
663}
664
665pub(super) fn min_osp_version_issue(describe: &DescribeV1) -> Option<String> {
666    let min_required = describe
667        .min_osp_version
668        .as_deref()
669        .map(str::trim)
670        .filter(|value| !value.is_empty())?;
671    let current_raw = env!("CARGO_PKG_VERSION");
672    let current = match Version::parse(current_raw) {
673        Ok(version) => version,
674        Err(err) => {
675            return Some(format!(
676                "osp version `{current_raw}` is invalid for plugin compatibility checks: {err}"
677            ));
678        }
679    };
680    let min = match Version::parse(min_required) {
681        Ok(version) => version,
682        Err(err) => {
683            return Some(format!(
684                "invalid min_osp_version `{min_required}` declared by plugin {}: {err}",
685                describe.plugin_id
686            ));
687        }
688    };
689
690    if current < min {
691        Some(format!(
692            "plugin {} requires osp >= {min}, current version is {current}",
693            describe.plugin_id
694        ))
695    } else {
696        None
697    }
698}
699
700fn load_and_validate_manifest(path: &Path) -> Result<ValidatedBundledManifest> {
701    let manifest = read_bundled_manifest(path)?;
702    validate_manifest_protocol(&manifest)?;
703    Ok(ValidatedBundledManifest {
704        by_exe: index_manifest_plugins(manifest.plugin)?,
705    })
706}
707
708fn read_bundled_manifest(path: &Path) -> Result<BundledManifest> {
709    let raw = std::fs::read_to_string(path)
710        .with_context(|| format!("failed to read manifest {}", path.display()))?;
711    toml::from_str::<BundledManifest>(&raw)
712        .with_context(|| format!("failed to parse manifest TOML at {}", path.display()))
713}
714
715fn validate_manifest_protocol(manifest: &BundledManifest) -> Result<()> {
716    if manifest.protocol_version != 1 {
717        return Err(anyhow!(
718            "unsupported manifest protocol_version {}",
719            manifest.protocol_version
720        ));
721    }
722    Ok(())
723}
724
725fn index_manifest_plugins(plugins: Vec<ManifestPlugin>) -> Result<HashMap<String, ManifestPlugin>> {
726    let mut by_exe: HashMap<String, ManifestPlugin> = HashMap::new();
727    let mut ids = HashSet::new();
728
729    for plugin in plugins {
730        validate_manifest_plugin(&plugin)?;
731        insert_manifest_plugin(&mut by_exe, &mut ids, plugin)?;
732    }
733
734    Ok(by_exe)
735}
736
737fn validate_manifest_plugin(plugin: &ManifestPlugin) -> Result<()> {
738    if plugin.id.trim().is_empty() {
739        return Err(anyhow!("manifest plugin id must not be empty"));
740    }
741    if plugin.exe.trim().is_empty() {
742        return Err(anyhow!("manifest plugin exe must not be empty"));
743    }
744    if plugin.version.trim().is_empty() {
745        return Err(anyhow!("manifest plugin version must not be empty"));
746    }
747    if plugin.commands.is_empty() {
748        return Err(anyhow!(
749            "manifest plugin {} must declare at least one command",
750            plugin.id
751        ));
752    }
753    Ok(())
754}
755
756fn insert_manifest_plugin(
757    by_exe: &mut HashMap<String, ManifestPlugin>,
758    ids: &mut HashSet<String>,
759    plugin: ManifestPlugin,
760) -> Result<()> {
761    if !ids.insert(plugin.id.clone()) {
762        return Err(anyhow!("duplicate plugin id in manifest: {}", plugin.id));
763    }
764    if by_exe.contains_key(&plugin.exe) {
765        return Err(anyhow!("duplicate plugin exe in manifest: {}", plugin.exe));
766    }
767    by_exe.insert(plugin.exe.clone(), plugin);
768    Ok(())
769}
770
771fn validate_manifest_describe(entry: &ManifestPlugin, describe: &DescribeV1) -> Result<()> {
772    if entry.id != describe.plugin_id {
773        return Err(anyhow!(
774            "manifest id mismatch: expected {}, got {}",
775            entry.id,
776            describe.plugin_id
777        ));
778    }
779
780    if entry.version != describe.plugin_version {
781        return Err(anyhow!(
782            "manifest version mismatch for {}: expected {}, got {}",
783            entry.id,
784            entry.version,
785            describe.plugin_version
786        ));
787    }
788
789    let mut expected = entry.commands.clone();
790    expected.sort();
791    expected.dedup();
792
793    let mut actual = describe
794        .commands
795        .iter()
796        .map(|cmd| cmd.name.clone())
797        .collect::<Vec<String>>();
798    actual.sort();
799    actual.dedup();
800
801    if expected != actual {
802        return Err(anyhow!(
803            "manifest commands mismatch for {}: expected {:?}, got {:?}",
804            entry.id,
805            expected,
806            actual
807        ));
808    }
809
810    Ok(())
811}
812
813fn validate_manifest_checksum(entry: &ManifestPlugin, path: &Path) -> Result<()> {
814    let Some(expected_checksum) = entry.checksum_sha256.as_deref() else {
815        return Ok(());
816    };
817    let expected_checksum = normalize_checksum(expected_checksum)?;
818    let actual_checksum = file_sha256_hex(path)?;
819    if expected_checksum != actual_checksum {
820        return Err(anyhow!(
821            "checksum mismatch for {}: expected {}, got {}",
822            entry.id,
823            expected_checksum,
824            actual_checksum
825        ));
826    }
827    Ok(())
828}
829
830fn describe_with_cache(
831    path: &Path,
832    source: PluginSource,
833    mode: DiscoveryMode,
834    cache: &mut DescribeCacheState<'_>,
835    process_timeout: Duration,
836) -> Result<DescribeV1> {
837    let key = describe_cache_key(path);
838    cache.seen_paths.insert(key.clone());
839    let (size, mtime_secs, mtime_nanos) = file_fingerprint(path)?;
840
841    if let Some(entry) = find_cached_describe(cache.file, &key, size, mtime_secs, mtime_nanos) {
842        tracing::trace!(path = %path.display(), "describe cache hit");
843        return Ok(entry.describe.clone());
844    }
845
846    if source == PluginSource::Path && mode == DiscoveryMode::Passive {
847        return Err(anyhow!(
848            "path-discovered plugin metadata unavailable until first command execution for {}; passive discovery does not execute PATH plugins",
849            path.display()
850        ));
851    }
852
853    tracing::trace!(path = %path.display(), "describe cache miss");
854
855    let describe = super::dispatch::describe_plugin(path, process_timeout)?;
856    upsert_cached_describe(
857        cache.file,
858        key,
859        size,
860        mtime_secs,
861        mtime_nanos,
862        describe.clone(),
863    );
864    *cache.dirty = true;
865
866    Ok(describe)
867}
868
869fn describe_cache_key(path: &Path) -> String {
870    path.to_string_lossy().to_string()
871}
872
873pub(super) fn find_cached_describe<'a>(
874    cache: &'a DescribeCacheFile,
875    key: &str,
876    size: u64,
877    mtime_secs: u64,
878    mtime_nanos: u32,
879) -> Option<&'a DescribeCacheEntry> {
880    cache.entries.iter().find(|entry| {
881        entry.path == key
882            && entry.size == size
883            && entry.mtime_secs == mtime_secs
884            && entry.mtime_nanos == mtime_nanos
885    })
886}
887
888pub(super) fn upsert_cached_describe(
889    cache: &mut DescribeCacheFile,
890    key: String,
891    size: u64,
892    mtime_secs: u64,
893    mtime_nanos: u32,
894    describe: DescribeV1,
895) {
896    if let Some(entry) = cache.entries.iter_mut().find(|entry| entry.path == key) {
897        entry.size = size;
898        entry.mtime_secs = mtime_secs;
899        entry.mtime_nanos = mtime_nanos;
900        entry.describe = describe;
901    } else {
902        cache.entries.push(DescribeCacheEntry {
903            path: key,
904            size,
905            mtime_secs,
906            mtime_nanos,
907            describe,
908        });
909    }
910}
911
912pub(super) fn prune_stale_describe_cache_entries(
913    cache: &mut DescribeCacheFile,
914    seen_paths: &HashSet<String>,
915) -> bool {
916    let before = cache.entries.len();
917    cache
918        .entries
919        .retain(|entry| seen_paths.contains(&entry.path));
920    cache.entries.len() != before
921}
922
923pub(super) fn file_fingerprint(path: &Path) -> Result<(u64, u64, u32)> {
924    let metadata = std::fs::metadata(path)
925        .with_context(|| format!("failed to read metadata for {}", path.display()))?;
926    let size = metadata.len();
927    let modified = metadata
928        .modified()
929        .with_context(|| format!("failed to read mtime for {}", path.display()))?;
930    let dur = modified
931        .duration_since(UNIX_EPOCH)
932        .with_context(|| format!("mtime before unix epoch for {}", path.display()))?;
933    Ok((size, dur.as_secs(), dur.subsec_nanos()))
934}
935
936fn bundled_plugin_dirs() -> Vec<PathBuf> {
937    let mut dirs = Vec::new();
938
939    if let Ok(path) = std::env::var("OSP_BUNDLED_PLUGIN_DIR") {
940        dirs.push(PathBuf::from(path));
941    }
942
943    if let Ok(exe_path) = std::env::current_exe()
944        && let Some(bin_dir) = exe_path.parent()
945    {
946        dirs.push(bin_dir.join("plugins"));
947        dirs.push(bin_dir.join("../lib/osp/plugins"));
948    }
949
950    dirs
951}
952
953pub(super) fn normalize_checksum(checksum: &str) -> Result<String> {
954    let trimmed = checksum.trim().to_ascii_lowercase();
955    if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
956        return Err(anyhow!(
957            "checksum must be a 64-char lowercase/uppercase hex string"
958        ));
959    }
960    Ok(trimmed)
961}
962
963pub(super) fn file_sha256_hex(path: &Path) -> Result<String> {
964    let file = std::fs::File::open(path).with_context(|| {
965        format!(
966            "failed to read plugin executable for checksum: {}",
967            path.display()
968        )
969    })?;
970    let mut reader = BufReader::new(file);
971    let mut hasher = Sha256::new();
972    let mut buffer = [0u8; 16 * 1024];
973
974    loop {
975        let read = reader.read(&mut buffer).with_context(|| {
976            format!(
977                "failed to stream plugin executable for checksum: {}",
978                path.display()
979            )
980        })?;
981        if read == 0 {
982            break;
983        }
984        hasher.update(&buffer[..read]);
985    }
986
987    let digest = hasher.finalize();
988
989    let mut out = String::with_capacity(digest.len() * 2);
990    for b in digest {
991        let _ = write!(&mut out, "{b:02x}");
992    }
993    Ok(out)
994}
995
996fn default_true() -> bool {
997    true
998}
999
1000fn is_plugin_executable(path: &Path) -> bool {
1001    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1002        return false;
1003    };
1004    if !name.starts_with(PLUGIN_EXECUTABLE_PREFIX) {
1005        return false;
1006    }
1007    if !has_supported_plugin_extension(path) {
1008        return false;
1009    }
1010    if !has_valid_plugin_suffix(name) {
1011        return false;
1012    }
1013    is_executable_file(path)
1014}
1015
1016#[cfg(windows)]
1017fn has_supported_plugin_extension(path: &Path) -> bool {
1018    match path.extension().and_then(|ext| ext.to_str()) {
1019        None => true,
1020        Some(ext) => ext.eq_ignore_ascii_case("exe"),
1021    }
1022}
1023
1024#[cfg(not(windows))]
1025fn has_supported_plugin_extension(path: &Path) -> bool {
1026    path.extension().is_none()
1027}
1028
1029#[cfg(windows)]
1030pub(super) fn has_valid_plugin_suffix(file_name: &str) -> bool {
1031    let base = file_name.strip_suffix(".exe").unwrap_or(file_name);
1032    let Some(suffix) = base.strip_prefix(PLUGIN_EXECUTABLE_PREFIX) else {
1033        return false;
1034    };
1035    !suffix.is_empty()
1036        && suffix
1037            .chars()
1038            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1039}
1040
1041#[cfg(not(windows))]
1042pub(super) fn has_valid_plugin_suffix(file_name: &str) -> bool {
1043    let Some(suffix) = file_name.strip_prefix(PLUGIN_EXECUTABLE_PREFIX) else {
1044        return false;
1045    };
1046    !suffix.is_empty()
1047        && suffix
1048            .chars()
1049            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1050}
1051
1052#[cfg(unix)]
1053fn is_executable_file(path: &Path) -> bool {
1054    use std::os::unix::fs::PermissionsExt;
1055
1056    match std::fs::metadata(path) {
1057        Ok(meta) if meta.is_file() => meta.permissions().mode() & 0o111 != 0,
1058        _ => false,
1059    }
1060}
1061
1062#[cfg(not(unix))]
1063fn is_executable_file(path: &Path) -> bool {
1064    path.is_file()
1065}