collective

Browse / github.com/storytold/soundcraft / patch

Plugin hosts: blocklist, paths-only scans and targeted rescans (CLAP, VST3)

Plugin hosts: blocklist, paths-only scans and targeted rescans (CLAP, VST3) ## Why Scanning, and creating any one CLAP or VST3 plugin, loads every binary in the standard plugin folders on each launch. Copy-protected plugins (iLok and the like) then pop licence dialogs, and a test harness that drives `soundcraft-cli` pays for — and risks crashes from — plugins it never uses. We hit this running SoundCraft headlessly as a cross-platform test host for our own plugins. ## What - `SOUNDCRAFT_PLUGIN_PATHS_ONLY=1`: scan only `VST3_PATH` / `CLAP_PATH`. - `SOUNDCRAFT_PLUGIN_BLOCKLIST` + `engine.plugin_blocklist {set, add, remove}`: bundles, folders or bare bundle names that

Status
open
Score
0.00
Adopters
0 other operators
Author
forwardaudio (imported from pull request #1028, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+1338 −50 in 19 files
Review
pending
Shared
2026-10-28
Id
e4b3ae86c672eacb45d853f5

How it went

No other operator has reported using it yet.

Diff

raw · crates/clap-host/Cargo.toml crates/clap-host/src/lib.rs crates/clap-host/src/registry.rs crates/clap-host/src/scan.rs crates/clap-host/tests/common/mod.rs crates/clap-host/tests/scan_control.rs crates/engine/src/cmd/clap.rs crates/engine/src/cmd/mod.rs crates/engine/src/cmd/plugin_scan.rs crates/engine/src/cmd/vst3.rs crates/vst3-host/Cargo.toml crates/vst3-host/src/lib.rs crates/vst3-host/src/registry.rs crates/vst3-host/src/scan.rs crates/vst3-host/tests/common/mod.rs crates/vst3-host/tests/scan_control.rs docs/control-protocol.md docs/gaps.md docs/plugin-parity.md

diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml--- a/crates/clap-host/Cargo.toml+++ b/crates/clap-host/Cargo.toml@@ -21,6 +21,10 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphic name = "editor_main_thread" harness = false +[[test]]+name = "scan_control"+harness = false+ [target.'cfg(not(target_arch = "wasm32"))'.dependencies] clap-sys = "0.5" libloading = "0.9"diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs--- a/crates/clap-host/src/lib.rs+++ b/crates/clap-host/src/lib.rs@@ -1,6 +1,8 @@ //! SoundCraft's CLAP plugin host. //!-//! Scans the standard CLAP folders (plus `CLAP_PATH`), loads `.clap` binaries in-process and+//! Scans the standard CLAP folders (plus `CLAP_PATH`; only `CLAP_PATH` when+//! `SOUNDCRAFT_PLUGIN_PATHS_ONLY` is set, and never what `SOUNDCRAFT_PLUGIN_BLOCKLIST` or+//! [`set_blocklist`] names), loads `.clap` binaries in-process and //! exposes each plugin as a [`soundcraft_dsp::Plugin`], so the mix engine treats third-party //! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids //! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].@@ -102,6 +104,24 @@ mod api {     pub fn add_search_dir(dir: &Path) -> usize {         registry::add_search_dir(dir)     }+    pub fn in_search_path(path: &Path) -> bool {+        registry::in_search_path(path)+    }+    pub fn register_search_dir(dir: &Path) {+        registry::register_search_dir(dir)+    }+    pub fn rescan_paths_where(targets: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<ClapDescriptor> {+        registry::rescan_paths_where(targets, allow)+    }+    pub fn rescan_paths(targets: &[PathBuf]) -> Vec<ClapDescriptor> {+        registry::rescan_paths(targets)+    }+    pub fn set_blocklist(entries: Vec<PathBuf>) {+        registry::set_blocklist(entries)+    }+    pub fn blocklist() -> Vec<PathBuf> {+        registry::blocklist()+    }     pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {         registry::scan_paths(dirs)     }@@ -132,6 +152,20 @@ mod api {     pub fn add_search_dir(_dir: &Path) -> usize {         0     }+    pub fn in_search_path(_path: &Path) -> bool {+        false+    }+    pub fn register_search_dir(_dir: &Path) {}+    pub fn rescan_paths_where(_targets: &[PathBuf], _allow: &dyn Fn(&Path) -> bool) -> Vec<ClapDescriptor> {+        Vec::new()+    }+    pub fn rescan_paths(_targets: &[PathBuf]) -> Vec<ClapDescriptor> {+        Vec::new()+    }+    pub fn set_blocklist(_entries: Vec<PathBuf>) {}+    pub fn blocklist() -> Vec<PathBuf> {+        Vec::new()+    }     pub fn scan_paths(_dirs: &[PathBuf]) -> Vec<ClapDescriptor> {         Vec::new()     }@@ -164,7 +198,45 @@ pub fn add_search_dir(dir: &Path) -> usize {     api::add_search_dir(dir) } -/// Scans the given folders without touching the cache.+/// True when `path` lies in a folder a full scan reads (the standard folders, the environment's+/// and those added here); [`rescan_paths`] accepts only such targets.+pub fn in_search_path(path: &Path) -> bool {+    api::in_search_path(path)+}++/// Adds a folder to the search path without scanning it (unlike [`add_search_dir`]).+pub fn register_search_dir(dir: &Path) {+    api::register_search_dir(dir)+}++/// Rescans only the given absolute bundle or folder paths inside the search path (others are+/// ignored), forgetting their cached plugins and failures, and returns what they hold now. Nothing+/// else is loaded, the plugins found can be created at once, and they win over another bundle with+/// the same id until a full [`rescan`]. A binary loaded earlier stays mapped for the life of the+/// process, so a rebuilt plugin's new code needs a new process.+pub fn rescan_paths(targets: &[PathBuf]) -> Vec<ClapDescriptor> {+    api::rescan_paths(targets)+}++/// [`rescan_paths`], loading only the bundles `allow` accepts: it is asked about every bundle+/// found below the targets (a caller confining file access passes its read check, which then+/// covers bundles reached through linked subfolders too).+pub fn rescan_paths_where(targets: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<ClapDescriptor> {+    api::rescan_paths_where(targets, allow)+}++/// Replaces the blocklist: plugins and folders that are never loaded (see [`scan::is_blocked`]+/// for how entries match). It starts as the entries of `SOUNDCRAFT_PLUGIN_BLOCKLIST`.+pub fn set_blocklist(entries: Vec<PathBuf>) {+    api::set_blocklist(entries)+}++/// The current blocklist.+pub fn blocklist() -> Vec<PathBuf> {+    api::blocklist()+}++/// Scans the given folders without touching the cache (blocked bundles are skipped). pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {     api::scan_paths(dirs) }diff --git a/crates/clap-host/src/registry.rs b/crates/clap-host/src/registry.rs--- a/crates/clap-host/src/registry.rs+++ b/crates/clap-host/src/registry.rs@@ -22,6 +22,19 @@ struct Registry {     infos: HashMap<String, &'static PluginInfo>,     /// Ids that failed to instantiate (not retried every UI frame).     failed: HashSet<String>,+    /// Plugins and folders never loaded ([`paths::is_blocked`]); read from the environment on+    /// first use, then replaced by [`set_blocklist`].+    blocked: Option<Vec<PathBuf>>,+    /// Plugins found by [`rescan_paths`]: creatable before any full scan without loading anything+    /// else, and preferred over another bundle with the same id (a rebuilt plugin over the+    /// installed one) until a full [`rescan`].+    targeted: Vec<ClapDescriptor>,+}++impl Registry {+    fn blocked(&mut self) -> &[PathBuf] {+        self.blocked.get_or_insert_with(paths::blocklist_from_env)+    } }  fn registry() -> &'static Mutex<Registry> {@@ -49,11 +62,28 @@ pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {         .collect()) } -/// Scans `dirs` (uncached). Unloadable files are logged and skipped; the first plugin with a-/// given id wins.+/// Scans `dirs` (uncached). Blocked bundles are never loaded; unloadable files are logged and+/// skipped; the first plugin with a given id wins. pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {+    let blocked = registry().lock().unwrap_or_else(PoisonError::into_inner).blocked().to_vec();+    scan_dirs(dirs, &blocked)+}++fn scan_dirs(dirs: &[PathBuf], blocked: &[PathBuf]) -> Vec<ClapDescriptor> {+    scan_dirs_where(dirs, blocked, &|_| true)+}++fn scan_dirs_where(dirs: &[PathBuf], blocked: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<ClapDescriptor> {     let mut out: Vec<ClapDescriptor> = Vec::new();     for path in paths::find_bundles(dirs) {+        if paths::is_blocked(&path, blocked) {+            log::info!("CLAP plugin {} is blocked: not loaded", path.display());+            continue;+        }+        if !allow(&path) {+            log::warn!("CLAP plugin {} is outside the allowed folders: not loaded", path.display());+            continue;+        }         match load_bundle(&path) {             Ok(ds) => {                 for d in ds {@@ -71,9 +101,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {  fn ensure_scanned(r: &mut Registry) -> &Vec<ClapDescriptor> {     if r.scanned.is_none() {-        let mut dirs = paths::default_search_paths();-        dirs.extend(r.extra_dirs.iter().cloned());-        r.scanned = Some(scan_paths(&dirs));+        let dirs = search_dirs(r);+        let mut found = scan_dirs(&dirs, r.blocked());+        let targeted = r.targeted.clone();+        merge(&mut found, &|_| false, &targeted);+        r.scanned = Some(found);     }     r.scanned.get_or_insert_with(Vec::new) }@@ -91,6 +123,7 @@ pub fn rescan() -> Vec<ClapDescriptor> {     let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);     r.scanned = None;     r.failed.clear();+    r.targeted.clear();     ensure_scanned(&mut r).clone() } @@ -99,7 +132,7 @@ pub fn add_search_dir(dir: &Path) -> usize {     if !r.extra_dirs.iter().any(|d| d == dir) {         r.extra_dirs.push(dir.to_path_buf());     }-    let found = scan_paths(&[dir.to_path_buf()]);+    let found = scan_dirs(&[dir.to_path_buf()], r.blocked());     let n = found.len();     let list = r.scanned.get_or_insert_with(Vec::new);     for d in found {@@ -110,6 +143,97 @@ pub fn add_search_dir(dir: &Path) -> usize {     n } +/// The folders a full scan reads: the standard ones and the environment's (see+/// [`paths::default_search_paths`]) and those added with [`add_search_dir`] or+/// [`register_search_dir`].+fn search_dirs(r: &Registry) -> Vec<PathBuf> {+    let mut dirs = paths::default_search_paths();+    dirs.extend(r.extra_dirs.iter().cloned());+    dirs+}++/// True when `path` lies in a folder a full scan reads, so loading it needs no new trust.+pub fn in_search_path(path: &Path) -> bool {+    let r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    search_dirs(&r).iter().any(|d| paths::is_inside(path, d))+}++/// Adds a folder to the search path without scanning it: the next full scan reads it, and+/// [`rescan_paths`] may target what it holds.+pub fn register_search_dir(dir: &Path) {+    let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    if !r.extra_dirs.iter().any(|d| d == dir) {+        r.extra_dirs.push(dir.to_path_buf());+    }+}++/// Rescans only `targets`: absolute bundle or folder paths inside the search path ([`in_search_path`]);+/// others are ignored, so a rescan never loads a binary a full scan wouldn't. Forgets the targets'+/// cached plugins and remembered failures, loads them again (unless blocked) and merges the result+/// into the cache, where it wins over another bundle with the same id. Returns what the targets hold+/// now. Before any full scan nothing else is loaded, and the plugins found can be created right away.+/// A binary loaded earlier in this process stays mapped, so a rebuilt plugin's new code needs a new+/// process; its descriptors are read again either way.+pub fn rescan_paths(targets: &[PathBuf]) -> Vec<ClapDescriptor> {+    rescan_paths_where(targets, &|_| true)+}++/// [`rescan_paths`], loading only the bundles `allow` accepts (checked for every bundle found+/// below the targets, so a caller confining file access also covers linked subfolders).+pub fn rescan_paths_where(targets: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<ClapDescriptor> {+    let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    let roots = search_dirs(&r);+    let targets: Vec<PathBuf> = targets+        .iter()+        .filter(|t| {+            let ok = t.is_absolute() && paths::entry_problem(t).is_none() && roots.iter().any(|d| paths::is_inside(t, d));+            if !ok {+                log::warn!("CLAP rescan: ignoring `{}`: not an absolute bundle or folder path in the search path", t.display());+            }+            ok+        })+        .cloned()+        .collect();+    let under = |p: &Path| targets.iter().any(|t| paths::is_under(p, t));+    let stale: Vec<String> = r.scanned.iter().flatten().chain(&r.targeted).filter(|d| under(&d.path)).map(|d| d.id.clone()).collect();+    let blocked = r.blocked().to_vec();+    let found = scan_dirs_where(&targets, &blocked, allow);+    for id in stale.iter().chain(found.iter().map(|d| &d.id)) {+        r.failed.remove(id);+    }+    merge(&mut r.targeted, &under, &found);+    if let Some(list) = r.scanned.as_mut() {+        merge(list, &under, &found);+    }+    found+}++/// Puts `found` into `list` in place of the plugins under the rescanned targets and of any other+/// bundle's plugin with the same id.+fn merge(list: &mut Vec<ClapDescriptor>, under: &dyn Fn(&Path) -> bool, found: &[ClapDescriptor]) {+    list.retain(|d| !under(&d.path) && !found.iter().any(|f| f.id == d.id));+    list.extend(found.iter().cloned());+    list.sort_by_key(|d| d.name.to_lowercase());+}++/// Replaces the blocklist ([`paths::is_blocked`] entries). Newly blocked plugins leave the cache+/// at once and can no longer be created (live instances keep running, and the parameter info of+/// a type already created stays available for them); unblocked ones come back with the next scan+/// of their folder ([`rescan`] or [`rescan_paths`]).+pub fn set_blocklist(entries: Vec<PathBuf>) {+    let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    if let Some(list) = r.scanned.as_mut() {+        list.retain(|d| !paths::is_blocked(&d.path, &entries));+    }+    r.targeted.retain(|d| !paths::is_blocked(&d.path, &entries));+    r.blocked = Some(entries);+}++/// The current blocklist.+pub fn blocklist() -> Vec<PathBuf> {+    registry().lock().unwrap_or_else(PoisonError::into_inner).blocked().to_vec()+}+ fn leak(s: String) -> &'static str {     Box::leak(s.into_boxed_str()) }@@ -167,10 +291,22 @@ fn instantiate_raw(id: &str) -> Result<(Instance, &'static PluginInfo), ClapErro         if r.failed.contains(id) {             return Err(ClapError::Instantiate(plugin_id.to_string(), "failed before".into()));         }-        ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| ClapError::NotFound(id.to_string()))?+        let known = r.scanned.iter().flatten().chain(&r.targeted).find(|d| d.id == id).cloned();+        let desc = match known {+            Some(d) => d,+            None => ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| ClapError::NotFound(id.to_string()))?,+        };+        if paths::is_blocked(&desc.path, r.blocked()) {+            return Err(ClapError::Instantiate(plugin_id.to_string(), "blocked".into()));+        }+        desc     };     let made = Bundle::load(&desc.path).and_then(|b| b.instantiate(plugin_id));     let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    // Blocked while it loaded: the new instance is dropped.+    if paths::is_blocked(&desc.path, r.blocked()) {+        return Err(ClapError::Instantiate(plugin_id.to_string(), "blocked".into()));+    }     match made {         Ok(inst) => {             let info = match r.infos.get(id) {diff --git a/crates/clap-host/src/scan.rs b/crates/clap-host/src/scan.rs--- a/crates/clap-host/src/scan.rs+++ b/crates/clap-host/src/scan.rs@@ -1,7 +1,7 @@ //! Finding CLAP plugins on disk (safe, pure `std::fs`) and mapping CLAP features to categories.  use soundcraft_dsp::Category;-use std::path::{Path, PathBuf};+use std::path::{Component, Path, PathBuf};  /// Directory recursion limit while scanning (CLAP folders may be nested, never deeply). const MAX_DEPTH: usize = 8;@@ -10,20 +10,141 @@ const MAX_BUNDLES: usize = 4096; /// Upper bound on directory entries visited by one scan. const MAX_ENTRIES: usize = 100_000; +/// Set (to anything but `0` or empty) to scan only the `CLAP_PATH` / `VST3_PATH` folders and skip+/// the standard locations: a test harness then loads only the plugins it names.+pub const PATHS_ONLY_ENV: &str = "SOUNDCRAFT_PLUGIN_PATHS_ONLY";+/// A path list (like `PATH`) of plugins and folders never loaded; see [`is_blocked`].+pub const BLOCKLIST_ENV: &str = "SOUNDCRAFT_PLUGIN_BLOCKLIST";+ /// The standard CLAP search locations for this OS (existing or not), followed by every entry-/// of the `CLAP_PATH` environment variable.+/// of the `CLAP_PATH` environment variable. With [`PATHS_ONLY_ENV`] set, only the `CLAP_PATH` entries. pub fn default_search_paths() -> Vec<PathBuf> {-    let mut v = os_search_paths(+    let os = os_search_paths(         std::env::var_os("HOME").map(PathBuf::from),         std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from),         std::env::var_os("LOCALAPPDATA").map(PathBuf::from),     );-    if let Some(cp) = std::env::var_os("CLAP_PATH") {-        v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty()));+    search_paths(os, std::env::var_os("CLAP_PATH").as_deref(), env_flag(PATHS_ONLY_ENV))+}++/// The standard locations followed by the entries of a path-list variable, or only those entries+/// when `only_env` is set.+pub fn search_paths(standard: Vec<PathBuf>, env: Option<&std::ffi::OsStr>, only_env: bool) -> Vec<PathBuf> {+    let mut v = if only_env { Vec::new() } else { standard };+    if let Some(cp) = env {+        v.extend(std::env::split_paths(cp).filter(|p| !p.as_os_str().is_empty()));     }     v } +/// True when an environment flag is set to anything but empty or `0`.+pub fn env_flag(name: &str) -> bool {+    std::env::var_os(name).is_some_and(|v| !v.is_empty() && v != "0")+}++/// The entries of [`BLOCKLIST_ENV`] (empty when unset).+pub fn blocklist_from_env() -> Vec<PathBuf> {+    let Some(v) = std::env::var_os(BLOCKLIST_ENV) else { return Vec::new() };+    std::env::split_paths(&v)+        .filter(|p| !p.as_os_str().is_empty())+        .filter(|p| match entry_problem(p) {+            Some(why) => {+                log::warn!("{BLOCKLIST_ENV}: ignoring `{}`: {why}", p.display());+                false+            }+            None => true,+        })+        .collect()+}++/// Why a blocklist entry or rescan target can't be used (`None` when it can). An entry is either a+/// bare bundle name (one plain component) or an absolute path below the root; relative paths+/// with folders, `..`, and a bare root would match unpredictably or block everything.+pub fn entry_problem(e: &Path) -> Option<&'static str> {+    let normal = e.components().filter(|c| matches!(c, Component::Normal(_))).count();+    if e.components().any(|c| c == Component::ParentDir) {+        Some("must not contain `..`")+    } else if !e.is_absolute() && !(normal == 1 && e.components().count() == 1) {+        Some("must be an absolute path or a bare bundle name")+    } else if e.is_absolute() && normal == 0 {+        Some("must name a folder or bundle below the root")+    } else {+        None+    }+}++/// A path as comparable components: `.` dropped, names lower-cased (plugin folders usually live+/// on case-insensitive file systems; on case-sensitive ones this can only block more).+fn folded(p: &Path) -> Vec<String> {+    p.components().filter(|c| *c != Component::CurDir).map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect()+}++/// True when `path` is `root` or lies inside it, compared by whole components.+pub fn is_under(path: &Path, root: &Path) -> bool {+    let r = folded(root);+    !r.is_empty() && folded(path).starts_with(&r)+}++/// `path` with its links resolved, without the `\\?\` prefix Windows adds (so it compares with+/// the paths people type); `None` when it doesn't exist.+fn resolved(path: &Path) -> Option<PathBuf> {+    let r = std::fs::canonicalize(path).ok()?;+    let text = r.to_string_lossy();+    Some(match text.strip_prefix(r"\\?\UNC\") {+        Some(rest) => PathBuf::from(format!(r"\\{rest}")),+        None => text.strip_prefix(r"\\?\").map_or_else(|| r.clone(), PathBuf::from),+    })+}++/// True when `path` is `root` or lies inside it, as written or with links resolved on both sides.+/// A link inside `root` (plugins are often symlinked in from a build folder) counts as inside, as+/// it does for a full scan, which follows links too. Unlike [`is_under`], which blocklists use and+/// which may match more than it should, this decides what may be loaded, so case is ignored only+/// on Windows: on macOS and Linux names compare exactly, and `/work/PLUGINS` is not inside+/// `/work/plugins` (a case-sensitive macOS volume may hold both). A path spelled in another case+/// on a case-insensitive volume still matches through the resolved comparison, since resolving+/// links yields the names the file system actually stores.+pub fn is_inside(path: &Path, root: &Path) -> bool {+    inside(path, root, false) || inside(path, root, true)+}++fn inside(path: &Path, root: &Path, resolve: bool) -> bool {+    let fold = cfg!(windows);+    let parts = |p: &Path| -> Vec<String> {+        let p = if resolve { resolved(p).unwrap_or_else(|| p.to_path_buf()) } else { p.to_path_buf() };+        p.components()+            .filter(|c| *c != Component::CurDir)+            .map(|c| {+                let s = c.as_os_str().to_string_lossy();+                if fold { s.to_lowercase() } else { s.into_owned() }+            })+            .collect()+    };+    let r = parts(root);+    !r.is_empty() && parts(path).starts_with(&r)+}++/// True when a blocklist entry covers the plugin at `path`. An absolute entry blocks that bundle+/// and everything inside a folder of that name, comparing links resolved on either side as well. A bare name blocks every bundle with that file name, with or without its extension+/// (`AutoAlign2.vst3` or `AutoAlign2`). Case is ignored; entries with an [`entry_problem`] never match.+pub fn is_blocked(path: &Path, entries: &[PathBuf]) -> bool {+    let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase());+    let stem = path.file_stem().map(|n| n.to_string_lossy().to_lowercase());+    let mut real: Option<Option<PathBuf>> = None;+    entries.iter().filter(|e| entry_problem(e).is_none()).any(|e| {+        if e.is_absolute() {+            if is_under(path, e) {+                return true;+            }+            let Some(p) = real.get_or_insert_with(|| resolved(path)).as_deref() else { return false };+            is_under(p, e) || resolved(e).is_some_and(|e| is_under(p, &e))+        } else {+            let e = e.to_string_lossy().to_lowercase();+            name.as_deref() == Some(e.as_str()) || stem.as_deref() == Some(e.as_str())+        }+    })+}+ /// The OS-specific standard locations, given the relevant environment values. pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {     let mut v = Vec::new();@@ -195,7 +316,84 @@ mod tests {         } else {             assert_eq!(v, vec![PathBuf::from("/home/u/.clap"), PathBuf::from("/usr/lib/clap"), PathBuf::from("/usr/local/lib/clap")]);         }-        assert!(default_search_paths().len() >= v.len().saturating_sub(1));+        if !env_flag(PATHS_ONLY_ENV) {+            assert!(default_search_paths().len() >= v.len().saturating_sub(1));+        }+    }++    /// A test path made absolute on every platform (Windows paths need a drive).+    fn abs(p: &str) -> PathBuf {+        if cfg!(windows) && p.starts_with('/') { PathBuf::from(format!("C:{p}")) } else { PathBuf::from(p) }+    }++    #[test]+    fn env_paths_extend_or_replace_the_standard_locations() {+        let std = vec![PathBuf::from("/Library/Audio/Plug-Ins/CLAP")];+        let env = std::env::join_paths(["/a", "", "/b"]).unwrap();+        assert_eq!(search_paths(std.clone(), Some(&env), false), vec![PathBuf::from("/Library/Audio/Plug-Ins/CLAP"), "/a".into(), "/b".into()]);+        assert_eq!(search_paths(std.clone(), Some(&env), true), vec![PathBuf::from("/a"), PathBuf::from("/b")]);+        assert!(search_paths(std, None, true).is_empty(), "paths-only without a path list scans nothing");+    }++    #[test]+    fn blocklist_entries_match_names_and_paths() {+        let p = abs("/Library/Audio/Plug-Ins/CLAP/vendor/Foo.clap");+        let v = |e: &[&str]| e.iter().map(|e| abs(e)).collect::<Vec<_>>();+        assert!(is_blocked(&p, &v(&["Foo.clap"])));+        assert!(is_blocked(&p, &v(&["FOO"])));+        assert!(!is_blocked(&p, &v(&["Fo"])));+        assert!(is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/CLAP/vendor"])));+        assert!(!is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/CLAP/vend"])));+        assert!(is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/./CLAP/VENDOR/"])), "`.` segments and case don't matter");+        assert!(!is_blocked(&p, &v(&["/", ".", "vendor/Foo.clap", "/Library/x/../Audio"])), "unusable entries never match");+        assert!(!is_blocked(&p, &[]));+    }++    #[cfg(unix)]+    #[test]+    fn loading_admission_is_exact_and_follows_links() {+        let d = tmp("inside");+        std::fs::create_dir_all(d.join("plugins/vendor")).unwrap();+        std::fs::create_dir_all(d.join("PLUGINS")).unwrap();+        std::os::unix::fs::symlink(d.join("plugins"), d.join("alias")).unwrap();+        assert!(is_inside(&d.join("plugins/vendor"), &d.join("plugins")));+        assert!(is_inside(&d.join("alias/vendor"), &d.join("plugins")), "through a link");+        // A bundle linked into the folder from a build tree elsewhere is inside, as for a full scan.+        std::fs::create_dir_all(d.join("build/Dev.clap")).unwrap();+        std::os::unix::fs::symlink(d.join("build/Dev.clap"), d.join("plugins/vendor/Dev.clap")).unwrap();+        assert!(is_inside(&d.join("plugins/vendor/Dev.clap"), &d.join("plugins")), "a linked-in bundle");+        assert!(!is_inside(&d.join("build/Dev.clap"), &d.join("plugins")), "but not its target by itself");+        assert!(!is_inside(&d.join("plugin"), &d.join("plugins")));+        // `PLUGINS` is its own folder on a case-sensitive volume and an alias of `plugins` on a+        // case-insensitive one; only the second is inside.+        let same = |a: &Path, b: &Path| {+            use std::os::unix::fs::MetadataExt;+            let (a, b) = (std::fs::metadata(a).unwrap(), std::fs::metadata(b).unwrap());+            (a.dev(), a.ino()) == (b.dev(), b.ino())+        };+        assert_eq!(is_inside(&d.join("PLUGINS"), &d.join("plugins")), same(&d.join("PLUGINS"), &d.join("plugins")));+        assert_eq!(is_inside(&d.join("PLUGINS/vendor"), &d.join("plugins")), d.join("PLUGINS/vendor").exists());+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn loading_admission_ignores_case_only_on_windows() {+        // Paths that don't exist compare as written.+        let root = abs("/no-such-root/plugins");+        assert!(is_inside(&abs("/no-such-root/plugins/vendor/Foo.clap"), &root));+        assert_eq!(is_inside(&abs("/no-such-root/PLUGINS/vendor/Foo.clap"), &root), cfg!(windows));+        assert_eq!(is_inside(&abs("/No-Such-Root/plugins"), &root), cfg!(windows));+    }++    #[test]+    fn entries_are_checked_before_use() {+        assert_eq!(entry_problem(Path::new("Foo.clap")), None);+        assert_eq!(entry_problem(&abs("/Library/Audio/Plug-Ins/CLAP")), None);+        assert!(entry_problem(Path::new("/")).is_some());+        assert!(entry_problem(Path::new("vendor/Foo.clap")).is_some());+        assert!(entry_problem(Path::new("/a/../b")).is_some());+        assert!(is_under(Path::new("/a/b/c.clap"), Path::new("/a/b")));+        assert!(!is_under(Path::new("/a/bc.clap"), Path::new("/a/b")));     }      #[test]diff --git a/crates/clap-host/tests/common/mod.rs b/crates/clap-host/tests/common/mod.rs--- a/crates/clap-host/tests/common/mod.rs+++ b/crates/clap-host/tests/common/mod.rs@@ -5,8 +5,22 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -/// Builds the fixture once and returns a folder containing `Fixture.clap`.+/// Builds the fixture once, installs it and adds the folder to the host's search path; returns+/// the folder containing `vendor/Fixture.clap`.+#[allow(dead_code)] // Not every test binary that shares this module uses it directly. pub fn fixture_dir() -> &'static Path {+    static DIR: OnceLock<&'static Path> = OnceLock::new();+    DIR.get_or_init(|| {+        let dir = install_fixture();+        assert_eq!(soundcraft_clap_host::add_search_dir(dir), 5);+        dir+    })+}++/// Builds and installs the fixture once without telling the host about it; returns the folder+/// containing `vendor/Fixture.clap`.+#[allow(dead_code)] // Not every test binary that shares this module uses it directly.+pub fn install_fixture() -> &'static Path {     static DIR: OnceLock<PathBuf> = OnceLock::new();     DIR.get_or_init(|| {         let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");@@ -40,7 +54,6 @@ pub fn fixture_dir() -> &'static Path {             std::fs::create_dir_all(dir.join("vendor")).unwrap();             std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();         }-        assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);         dir     }) }diff --git a/crates/clap-host/tests/scan_control.rs b/crates/clap-host/tests/scan_control.rsnew file mode 100644--- /dev/null+++ b/crates/clap-host/tests/scan_control.rs@@ -0,0 +1,87 @@+//! Blocklist and targeted rescans against the fixture, in a process of its own (`harness =+//! false`): the blocklist is process-wide, so the other tests must not see it.++mod common;++use soundcraft_clap_host as host;++fn main() {+    let dir = common::install_fixture();+    let bundle = dir.join("vendor/Fixture.clap");++    // Outside the search path a rescan loads nothing: it never reaches further than a full scan.+    assert!(host::rescan_paths(std::slice::from_ref(&bundle)).is_empty());+    assert!(!host::in_search_path(&bundle));+    host::register_search_dir(dir);+    assert!(host::in_search_path(&bundle));++    // Rescanning one bundle before any scan loads only it, and its plugins can be created at once.+    let found = host::rescan_paths(std::slice::from_ref(&bundle));+    assert_eq!(found.len(), 5);+    let gain = found.iter().find(|d| d.name == "Fixture Gain").expect("fixture gain").id.clone();+    assert!(host::cached().is_none(), "no full scan");+    assert!(host::instantiate(&gain).is_ok());+    assert!(host::cached().is_none(), "creating a rescanned plugin needs no full scan");++    // Relative targets are ignored and leave the known plugins alone.+    assert!(host::rescan_paths(&["Fixture.clap".into(), "vendor/Fixture.clap".into()]).is_empty());+    assert!(host::instantiate(&gain).is_ok());++    // Blocked by name: it can no longer be created or found. (Looking up an id that isn't known+    // runs the full scan, which with SOUNDCRAFT_PLUGIN_PATHS_ONLY=1 covers only the env paths.)+    host::set_blocklist(vec!["fixture".into()]);+    assert!(host::instantiate(&gain).is_err());+    assert!(host::rescan_paths(std::slice::from_ref(&bundle)).is_empty());+    assert!(host::scan().iter().all(|d| d.id != gain));++    // Blocked by folder path, given with a `.` segment.+    host::set_blocklist(vec![dir.join("./vendor")]);+    assert!(host::scan_paths(&[dir.to_path_buf()]).is_empty());++    // Unblocked: back with the next rescan of its folder, in the cached list too.+    host::set_blocklist(Vec::new());+    assert_eq!(host::rescan_paths(&[dir.join("vendor")]).len(), 5);+    assert!(host::cached().is_some_and(|l| l.iter().any(|d| d.id == gain)));+    assert!(host::instantiate(&gain).is_ok());++    // Blocking evicts it from the cached list straight away.+    host::set_blocklist(vec![bundle.clone()]);+    assert!(host::cached().is_some_and(|l| l.iter().all(|d| d.id != gain)));+    assert!(host::instantiate(&gain).is_err());++    // A rebuilt copy elsewhere in the search path, rescanned, wins over the installed bundle.+    host::set_blocklist(Vec::new());+    let dev = dir.join("dev");+    copy_dir(&dir.join("vendor"), &dev);+    host::register_search_dir(&dev);+    let dev_bundle = dev.join("Fixture.clap");+    assert!(host::rescan_paths(std::slice::from_ref(&dev_bundle)).iter().any(|d| d.id == gain && d.path == dev_bundle));+    let path_of = |id: &str| host::cached().and_then(|l| l.into_iter().find(|d| d.id == id)).map(|d| d.path);+    assert_eq!(path_of(&gain), Some(dev_bundle.clone()), "the rescanned copy replaces the installed one");+    assert!(host::instantiate(&gain).is_ok());+    assert_eq!(host::rescan_paths(std::slice::from_ref(&bundle)).len(), 5);+    assert_eq!(path_of(&gain), Some(bundle.clone()), "and rescanning the installed one switches back");++    // A caller's check (the engine passes its read-root check) is asked about every bundle found+    // below a target, not only about the target.+    let asked = std::cell::RefCell::new(Vec::new());+    let none = host::rescan_paths_where(&[dir.to_path_buf()], &|b| {+        asked.borrow_mut().push(b.to_path_buf());+        false+    });+    assert!(none.is_empty());+    assert!(asked.borrow().contains(&bundle) && asked.borrow().contains(&dev_bundle), "{:?}", asked.borrow());+    println!("scan_control: ok");+}++fn copy_dir(from: &std::path::Path, to: &std::path::Path) {+    std::fs::create_dir_all(to).unwrap();+    for e in std::fs::read_dir(from).unwrap().flatten() {+        let (src, dst) = (e.path(), to.join(e.file_name()));+        if src.is_dir() {+            copy_dir(&src, &dst);+        } else {+            std::fs::copy(&src, &dst).unwrap();+        }+    }+}diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs--- a/crates/engine/src/cmd/clap.rs+++ b/crates/engine/src/cmd/clap.rs@@ -2,8 +2,9 @@ //! commands. Hosting itself lives in `soundcraft-clap-host`. use super::{CommandSpec, always}; +use super::plugin_scan::{self, Rescan}; use crate::cmd;-use serde_json::{Value, json};+use serde_json::json; use soundcraft_dsp::PluginInfo;  pub fn specs() -> Vec<CommandSpec> {@@ -12,13 +13,13 @@ pub fn specs() -> Vec<CommandSpec> {         "List CLAP Plugins",         [],         None,-        "{rescan?: bool} → [{id: \"clap:…\", plugin_id, name, vendor, version, description, features, category, is_instrument, path}]",+        "{rescan?: true (everything) | [bundle or folder path] (only those, and lists only them)} → [{id: \"clap:…\", plugin_id, name, vendor, version, description, features, category, is_instrument, path}]",         always,         |_, p| {-            let list = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) {-                soundcraft_clap_host::rescan()-            } else {-                soundcraft_clap_host::scan()+            let list = match plugin_scan::rescan_param("engine.clap_plugins", p, soundcraft_clap_host::in_search_path)? {+                Rescan::No => soundcraft_clap_host::scan(),+                Rescan::All => soundcraft_clap_host::rescan(),+                Rescan::Paths(targets) => soundcraft_clap_host::rescan_paths_where(&targets, &|b| crate::file_access::check_read(b).is_ok()),             };             Ok(json!(list))         }diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs--- a/crates/engine/src/cmd/mod.rs+++ b/crates/engine/src/cmd/mod.rs@@ -23,6 +23,7 @@ mod midi; mod mix; mod more_util; mod options;+mod plugin_scan; mod plugin_state; mod query; pub(crate) mod sampler;@@ -152,6 +153,7 @@ pub fn command_specs() -> &'static [CommandSpec] {         v.extend(clap::specs());         v.extend(vst3::specs());         v.extend(surround::specs());+        v.extend(plugin_scan::specs());         v.extend(plugin_state::specs());         v.extend(instrument::specs());         v.extend(sampler::specs());diff --git a/crates/engine/src/cmd/plugin_scan.rs b/crates/engine/src/cmd/plugin_scan.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/plugin_scan.rs@@ -0,0 +1,197 @@+//! Which third-party plugins get loaded: the blocklist shared by the CLAP and VST3 hosts+//! (`engine.plugin_blocklist`) and the path lists that `engine.clap_plugins` /+//! `engine.vst3_plugins` take to rescan only some plugins. Audio Units are listed by the system,+//! not by loading binaries, so they are not affected.+use super::{CommandSpec, always, bad};+use crate::{Result, cmd};++use serde_json::{Value, json};+use std::path::{Component, Path, PathBuf};++/// Most entries one path list may hold (hostile input).+const MAX_ENTRIES: usize = 1024;+/// Longest accepted entry, in bytes.+const MAX_ENTRY_LEN: usize = 4096;++pub fn specs() -> Vec<CommandSpec> {+    vec![cmd!(+        query "engine.plugin_blocklist",+        "Plugin Blocklist",+        [],+        None,+        "{set?: [entry], add?: [entry], remove?: [entry]} → {blocklist: [entry]}, applied in that order. Blocked CLAP and VST3 \+         plugins are never loaded. An entry is an absolute bundle or folder path (blocks it and what it holds, also through links) \+         or a bare bundle name, with or without extension (`AutoAlign2.vst3`, `AutoAlign2`); case and `.` segments are ignored, \+         and so are duplicates. Starts as `SOUNDCRAFT_PLUGIN_BLOCKLIST` (a path list). Newly blocked plugins disappear from the \+         lists at once; unblocked ones return with the next rescan of their folder.",+        always,+        |_, p| {+            const ID: &str = "engine.plugin_blocklist";+            let mut list = match p.get("set") {+                Some(v) => entry_list(ID, "set", v)?,+                None => soundcraft_vst3_host::blocklist(),+            };+            if let Some(v) = p.get("add") {+                list.extend(entry_list(ID, "add", v)?);+            }+            if let Some(v) = p.get("remove") {+                let gone: Vec<String> = entry_list(ID, "remove", v)?.iter().map(|e| key(e)).collect();+                list.retain(|e| !gone.contains(&key(e)));+            }+            let mut seen = Vec::new();+            list.retain(|e| {+                let k = key(e);+                let new = !seen.contains(&k);+                seen.push(k);+                new+            });+            if list.len() > MAX_ENTRIES {+                return Err(bad(ID, format!("the blocklist holds at most {MAX_ENTRIES} entries")));+            }+            soundcraft_clap_host::set_blocklist(list.clone());+            soundcraft_vst3_host::set_blocklist(list.clone());+            Ok(json!({"blocklist": list.iter().map(|e| e.to_string_lossy()).collect::<Vec<_>>()}))+        }+    )]+}++/// An entry compared the way the hosts match it: `.` dropped, case ignored.+fn key(e: &Path) -> String {+    e.components().filter(|c| *c != Component::CurDir).map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect::<Vec<_>>().join("/")+}++/// A JSON array of non-empty path strings, bounded in count and length.+pub fn path_list(cmd: &str, key: &str, v: &Value) -> Result<Vec<PathBuf>> {+    let items = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be an array of paths")))?;+    if items.len() > MAX_ENTRIES {+        return Err(bad(cmd, format!("`{key}` holds at most {MAX_ENTRIES} entries")));+    }+    items+        .iter()+        .map(|i| match i.as_str() {+            Some(s) if !s.is_empty() && s.len() <= MAX_ENTRY_LEN => Ok(PathBuf::from(s)),+            Some(_) => Err(bad(cmd, format!("`{key}` entries must be 1 to {MAX_ENTRY_LEN} bytes long"))),+            None => Err(bad(cmd, format!("`{key}` must be an array of paths"))),+        })+        .collect()+}++/// A [`path_list`] of blocklist entries: absolute paths or bare bundle names.+fn entry_list(cmd: &str, key: &str, v: &Value) -> Result<Vec<PathBuf>> {+    let list = path_list(cmd, key, v)?;+    if let Some((e, why)) = list.iter().find_map(|e| soundcraft_vst3_host::scan::entry_problem(e).map(|w| (e, w))) {+        return Err(bad(cmd, format!("`{key}` entry `{}` {why}", e.display())));+    }+    Ok(list)+}++/// What `rescan` asks for: nothing, everything (`true`) or only some bundles and folders (a path list).+pub enum Rescan {+    No,+    All,+    Paths(Vec<PathBuf>),+}++/// Reads `rescan`. Its paths load plugin binaries, so they must be absolute, inside the format's+/// search path (`in_search_path`: a rescan never loads what a full scan wouldn't) and readable+/// under the automation roots in force (`--automation-read-root`), like any file a command reads.+pub fn rescan_param(cmd: &str, p: &Value, in_search_path: fn(&Path) -> bool) -> Result<Rescan> {+    match p.get("rescan") {+        None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(Rescan::No),+        Some(Value::Bool(true)) => Ok(Rescan::All),+        Some(v) => {+            let list = path_list(cmd, "rescan", v)?;+            for t in &list {+                if !t.is_absolute() || soundcraft_vst3_host::scan::entry_problem(t).is_some() {+                    return Err(bad(cmd, format!("`rescan` entry `{}` must be an absolute bundle or folder path", t.display())));+                }+                if !in_search_path(t) {+                    return Err(bad(+                        cmd,+                        format!("`rescan` entry `{}` is not in the plugin search path (the standard folders and the *_PATH folders)", t.display()),+                    ));+                }+                crate::file_access::check_read(t).map_err(crate::EngineError::Io)?;+            }+            Ok(Rescan::Paths(list))+        }+    }+}++#[cfg(test)]+mod tests {+    use crate::Engine;+    use serde_json::json;++    #[test]+    fn blocklist_edits_apply_to_both_hosts() {+        let mut e = Engine::default();+        let before = e.execute("engine.plugin_blocklist", &json!({})).unwrap()["blocklist"].clone();+        // Absolute on every platform (Windows paths need a drive).+        let abs = |p: &str| if cfg!(windows) { format!("C:{p}") } else { p.to_string() };+        let (a, a2, c) = (abs("/nowhere/A.vst3"), abs("/nowhere/./a.VST3"), abs("/nowhere/C.clap"));+        let r = e.execute("engine.plugin_blocklist", &json!({"set": [a, a2], "add": ["B", "b", c]})).unwrap();+        assert_eq!(r["blocklist"], json!([a, "B", c]), "duplicates by case and `.` dropped");+        let r = e.execute("engine.plugin_blocklist", &json!({"remove": ["b"]})).unwrap();+        assert_eq!(r["blocklist"], json!([a, c]), "removal ignores case too");+        assert_eq!(soundcraft_clap_host::blocklist(), soundcraft_vst3_host::blocklist());+        e.execute("engine.plugin_blocklist", &json!({"set": before})).unwrap();+    }++    #[test]+    fn bad_path_lists_are_errors() {+        let mut e = Engine::default();+        for p in [+            json!({"add": "A.vst3"}),+            json!({"set": [1]}),+            json!({"remove": [""]}),+            json!({"add": ["x".repeat(5000)]}),+            json!({"add": ["/"]}),+            json!({"add": ["vendor/A.vst3"]}),+            json!({"add": ["/a/../b"]}),+        ] {+            assert!(e.execute("engine.plugin_blocklist", &p).is_err(), "{p}");+        }+        assert!(e.execute("engine.vst3_plugins", &json!({"rescan": "yes"})).is_err());+        assert!(e.execute("engine.clap_plugins", &json!({"rescan": [3]})).is_err());+        assert!(e.execute("engine.vst3_plugins", &json!({"rescan": ["Fixture.vst3"]})).is_err(), "relative targets would match anywhere");+        assert!(e.execute("engine.clap_plugins", &json!({"rescan": ["/"]})).is_err());+    }++    #[test]+    fn rescan_paths_stay_inside_the_automation_read_roots() {+        let base = std::env::temp_dir().join(format!("soundcraft-rescan-roots-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&base);+        std::fs::create_dir_all(base.join("in")).unwrap();+        std::fs::create_dir_all(base.join("elsewhere")).unwrap();+        // Both folders are in the search path, so only the read roots tell them apart.+        for d in ["in", "elsewhere"] {+            soundcraft_vst3_host::register_search_dir(&base.join(d));+            soundcraft_clap_host::register_search_dir(&base.join(d));+        }+        let roots = crate::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();+        crate::file_access::confine(roots.as_ref(), || {+            let mut e = Engine::default();+            let inside = json!({"rescan": [base.join("in").to_string_lossy()]});+            let outside = json!({"rescan": [base.join("elsewhere").to_string_lossy()]});+            assert_eq!(e.execute("engine.vst3_plugins", &inside).unwrap(), json!([]));+            assert!(e.execute("engine.vst3_plugins", &outside).is_err(), "no plugin binaries from outside the read roots");+            assert!(e.execute("engine.clap_plugins", &outside).is_err());+        });+        let _ = std::fs::remove_dir_all(&base);+    }++    #[test]+    fn rescan_targets_must_be_in_the_search_path() {+        let mut e = Engine::default();+        let base = std::env::temp_dir().join(format!("soundcraft-rescan-search-{}", std::process::id()));+        let p = json!({"rescan": [base.join("vendor").to_string_lossy()]});+        let err = e.execute("engine.vst3_plugins", &p).unwrap_err().to_string();+        assert!(err.contains("search path"), "{err}");+        assert!(e.execute("engine.clap_plugins", &p).is_err());+        soundcraft_vst3_host::register_search_dir(&base);+        soundcraft_clap_host::register_search_dir(&base);+        assert_eq!(e.execute("engine.vst3_plugins", &p).unwrap(), json!([]), "a missing folder in the search path holds nothing");+        assert_eq!(e.execute("engine.clap_plugins", &p).unwrap(), json!([]));+    }+}diff --git a/crates/engine/src/cmd/vst3.rs b/crates/engine/src/cmd/vst3.rs--- a/crates/engine/src/cmd/vst3.rs+++ b/crates/engine/src/cmd/vst3.rs@@ -3,8 +3,9 @@ //! `cmd::clap::plugin_info`. use super::{CommandSpec, always}; +use super::plugin_scan::{self, Rescan}; use crate::cmd;-use serde_json::{Value, json};+use serde_json::json;  pub fn specs() -> Vec<CommandSpec> {     vec![cmd!(@@ -12,13 +13,13 @@ pub fn specs() -> Vec<CommandSpec> {         "List VST3 Plugins",         [],         None,-        "{rescan?: bool} → [{id: \"vst3:<class id hex>\", class_id, name, vendor, version, sdk_version, sub_categories, category, is_instrument, path}]",+        "{rescan?: true (everything) | [bundle or folder path] (only those, and lists only them)} → [{id: \"vst3:<class id hex>\", class_id, name, vendor, version, sdk_version, sub_categories, category, is_instrument, path}]",         always,         |_, p| {-            let list = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) {-                soundcraft_vst3_host::rescan()-            } else {-                soundcraft_vst3_host::scan()+            let list = match plugin_scan::rescan_param("engine.vst3_plugins", p, soundcraft_vst3_host::in_search_path)? {+                Rescan::No => soundcraft_vst3_host::scan(),+                Rescan::All => soundcraft_vst3_host::rescan(),+                Rescan::Paths(targets) => soundcraft_vst3_host::rescan_paths_where(&targets, &|b| crate::file_access::check_read(b).is_ok()),             };             Ok(json!(list))         }diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml--- a/crates/vst3-host/Cargo.toml+++ b/crates/vst3-host/Cargo.toml@@ -31,6 +31,10 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphic name = "editor_main_thread" harness = false +[[test]]+name = "scan_control"+harness = false+ [[test]] name = "module_exit" harness = falsediff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs--- a/crates/vst3-host/src/lib.rs+++ b/crates/vst3-host/src/lib.rs@@ -1,6 +1,8 @@ //! SoundCraft's VST3 plugin host. //!-//! Scans the standard VST3 folders (plus `VST3_PATH`), loads `.vst3` bundles in-process and+//! Scans the standard VST3 folders (plus `VST3_PATH`; only `VST3_PATH` when+//! `SOUNDCRAFT_PLUGIN_PATHS_ONLY` is set, and never what `SOUNDCRAFT_PLUGIN_BLOCKLIST` or+//! [`set_blocklist`] names), loads `.vst3` bundles in-process and //! exposes each "Audio Module Class" as a [`soundcraft_dsp::Plugin`], so the mix engine treats //! third-party plugins exactly like the built-in ones. Plugin ids are `vst3:<class id hex>` (32 //! upper-case hex digits of the component's class id); parameter ids are VST3 `ParamID`s as@@ -128,6 +130,24 @@ mod api {     pub fn add_search_dir(dir: &Path) -> usize {         registry::add_search_dir(dir)     }+    pub fn in_search_path(path: &Path) -> bool {+        registry::in_search_path(path)+    }+    pub fn register_search_dir(dir: &Path) {+        registry::register_search_dir(dir)+    }+    pub fn rescan_paths_where(targets: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<Vst3Descriptor> {+        registry::rescan_paths_where(targets, allow)+    }+    pub fn rescan_paths(targets: &[PathBuf]) -> Vec<Vst3Descriptor> {+        registry::rescan_paths(targets)+    }+    pub fn set_blocklist(entries: Vec<PathBuf>) {+        registry::set_blocklist(entries)+    }+    pub fn blocklist() -> Vec<PathBuf> {+        registry::blocklist()+    }     pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {         registry::scan_paths(dirs)     }@@ -161,6 +181,20 @@ mod api {     pub fn add_search_dir(_dir: &Path) -> usize {         0     }+    pub fn in_search_path(_path: &Path) -> bool {+        false+    }+    pub fn register_search_dir(_dir: &Path) {}+    pub fn rescan_paths_where(_targets: &[PathBuf], _allow: &dyn Fn(&Path) -> bool) -> Vec<Vst3Descriptor> {+        Vec::new()+    }+    pub fn rescan_paths(_targets: &[PathBuf]) -> Vec<Vst3Descriptor> {+        Vec::new()+    }+    pub fn set_blocklist(_entries: Vec<PathBuf>) {}+    pub fn blocklist() -> Vec<PathBuf> {+        Vec::new()+    }     pub fn scan_paths(_dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {         Vec::new()     }@@ -194,7 +228,45 @@ pub fn add_search_dir(dir: &Path) -> usize {     api::add_search_dir(dir) } -/// Scans the given folders without touching the cache.+/// True when `path` lies in a folder a full scan reads (the standard folders, the environment's+/// and those added here); [`rescan_paths`] accepts only such targets.+pub fn in_search_path(path: &Path) -> bool {+    api::in_search_path(path)+}++/// Adds a folder to the search path without scanning it (unlike [`add_search_dir`]).+pub fn register_search_dir(dir: &Path) {+    api::register_search_dir(dir)+}++/// Rescans only the given absolute bundle or folder paths inside the search path (others are+/// ignored), forgetting their cached plugins and failures, and returns what they hold now. Nothing+/// else is loaded, the plugins found can be created at once, and they win over another bundle with+/// the same id until a full [`rescan`]. A binary loaded earlier stays mapped for the life of the+/// process, so a rebuilt plugin's new code needs a new process.+pub fn rescan_paths(targets: &[PathBuf]) -> Vec<Vst3Descriptor> {+    api::rescan_paths(targets)+}++/// [`rescan_paths`], loading only the bundles `allow` accepts: it is asked about every bundle+/// found below the targets (a caller confining file access passes its read check, which then+/// covers bundles reached through linked subfolders too).+pub fn rescan_paths_where(targets: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<Vst3Descriptor> {+    api::rescan_paths_where(targets, allow)+}++/// Replaces the blocklist: plugins and folders that are never loaded (see [`scan::is_blocked`]+/// for how entries match). It starts as the entries of `SOUNDCRAFT_PLUGIN_BLOCKLIST`.+pub fn set_blocklist(entries: Vec<PathBuf>) {+    api::set_blocklist(entries)+}++/// The current blocklist.+pub fn blocklist() -> Vec<PathBuf> {+    api::blocklist()+}++/// Scans the given folders without touching the cache (blocked bundles are skipped). pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {     api::scan_paths(dirs) }diff --git a/crates/vst3-host/src/registry.rs b/crates/vst3-host/src/registry.rs--- a/crates/vst3-host/src/registry.rs+++ b/crates/vst3-host/src/registry.rs@@ -21,6 +21,19 @@ struct Registry {     infos: HashMap<String, &'static PluginInfo>,     /// Ids that failed to instantiate (not retried every UI frame).     failed: HashSet<String>,+    /// Plugins and folders never loaded ([`paths::is_blocked`]); read from the environment on+    /// first use, then replaced by [`set_blocklist`].+    blocked: Option<Vec<PathBuf>>,+    /// Plugins found by [`rescan_paths`]: creatable before any full scan without loading anything+    /// else, and preferred over another bundle with the same id (a rebuilt plugin over the+    /// installed one) until a full [`rescan`].+    targeted: Vec<Vst3Descriptor>,+}++impl Registry {+    fn blocked(&mut self) -> &[PathBuf] {+        self.blocked.get_or_insert_with(paths::blocklist_from_env)+    } }  fn registry() -> &'static Mutex<Registry> {@@ -53,11 +66,28 @@ pub fn load_bundle(path: &Path) -> Result<Vec<Vst3Descriptor>, Vst3Error> {         .collect()) } -/// Scans `dirs` (uncached). Unloadable files are logged and skipped; the first plugin with a-/// given id wins.+/// Scans `dirs` (uncached). Blocked bundles are never loaded; unloadable files are logged and+/// skipped; the first plugin with a given id wins. pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {+    let blocked = registry().lock().unwrap_or_else(PoisonError::into_inner).blocked().to_vec();+    scan_dirs(dirs, &blocked)+}++fn scan_dirs(dirs: &[PathBuf], blocked: &[PathBuf]) -> Vec<Vst3Descriptor> {+    scan_dirs_where(dirs, blocked, &|_| true)+}++fn scan_dirs_where(dirs: &[PathBuf], blocked: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<Vst3Descriptor> {     let mut out: Vec<Vst3Descriptor> = Vec::new();     for path in paths::find_bundles(dirs) {+        if paths::is_blocked(&path, blocked) {+            log::info!("VST3 plugin {} is blocked: not loaded", path.display());+            continue;+        }+        if !allow(&path) {+            log::warn!("VST3 plugin {} is outside the allowed folders: not loaded", path.display());+            continue;+        }         match load_bundle(&path) {             Ok(ds) => {                 for d in ds {@@ -75,9 +105,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {  fn ensure_scanned(r: &mut Registry) -> &Vec<Vst3Descriptor> {     if r.scanned.is_none() {-        let mut dirs = paths::default_search_paths();-        dirs.extend(r.extra_dirs.iter().cloned());-        r.scanned = Some(scan_paths(&dirs));+        let dirs = search_dirs(r);+        let mut found = scan_dirs(&dirs, r.blocked());+        let targeted = r.targeted.clone();+        merge(&mut found, &|_| false, &targeted);+        r.scanned = Some(found);     }     r.scanned.get_or_insert_with(Vec::new) }@@ -95,6 +127,7 @@ pub fn rescan() -> Vec<Vst3Descriptor> {     let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);     r.scanned = None;     r.failed.clear();+    r.targeted.clear();     ensure_scanned(&mut r).clone() } @@ -103,7 +136,7 @@ pub fn add_search_dir(dir: &Path) -> usize {     if !r.extra_dirs.iter().any(|d| d == dir) {         r.extra_dirs.push(dir.to_path_buf());     }-    let found = scan_paths(&[dir.to_path_buf()]);+    let found = scan_dirs(&[dir.to_path_buf()], r.blocked());     let n = found.len();     let list = r.scanned.get_or_insert_with(Vec::new);     for d in found {@@ -114,6 +147,97 @@ pub fn add_search_dir(dir: &Path) -> usize {     n } +/// The folders a full scan reads: the standard ones and the environment's (see+/// [`paths::default_search_paths`]) and those added with [`add_search_dir`] or+/// [`register_search_dir`].+fn search_dirs(r: &Registry) -> Vec<PathBuf> {+    let mut dirs = paths::default_search_paths();+    dirs.extend(r.extra_dirs.iter().cloned());+    dirs+}++/// True when `path` lies in a folder a full scan reads, so loading it needs no new trust.+pub fn in_search_path(path: &Path) -> bool {+    let r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    search_dirs(&r).iter().any(|d| paths::is_inside(path, d))+}++/// Adds a folder to the search path without scanning it: the next full scan reads it, and+/// [`rescan_paths`] may target what it holds.+pub fn register_search_dir(dir: &Path) {+    let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    if !r.extra_dirs.iter().any(|d| d == dir) {+        r.extra_dirs.push(dir.to_path_buf());+    }+}++/// Rescans only `targets`: absolute bundle or folder paths inside the search path ([`in_search_path`]);+/// others are ignored, so a rescan never loads a binary a full scan wouldn't. Forgets the targets'+/// cached plugins and remembered failures, loads them again (unless blocked) and merges the result+/// into the cache, where it wins over another bundle with the same id. Returns what the targets hold+/// now. Before any full scan nothing else is loaded, and the plugins found can be created right away.+/// A binary loaded earlier in this process stays mapped, so a rebuilt plugin's new code needs a new+/// process; its descriptors are read again either way.+pub fn rescan_paths(targets: &[PathBuf]) -> Vec<Vst3Descriptor> {+    rescan_paths_where(targets, &|_| true)+}++/// [`rescan_paths`], loading only the bundles `allow` accepts (checked for every bundle found+/// below the targets, so a caller confining file access also covers linked subfolders).+pub fn rescan_paths_where(targets: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec<Vst3Descriptor> {+    let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    let roots = search_dirs(&r);+    let targets: Vec<PathBuf> = targets+        .iter()+        .filter(|t| {+            let ok = t.is_absolute() && paths::entry_problem(t).is_none() && roots.iter().any(|d| paths::is_inside(t, d));+            if !ok {+                log::warn!("VST3 rescan: ignoring `{}`: not an absolute bundle or folder path in the search path", t.display());+            }+            ok+        })+        .cloned()+        .collect();+    let under = |p: &Path| targets.iter().any(|t| paths::is_under(p, t));+    let stale: Vec<String> = r.scanned.iter().flatten().chain(&r.targeted).filter(|d| under(&d.path)).map(|d| d.id.clone()).collect();+    let blocked = r.blocked().to_vec();+    let found = scan_dirs_where(&targets, &blocked, allow);+    for id in stale.iter().chain(found.iter().map(|d| &d.id)) {+        r.failed.remove(id);+    }+    merge(&mut r.targeted, &under, &found);+    if let Some(list) = r.scanned.as_mut() {+        merge(list, &under, &found);+    }+    found+}++/// Puts `found` into `list` in place of the plugins under the rescanned targets and of any other+/// bundle's plugin with the same id.+fn merge(list: &mut Vec<Vst3Descriptor>, under: &dyn Fn(&Path) -> bool, found: &[Vst3Descriptor]) {+    list.retain(|d| !under(&d.path) && !found.iter().any(|f| f.id == d.id));+    list.extend(found.iter().cloned());+    list.sort_by_key(|d| d.name.to_lowercase());+}++/// Replaces the blocklist ([`paths::is_blocked`] entries). Newly blocked plugins leave the cache+/// at once and can no longer be created (live instances keep running, and the parameter info of+/// a type already created stays available for them); unblocked ones come back with the next scan+/// of their folder ([`rescan`] or [`rescan_paths`]).+pub fn set_blocklist(entries: Vec<PathBuf>) {+    let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    if let Some(list) = r.scanned.as_mut() {+        list.retain(|d| !paths::is_blocked(&d.path, &entries));+    }+    r.targeted.retain(|d| !paths::is_blocked(&d.path, &entries));+    r.blocked = Some(entries);+}++/// The current blocklist.+pub fn blocklist() -> Vec<PathBuf> {+    registry().lock().unwrap_or_else(PoisonError::into_inner).blocked().to_vec()+}+ fn leak(s: String) -> &'static str {     Box::leak(s.into_boxed_str()) }@@ -205,10 +329,22 @@ fn instantiate_raw(id: &str) -> Result<(Instance, &'static PluginInfo), Vst3Erro         if r.failed.contains(id) {             return Err(Vst3Error::Instantiate(id.to_string(), "failed before".into()));         }-        ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| Vst3Error::NotFound(id.to_string()))?+        let known = r.scanned.iter().flatten().chain(&r.targeted).find(|d| d.id == id).cloned();+        let desc = match known {+            Some(d) => d,+            None => ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| Vst3Error::NotFound(id.to_string()))?,+        };+        if paths::is_blocked(&desc.path, r.blocked()) {+            return Err(Vst3Error::Instantiate(id.to_string(), "blocked".into()));+        }+        desc     };     let made = Bundle::load(&desc.path).and_then(|b| b.instantiate(&cid));     let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);+    // Blocked while it loaded: the new instance is dropped.+    if paths::is_blocked(&desc.path, r.blocked()) {+        return Err(Vst3Error::Instantiate(id.to_string(), "blocked".into()));+    }     match made {         Ok(inst) => {             let info = match r.infos.get(id) {diff --git a/crates/vst3-host/src/scan.rs b/crates/vst3-host/src/scan.rs--- a/crates/vst3-host/src/scan.rs+++ b/crates/vst3-host/src/scan.rs@@ -2,7 +2,7 @@ //! mapping VST3 sub-categories to SoundCraft categories.  use soundcraft_dsp::Category;-use std::path::{Path, PathBuf};+use std::path::{Component, Path, PathBuf};  /// Directory recursion limit while scanning (vendors nest folders, never deeply). const MAX_DEPTH: usize = 8;@@ -33,21 +33,142 @@ impl Os {     } } +/// Set (to anything but `0` or empty) to scan only the `VST3_PATH` / `CLAP_PATH` folders and skip+/// the standard locations: a test harness then loads only the plugins it names.+pub const PATHS_ONLY_ENV: &str = "SOUNDCRAFT_PLUGIN_PATHS_ONLY";+/// A path list (like `PATH`) of plugins and folders never loaded; see [`is_blocked`].+pub const BLOCKLIST_ENV: &str = "SOUNDCRAFT_PLUGIN_BLOCKLIST";+ /// The standard VST3 search locations for this OS (existing or not), followed by every entry of-/// the `VST3_PATH` environment variable.+/// the `VST3_PATH` environment variable. With [`PATHS_ONLY_ENV`] set, only the `VST3_PATH` entries. pub fn default_search_paths() -> Vec<PathBuf> {-    let mut v = os_search_paths(+    let os = os_search_paths(         Os::current(),         std::env::var_os("HOME").map(PathBuf::from),         std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from),         std::env::var_os("LOCALAPPDATA").map(PathBuf::from),     );-    if let Some(cp) = std::env::var_os("VST3_PATH") {-        v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty()));+    search_paths(os, std::env::var_os("VST3_PATH").as_deref(), env_flag(PATHS_ONLY_ENV))+}++/// The standard locations followed by the entries of a path-list variable, or only those entries+/// when `only_env` is set.+pub fn search_paths(standard: Vec<PathBuf>, env: Option<&std::ffi::OsStr>, only_env: bool) -> Vec<PathBuf> {+    let mut v = if only_env { Vec::new() } else { standard };+    if let Some(cp) = env {+        v.extend(std::env::split_paths(cp).filter(|p| !p.as_os_str().is_empty()));     }     v } +/// True when an environment flag is set to anything but empty or `0`.+pub fn env_flag(name: &str) -> bool {+    std::env::var_os(name).is_some_and(|v| !v.is_empty() && v != "0")+}++/// The entries of [`BLOCKLIST_ENV`] (empty when unset).+pub fn blocklist_from_env() -> Vec<PathBuf> {+    let Some(v) = std::env::var_os(BLOCKLIST_ENV) else { return Vec::new() };+    std::env::split_paths(&v)+        .filter(|p| !p.as_os_str().is_empty())+        .filter(|p| match entry_problem(p) {+            Some(why) => {+                log::warn!("{BLOCKLIST_ENV}: ignoring `{}`: {why}", p.display());+                false+            }+            None => true,+        })+        .collect()+}++/// Why a blocklist entry or rescan target can't be used (`None` when it can). An entry is either a+/// bare bundle name (one plain component) or an absolute path below the root; relative paths+/// with folders, `..`, and a bare root would match unpredictably or block everything.+pub fn entry_problem(e: &Path) -> Option<&'static str> {+    let normal = e.components().filter(|c| matches!(c, Component::Normal(_))).count();+    if e.components().any(|c| c == Component::ParentDir) {+        Some("must not contain `..`")+    } else if !e.is_absolute() && !(normal == 1 && e.components().count() == 1) {+        Some("must be an absolute path or a bare bundle name")+    } else if e.is_absolute() && normal == 0 {+        Some("must name a folder or bundle below the root")+    } else {+        None+    }+}++/// A path as comparable components: `.` dropped, names lower-cased (plugin folders usually live+/// on case-insensitive file systems; on case-sensitive ones this can only block more).+fn folded(p: &Path) -> Vec<String> {+    p.components().filter(|c| *c != Component::CurDir).map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect()+}++/// True when `path` is `root` or lies inside it, compared by whole components.+pub fn is_under(path: &Path, root: &Path) -> bool {+    let r = folded(root);+    !r.is_empty() && folded(path).starts_with(&r)+}++/// `path` with its links resolved, without the `\\?\` prefix Windows adds (so it compares with+/// the paths people type); `None` when it doesn't exist.+fn resolved(path: &Path) -> Option<PathBuf> {+    let r = std::fs::canonicalize(path).ok()?;+    let text = r.to_string_lossy();+    Some(match text.strip_prefix(r"\\?\UNC\") {+        Some(rest) => PathBuf::from(format!(r"\\{rest}")),+        None => text.strip_prefix(r"\\?\").map_or_else(|| r.clone(), PathBuf::from),+    })+}++/// True when `path` is `root` or lies inside it, as written or with links resolved on both sides.+/// A link inside `root` (plugins are often symlinked in from a build folder) counts as inside, as+/// it does for a full scan, which follows links too. Unlike [`is_under`], which blocklists use and+/// which may match more than it should, this decides what may be loaded, so case is ignored only+/// on Windows: on macOS and Linux names compare exactly, and `/work/PLUGINS` is not inside+/// `/work/plugins` (a case-sensitive macOS volume may hold both). A path spelled in another case+/// on a case-insensitive volume still matches through the resolved comparison, since resolving+/// links yields the names the file system actually stores.+pub fn is_inside(path: &Path, root: &Path) -> bool {+    inside(path, root, false) || inside(path, root, true)+}++fn inside(path: &Path, root: &Path, resolve: bool) -> bool {+    let fold = cfg!(windows);+    let parts = |p: &Path| -> Vec<String> {+        let p = if resolve { resolved(p).unwrap_or_else(|| p.to_path_buf()) } else { p.to_path_buf() };+        p.components()+            .filter(|c| *c != Component::CurDir)+            .map(|c| {+                let s = c.as_os_str().to_string_lossy();+                if fold { s.to_lowercase() } else { s.into_owned() }+            })+            .collect()+    };+    let r = parts(root);+    !r.is_empty() && parts(path).starts_with(&r)+}++/// True when a blocklist entry covers the plugin at `path`. An absolute entry blocks that bundle+/// and everything inside a folder of that name, comparing links resolved on either side as well. A bare name blocks every bundle with that file name, with or without its extension+/// (`AutoAlign2.vst3` or `AutoAlign2`). Case is ignored; entries with an [`entry_problem`] never match.+pub fn is_blocked(path: &Path, entries: &[PathBuf]) -> bool {+    let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase());+    let stem = path.file_stem().map(|n| n.to_string_lossy().to_lowercase());+    let mut real: Option<Option<PathBuf>> = None;+    entries.iter().filter(|e| entry_problem(e).is_none()).any(|e| {+        if e.is_absolute() {+            if is_under(path, e) {+                return true;+            }+            let Some(p) = real.get_or_insert_with(|| resolved(path)).as_deref() else { return false };+            is_under(p, e) || resolved(e).is_some_and(|e| is_under(p, &e))+        } else {+            let e = e.to_string_lossy().to_lowercase();+            name.as_deref() == Some(e.as_str()) || stem.as_deref() == Some(e.as_str())+        }+    })+}+ /// The OS-specific standard locations, given the relevant environment values. pub fn os_search_paths(os: Os, home: Option<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {     let mut v = Vec::new();@@ -300,7 +421,115 @@ mod tests {         assert_eq!(win.len(), 2);         let unix = os_search_paths(Os::Unix, Some("/home/u".into()), None, None);         assert_eq!(unix, vec![PathBuf::from("/home/u/.vst3"), PathBuf::from("/usr/lib/vst3"), PathBuf::from("/usr/local/lib/vst3")]);-        assert!(!default_search_paths().is_empty());+        if !env_flag(PATHS_ONLY_ENV) {+            assert!(!default_search_paths().is_empty());+        }+    }++    /// A test path made absolute on every platform (Windows paths need a drive).+    fn abs(p: &str) -> PathBuf {+        if cfg!(windows) && p.starts_with('/') { PathBuf::from(format!("C:{p}")) } else { PathBuf::from(p) }+    }++    #[test]+    fn env_paths_extend_or_replace_the_standard_locations() {+        let std = vec![PathBuf::from("/Library/Audio/Plug-Ins/VST3")];+        let env = std::env::join_paths(["/a", "", "/b"]).unwrap();+        assert_eq!(search_paths(std.clone(), Some(&env), false), vec![PathBuf::from("/Library/Audio/Plug-Ins/VST3"), "/a".into(), "/b".into()]);+        assert_eq!(search_paths(std.clone(), Some(&env), true), vec![PathBuf::from("/a"), PathBuf::from("/b")]);+        assert_eq!(search_paths(std.clone(), None, false), std);+        assert!(search_paths(std, None, true).is_empty(), "paths-only without a path list scans nothing");+    }++    #[test]+    fn blocklist_entries_match_names_and_paths() {+        let p = abs("/Library/Audio/Plug-Ins/VST3/AutoAlign2.vst3");+        let v = |e: &[&str]| e.iter().map(|e| abs(e)).collect::<Vec<_>>();+        assert!(is_blocked(&p, &v(&["AutoAlign2.vst3"])));+        assert!(is_blocked(&p, &v(&["autoalign2"])), "name without extension, any case");+        assert!(!is_blocked(&p, &v(&["AutoAlign"])), "names match whole, not as prefixes");+        assert!(is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/VST3/AutoAlign2.vst3"])));+        assert!(is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/VST3"])), "a folder blocks what it holds");+        assert!(is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/VST3/"])));+        assert!(!is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/VST"])), "path prefixes stop at separators");+        assert!(!is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/VST3/forwardaudio"])));+        assert!(is_blocked(&p, &v(&["/Library/Audio/Plug-Ins/./VST3/autoalign2.VST3"])), "`.` segments and case don't matter");+        assert!(is_blocked(&p, &v(&["//Library//Audio/Plug-Ins/VST3"])));+        assert!(!is_blocked(&p, &v(&["/", ".", "VST3/AutoAlign2.vst3", "./AutoAlign2.vst3", "/Library/x/../Audio"])), "unusable entries never match");+        assert!(!is_blocked(&p, &[]));+    }++    #[cfg(windows)]+    #[test]+    fn blocklist_paths_match_either_separator_on_windows() {+        let p = PathBuf::from(r"C:\Program Files\Common Files\VST3\Foo.vst3");+        assert!(is_blocked(&p, &[PathBuf::from("C:/Program Files/Common Files/VST3")]));+        assert!(is_blocked(&p, &[PathBuf::from(r"c:\program files\common files\vst3\foo.vst3")]));+    }++    #[cfg(unix)]+    #[test]+    fn a_link_to_a_blocked_bundle_is_blocked_too() {+        let d = tmp("link");+        std::fs::create_dir_all(d.join("real/X.vst3")).unwrap();+        std::fs::create_dir_all(d.join("links")).unwrap();+        std::os::unix::fs::symlink(d.join("real/X.vst3"), d.join("links/Y.vst3")).unwrap();+        let real = std::fs::canonicalize(d.join("real")).unwrap();+        assert!(is_blocked(&d.join("links/Y.vst3"), &[real]));+        assert!(!is_blocked(&d.join("links/Y.vst3"), &[d.join("other")]));+        // And the other way round: an entry naming a link covers the folder it leads to.+        std::os::unix::fs::symlink(d.join("real"), d.join("alias")).unwrap();+        assert!(is_blocked(&std::fs::canonicalize(d.join("real/X.vst3")).unwrap(), &[d.join("alias")]));+        let _ = std::fs::remove_dir_all(&d);+    }++    #[cfg(unix)]+    #[test]+    fn loading_admission_is_exact_and_follows_links() {+        let d = tmp("inside");+        std::fs::create_dir_all(d.join("plugins/vendor")).unwrap();+        std::fs::create_dir_all(d.join("PLUGINS")).unwrap();+        std::os::unix::fs::symlink(d.join("plugins"), d.join("alias")).unwrap();+        assert!(is_inside(&d.join("plugins/vendor"), &d.join("plugins")));+        assert!(is_inside(&d.join("alias/vendor"), &d.join("plugins")), "through a link");+        // A bundle linked into the folder from a build tree elsewhere is inside, as for a full scan.+        std::fs::create_dir_all(d.join("build/Dev.vst3")).unwrap();+        std::os::unix::fs::symlink(d.join("build/Dev.vst3"), d.join("plugins/vendor/Dev.vst3")).unwrap();+        assert!(is_inside(&d.join("plugins/vendor/Dev.vst3"), &d.join("plugins")), "a linked-in bundle");+        assert!(!is_inside(&d.join("build/Dev.vst3"), &d.join("plugins")), "but not its target by itself");+        assert!(!is_inside(&d.join("plugin"), &d.join("plugins")));+        // `PLUGINS` is its own folder on a case-sensitive volume and an alias of `plugins` on a+        // case-insensitive one; only the second is inside.+        let same = |a: &Path, b: &Path| {+            use std::os::unix::fs::MetadataExt;+            let (a, b) = (std::fs::metadata(a).unwrap(), std::fs::metadata(b).unwrap());+            (a.dev(), a.ino()) == (b.dev(), b.ino())+        };+        assert_eq!(is_inside(&d.join("PLUGINS"), &d.join("plugins")), same(&d.join("PLUGINS"), &d.join("plugins")));+        assert_eq!(is_inside(&d.join("PLUGINS/vendor"), &d.join("plugins")), d.join("PLUGINS/vendor").exists());+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn loading_admission_ignores_case_only_on_windows() {+        // Paths that don't exist compare as written.+        let root = abs("/no-such-root/plugins");+        assert!(is_inside(&abs("/no-such-root/plugins/vendor/Foo.vst3"), &root));+        assert_eq!(is_inside(&abs("/no-such-root/PLUGINS/vendor/Foo.vst3"), &root), cfg!(windows));+        assert_eq!(is_inside(&abs("/No-Such-Root/plugins"), &root), cfg!(windows));+    }++    #[test]+    fn entries_are_checked_before_use() {+        assert_eq!(entry_problem(Path::new("AutoAlign2.vst3")), None);+        assert_eq!(entry_problem(&abs("/Library/Audio/Plug-Ins/VST3")), None);+        assert!(entry_problem(Path::new("/")).is_some());+        assert!(entry_problem(Path::new("VST3/Foo.vst3")).is_some());+        assert!(entry_problem(Path::new("./Foo.vst3")).is_some());+        assert!(entry_problem(Path::new("/a/../b")).is_some());+        assert!(is_under(Path::new("/a/b/c.vst3"), Path::new("/a/b")));+        assert!(!is_under(Path::new("/a/bc.vst3"), Path::new("/a/b")));+        assert!(!is_under(Path::new("/a"), Path::new("")));     }      #[test]diff --git a/crates/vst3-host/tests/common/mod.rs b/crates/vst3-host/tests/common/mod.rs--- a/crates/vst3-host/tests/common/mod.rs+++ b/crates/vst3-host/tests/common/mod.rs@@ -5,8 +5,22 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -/// Builds the fixture once and returns a folder containing `Fixture.vst3`.+/// Builds the fixture once, installs it and adds the folder to the host's search path; returns+/// the folder containing `vendor/Fixture.vst3`.+#[allow(dead_code)] // Not every test binary that shares this module uses it directly. pub fn fixture_dir() -> &'static Path {+    static DIR: OnceLock<&'static Path> = OnceLock::new();+    DIR.get_or_init(|| {+        let dir = install_fixture();+        assert_eq!(soundcraft_vst3_host::add_search_dir(dir), 2);+        dir+    })+}++/// Builds and installs the fixture once without telling the host about it; returns the folder+/// containing `vendor/Fixture.vst3`.+#[allow(dead_code)] // Not every test binary that shares this module uses it directly.+pub fn install_fixture() -> &'static Path {     static DIR: OnceLock<PathBuf> = OnceLock::new();     DIR.get_or_init(|| {         let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");@@ -35,7 +49,6 @@ pub fn fixture_dir() -> &'static Path {         let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder);         std::fs::create_dir_all(&bin).unwrap();         std::fs::copy(&built, bin.join(exe)).unwrap();-        assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 2);         dir     }) }diff --git a/crates/vst3-host/tests/scan_control.rs b/crates/vst3-host/tests/scan_control.rsnew file mode 100644--- /dev/null+++ b/crates/vst3-host/tests/scan_control.rs@@ -0,0 +1,87 @@+//! Blocklist and targeted rescans against the fixture, in a process of its own (`harness =+//! false`): the blocklist is process-wide, so the other tests must not see it.++mod common;++use soundcraft_vst3_host as host;++fn main() {+    let dir = common::install_fixture();+    let bundle = dir.join("vendor/Fixture.vst3");++    // Outside the search path a rescan loads nothing: it never reaches further than a full scan.+    assert!(host::rescan_paths(std::slice::from_ref(&bundle)).is_empty());+    assert!(!host::in_search_path(&bundle));+    host::register_search_dir(dir);+    assert!(host::in_search_path(&bundle));++    // Rescanning one bundle before any scan loads only it, and its plugins can be created at once.+    let found = host::rescan_paths(std::slice::from_ref(&bundle));+    assert_eq!(found.len(), 2);+    let gain = found.iter().find(|d| d.name == "Fixture Gain").expect("fixture gain").id.clone();+    assert!(host::cached().is_none(), "no full scan");+    assert!(host::instantiate(&gain).is_ok());+    assert!(host::cached().is_none(), "creating a rescanned plugin needs no full scan");++    // Relative targets are ignored and leave the known plugins alone.+    assert!(host::rescan_paths(&["Fixture.vst3".into(), "vendor/Fixture.vst3".into()]).is_empty());+    assert!(host::instantiate(&gain).is_ok());++    // Blocked by name: it can no longer be created or found. (Looking up an id that isn't known+    // runs the full scan, which with SOUNDCRAFT_PLUGIN_PATHS_ONLY=1 covers only the env paths.)+    host::set_blocklist(vec!["fixture".into()]);+    assert!(host::instantiate(&gain).is_err());+    assert!(host::rescan_paths(std::slice::from_ref(&bundle)).is_empty());+    assert!(host::scan().iter().all(|d| d.id != gain));++    // Blocked by folder path, given with a `.` segment.+    host::set_blocklist(vec![dir.join("./vendor")]);+    assert!(host::scan_paths(&[dir.to_path_buf()]).is_empty());++    // Unblocked: back with the next rescan of its folder, in the cached list too.+    host::set_blocklist(Vec::new());+    assert_eq!(host::rescan_paths(&[dir.join("vendor")]).len(), 2);+    assert!(host::cached().is_some_and(|l| l.iter().any(|d| d.id == gain)));+    assert!(host::instantiate(&gain).is_ok());++    // Blocking evicts it from the cached list straight away.+    host::set_blocklist(vec![bundle.clone()]);+    assert!(host::cached().is_some_and(|l| l.iter().all(|d| d.id != gain)));+    assert!(host::instantiate(&gain).is_err());++    // A rebuilt copy elsewhere in the search path, rescanned, wins over the installed bundle.+    host::set_blocklist(Vec::new());+    let dev = dir.join("dev");+    copy_dir(&dir.join("vendor"), &dev);+    host::register_search_dir(&dev);+    let dev_bundle = dev.join("Fixture.vst3");+    assert!(host::rescan_paths(std::slice::from_ref(&dev_bundle)).iter().any(|d| d.id == gain && d.path == dev_bundle));+    let path_of = |id: &str| host::cached().and_then(|l| l.into_iter().find(|d| d.id == id)).map(|d| d.path);+    assert_eq!(path_of(&gain), Some(dev_bundle.clone()), "the rescanned copy replaces the installed one");+    assert!(host::instantiate(&gain).is_ok());+    assert_eq!(host::rescan_paths(std::slice::from_ref(&bundle)).len(), 2);+    assert_eq!(path_of(&gain), Some(bundle.clone()), "and rescanning the installed one switches back");++    // A caller's check (the engine passes its read-root check) is asked about every bundle found+    // below a target, not only about the target.+    let asked = std::cell::RefCell::new(Vec::new());+    let none = host::rescan_paths_where(&[dir.to_path_buf()], &|b| {+        asked.borrow_mut().push(b.to_path_buf());+        false+    });+    assert!(none.is_empty());+    assert!(asked.borrow().contains(&bundle) && asked.borrow().contains(&dev_bundle), "{:?}", asked.borrow());+    println!("scan_control: ok");+}++fn copy_dir(from: &std::path::Path, to: &std::path::Path) {+    std::fs::create_dir_all(to).unwrap();+    for e in std::fs::read_dir(from).unwrap().flatten() {+        let (src, dst) = (e.path(), to.join(e.file_name()));+        if src.is_dir() {+            copy_dir(&src, &dst);+        } else {+            std::fs::copy(&src, &dst).unwrap();+        }+    }+}diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -24,6 +24,36 @@ input opened by arm/monitor commands. An automated UI smoke run must never arm a monitoring, or start recording. Control input injects egui events; it does not establish operating-system input delivery or screen-reader acceptance. Native screenshots use the presented viewport frame. +## Choosing which plugins load++Scanning loads every CLAP and VST3 binary it finds, and so does creating any one plugin, since that+starts with a scan. Plugins with copy protection may show licence dialogs while loading. To keep+an automated run to the plugins it uses:++| Setting | Effect |+|---|---|+| `SOUNDCRAFT_PLUGIN_PATHS_ONLY=1` | Scan only `VST3_PATH` and `CLAP_PATH` (path lists), not the standard plugin folders |+| `SOUNDCRAFT_PLUGIN_BLOCKLIST=…` | A path list of plugins never loaded: absolute paths of bundles or folders (also matched where links lead), or bare bundle names with or without extension (`AutoAlign2.vst3`, `AutoAlign2`); case and `.` segments are ignored, unusable entries are logged and skipped |+| `engine.plugin_blocklist {set?, add?, remove?}` | Change the blocklist while running (CLAP and VST3 together); blocked plugins leave the lists at once, unblocked ones return with the next rescan |+| `engine.vst3_plugins {"rescan": [path, …]}` | Reload only those bundles or folders (likewise `engine.clap_plugins`) and list only them. Paths must be absolute and inside the search path (standard folders, `VST3_PATH`/`CLAP_PATH`), so a rescan never loads what a full scan wouldn't; under `--automation-read-root` they, and every bundle found below them, must also lie inside a read root. Before any full scan the plugins found can be inserted without loading anything else, and they win over another bundle with the same id (a rebuilt plugin over the installed one) until a full rescan |++```sh+SOUNDCRAFT_PLUGIN_PATHS_ONLY=1 VST3_PATH=/Library/Audio/Plug-Ins/VST3/MyVendor \+  soundcraft-cli run --cmd 'file.import_audio={"path":"in.wav"}' \+  --cmd 'mix.insert={"track":"in","plugin":"vst3:<class id>"}' --bounce out.wav++# Or name the one bundle; nothing else is loaded:+soundcraft-cli run --cmd 'engine.vst3_plugins={"rescan":["/Library/Audio/Plug-Ins/VST3/MyVendor/My.vst3"]}' \+  --cmd 'file.import_audio={"path":"in.wav"}' --cmd 'mix.insert={"track":"in","plugin":"vst3:<class id>"}' --bounce out.wav+```++Asking for a plugin id that no scan has listed yet runs the full scan, and so does a plain+`engine.vst3_plugins {}` / `engine.clap_plugins {}` listing.++A binary stays loaded for the life of the process, so testing a rebuilt plugin needs a new+process (each `soundcraft-cli run` is one). Audio Units are listed by the system without loading+them and are not affected.+ ## Wire format  Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-13: plugin blocklist UI and scan cache; previously trivial: G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**@@ -27,7 +27,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t | G-10 | No control surfaces (HUI/MCU, EUCON) | Hardware | [hardware-parity.md](hardware-parity.md) | 40–70 | | G-11 | No external sync (MTC, LTC, MIDI clock, video reference) | Hardware | [hardware-parity.md](hardware-parity.md) | 15–25 | | G-12 | Immersive delivery: no Dolby Atmos/ADM BWF, MPEG-H, binaural | Features | [file-format-parity.md](file-format-parity.md) | 50–80 |-| G-13 | Sidechain inputs, ARA, out-of-process plugin sandboxing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 30–50 |+| G-13 | Sidechain inputs, ARA, out-of-process plugin sandboxing; Plug-In Manager UI with a saved blocklist and an on-disk scan cache (the blocklist and targeted rescans exist as commands only) | Ecosystem | [plugin-parity.md](plugin-parity.md) | 35–60 | | G-14 | Windows/Linux/Web exercised only lightly at runtime; no PR CI running the tests | Platforms / Stability | this file | 20–40 | | G-15 | Elastic Audio depth: one algorithm, no Elastic Pitch | Features | [target-app-parity.md](target-app-parity.md) | 30–45 | | G-16 | Session templates, track presets, workspace browser, batch rename | Features | [target-app-parity.md](target-app-parity.md) | 25–40 |@@ -167,7 +167,10 @@ SDKs are licensed, so we'd write our own ADM and object renderer. ### G-13. Plugin ecosystem depth  No sidechain (key input) buses for any host; no ARA (Melodyne-style editing is a menu in Pro-Tools); plugins run in-process, so a crashing plugin can take the session down. ≈ 30–50 h. AAX+Tools); plugins run in-process, so a crashing plugin can take the session down. Plugins can be+blocklisted and rescanned one by one, but only through commands (`engine.plugin_blocklist`,+`rescan: [path]`): there is no Plug-In Manager UI, the blocklist is not saved in preferences, and+scans are not cached on disk, so every launch loads each unblocked binary again. ≈ 35–60 h. AAX cannot be hosted (#109): Avid's AAX SDK licence and PACE signing; owner decision, likely never.  ### G-14. Platforms and CI@@ -221,6 +224,7 @@ missing. Out of scope unless the owner decides otherwise.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | trivial | G-13: blocklist and targeted rescans landed as commands; Plug-In Manager UI, saved blocklist and on-disk scan cache remain | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md--- a/docs/plugin-parity.md+++ b/docs/plugin-parity.md@@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (plugin blocklist and scoped scans; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from@@ -40,6 +40,7 @@ User report #152: a saved instrument level does not reach the bounce. | Plugin format | AAX only (Native and DSP) | CLAP, VST3, Audio Units (macOS) | Users' libraries mostly ship VST3/AU too. AAX (#109) needs Avid's SDK licence and PACE signing: owner decision, likely never | | VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable | | Scan, vendor menus, instrument picker | ✅ | ✅ | #23 |+| Skip plugins at scan (blocklist), rescan chosen plugins | ✅ (Plug-In Manager) | 🟡 `engine.plugin_blocklist`, `SOUNDCRAFT_PLUGIN_BLOCKLIST`, `rescan: [paths]`, `SOUNDCRAFT_PLUGIN_PATHS_ONLY` (CLAP and VST3) | No UI yet and not saved in preferences; the scan is not cached on disk, so every launch loads each unblocked binary again | | Audio, parameters, automation, latency (PDC) | ✅ | ✅ | | | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | | State saved in sessions, presets | ✅ | ✅ | |@@ -58,6 +59,7 @@ User report #152: a saved instrument level does not reach the bounce.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | CLAP and VST3 hosts: blocklist (never loaded), rescan of chosen bundles or folders, and paths-only scans for test harnesses | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |