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}