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}