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