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