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