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