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