Skip to main content

vst3_host/
discovery.rs

1//! VST3 plugin discovery functionality
2
3use crate::{error::Result, plugin::PluginInfo};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::ptr;
7use std::time::Duration;
8
9/// Default time to wait for the discovery probe to introspect a single plugin before
10/// treating it as hung and killing the child process.
11pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
12
13/// Factory-level metadata (the plugin vendor's identity).
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct FactoryInfo {
16    /// Vendor / manufacturer name.
17    pub vendor: String,
18    /// Vendor URL.
19    pub url: String,
20    /// Vendor contact email.
21    pub email: String,
22    /// Raw factory flags.
23    pub flags: i32,
24}
25
26/// Factory capability flags declared by `moduleinfo.json`.
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ModuleFactoryFlags {
29    /// Factory and class strings use Unicode.
30    pub unicode: bool,
31    /// Class objects may be discarded after use.
32    pub classes_discardable: bool,
33    /// The factory performs a license check.
34    pub license_check: bool,
35    /// Component objects must not be discarded.
36    pub component_non_discardable: bool,
37}
38
39/// Factory metadata declared by a bundle's `moduleinfo.json`.
40#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41pub struct ModuleFactoryInfo {
42    /// Vendor / manufacturer name.
43    pub vendor: String,
44    /// Vendor URL.
45    pub url: String,
46    /// Vendor contact email.
47    pub email: String,
48    /// Factory capabilities.
49    pub flags: ModuleFactoryFlags,
50}
51
52/// One class declared by a bundle's `moduleinfo.json`.
53#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
54pub struct ModuleClassInfo {
55    /// Canonical, uppercase 32-hex-character class id.
56    pub class_id: String,
57    /// VST3 class category, such as `Audio Module Class`.
58    pub category: String,
59    /// Display name.
60    pub name: String,
61    /// Class vendor.
62    pub vendor: String,
63    /// Class version.
64    pub version: String,
65    /// VST3 SDK version used to build the class.
66    pub sdk_version: String,
67    /// Declared VST3 sub-categories.
68    pub sub_categories: Vec<String>,
69    /// Raw class flags.
70    pub class_flags: i32,
71    /// Instantiation cardinality.
72    pub cardinality: i32,
73    /// UI snapshots declared for this class, with paths resolved inside the bundle.
74    pub snapshots: Vec<PluginSnapshot>,
75}
76
77/// A pre-rendered VST3 plug-in UI snapshot.
78#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
79pub struct PluginSnapshot {
80    /// Current canonical audio-processor class id represented by the image.
81    pub class_id: String,
82    /// Display scale factor (`1.0` for the unscaled snapshot).
83    pub scale_factor: f64,
84    /// Snapshot PNG path inside the VST3 bundle.
85    pub path: PathBuf,
86}
87
88/// A `moduleinfo.json` class-id migration from one or more retired ids to a current id.
89#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
90pub struct ClassCompatibility {
91    /// Current replacement class id.
92    pub new_class_id: String,
93    /// Retired class ids replaced by [`Self::new_class_id`].
94    pub old_class_ids: Vec<String>,
95}
96
97/// Validated metadata from a VST3 bundle's `moduleinfo.json`.
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99pub struct ModuleInfo {
100    /// The moduleinfo file that was read.
101    pub source: PathBuf,
102    /// Module display name.
103    pub name: String,
104    /// Module version.
105    pub version: String,
106    /// Factory identity.
107    pub factory: ModuleFactoryInfo,
108    /// Classes declared by the module.
109    pub classes: Vec<ModuleClassInfo>,
110    /// Replacement mappings for retired class ids.
111    pub compatibility: Vec<ClassCompatibility>,
112}
113
114impl ModuleInfo {
115    /// Resolve a current or retired class id to the current class id exported by this module.
116    pub fn resolve_class_id(&self, requested_class_id: &str) -> Option<&str> {
117        if let Some(class) = self.classes.iter().find(|class| {
118            crate::internal::utils::class_uid_matches(&class.class_id, requested_class_id)
119        }) {
120            return Some(&class.class_id);
121        }
122        self.compatibility.iter().find_map(|mapping| {
123            mapping
124                .old_class_ids
125                .iter()
126                .any(|old| crate::internal::utils::class_uid_matches(old, requested_class_id))
127                .then_some(mapping.new_class_id.as_str())
128        })
129    }
130
131    /// Return the retired class ids replaced by `current_class_id`.
132    pub fn replaced_class_ids(&self, current_class_id: &str) -> &[String] {
133        self.compatibility
134            .iter()
135            .find(|mapping| {
136                crate::internal::utils::class_uid_matches(&mapping.new_class_id, current_class_id)
137            })
138            .map_or(&[], |mapping| mapping.old_class_ids.as_slice())
139    }
140}
141
142/// Read and validate the standard `moduleinfo.json` from a VST3 bundle.
143///
144/// Current bundles place it in `Contents/Resources`; the SDK 3.7.5
145/// `Contents/moduleinfo.json` location is accepted as a fallback. Returns `Ok(None)` when
146/// neither file exists. File size, collection counts, string sizes, integer ranges, class ids,
147/// and replacement mappings are bounded and validated before metadata is returned.
148pub fn read_module_info(path: &Path) -> Result<Option<ModuleInfo>> {
149    crate::internal::module_info::read(path)
150}
151
152/// Return the class-id replacement mappings advertised by a VST3 module.
153///
154/// A validated `moduleinfo.json` is authoritative. When it is absent, this loads the factory,
155/// locates its optional `Plugin Compatibility Class`, requests exactly
156/// `IPluginCompatibility`, and parses its bounded UTF-8 JSON5 stream.
157pub fn get_plugin_compatibility(path: &Path) -> Result<Vec<ClassCompatibility>> {
158    if let Some(module_info) = read_module_info(path)? {
159        return Ok(module_info.compatibility);
160    }
161
162    use vst3::{ComPtr, Steinberg::Vst::IHostApplication, Steinberg::*};
163    unsafe {
164        // Declared first so it outlives the module and factory — see `get_plugin_info` for
165        // why `setHostContext` makes this ordering load-bearing.
166        let host_app = crate::internal::com_implementations::create_host_application();
167        let host_ctx = host_app.to_com_ptr::<IHostApplication>();
168        let context = host_ctx
169            .as_ref()
170            .map(|pointer| pointer.as_ptr() as *mut FUnknown)
171            .unwrap_or(ptr::null_mut());
172
173        let module = crate::internal::module_loader::load_module(path)?;
174        let factory_ptr = module.get_factory()?;
175        let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
176            crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
177        })?;
178        if let Some(factory3) = factory.cast::<IPluginFactory3>() {
179            let result = factory3.setHostContext(context);
180            if result != kResultOk && result != kResultTrue {
181                log::warn!(
182                    "IPluginFactory3::setHostContext failed during compatibility discovery: \
183                     {result:#x}"
184                );
185            }
186        }
187        crate::internal::module_info::read_factory_compatibility(&factory)
188    }
189}
190
191/// Discover standard UI snapshot PNGs for a current audio-processor class id.
192///
193/// Only files in `Contents/Resources/Snapshots` whose names follow
194/// `<CID>_snapshot.png` or `<CID>_snapshot_<scale>x.png` are returned. This reads directory
195/// metadata only; it does not open or decode images. Retired compatibility ids are not
196/// resolved here—the caller must provide the current canonical class id.
197pub fn discover_plugin_snapshots(
198    path: &Path,
199    current_class_id: &str,
200) -> Result<Vec<PluginSnapshot>> {
201    crate::internal::module_info::discover_snapshots(path, current_class_id)
202}
203
204/// One class exported by a plugin's factory.
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ClassInfo {
207    /// Class display name.
208    pub name: String,
209    /// Class category (e.g. "Audio Module Class").
210    pub category: String,
211    /// Class id, hex-encoded.
212    pub class_id: String,
213    /// Instantiation cardinality.
214    pub cardinality: i32,
215    /// Version string (if available).
216    pub version: String,
217}
218
219/// One audio or event bus.
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct BusInfo {
222    /// Bus display name.
223    pub name: String,
224    /// Bus type (Main = 0, Aux = 1).
225    pub bus_type: i32,
226    /// Raw bus flags.
227    pub flags: i32,
228    /// Number of channels on this bus.
229    pub channel_count: i32,
230}
231
232/// The plugin's full bus layout.
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct BusLayout {
235    /// Audio input buses.
236    pub audio_inputs: Vec<BusInfo>,
237    /// Audio output buses.
238    pub audio_outputs: Vec<BusInfo>,
239    /// Event (MIDI) input buses.
240    pub event_inputs: Vec<BusInfo>,
241    /// Event (MIDI) output buses.
242    pub event_outputs: Vec<BusInfo>,
243}
244
245/// A deep introspection report for a VST3 plugin — factory, classes, and bus layout.
246/// This is the static metadata a plugin *inspector* UI needs, beyond the lightweight
247/// [`PluginInfo`]. For the parameter list, load the plugin and call
248/// [`crate::Plugin::get_parameters`] (which runs the full controller logic).
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DetailedPluginInfo {
251    /// The basic metadata (also part of this report for convenience).
252    pub info: PluginInfo,
253    /// Factory / vendor identity.
254    pub factory: FactoryInfo,
255    /// All classes exported by the factory.
256    pub classes: Vec<ClassInfo>,
257    /// Full audio + event bus layout.
258    pub buses: BusLayout,
259    /// Validated static bundle metadata and class-id replacement mappings, when present.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub module_info: Option<ModuleInfo>,
262    /// Effective current/retired class-id mappings (moduleinfo, or runtime fallback).
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub compatibility: Vec<ClassCompatibility>,
265}
266
267/// A complete, serializable report of a plugin: static introspection plus its parameter
268/// list. Build it after loading the plugin and serialize to JSON for export (e.g. the
269/// inspector's "Copy JSON", or feeding plugin metadata to other tools).
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct PluginReport {
272    /// Static introspection: factory, classes, bus layout, basic info.
273    pub detailed: DetailedPluginInfo,
274    /// The plugin's parameters (normalized values + metadata).
275    pub parameters: Vec<crate::parameters::Parameter>,
276}
277
278impl PluginReport {
279    /// Bundle a [`DetailedPluginInfo`] with a parameter list (from
280    /// [`crate::Plugin::get_parameters`]).
281    pub fn new(
282        detailed: DetailedPluginInfo,
283        parameters: Vec<crate::parameters::Parameter>,
284    ) -> Self {
285        Self {
286            detailed,
287            parameters,
288        }
289    }
290
291    /// Serialize the report to pretty-printed JSON.
292    pub fn to_json(&self) -> serde_json::Result<String> {
293        serde_json::to_string_pretty(self)
294    }
295}
296
297/// Scan standard VST3 directories for plugins
298pub fn scan_standard_paths() -> Vec<PathBuf> {
299    let mut paths = Vec::new();
300
301    #[cfg(target_os = "macos")]
302    {
303        paths.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
304        if let Ok(home) = std::env::var("HOME") {
305            paths.push(PathBuf::from(format!(
306                "{}/Library/Audio/Plug-Ins/VST3",
307                home
308            )));
309        }
310    }
311
312    #[cfg(target_os = "windows")]
313    {
314        paths.push(PathBuf::from(r"C:\Program Files\Common Files\VST3"));
315        paths.push(PathBuf::from(r"C:\Program Files (x86)\Common Files\VST3"));
316    }
317
318    #[cfg(target_os = "linux")]
319    {
320        paths.push(PathBuf::from("/usr/lib/vst3"));
321        paths.push(PathBuf::from("/usr/local/lib/vst3"));
322        if let Ok(home) = std::env::var("HOME") {
323            paths.push(PathBuf::from(format!("{}/.vst3", home)));
324        }
325    }
326
327    paths
328}
329
330/// Scan directories for VST3 plugins
331pub fn scan_directories(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
332    let mut plugins = Vec::new();
333
334    // Directories already visited, by canonical path. A symlink pointing at an ancestor makes the
335    // recursion below unbounded — `is_dir()` follows symlinks — so a user whose plug-in folder
336    // contains one would hang the scan while `plugins` grew forever with textually-distinct
337    // duplicates of the same file (which `dedup` can't collapse, since the paths differ).
338    let mut visited = std::collections::HashSet::new();
339    for path in paths {
340        if path.exists() {
341            scan_directory(path, &mut plugins, &mut visited)?;
342        }
343    }
344
345    // Remove duplicates and sort
346    plugins.sort();
347    plugins.dedup();
348
349    Ok(plugins)
350}
351
352/// Recursively scan a directory for VST3 plugins.
353///
354/// `visited` holds the canonical path of every directory already descended into, so a symlink
355/// loop terminates instead of recursing forever.
356fn scan_directory(
357    dir: &Path,
358    plugins: &mut Vec<PathBuf>,
359    visited: &mut std::collections::HashSet<PathBuf>,
360) -> Result<()> {
361    // Resolve through symlinks so two routes to the same directory collapse to one entry. A
362    // directory we can't canonicalize (permissions, a broken link) is simply not descended into.
363    match dir.canonicalize() {
364        Ok(real) => {
365            if !visited.insert(real) {
366                return Ok(());
367            }
368        }
369        Err(_) => return Ok(()),
370    }
371
372    if let Ok(entries) = std::fs::read_dir(dir) {
373        for entry in entries.flatten() {
374            let path = entry.path();
375
376            // Check if it's a VST3 bundle/file
377            if let Some(ext) = path.extension() {
378                if ext == "vst3" {
379                    plugins.push(path.clone());
380                }
381            }
382
383            // Recursively scan subdirectories (but not .vst3 bundles)
384            if path.is_dir() && path.extension() != Some(std::ffi::OsStr::new("vst3")) {
385                scan_directory(&path, plugins, visited)?;
386            }
387        }
388    }
389
390    Ok(())
391}
392
393/// Get metadata for a VST3 plugin without fully loading it
394pub fn get_plugin_info(path: &Path) -> Result<PluginInfo> {
395    use vst3::Steinberg::Vst::BusDirections_::*;
396    use vst3::Steinberg::Vst::MediaTypes_::*;
397    use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
398
399    unsafe {
400        // Declared before the module and factory so it drops *after* them: locals drop in
401        // reverse declaration order, and `IPluginFactory3::setHostContext` stores this pointer
402        // in a module-global without an addRef (the SDK's `CPluginFactory` keeps it in
403        // `gPluginContext`). Releasing the host application before the module unloads would
404        // leave that global dangling for the rest of the plugin's teardown.
405        let host_app = crate::internal::com_implementations::create_host_application();
406        let host_ctx = host_app.to_com_ptr::<IHostApplication>();
407        let context = host_ctx
408            .as_ref()
409            .map(|p| p.as_ptr() as *mut FUnknown)
410            .unwrap_or(ptr::null_mut());
411
412        // Load the module using our VST3-compliant module loader
413        let module = crate::internal::module_loader::load_module(path)?;
414
415        // Get factory using the proper VST3 loading sequence
416        let factory_ptr = module.get_factory()?;
417
418        let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
419            crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
420        })?;
421        if let Some(factory3) = factory.cast::<IPluginFactory3>() {
422            let result = factory3.setHostContext(context);
423            if result != kResultOk && result != kResultTrue {
424                log::warn!("IPluginFactory3::setHostContext failed during discovery: {result:#x}");
425            }
426        }
427
428        // Get factory info
429        let mut factory_info: PFactoryInfo = std::mem::zeroed();
430        factory.getFactoryInfo(&mut factory_info);
431
432        let vendor = crate::internal::utils::c_str_to_string(&factory_info.vendor);
433
434        // Find audio component
435        let num_classes = factory.countClasses();
436        let mut plugin_name = String::new();
437        let mut category = String::new();
438        let mut version = String::new();
439        let mut uid = String::new();
440        let mut has_midi_input = false;
441        let mut has_midi_output = false;
442        let mut audio_inputs = 0u32;
443        let mut audio_outputs = 0u32;
444        let mut has_gui = false;
445
446        for i in 0..num_classes {
447            let mut class_info: PClassInfo = std::mem::zeroed();
448            if factory.getClassInfo(i, &mut class_info) == kResultOk {
449                let class_category = crate::internal::utils::c_str_to_string(&class_info.category);
450
451                if class_category.contains("Audio Module Class") {
452                    plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
453
454                    // Real version + sub-categories via IPluginFactory2 (PClassInfo.category
455                    // is just "Audio Module Class"; the useful sub-categories live in
456                    // PClassInfo2.subCategories). Left empty rather than faked when absent.
457                    if let Some(f2) = factory.cast::<IPluginFactory2>() {
458                        let mut info2: PClassInfo2 = std::mem::zeroed();
459                        if f2.getClassInfo2(i, &mut info2) == kResultOk {
460                            version = crate::internal::utils::c_str_to_string(&info2.version);
461                            category =
462                                crate::internal::utils::c_str_to_string(&info2.subCategories);
463                        }
464                    }
465                    if let Some(f3) = factory.cast::<IPluginFactory3>() {
466                        let mut info3: PClassInfoW = std::mem::zeroed();
467                        if f3.getClassInfoUnicode(i, &mut info3) == kResultOk {
468                            let utf16 = |value: &[u16]| {
469                                let end =
470                                    value.iter().position(|&ch| ch == 0).unwrap_or(value.len());
471                                String::from_utf16_lossy(&value[..end])
472                            };
473                            let unicode_name = utf16(&info3.name);
474                            let unicode_version = utf16(&info3.version);
475                            if !unicode_name.is_empty() {
476                                plugin_name = unicode_name;
477                            }
478                            if !unicode_version.is_empty() {
479                                version = unicode_version;
480                            }
481                            let unicode_category =
482                                crate::internal::utils::c_str_to_string(&info3.subCategories);
483                            if !unicode_category.is_empty() {
484                                category = unicode_category;
485                            }
486                        }
487                    }
488
489                    uid = crate::internal::utils::format_class_uid(&class_info.cid);
490
491                    // Try to create component to get more info
492                    let mut component_ptr: *mut IComponent = ptr::null_mut();
493                    let result = factory.createInstance(
494                        class_info.cid.as_ptr() as *const std::os::raw::c_char,
495                        IComponent::IID.as_ptr() as *const std::os::raw::c_char,
496                        &mut component_ptr as *mut _ as *mut _,
497                    );
498
499                    if result == kResultOk && !component_ptr.is_null() {
500                        let component =
501                            ComPtr::<IComponent>::from_raw(component_ptr).ok_or_else(|| {
502                                crate::error::Error::Other("Failed to wrap component".to_string())
503                            })?;
504
505                        // Initialize with a host context (null crashes u-he/Waves plugins).
506                        component.initialize(context);
507
508                        // Get bus counts
509                        audio_inputs = component.getBusCount(kAudio as i32, kInput as i32) as u32;
510                        audio_outputs = component.getBusCount(kAudio as i32, kOutput as i32) as u32;
511
512                        // MIDI capability from event bus presence.
513                        has_midi_input = component.getBusCount(kEvent as i32, kInput as i32) > 0;
514                        has_midi_output = component.getBusCount(kEvent as i32, kOutput as i32) > 0;
515
516                        // GUI detection (lightweight). A plugin has an editor when it provides
517                        // an edit controller — either the component itself implements
518                        // IEditController (single-component) or it names a separate controller
519                        // class. The previous check only handled the single-component case, so
520                        // it wrongly reported "no GUI" for the common separate-component
521                        // plugins. A precise createView probe needs the plugin's full setup
522                        // (component handler + activation) that only the load path performs;
523                        // controller presence is the reliable fast signal here.
524                        has_gui = component.cast::<IEditController>().is_some() || {
525                            let mut cid: [std::os::raw::c_char; 16] = [0; 16];
526                            component.getControllerClassId(&mut cid) == kResultOk
527                        };
528
529                        // Cleanup
530                        component.terminate();
531                    }
532
533                    break;
534                }
535            }
536        }
537
538        // If no audio component found, use first class
539        if plugin_name.is_empty() && num_classes > 0 {
540            let mut class_info: PClassInfo = std::mem::zeroed();
541            if factory.getClassInfo(0, &mut class_info) == kResultOk {
542                plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
543            }
544        }
545
546        Ok(PluginInfo {
547            path: path.to_path_buf(),
548            name: if plugin_name.is_empty() {
549                path.file_stem()
550                    .and_then(|s| s.to_str())
551                    .unwrap_or("Unknown")
552                    .to_string()
553            } else {
554                plugin_name
555            },
556            vendor,
557            version,
558            category,
559            uid,
560            audio_inputs,
561            audio_outputs,
562            has_midi_input,
563            has_midi_output,
564            has_gui,
565        })
566    }
567}
568
569/// Deep-introspect a VST3 plugin: factory identity, exported classes, and bus layout.
570///
571/// Heavier than [`get_plugin_info`] (it enumerates every class and bus) but still does
572/// not require driving audio. For the parameter list, load the plugin and call
573/// [`crate::Plugin::get_parameters`].
574pub fn get_detailed_plugin_info(path: &Path) -> Result<DetailedPluginInfo> {
575    use vst3::Steinberg::Vst::BusDirections_::*;
576    use vst3::Steinberg::Vst::BusInfo as VstBusInfo;
577    use vst3::Steinberg::Vst::MediaTypes_::*;
578    use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
579
580    // Static metadata is read before loading code so malformed or hostile metadata is rejected
581    // by the bounded parser. Runtime bus information still comes from the component.
582    let module_info = read_module_info(path)?;
583
584    // Reuse the lightweight pass for the basic info.
585    let info = get_plugin_info(path)?;
586
587    unsafe {
588        // Declared first so it outlives the module and factory — see `get_plugin_info` for
589        // why `setHostContext` makes this ordering load-bearing.
590        let host_app = crate::internal::com_implementations::create_host_application();
591        let host_ctx = host_app.to_com_ptr::<IHostApplication>();
592        let context = host_ctx
593            .as_ref()
594            .map(|p| p.as_ptr() as *mut FUnknown)
595            .unwrap_or(ptr::null_mut());
596
597        let module = crate::internal::module_loader::load_module(path)?;
598        let factory_ptr = module.get_factory()?;
599        let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
600            crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
601        })?;
602        if let Some(factory3) = factory.cast::<IPluginFactory3>() {
603            let result = factory3.setHostContext(context);
604            if result != kResultOk && result != kResultTrue {
605                log::warn!(
606                    "IPluginFactory3::setHostContext failed during detailed discovery: \
607                     {result:#x}"
608                );
609            }
610        }
611        let compatibility = match module_info.as_ref() {
612            Some(module_info) => module_info.compatibility.clone(),
613            None => crate::internal::module_info::read_factory_compatibility(&factory)?,
614        };
615
616        // Factory identity.
617        let mut fi: PFactoryInfo = std::mem::zeroed();
618        factory.getFactoryInfo(&mut fi);
619        let factory_info = FactoryInfo {
620            vendor: crate::internal::utils::c_str_to_string(&fi.vendor),
621            url: crate::internal::utils::c_str_to_string(&fi.url),
622            email: crate::internal::utils::c_str_to_string(&fi.email),
623            flags: fi.flags,
624        };
625
626        // Exported classes + locate the audio component class id.
627        let num_classes = factory.countClasses();
628        let mut classes = Vec::new();
629        let mut audio_cid: Option<[std::os::raw::c_char; 16]> = None;
630        for i in 0..num_classes {
631            let mut ci: PClassInfo = std::mem::zeroed();
632            if factory.getClassInfo(i, &mut ci) == kResultOk {
633                let category = crate::internal::utils::c_str_to_string(&ci.category);
634                let class_id = crate::internal::utils::format_class_uid(&ci.cid);
635                if category.contains("Audio Module Class") && audio_cid.is_none() {
636                    audio_cid = Some(ci.cid);
637                }
638                let mut name = crate::internal::utils::c_str_to_string(&ci.name);
639                let mut version = String::new();
640                if let Some(factory3) = factory.cast::<IPluginFactory3>() {
641                    let mut info3: PClassInfoW = std::mem::zeroed();
642                    if factory3.getClassInfoUnicode(i, &mut info3) == kResultOk {
643                        let utf16 = |value: &[u16]| {
644                            let end = value.iter().position(|&ch| ch == 0).unwrap_or(value.len());
645                            String::from_utf16_lossy(&value[..end])
646                        };
647                        let unicode_name = utf16(&info3.name);
648                        if !unicode_name.is_empty() {
649                            name = unicode_name;
650                        }
651                        version = utf16(&info3.version);
652                    }
653                } else if let Some(factory2) = factory.cast::<IPluginFactory2>() {
654                    let mut info2: PClassInfo2 = std::mem::zeroed();
655                    if factory2.getClassInfo2(i, &mut info2) == kResultOk {
656                        version = crate::internal::utils::c_str_to_string(&info2.version);
657                    }
658                }
659                classes.push(ClassInfo {
660                    name,
661                    category,
662                    class_id,
663                    cardinality: ci.cardinality,
664                    version,
665                });
666            }
667        }
668
669        // Bus layout from the audio component.
670        let mut buses = BusLayout::default();
671        if let Some(cid) = audio_cid {
672            let mut component_ptr: *mut IComponent = ptr::null_mut();
673            let result = factory.createInstance(
674                cid.as_ptr(),
675                IComponent::IID.as_ptr() as *const std::os::raw::c_char,
676                &mut component_ptr as *mut _ as *mut _,
677            );
678            if result == kResultOk && !component_ptr.is_null() {
679                if let Some(component) = ComPtr::<IComponent>::from_raw(component_ptr) {
680                    // Initialize with a host context (null crashes u-he/Waves plugins).
681                    component.initialize(context);
682
683                    let collect = |media: i32, dir: i32| -> Vec<crate::discovery::BusInfo> {
684                        let mut out = Vec::new();
685                        let count = component.getBusCount(media, dir);
686                        for i in 0..count {
687                            let mut bi: VstBusInfo = std::mem::zeroed();
688                            if component.getBusInfo(media, dir, i, &mut bi) == kResultOk {
689                                out.push(crate::discovery::BusInfo {
690                                    name: crate::internal::utils::vst_string_to_string(&bi.name),
691                                    bus_type: bi.busType,
692                                    flags: bi.flags as i32,
693                                    channel_count: bi.channelCount,
694                                });
695                            }
696                        }
697                        out
698                    };
699
700                    buses.audio_inputs = collect(kAudio as i32, kInput as i32);
701                    buses.audio_outputs = collect(kAudio as i32, kOutput as i32);
702                    buses.event_inputs = collect(kEvent as i32, kInput as i32);
703                    buses.event_outputs = collect(kEvent as i32, kOutput as i32);
704
705                    component.terminate();
706                }
707            }
708        }
709
710        Ok(DetailedPluginInfo {
711            info,
712            factory: factory_info,
713            classes,
714            buses,
715            module_info,
716            compatibility,
717        })
718    }
719}
720
721// ---------------------------------------------------------------------------
722// Crash-resistant ("safe") discovery via a probe subprocess.
723//
724// `get_plugin_info` / `get_detailed_plugin_info` INSTANTIATE each plugin in-process to
725// introspect it. Some installed plugins (licensed plugins that fail their auth check,
726// etc.) call `abort()` or trigger a pure-virtual call during instantiation — which kills
727// the whole host process. A Rust `catch_unwind` cannot help: an `abort()` terminates the
728// process, it does not unwind. The only robust isolation is to do the risky introspection
729// in a child process so the crash kills the child, not us.
730//
731// This path is independent of the run-time isolation IPC (`process_isolation` /
732// `vst3-host-helper`): it spawns a dedicated, minimal `vst3-host-probe` binary once per
733// plugin, reads one JSON line of `DetailedPluginInfo` from its stdout, and skips any
734// plugin whose probe crashed / timed out / exited non-zero. Correctness over speed: a
735// process spawn per plugin is slower than the in-process scan, which is the accepted
736// trade-off for a crash-proof scan.
737// ---------------------------------------------------------------------------
738
739/// Why a single plugin was skipped during a safe scan. Surfaced via
740/// [`SafeDiscoveryReport`] so callers can log or display *why* a plugin was omitted.
741#[derive(Debug, Clone)]
742pub enum SafeDiscoverySkip {
743    /// The probe process crashed (e.g. the plugin called `abort()` or made a
744    /// pure-virtual call) — exactly the case in-process scanning cannot survive.
745    Crashed {
746        /// The plugin path that was skipped.
747        path: PathBuf,
748        /// Human-readable detail (exit status / signal).
749        detail: String,
750    },
751    /// The probe did not finish within the timeout and was killed.
752    TimedOut {
753        /// The plugin path that was skipped.
754        path: PathBuf,
755    },
756    /// The probe ran but reported a (non-crash) failure introspecting the plugin.
757    Failed {
758        /// The plugin path that was skipped.
759        path: PathBuf,
760        /// Error detail from the probe (or this process).
761        detail: String,
762    },
763}
764
765impl SafeDiscoverySkip {
766    /// The plugin path that was skipped.
767    pub fn path(&self) -> &Path {
768        match self {
769            SafeDiscoverySkip::Crashed { path, .. }
770            | SafeDiscoverySkip::TimedOut { path }
771            | SafeDiscoverySkip::Failed { path, .. } => path,
772        }
773    }
774}
775
776/// Result of a crash-resistant scan: the plugins that introspected cleanly, plus a record
777/// of every plugin that was skipped and why.
778#[derive(Debug, Default)]
779pub struct SafeDiscoveryReport {
780    /// Plugins that introspected successfully.
781    pub plugins: Vec<DetailedPluginInfo>,
782    /// Plugins that were skipped (crashed / timed out / failed), with the reason.
783    pub skipped: Vec<SafeDiscoverySkip>,
784    /// Why the scan could not run at all, if it could not — the probe binary was missing or
785    /// unusable, so **no plugin was examined**. An empty report with `error: None` means the
786    /// scan ran and found nothing; an empty report with `error: Some(..)` means it never ran,
787    /// and a host should say so rather than claim there are no plugins installed.
788    pub error: Option<String>,
789}
790
791impl SafeDiscoveryReport {
792    /// Whether the scan actually ran. `false` means [`Self::error`] explains why not, and
793    /// [`Self::plugins`] / [`Self::skipped`] are empty for that reason alone.
794    pub fn scan_ran(&self) -> bool {
795        self.error.is_none()
796    }
797}
798
799/// Whether this executable is itself running from a cargo `target/{debug,release}` tree — i.e.
800/// it is a `cargo run` / `cargo test` / example binary rather than a deployed application.
801///
802/// Used to decide whether it is reasonable to go looking for sibling helper binaries in ancestor
803/// directories: inside a build tree that is the whole point, and outside one it would mean
804/// executing something from a path the host doesn't control.
805pub(crate) fn running_from_cargo_target(exe_dir: &Path) -> bool {
806    exe_dir.ancestors().any(|dir| {
807        matches!(
808            dir.file_name().and_then(|n| n.to_str()),
809            Some("debug") | Some("release")
810        ) && dir
811            .parent()
812            .and_then(|p| p.file_name())
813            .and_then(|n| n.to_str())
814            == Some("target")
815    })
816}
817
818/// Locate the `vst3-host-probe` binary that does the risky introspection out-of-process.
819///
820/// Mirrors the heuristic the isolation layer uses to find `vst3-host-helper` (same exe
821/// directory → examples parent → cargo `target/{debug,release}`), and honours an explicit
822/// override via the `VST3_HOST_PROBE_PATH` environment variable. Kept self-contained here
823/// rather than reusing the isolation module's resolver so the two stay decoupled.
824fn find_probe_binary() -> std::result::Result<PathBuf, String> {
825    const PROBE_NAME: &str = "vst3-host-probe";
826
827    if let Some(p) = std::env::var_os("VST3_HOST_PROBE_PATH").map(PathBuf::from) {
828        if p.exists() {
829            return Ok(p);
830        }
831        return Err(format!(
832            "VST3_HOST_PROBE_PATH does not exist: {}",
833            p.display()
834        ));
835    }
836
837    let exe_path =
838        std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
839    let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
840
841    // Same directory as the current executable.
842    let direct = exe_dir.join(PROBE_NAME);
843    if direct.exists() {
844        return Ok(direct);
845    }
846
847    // If we're in an examples/ directory, try the parent (where bins land).
848    if exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
849        if let Some(parent) = exe_dir.parent() {
850            let p = parent.join(PROBE_NAME);
851            if p.exists() {
852                return Ok(p);
853            }
854        }
855    }
856
857    // Walk up looking for a cargo target/{debug,release} that holds the probe.
858    //
859    // Only when *we* are running from inside a cargo target directory — i.e. a `cargo run`/`cargo
860    // test` binary, which is the case this fallback exists for (test and example binaries live in
861    // `target/<profile>/deps` and `…/examples`, so neither check above finds the sibling helper).
862    // A shipped application's executable is not under `target/<profile>/`, and for it this walk
863    // would be a liability: it reaches into directories an unprivileged process can write, and
864    // whatever it finds is executed and then trusted for everything the host believes about a
865    // plugin. Deployed builds use `VST3_HOST_PROBE_PATH` or a binary beside the executable.
866    if running_from_cargo_target(exe_dir) {
867        let mut current = exe_dir;
868        while let Some(parent) = current.parent() {
869            for profile in ["debug", "release"] {
870                let candidate = parent.join("target").join(profile).join(PROBE_NAME);
871                if candidate.exists() {
872                    return Ok(candidate);
873                }
874            }
875            if parent.join("Cargo.toml").exists() {
876                break;
877            }
878            current = parent;
879        }
880    }
881
882    Err(format!(
883        "Probe executable '{PROBE_NAME}' not found near {} or in target/{{debug,release}}. \
884         Build it with `cargo build --bin vst3-host-probe`, or set VST3_HOST_PROBE_PATH.",
885        exe_dir.display()
886    ))
887}
888
889/// Outcome of probing a single plugin out-of-process.
890enum ProbeOutcome {
891    /// Introspection succeeded.
892    Ok(Box<DetailedPluginInfo>),
893    /// The probe process crashed (killed by a signal / non-graceful exit).
894    Crashed(String),
895    /// The probe exceeded the timeout and was killed.
896    TimedOut,
897    /// The probe ran but reported a (non-crash) failure.
898    Failed(String),
899}
900
901/// Extra time allowed for the probe's already-written output to reach us after the child has
902/// exited, when the timeout budget is already spent. Bounded, unlike waiting for pipe EOF.
903const PROBE_OUTPUT_GRACE: Duration = Duration::from_millis(250);
904
905/// Run the probe binary against one plugin path with a timeout, returning the parsed
906/// outcome. The crash of a misbehaving plugin kills *the probe child*, surfacing here as
907/// [`ProbeOutcome::Crashed`] rather than taking down this process.
908///
909/// Every wait here is bounded. A plugin that spawns a grandchild (a license daemon, say)
910/// hands it the inherited stdout pipe, whose write end then stays open after the probe itself
911/// exits or is killed — so a read-to-EOF, or a `join()` on the thread performing it, would
912/// outlive the timeout by the grandchild's lifetime and defeat the very timeout the safe scan
913/// exists for. The reader thread is therefore detached and reports through a channel we only
914/// ever wait on with a deadline; it reads a line at a time so the probe's single JSON line
915/// arrives without EOF.
916fn run_probe(probe: &Path, plugin: &Path, timeout: Duration) -> ProbeOutcome {
917    use std::process::{Command, Stdio};
918
919    let mut child = match Command::new(probe)
920        .arg(plugin)
921        .stdin(Stdio::null())
922        .stdout(Stdio::piped())
923        .stderr(Stdio::null())
924        .spawn()
925    {
926        Ok(c) => c,
927        Err(e) => return ProbeOutcome::Failed(format!("failed to spawn probe: {e}")),
928    };
929
930    // Read stdout on a detached thread so we can enforce a wall-clock timeout on the child.
931    let stdout = match child.stdout.take() {
932        Some(s) => s,
933        None => {
934            let _ = child.kill();
935            let _ = child.wait();
936            return ProbeOutcome::Failed("probe produced no stdout pipe".to_string());
937        }
938    };
939    let (tx, rx) = std::sync::mpsc::channel::<String>();
940    std::thread::spawn(move || {
941        use std::io::BufRead;
942        let mut line = String::new();
943        // One JSON object on one line is the probe's entire protocol, so a line read (rather
944        // than read-to-end) completes as soon as it is written, whoever else holds the pipe.
945        let mut reader = std::io::BufReader::new(stdout);
946        let _ = reader.read_line(&mut line);
947        // The receiver is gone once `run_probe` has returned; dropping the line is correct.
948        let _ = tx.send(line);
949    });
950
951    /// Time left before `deadline`, never zero: a bounded grace so output the child already
952    /// wrote is not thrown away just because the budget ran out at the same moment.
953    fn remaining(deadline: std::time::Instant) -> Duration {
954        deadline
955            .saturating_duration_since(std::time::Instant::now())
956            .max(PROBE_OUTPUT_GRACE)
957    }
958
959    let deadline = std::time::Instant::now() + timeout;
960    loop {
961        match child.try_wait() {
962            Ok(Some(status)) => {
963                // Child exited; collect what it printed, still under a deadline.
964                let output = rx.recv_timeout(remaining(deadline)).unwrap_or_default();
965                if status.success() {
966                    let line = output.trim();
967                    return match serde_json::from_str::<DetailedPluginInfo>(line) {
968                        Ok(info) => ProbeOutcome::Ok(Box::new(info)),
969                        Err(e) => ProbeOutcome::Failed(format!(
970                            "probe succeeded but its output did not parse: {e}"
971                        )),
972                    };
973                }
974                // Non-success exit. A signal-kill (segfault/abort) has no exit code on
975                // Unix; treat both signal deaths and explicit non-zero exits as a crash —
976                // the point of the safe path is that *neither* is fatal to us.
977                return ProbeOutcome::Crashed(format!("probe exited with {status}"));
978            }
979            Ok(None) => {
980                if std::time::Instant::now() >= deadline {
981                    let _ = child.kill();
982                    let _ = child.wait();
983                    return ProbeOutcome::TimedOut;
984                }
985                std::thread::sleep(Duration::from_millis(20));
986            }
987            Err(e) => {
988                let _ = child.kill();
989                let _ = child.wait();
990                return ProbeOutcome::Failed(format!("failed to wait on probe: {e}"));
991            }
992        }
993    }
994}
995
996/// Crash-resistantly introspect a single plugin out-of-process.
997///
998/// Spawns the `vst3-host-probe` binary to do the risky instantiation in a child process,
999/// so a plugin that `abort()`s or makes a pure-virtual call during init kills the child
1000/// instead of this process. Returns `Ok(info)` on success; `Err` (with a descriptive
1001/// message) if the probe crashed, timed out, failed, or could not be located — callers
1002/// that want a "skip the bad one and keep going" scan should use
1003/// [`discover_plugins_safe`] instead, which never returns an error for a single bad plugin.
1004pub fn probe_plugin_info_isolated(path: &Path, timeout: Duration) -> Result<DetailedPluginInfo> {
1005    let probe = find_probe_binary().map_err(crate::Error::Other)?;
1006    match run_probe(&probe, path, timeout) {
1007        ProbeOutcome::Ok(info) => Ok(*info),
1008        ProbeOutcome::Crashed(detail) => Err(crate::Error::PluginLoadFailed(format!(
1009            "probe crashed introspecting {}: {detail}",
1010            path.display()
1011        ))),
1012        ProbeOutcome::TimedOut => Err(crate::Error::PluginTimeout),
1013        ProbeOutcome::Failed(detail) => Err(crate::Error::PluginLoadFailed(detail)),
1014    }
1015}
1016
1017/// Crash-resistantly discover plugins in `paths`: introspect every `.vst3` bundle in a
1018/// child process and **skip** any plugin whose probe crashes, hangs, or fails — the scan
1019/// always completes and returns the plugins it could introspect.
1020///
1021/// This is the robust answer to "one bad plugin in the folder takes down the scan": an
1022/// `abort()`/pure-virtual-call during instantiation kills the probe child, not the host.
1023/// Each skipped plugin is logged (`log::warn!`) and recorded in
1024/// [`SafeDiscoveryReport::skipped`].
1025///
1026/// Trade-off: this spawns one `vst3-host-probe` process per plugin, so it is slower than
1027/// the in-process [`crate::Vst3Host::discover_plugins`]. Use it for a robust "safe scan"
1028/// of an untrusted folder; keep the in-process path for speed when you trust the plugins.
1029///
1030/// If the probe binary cannot be located the scan cannot run at all: the returned report is
1031/// empty and carries the reason in [`SafeDiscoveryReport::error`]. Check
1032/// [`SafeDiscoveryReport::scan_ran`] before reporting "no plugins found" — the two are
1033/// otherwise indistinguishable.
1034pub fn discover_plugins_safe(paths: &[PathBuf], timeout: Duration) -> SafeDiscoveryReport {
1035    let probe = match find_probe_binary() {
1036        Ok(p) => p,
1037        Err(e) => {
1038            log::warn!("Safe discovery unavailable: {e}");
1039            return SafeDiscoveryReport {
1040                error: Some(e),
1041                ..Default::default()
1042            };
1043        }
1044    };
1045
1046    let plugin_paths = scan_directories(paths).unwrap_or_default();
1047    let mut report = SafeDiscoveryReport::default();
1048
1049    for path in plugin_paths {
1050        match run_probe(&probe, &path, timeout) {
1051            ProbeOutcome::Ok(info) => report.plugins.push(*info),
1052            ProbeOutcome::Crashed(detail) => {
1053                log::warn!(
1054                    "Skipping plugin that crashed the probe: {} ({detail})",
1055                    path.display()
1056                );
1057                report
1058                    .skipped
1059                    .push(SafeDiscoverySkip::Crashed { path, detail });
1060            }
1061            ProbeOutcome::TimedOut => {
1062                log::warn!("Skipping plugin whose probe timed out: {}", path.display());
1063                report.skipped.push(SafeDiscoverySkip::TimedOut { path });
1064            }
1065            ProbeOutcome::Failed(detail) => {
1066                log::warn!(
1067                    "Skipping plugin the probe could not introspect: {} ({detail})",
1068                    path.display()
1069                );
1070                report
1071                    .skipped
1072                    .push(SafeDiscoverySkip::Failed { path, detail });
1073            }
1074        }
1075    }
1076
1077    report
1078}
1079
1080/// Platform-specific VST3 binary path resolution
1081pub fn get_vst3_binary_path(bundle_path: &Path) -> Result<PathBuf> {
1082    // If it's already pointing to the binary, use it
1083    if bundle_path.is_file() {
1084        return Ok(bundle_path.to_path_buf());
1085    }
1086
1087    // Platform-specific VST3 bundle handling
1088    #[cfg(target_os = "macos")]
1089    {
1090        // macOS: .vst3 bundle structure
1091        if bundle_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
1092            let contents_path = bundle_path.join("Contents").join("MacOS");
1093            if let Ok(entries) = std::fs::read_dir(&contents_path) {
1094                for entry in entries.flatten() {
1095                    let file_path = entry.path();
1096                    if file_path.is_file() {
1097                        if let Some(name) = file_path.file_name() {
1098                            if let Some(name_str) = name.to_str() {
1099                                // Skip hidden files and common non-binary files
1100                                if !name_str.starts_with('.')
1101                                    && !name_str.ends_with(".plist")
1102                                    && !name_str.ends_with(".txt")
1103                                {
1104                                    return Ok(file_path);
1105                                }
1106                            }
1107                        }
1108                    }
1109                }
1110            }
1111        }
1112    }
1113
1114    #[cfg(target_os = "windows")]
1115    {
1116        // Windows: .vst3 file or folder structure
1117        if bundle_path.is_dir() {
1118            // Look for the .vst3 in the per-arch Contents folder. VST3 uses `arm64-win`
1119            // (and `arm64ec-win`) for ARM64 — not `aarch64-win`. Native arch first.
1120            let contents = bundle_path.join("Contents");
1121            let arm64_path = contents.join("arm64-win");
1122            let arm64ec_path = contents.join("arm64ec-win");
1123            let x64_path = contents.join("x86_64-win");
1124            let x86_path = contents.join("x86-win");
1125
1126            for contents_path in &[arm64_path, arm64ec_path, x64_path, x86_path] {
1127                if let Ok(entries) = std::fs::read_dir(contents_path) {
1128                    for entry in entries.flatten() {
1129                        let file_path = entry.path();
1130                        if file_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
1131                            return Ok(file_path);
1132                        }
1133                    }
1134                }
1135            }
1136        }
1137    }
1138
1139    #[cfg(target_os = "linux")]
1140    {
1141        // Linux: Similar to Windows
1142        if bundle_path.is_dir() {
1143            let contents_path = bundle_path.join("Contents");
1144            let arch_paths = [
1145                contents_path.join("aarch64-linux"),
1146                contents_path.join("x86_64-linux"),
1147                contents_path.join("i386-linux"),
1148            ];
1149
1150            for arch_path in &arch_paths {
1151                if let Ok(entries) = std::fs::read_dir(arch_path) {
1152                    for entry in entries.flatten() {
1153                        let file_path = entry.path();
1154                        if file_path.extension() == Some(std::ffi::OsStr::new("so")) {
1155                            return Ok(file_path);
1156                        }
1157                    }
1158                }
1159            }
1160        }
1161    }
1162
1163    Err(crate::Error::PluginNotFound(format!(
1164        "Could not find VST3 binary in bundle: {}",
1165        bundle_path.display()
1166    )))
1167}
1168
1169#[cfg(test)]
1170mod report_tests {
1171    use super::*;
1172    use crate::plugin::PluginInfo;
1173
1174    #[test]
1175    fn plugin_report_serializes_and_round_trips() {
1176        let detail = DetailedPluginInfo {
1177            info: PluginInfo {
1178                path: std::path::PathBuf::from("/x/Dexed.vst3"),
1179                name: "Dexed".into(),
1180                vendor: "Digital Suburban".into(),
1181                version: "1.0.0".into(),
1182                category: "Instrument|Synth".into(),
1183                uid: "ABCD".into(),
1184                audio_inputs: 0,
1185                audio_outputs: 1,
1186                has_midi_input: true,
1187                has_midi_output: true,
1188                has_gui: true,
1189            },
1190            factory: FactoryInfo {
1191                vendor: "Digital Suburban".into(),
1192                ..Default::default()
1193            },
1194            classes: vec![ClassInfo {
1195                name: "Dexed".into(),
1196                ..Default::default()
1197            }],
1198            buses: BusLayout::default(),
1199            module_info: None,
1200            compatibility: Vec::new(),
1201        };
1202        let report = PluginReport::new(detail, Vec::new());
1203        let json = report.to_json().expect("to_json");
1204        // The export round-trips and preserves the accurate metadata.
1205        let back: PluginReport = serde_json::from_str(&json).expect("round-trip");
1206        assert_eq!(back.detailed.info.name, "Dexed");
1207        assert_eq!(back.detailed.info.category, "Instrument|Synth");
1208        assert!(back.detailed.info.has_midi_output);
1209        assert_eq!(back.detailed.classes.len(), 1);
1210    }
1211}
1212
1213#[cfg(test)]
1214mod scan_tests {
1215    use super::*;
1216
1217    /// A symlink pointing back at an ancestor makes the recursive scan unbounded, because
1218    /// `Path::is_dir` follows symlinks. It hung and grew `plugins` forever with textually distinct
1219    /// duplicates of the same file — which `dedup` cannot collapse, since the paths differ.
1220    #[cfg(unix)]
1221    #[test]
1222    fn scan_terminates_on_a_symlink_cycle_and_does_not_duplicate() {
1223        use std::os::unix::fs::symlink;
1224
1225        let root = std::env::temp_dir().join(format!("vst3-scan-cycle-{}", std::process::id()));
1226        let _ = std::fs::remove_dir_all(&root);
1227        std::fs::create_dir_all(&root).expect("mk root");
1228        std::fs::create_dir_all(root.join("Real.vst3")).expect("mk bundle");
1229        // Three branches, each looping back to the root: without a visited set this explodes.
1230        for name in ["a", "b", "c"] {
1231            let sub = root.join(name);
1232            std::fs::create_dir_all(&sub).expect("mk sub");
1233            symlink(&root, sub.join("loop")).expect("symlink");
1234        }
1235
1236        let found = scan_directories(std::slice::from_ref(&root)).expect("scan");
1237
1238        let bundles: Vec<_> = found
1239            .iter()
1240            .filter(|p| p.file_name() == Some(std::ffi::OsStr::new("Real.vst3")))
1241            .collect();
1242        assert_eq!(
1243            bundles.len(),
1244            1,
1245            "the same bundle was reported {} times through symlink routes: {found:?}",
1246            bundles.len()
1247        );
1248
1249        let _ = std::fs::remove_dir_all(&root);
1250    }
1251}