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        describe_commands: Vec::new(),
420        command_specs: commands
421            .iter()
422            .map(|name| CommandSpec::new(name.clone()))
423            .collect(),
424        commands,
425        issue: None,
426        default_enabled: manifest_entry
427            .as_ref()
428            .map(|entry| entry.enabled_by_default)
429            .unwrap_or(true),
430    }
431}
432
433fn apply_manifest_discovery_issue(
434    issue: &mut Option<String>,
435    manifest_state: &ManifestState,
436    manifest_entry: Option<&ManifestPlugin>,
437) {
438    if let Some(message) = manifest_discovery_issue(manifest_state, manifest_entry) {
439        super::state::merge_issue(issue, message);
440    }
441}
442
443fn describe_eligibility(
444    source: PluginSource,
445    manifest_state: &ManifestState,
446    manifest_entry: Option<&ManifestPlugin>,
447    executable: &Path,
448) -> Result<DescribeEligibility> {
449    if source != PluginSource::Bundled {
450        return Ok(DescribeEligibility::Allowed);
451    }
452
453    match manifest_state {
454        ManifestState::Missing | ManifestState::Invalid(_) => return Ok(DescribeEligibility::Skip),
455        ManifestState::Valid(_) if manifest_entry.is_none() => {
456            return Ok(DescribeEligibility::Skip);
457        }
458        ManifestState::NotBundled | ManifestState::Valid(_) => {}
459    }
460
461    if let Some(entry) = manifest_entry {
462        validate_manifest_checksum(entry, executable)?;
463    }
464
465    Ok(DescribeEligibility::Allowed)
466}
467
468fn manifest_discovery_issue(
469    manifest_state: &ManifestState,
470    manifest_entry: Option<&ManifestPlugin>,
471) -> Option<String> {
472    match manifest_state {
473        ManifestState::Missing => Some(format!("bundled {} not found", BUNDLED_MANIFEST_FILE)),
474        ManifestState::Invalid(err) => Some(format!("bundled manifest invalid: {err}")),
475        ManifestState::Valid(_) if manifest_entry.is_none() => {
476            Some("plugin executable not present in bundled manifest".to_string())
477        }
478        ManifestState::NotBundled | ManifestState::Valid(_) => None,
479    }
480}
481
482fn apply_describe_metadata(
483    plugin: &mut DiscoveredPlugin,
484    describe: &DescribeV1,
485    manifest_entry: Option<&ManifestPlugin>,
486) {
487    if let Some(entry) = manifest_entry {
488        plugin.default_enabled = entry.enabled_by_default;
489        if let Err(err) = validate_manifest_describe(entry, describe) {
490            super::state::merge_issue(&mut plugin.issue, err.to_string());
491            return;
492        }
493    }
494
495    plugin.plugin_id = describe.plugin_id.clone();
496    plugin.plugin_version = Some(describe.plugin_version.clone());
497    plugin.commands = describe
498        .commands
499        .iter()
500        .map(|cmd| cmd.name.clone())
501        .collect::<Vec<String>>();
502    plugin.describe_commands = describe.commands.clone();
503    plugin.command_specs = describe
504        .commands
505        .iter()
506        .map(to_command_spec)
507        .collect::<Vec<CommandSpec>>();
508
509    if let Some(issue) = min_osp_version_issue(describe) {
510        super::state::merge_issue(&mut plugin.issue, issue);
511    }
512}
513
514pub(super) fn min_osp_version_issue(describe: &DescribeV1) -> Option<String> {
515    let min_required = describe
516        .min_osp_version
517        .as_deref()
518        .map(str::trim)
519        .filter(|value| !value.is_empty())?;
520    let current_raw = env!("CARGO_PKG_VERSION");
521    let current = match Version::parse(current_raw) {
522        Ok(version) => version,
523        Err(err) => {
524            return Some(format!(
525                "osp version `{current_raw}` is invalid for plugin compatibility checks: {err}"
526            ));
527        }
528    };
529    let min = match Version::parse(min_required) {
530        Ok(version) => version,
531        Err(err) => {
532            return Some(format!(
533                "invalid min_osp_version `{min_required}` declared by plugin {}: {err}",
534                describe.plugin_id
535            ));
536        }
537    };
538
539    if current < min {
540        Some(format!(
541            "plugin {} requires osp >= {min}, current version is {current}",
542            describe.plugin_id
543        ))
544    } else {
545        None
546    }
547}
548
549fn load_and_validate_manifest(path: &Path) -> Result<ValidatedBundledManifest> {
550    let manifest = read_bundled_manifest(path)?;
551    validate_manifest_protocol(&manifest)?;
552    Ok(ValidatedBundledManifest {
553        by_exe: index_manifest_plugins(manifest.plugin)?,
554    })
555}
556
557fn read_bundled_manifest(path: &Path) -> Result<BundledManifest> {
558    let raw = std::fs::read_to_string(path)
559        .with_context(|| format!("failed to read manifest {}", path.display()))?;
560    toml::from_str::<BundledManifest>(&raw)
561        .with_context(|| format!("failed to parse manifest TOML at {}", path.display()))
562}
563
564fn validate_manifest_protocol(manifest: &BundledManifest) -> Result<()> {
565    if manifest.protocol_version != 1 {
566        return Err(anyhow!(
567            "unsupported manifest protocol_version {}",
568            manifest.protocol_version
569        ));
570    }
571    Ok(())
572}
573
574fn index_manifest_plugins(plugins: Vec<ManifestPlugin>) -> Result<HashMap<String, ManifestPlugin>> {
575    let mut by_exe: HashMap<String, ManifestPlugin> = HashMap::new();
576    let mut ids = HashSet::new();
577
578    for plugin in plugins {
579        validate_manifest_plugin(&plugin)?;
580        insert_manifest_plugin(&mut by_exe, &mut ids, plugin)?;
581    }
582
583    Ok(by_exe)
584}
585
586fn validate_manifest_plugin(plugin: &ManifestPlugin) -> Result<()> {
587    if plugin.id.trim().is_empty() {
588        return Err(anyhow!("manifest plugin id must not be empty"));
589    }
590    if plugin.exe.trim().is_empty() {
591        return Err(anyhow!("manifest plugin exe must not be empty"));
592    }
593    if plugin.version.trim().is_empty() {
594        return Err(anyhow!("manifest plugin version must not be empty"));
595    }
596    if plugin.commands.is_empty() {
597        return Err(anyhow!(
598            "manifest plugin {} must declare at least one command",
599            plugin.id
600        ));
601    }
602    Ok(())
603}
604
605fn insert_manifest_plugin(
606    by_exe: &mut HashMap<String, ManifestPlugin>,
607    ids: &mut HashSet<String>,
608    plugin: ManifestPlugin,
609) -> Result<()> {
610    if !ids.insert(plugin.id.clone()) {
611        return Err(anyhow!("duplicate plugin id in manifest: {}", plugin.id));
612    }
613    if by_exe.contains_key(&plugin.exe) {
614        return Err(anyhow!("duplicate plugin exe in manifest: {}", plugin.exe));
615    }
616    by_exe.insert(plugin.exe.clone(), plugin);
617    Ok(())
618}
619
620fn validate_manifest_describe(entry: &ManifestPlugin, describe: &DescribeV1) -> Result<()> {
621    if entry.id != describe.plugin_id {
622        return Err(anyhow!(
623            "manifest id mismatch: expected {}, got {}",
624            entry.id,
625            describe.plugin_id
626        ));
627    }
628
629    if entry.version != describe.plugin_version {
630        return Err(anyhow!(
631            "manifest version mismatch for {}: expected {}, got {}",
632            entry.id,
633            entry.version,
634            describe.plugin_version
635        ));
636    }
637
638    let mut expected = entry.commands.clone();
639    expected.sort();
640    expected.dedup();
641
642    let mut actual = describe
643        .commands
644        .iter()
645        .map(|cmd| cmd.name.clone())
646        .collect::<Vec<String>>();
647    actual.sort();
648    actual.dedup();
649
650    if expected != actual {
651        return Err(anyhow!(
652            "manifest commands mismatch for {}: expected {:?}, got {:?}",
653            entry.id,
654            expected,
655            actual
656        ));
657    }
658
659    Ok(())
660}
661
662fn validate_manifest_checksum(entry: &ManifestPlugin, path: &Path) -> Result<()> {
663    let Some(expected_checksum) = entry.checksum_sha256.as_deref() else {
664        return Ok(());
665    };
666    let expected_checksum = normalize_checksum(expected_checksum)?;
667    let actual_checksum = file_sha256_hex(path)?;
668    if expected_checksum != actual_checksum {
669        return Err(anyhow!(
670            "checksum mismatch for {}: expected {}, got {}",
671            entry.id,
672            expected_checksum,
673            actual_checksum
674        ));
675    }
676    Ok(())
677}
678
679fn describe_with_cache(
680    path: &Path,
681    cache: &mut DescribeCacheFile,
682    seen_describe_paths: &mut HashSet<String>,
683    cache_dirty: &mut bool,
684    process_timeout: Duration,
685) -> Result<DescribeV1> {
686    let key = describe_cache_key(path);
687    seen_describe_paths.insert(key.clone());
688    let (size, mtime_secs, mtime_nanos) = file_fingerprint(path)?;
689
690    if let Some(entry) = find_cached_describe(cache, &key, size, mtime_secs, mtime_nanos) {
691        tracing::trace!(path = %path.display(), "describe cache hit");
692        return Ok(entry.describe.clone());
693    }
694
695    tracing::trace!(path = %path.display(), "describe cache miss");
696
697    let describe = super::dispatch::describe_plugin(path, process_timeout)?;
698    upsert_cached_describe(cache, key, size, mtime_secs, mtime_nanos, describe.clone());
699    *cache_dirty = true;
700
701    Ok(describe)
702}
703
704fn describe_cache_key(path: &Path) -> String {
705    path.to_string_lossy().to_string()
706}
707
708pub(super) fn find_cached_describe<'a>(
709    cache: &'a DescribeCacheFile,
710    key: &str,
711    size: u64,
712    mtime_secs: u64,
713    mtime_nanos: u32,
714) -> Option<&'a DescribeCacheEntry> {
715    cache.entries.iter().find(|entry| {
716        entry.path == key
717            && entry.size == size
718            && entry.mtime_secs == mtime_secs
719            && entry.mtime_nanos == mtime_nanos
720    })
721}
722
723pub(super) fn upsert_cached_describe(
724    cache: &mut DescribeCacheFile,
725    key: String,
726    size: u64,
727    mtime_secs: u64,
728    mtime_nanos: u32,
729    describe: DescribeV1,
730) {
731    if let Some(entry) = cache.entries.iter_mut().find(|entry| entry.path == key) {
732        entry.size = size;
733        entry.mtime_secs = mtime_secs;
734        entry.mtime_nanos = mtime_nanos;
735        entry.describe = describe;
736    } else {
737        cache.entries.push(DescribeCacheEntry {
738            path: key,
739            size,
740            mtime_secs,
741            mtime_nanos,
742            describe,
743        });
744    }
745}
746
747pub(super) fn prune_stale_describe_cache_entries(
748    cache: &mut DescribeCacheFile,
749    seen_paths: &HashSet<String>,
750) -> bool {
751    let before = cache.entries.len();
752    cache
753        .entries
754        .retain(|entry| seen_paths.contains(&entry.path));
755    cache.entries.len() != before
756}
757
758pub(super) fn file_fingerprint(path: &Path) -> Result<(u64, u64, u32)> {
759    let metadata = std::fs::metadata(path)
760        .with_context(|| format!("failed to read metadata for {}", path.display()))?;
761    let size = metadata.len();
762    let modified = metadata
763        .modified()
764        .with_context(|| format!("failed to read mtime for {}", path.display()))?;
765    let dur = modified
766        .duration_since(UNIX_EPOCH)
767        .with_context(|| format!("mtime before unix epoch for {}", path.display()))?;
768    Ok((size, dur.as_secs(), dur.subsec_nanos()))
769}
770
771fn bundled_plugin_dirs() -> Vec<PathBuf> {
772    let mut dirs = Vec::new();
773
774    if let Ok(path) = std::env::var("OSP_BUNDLED_PLUGIN_DIR") {
775        dirs.push(PathBuf::from(path));
776    }
777
778    if let Ok(exe_path) = std::env::current_exe()
779        && let Some(bin_dir) = exe_path.parent()
780    {
781        dirs.push(bin_dir.join("plugins"));
782        dirs.push(bin_dir.join("../lib/osp/plugins"));
783    }
784
785    dirs
786}
787
788pub(super) fn normalize_checksum(checksum: &str) -> Result<String> {
789    let trimmed = checksum.trim().to_ascii_lowercase();
790    if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
791        return Err(anyhow!(
792            "checksum must be a 64-char lowercase/uppercase hex string"
793        ));
794    }
795    Ok(trimmed)
796}
797
798pub(super) fn file_sha256_hex(path: &Path) -> Result<String> {
799    let file = std::fs::File::open(path).with_context(|| {
800        format!(
801            "failed to read plugin executable for checksum: {}",
802            path.display()
803        )
804    })?;
805    let mut reader = BufReader::new(file);
806    let mut hasher = Sha256::new();
807    let mut buffer = [0u8; 16 * 1024];
808
809    loop {
810        let read = reader.read(&mut buffer).with_context(|| {
811            format!(
812                "failed to stream plugin executable for checksum: {}",
813                path.display()
814            )
815        })?;
816        if read == 0 {
817            break;
818        }
819        hasher.update(&buffer[..read]);
820    }
821
822    let digest = hasher.finalize();
823
824    let mut out = String::with_capacity(digest.len() * 2);
825    for b in digest {
826        let _ = write!(&mut out, "{b:02x}");
827    }
828    Ok(out)
829}
830
831fn default_true() -> bool {
832    true
833}
834
835fn is_plugin_executable(path: &Path) -> bool {
836    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
837        return false;
838    };
839    if !name.starts_with(PLUGIN_EXECUTABLE_PREFIX) {
840        return false;
841    }
842    if !has_supported_plugin_extension(path) {
843        return false;
844    }
845    if !has_valid_plugin_suffix(name) {
846        return false;
847    }
848    is_executable_file(path)
849}
850
851#[cfg(windows)]
852fn has_supported_plugin_extension(path: &Path) -> bool {
853    match path.extension().and_then(|ext| ext.to_str()) {
854        None => true,
855        Some(ext) => ext.eq_ignore_ascii_case("exe"),
856    }
857}
858
859#[cfg(not(windows))]
860fn has_supported_plugin_extension(path: &Path) -> bool {
861    path.extension().is_none()
862}
863
864#[cfg(windows)]
865pub(super) fn has_valid_plugin_suffix(file_name: &str) -> bool {
866    let base = file_name.strip_suffix(".exe").unwrap_or(file_name);
867    let Some(suffix) = base.strip_prefix(PLUGIN_EXECUTABLE_PREFIX) else {
868        return false;
869    };
870    !suffix.is_empty()
871        && suffix
872            .chars()
873            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
874}
875
876#[cfg(not(windows))]
877pub(super) fn has_valid_plugin_suffix(file_name: &str) -> bool {
878    let Some(suffix) = file_name.strip_prefix(PLUGIN_EXECUTABLE_PREFIX) else {
879        return false;
880    };
881    !suffix.is_empty()
882        && suffix
883            .chars()
884            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
885}
886
887#[cfg(unix)]
888fn is_executable_file(path: &Path) -> bool {
889    use std::os::unix::fs::PermissionsExt;
890
891    match std::fs::metadata(path) {
892        Ok(meta) if meta.is_file() => meta.permissions().mode() & 0o111 != 0,
893        _ => false,
894    }
895}
896
897#[cfg(not(unix))]
898fn is_executable_file(path: &Path) -> bool {
899    path.is_file()
900}