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