Skip to main content

ferraus_plugins/sounds/
mod.rs

1//! The installable sounds registry: a signed JSON manifest of free instruments, SoundFonts,
2//! SFZ libraries, and impulse responses, and the `sounds ...` commands.
3//!
4//! The manifest (`sounds/registry.json`) is embedded in the binary together with its
5//! Ed25519 signature (`registry.json.sig`); `sounds update` fetches a newer signed copy into
6//! the cache. Entries without a verified SHA-256 are refused unless
7//! `FERRAUS_ALLOW_UNVERIFIED=1`. ferraus never runs vendor installers and writes only to
8//! per-user folders.
9
10pub mod install;
11
12use crate::client::default_watchdog;
13use crate::resolve::{self, SoundKind, SoundsEnv};
14use base64::Engine;
15use ed25519_dalek::{Signature, Verifier, VerifyingKey};
16use ferraus_core::op::Inputs;
17use ferraus_core::prelude::*;
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22/// The embedded manifest and its detached signature.
23pub const EMBEDDED_REGISTRY: &[u8] = include_bytes!("../../sounds/registry.json");
24pub const EMBEDDED_SIGNATURE: &str = include_str!("../../sounds/registry.json.sig");
25/// Public half of the registry signing key (base64, 32 bytes). This is the development
26/// key; release builds are signed in CI with a key held only as a CI secret.
27pub const REGISTRY_PUBLIC_KEY: &str = include_str!("../../sounds/registry-public-key.txt");
28/// Where `sounds update` looks for a newer manifest (`<url>` and `<url>.sig`).
29pub const DEFAULT_REGISTRY_URL: &str = "https://djynnius.github.io/ferraus/sounds/registry.json";
30
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
32pub struct License {
33    pub spdx: String,
34    #[serde(default)]
35    pub text: String,
36    #[serde(default)]
37    pub url: String,
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
41pub struct Download {
42    /// `macos`, `linux`, `windows`, or `any`.
43    pub platform: String,
44    pub url: String,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub mirrors: Vec<String>,
47    /// `zip`, `tar.gz`, `tar.xz`, `tar`, `sf2`, `sfz`, `wav`, `none`, or an installer type
48    /// (`dmg`, `pkg`, `exe`, `deb`) that ferraus refuses to run.
49    pub archive: String,
50    #[serde(default)]
51    pub size_bytes: Option<u64>,
52    #[serde(default)]
53    pub sha256: Option<String>,
54    /// Where the useful files sit inside the archive (informational).
55    #[serde(default, skip_serializing_if = "String::is_empty")]
56    pub contents: String,
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
60pub struct InstallFrom {
61    pub url: String,
62    #[serde(default)]
63    pub note: String,
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
67pub struct Entry {
68    pub id: String,
69    pub name: String,
70    #[serde(rename = "type")]
71    pub kind: SoundKind,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub formats: Vec<String>,
74    #[serde(default)]
75    pub version: Option<String>,
76    #[serde(default)]
77    pub homepage: String,
78    #[serde(default)]
79    pub description: String,
80    pub license: License,
81    #[serde(default)]
82    pub platforms: Vec<String>,
83    #[serde(default)]
84    pub downloads: Vec<Download>,
85    /// `verified` (every download hashed), `unverified`, or `vendor` (install --from only).
86    pub status: String,
87    #[serde(default)]
88    pub category: Option<String>,
89    #[serde(default)]
90    pub tags: Vec<String>,
91    /// Instrument names this entry is a good default for (`sounds alias piano=<id>`).
92    #[serde(default)]
93    pub aliases: Vec<String>,
94    #[serde(default)]
95    pub install_from: Option<InstallFrom>,
96    #[serde(default, skip_serializing_if = "String::is_empty")]
97    pub notes: String,
98}
99
100impl Entry {
101    /// The downloads to fetch on this platform (all parts), or `None` when the entry has
102    /// nothing for it.
103    pub fn downloads_for(&self, os: &str) -> Vec<&Download> {
104        let own: Vec<&Download> = self.downloads.iter().filter(|d| d.platform == os).collect();
105        if !own.is_empty() {
106            return own;
107        }
108        self.downloads.iter().filter(|d| d.platform == "any").collect()
109    }
110
111    pub fn size_for(&self, os: &str) -> Option<u64> {
112        let d = self.downloads_for(os);
113        if d.is_empty() || d.iter().any(|d| d.size_bytes.is_none()) {
114            return None;
115        }
116        Some(d.iter().filter_map(|d| d.size_bytes).sum())
117    }
118
119    pub fn verified_for(&self, os: &str) -> bool {
120        let d = self.downloads_for(os);
121        !d.is_empty() && d.iter().all(|d| d.sha256.as_deref().map(|s| s.len() == 64).unwrap_or(false))
122    }
123
124    pub fn is_vendor_only(&self) -> bool {
125        self.install_from.is_some() && self.downloads.is_empty()
126    }
127}
128
129#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
130pub struct Manifest {
131    pub schema: u32,
132    /// ISO date; a cached manifest replaces the embedded one only when newer.
133    pub updated: String,
134    pub entries: Vec<Entry>,
135}
136
137impl Manifest {
138    /// Look up by id, then name, then a suggested alias (`piano`), preferring entries
139    /// that are verified for this platform.
140    pub fn get(&self, id: &str) -> Option<&Entry> {
141        self.entries
142            .iter()
143            .find(|e| e.id == id)
144            .or_else(|| self.entries.iter().find(|e| e.id.eq_ignore_ascii_case(id)))
145            .or_else(|| self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(id)))
146            .or_else(|| {
147                let os = current_os();
148                let mut hits: Vec<&Entry> =
149                    self.entries.iter().filter(|e| e.aliases.iter().any(|a| a.eq_ignore_ascii_case(id))).collect();
150                hits.sort_by_key(|e| (!e.verified_for(os), e.is_vendor_only()));
151                hits.first().copied()
152            })
153    }
154
155    /// Entries matching every word of `query` in id, name, tags, category, aliases, type,
156    /// or license; exact id/name/alias hits first.
157    pub fn search(&self, query: &str) -> Vec<&Entry> {
158        let words: Vec<String> = query.to_ascii_lowercase().split_whitespace().map(|s| s.to_string()).collect();
159        let mut scored: Vec<(u32, &Entry)> = Vec::new();
160        for e in &self.entries {
161            let hay = format!(
162                "{} {} {} {} {} {} {} {}",
163                e.id,
164                e.name,
165                e.tags.join(" "),
166                e.category.clone().unwrap_or_default(),
167                e.aliases.join(" "),
168                e.kind.as_str(),
169                e.formats.join(" "),
170                e.license.spdx
171            )
172            .to_ascii_lowercase();
173            if !words.iter().all(|w| hay.contains(w.as_str())) {
174                continue;
175            }
176            let q = query.trim().to_ascii_lowercase();
177            let score = if e.id == q || e.name.to_ascii_lowercase() == q {
178                0
179            } else if e.aliases.iter().any(|a| a == &q) {
180                1
181            } else if e.tags.iter().any(|t| t == &q) || e.category.as_deref() == Some(q.as_str()) {
182                2
183            } else {
184                3
185            };
186            scored.push((score, e));
187        }
188        scored.sort_by_key(|(s, e)| (*s, e.id.clone()));
189        scored.into_iter().map(|(_, e)| e).collect()
190    }
191}
192
193/// The running OS as the registry names it.
194pub fn current_os() -> &'static str {
195    match std::env::consts::OS {
196        "macos" => "macos",
197        "windows" => "windows",
198        _ => "linux",
199    }
200}
201
202fn decode_key(b64: &str) -> Result<VerifyingKey> {
203    let bytes = base64::engine::general_purpose::STANDARD
204        .decode(b64.trim())
205        .map_err(|e| Error::internal(format!("bad registry public key: {e}")))?;
206    let arr: [u8; 32] = bytes.try_into().map_err(|_| Error::internal("registry public key must be 32 bytes"))?;
207    VerifyingKey::from_bytes(&arr).map_err(|e| Error::internal(format!("bad registry public key: {e}")))
208}
209
210/// Verify a detached Ed25519 signature (base64) over `bytes` with a base64 public key.
211pub fn verify_signature(bytes: &[u8], signature_b64: &str, public_key_b64: &str) -> Result<()> {
212    let key = decode_key(public_key_b64)?;
213    let sig = base64::engine::general_purpose::STANDARD
214        .decode(signature_b64.trim())
215        .map_err(|_| Error::verification("registry signature is not valid base64"))?;
216    let sig: [u8; 64] = sig.try_into().map_err(|_| Error::verification("registry signature must be 64 bytes"))?;
217    key.verify(bytes, &Signature::from_bytes(&sig))
218        .map_err(|_| Error::verification("sounds registry signature does not match its contents"))
219}
220
221/// Parse and verify a manifest.
222pub fn parse_signed(bytes: &[u8], signature_b64: &str, public_key_b64: &str) -> Result<Manifest> {
223    verify_signature(bytes, signature_b64, public_key_b64)?;
224    let m: Manifest =
225        serde_json::from_slice(bytes).map_err(|e| Error::verification(format!("sounds registry is malformed: {e}")))?;
226    Ok(m)
227}
228
229fn cached_registry(env: &SoundsEnv) -> PathBuf {
230    env.cache_dir.join("sounds").join("registry.json")
231}
232
233/// The manifest in use: the cached one from `sounds update` when it verifies and is newer
234/// than the embedded one, else the embedded one.
235pub fn load_manifest(env: &SoundsEnv) -> Result<Manifest> {
236    let embedded = parse_signed(EMBEDDED_REGISTRY, EMBEDDED_SIGNATURE, REGISTRY_PUBLIC_KEY)
237        .map_err(|e| Error::internal(format!("embedded sounds registry failed verification: {}", e.message)))?;
238    let cp = cached_registry(env);
239    if let (Ok(b), Ok(s)) = (std::fs::read(&cp), std::fs::read_to_string(cp.with_extension("json.sig"))) {
240        if let Ok(m) = parse_signed(&b, &s, REGISTRY_PUBLIC_KEY) {
241            if m.updated > embedded.updated {
242                return Ok(m);
243            }
244        }
245    }
246    Ok(embedded)
247}
248
249fn human_size(n: Option<u64>) -> String {
250    match n {
251        None => "unknown".into(),
252        Some(b) if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64),
253        Some(b) if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
254        Some(b) => format!("{:.0} KB", b as f64 / 1024.0),
255    }
256}
257
258fn entry_summary(e: &Entry, env: &SoundsEnv) -> Value {
259    let os = current_os();
260    let installed = resolve::read_installed(env).iter().any(|r| r.id == e.id);
261    json!({
262        "id": e.id, "name": e.name, "type": e.kind, "formats": e.formats,
263        "version": e.version, "license": e.license.spdx, "category": e.category,
264        "tags": e.tags, "aliases": e.aliases,
265        "status": if e.is_vendor_only() { "vendor".to_string() } else if e.verified_for(os) { "verified".to_string() } else { e.status.clone() },
266        "available_here": !e.downloads_for(os).is_empty() || e.install_from.is_some(),
267        "size_bytes": e.size_for(os), "size": human_size(e.size_for(os)),
268        "installed": installed,
269        "install_from": e.install_from,
270    })
271}
272
273pub fn ops() -> Vec<OpSpec> {
274    vec![
275        OpSpec::custom("sounds search", Category::Plugins, "Search the sounds registry", sounds_search)
276            .about("Searches the curated registry of free instruments, SoundFonts, SFZ libraries, and impulse responses by name, tag (piano, synth, drums, strings), type, or license. Shows size, license, and whether each entry is verified (has a pinned SHA-256) for this platform.")
277            .positional("query")
278            .param(param("query", ParamKind::Str).help("Words to match in name, id, tags, category, type, or license (empty lists everything)"))
279            .param(param("type", ParamKind::List).help("Only these types: clap, vst3, sf2, sfz, ir"))
280            .param(param("license", ParamKind::Str).help("Only entries whose SPDX license contains this text (e.g. CC0, GPL, MIT)"))
281            .example("ferraus sounds search piano", "Find piano sounds")
282            .example("ferraus sounds search reverb --type ir,clap --license CC0 --json", "CC0 reverbs and impulse responses as JSON")
283            .example("ferraus sounds search strings --type sfz", "String libraries in SFZ format"),
284        OpSpec::custom("sounds info", Category::Plugins, "Show a registry entry's license, size, and downloads", sounds_info)
285            .about("Prints the license text and download size before anything is downloaded, plus the files that would be fetched on this platform and whether they have pinned SHA-256 hashes.")
286            .positional("id")
287            .param(param("id", ParamKind::Str).required().help("Registry id or name (see 'sounds search')"))
288            .example("ferraus sounds info salamander-grand-piano", "License and size of the Salamander piano")
289            .example("ferraus sounds info gm-basic --json", "Entry details as JSON")
290            .example("ferraus sounds info surge-xt", "Version, license, and download size of the Surge XT synthesizer"),
291        OpSpec::custom("sounds install", Category::Plugins, "Download, verify, and install a sound", sounds_install)
292            .about("Downloads a registry entry (resuming partial downloads), verifies its SHA-256, refuses any mismatch, unpacks zip and tar archives, and places plugins in the per-user plugin folder and SoundFonts, SFZ libraries, and impulse responses in <data>/ferraus/sounds/, then rescans. The license is printed first. Downloads need --yes (or auto-download enabled in the config); --offline forbids them. Entries without a pinned hash are refused unless FERRAUS_ALLOW_UNVERIFIED=1. With --from, registers a bundle you downloaded yourself (for free-but-proprietary instruments whose vendors require their own installer); ferraus never runs installers or asks for administrator rights.")
293            .positional("id")
294            .param(param("id", ParamKind::Str).help("Registry id (optional with --from, where it names the entry the file belongs to)"))
295            .param(param("from", ParamKind::Path).help("Install a file or folder you downloaded yourself: a .clap/.vst3 bundle, .sf2, .sfz library folder, IR folder, or a zip/tar archive of those"))
296            .param(param("name", ParamKind::Str).help("Name for a --from install (default: the file name)"))
297            .example("ferraus sounds install gm-basic -y", "Download and install the default General MIDI SoundFont")
298            .example("ferraus sounds install vital --from ~/Downloads/VitalInstaller.zip", "Register a plugin downloaded from its vendor")
299            .example("ferraus sounds install --from ~/Downloads/MyStrings.sf2 --name my-strings", "Install a SoundFont you already have under your own name"),
300        OpSpec::custom("sounds list", Category::Plugins, "List installed sounds, or what a folder contains", sounds_list)
301            .about("Without --path, lists installed sounds and everything in --sound-path folders. With --path, shows what that folder contains (CLAP/VST3 plugins, SF2, SFZ, impulse responses) with the category ferraus inferred for each: keys, strings, guitar, bass, drums, synth, pad, brass, winds, vocal, fx.")
302            .inputs(Inputs::NONE)
303            .param(param("path", ParamKind::Path).help("Folder to inspect instead of the installed sounds"))
304            .param(param("category", ParamKind::Str).help("Only sounds in this category (keys, strings, guitar, bass, drums, synth, pad, brass, winds, vocal, fx)"))
305            .example("ferraus sounds list", "Installed sounds and sound-path folders")
306            .example("ferraus sounds list --path ~/Samples --json", "What a folder holds, with inferred categories, as JSON")
307            .example("ferraus sounds list --category keys", "Only installed keyboard sounds (pianos, electric pianos, organs)"),
308        OpSpec::custom("sounds remove", Category::Plugins, "Remove an installed sound", sounds_remove)
309            .about("Deletes the files an install created (only inside ferraus' sounds folder and the per-user plugin folders) and updates the plugin scan cache.")
310            .positional("id")
311            .param(param("id", ParamKind::Str).required().help("Installed sound id (see 'sounds list')"))
312            .example("ferraus sounds remove gm-basic", "Uninstall the GM SoundFont")
313            .example("ferraus sounds remove dexed --json", "Uninstall a plugin and report the removed paths")
314            .example("ferraus sounds remove salamander-grand-piano", "Free the disk space used by the Salamander piano"),
315        OpSpec::custom("sounds update", Category::Plugins, "Fetch the latest signed sounds registry", sounds_update)
316            .about("Downloads the newest registry manifest and its signature, verifies the Ed25519 signature against the key built into ferraus, and caches it when it is newer than the built-in one. Also reports installed sounds whose registry version changed.")
317            .inputs(Inputs::NONE)
318            .param(param("url", ParamKind::Str).help("Manifest URL (default: the project's published registry, or FERRAUS_SOUNDS_REGISTRY_URL)").advanced())
319            .example("ferraus sounds update", "Refresh the registry")
320            .example("ferraus sounds update --json", "Refresh and report changes as JSON")
321            .example("ferraus sounds update --url https://example.com/sounds/registry.json", "Use a mirror or private registry instead of the published one"),
322        OpSpec::custom("sounds alias", Category::Plugins, "Set what an instrument name resolves to", sounds_alias)
323            .about("Writes [aliases] in <config>/sounds/aliases.toml so --instrument piano or --to piano picks the sound you chose. The target is an installed sound id, a registry id, or an explicit source such as plugin:\"Surge XT\" or sf2:/path/gm.sf2. With no arguments, lists the aliases.")
324            .positional("assignment")
325            .param(param("assignment", ParamKind::Str).repeatable().help("name=target pairs, e.g. piano=salamander-grand-piano"))
326            .param(param("remove", ParamKind::List).help("Alias names to delete"))
327            .example("ferraus sounds alias piano=salamander-grand-piano", "Make 'piano' mean the Salamander Grand Piano")
328            .example("ferraus sounds alias strings=sfz:/libs/vsco/Strings.sfz pad='plugin:\"Surge XT\"'", "Point two names at explicit sources")
329            .example("ferraus sounds alias --remove piano,pad", "Delete two aliases so the names resolve to their defaults again"),
330        OpSpec::custom("sounds scan", Category::Plugins, "Index a folder of sounds", sounds_scan)
331            .about("Indexes a folder once (plugins are probed in a host subprocess) and caches the result in <cache>/sounds/index/. With --watch, keeps running and re-indexes whenever files in the folder change (polling every --interval).")
332            .positional("dir")
333            .param(param("dir", ParamKind::Path).required().help("Folder to index"))
334            .param(param("watch", ParamKind::Flag).help("Keep watching and re-index on changes until interrupted"))
335            .param(param("interval", ParamKind::Time).default("2s").help("Polling interval for --watch"))
336            .param(param("watch-cycles", ParamKind::Int).help("Stop --watch after this many polls (for scripts and tests)").advanced())
337            .example("ferraus sounds scan ~/Samples", "Index a sample folder")
338            .example("ferraus sounds scan ~/Samples --watch --interval 5s", "Re-index whenever the folder changes")
339            .example("ferraus sounds scan /Volumes/Libraries/SFZ --json", "Index an external drive of SFZ libraries and report what was found"),
340    ]
341}
342
343fn sounds_search(a: &Args, _ctx: &mut OpCtx) -> Result<Value> {
344    let env = SoundsEnv::from_env();
345    let m = load_manifest(&env)?;
346    let q = a.str("query").unwrap_or_default();
347    let types: Vec<SoundKind> = a
348        .list("type")
349        .iter()
350        .map(|t| {
351            SoundKind::parse(t).ok_or_else(|| Error::usage(format!("--type: '{t}' is not clap, vst3, sf2, sfz, or ir")))
352        })
353        .collect::<Result<_>>()?;
354    let lic = a.str("license").map(|s| s.to_ascii_lowercase());
355    let hits: Vec<Value> = m
356        .search(&q)
357        .into_iter()
358        .filter(|e| {
359            types.is_empty()
360                || types.contains(&e.kind)
361                || e.formats.iter().any(|f| types.iter().any(|t| t.as_str() == f))
362        })
363        .filter(|e| lic.as_ref().map(|l| e.license.spdx.to_ascii_lowercase().contains(l)).unwrap_or(true))
364        .map(|e| entry_summary(e, &env))
365        .collect();
366    Ok(json!({"query": q, "count": hits.len(), "results": hits, "registry_updated": m.updated}))
367}
368
369fn find_entry<'a>(m: &'a Manifest, id: &str) -> Result<&'a Entry> {
370    m.get(id).ok_or_else(|| {
371        let near: Vec<&str> = m.search(id).iter().take(5).map(|e| e.id.as_str()).collect();
372        Error::usage(format!(
373            "no sound '{id}' in the registry{}",
374            if near.is_empty() { String::new() } else { format!("; did you mean: {}", near.join(", ")) }
375        ))
376        .with_hint(format!("ferraus sounds search {id}"))
377    })
378}
379
380fn license_block(e: &Entry) -> String {
381    format!(
382        "{} — license {}{}\n{}",
383        e.name,
384        e.license.spdx,
385        if e.license.url.is_empty() { String::new() } else { format!(" ({})", e.license.url) },
386        e.license.text
387    )
388}
389
390fn sounds_info(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
391    let env = SoundsEnv::from_env();
392    let m = load_manifest(&env)?;
393    let e = find_entry(&m, &a.str("id").unwrap_or_default())?;
394    ctx.progress.log(&license_block(e));
395    let os = current_os();
396    let mut v = entry_summary(e, &env);
397    v["license"] = serde_json::to_value(&e.license)?;
398    v["license_text"] = json!(license_block(e));
399    v["homepage"] = json!(e.homepage);
400    v["description"] = json!(e.description);
401    v["notes"] = json!(e.notes);
402    v["platform"] = json!(os);
403    v["downloads"] = serde_json::to_value(e.downloads_for(os))?;
404    v["all_downloads"] = serde_json::to_value(&e.downloads)?;
405    Ok(v)
406}
407
408fn sounds_install(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
409    let env = SoundsEnv::from_env();
410    let m = load_manifest(&env)?;
411    let id = a.str("id");
412    if let Some(from) = a.path("from") {
413        ctx.check_allowed(&from)?;
414        let entry = match &id {
415            Some(i) => Some(find_entry(&m, i)?.clone()),
416            None => None,
417        };
418        let rec = install::install_from(&env, &from, entry.as_ref(), a.str("name").as_deref(), ctx.globals.dry_run)?;
419        return Ok(json!({"installed": rec, "source": "user", "dry_run": ctx.globals.dry_run}));
420    }
421    let id = id.ok_or_else(|| {
422        Error::usage("name a registry id, or pass --from <file>").with_hint("ferraus sounds search piano")
423    })?;
424    let e = find_entry(&m, &id)?.clone();
425    if e.id != id {
426        ctx.note("resolved_id", e.id.clone());
427    }
428    ctx.progress.log(&license_block(&e));
429    let opts = install::InstallOptions {
430        yes: ctx.globals.yes || std::env::var("FERRAUS_AUTO_DOWNLOAD").map(|v| v == "1").unwrap_or(false),
431        offline: ctx.globals.offline || std::env::var("FERRAUS_OFFLINE").map(|v| v == "1").unwrap_or(false),
432        allow_unverified: std::env::var("FERRAUS_ALLOW_UNVERIFIED").map(|v| v == "1").unwrap_or(false),
433        dry_run: ctx.globals.dry_run,
434        os: current_os().to_string(),
435        watchdog: default_watchdog(),
436    };
437    let progress = ctx.progress;
438    let command = ctx.command.clone();
439    let rec = install::install_entry(&env, &e, &opts, &mut |f| progress.progress(&command, f))?;
440    if let Some(r) = &rec {
441        // installed paths are reported in the result, not as outputs: a sidecar has no
442        // place inside the sounds folder
443        ctx.metric("installed_paths", r.paths.len());
444        // `sounds install piano` also makes `piano` resolve to what was installed
445        if e.aliases.iter().any(|a| a.eq_ignore_ascii_case(&id)) && !id.eq_ignore_ascii_case(&e.id) {
446            let mut aliases = resolve::read_aliases(&env);
447            if !aliases.contains_key(&id) {
448                aliases.insert(id.clone(), e.id.clone());
449                resolve::write_aliases(&env, &aliases)?;
450                ctx.note("alias_set", format!("{id}={}", e.id));
451            }
452        }
453    }
454    Ok(json!({
455        "id": e.id,
456        "license": e.license,
457        "license_text": license_block(&e),
458        "installed": rec,
459        "dry_run": ctx.globals.dry_run,
460        "plan": if rec.is_none() { serde_json::to_value(e.downloads_for(current_os()))? } else { Value::Null },
461    }))
462}
463
464fn sound_json(s: &resolve::Sound) -> Value {
465    json!({
466        "id": s.id, "name": s.name, "type": s.kind, "category": s.category,
467        "instrument": s.instrument, "path": s.path.display().to_string(),
468        "plugin_id": s.plugin_id, "source": s.source(), "origin": s.origin,
469        "registry_id": s.registry_id, "tags": s.tags,
470    })
471}
472
473fn sounds_list(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
474    let env = SoundsEnv::from_env();
475    let cat = a.str("category").map(|c| c.to_ascii_lowercase());
476    let keep = |s: &resolve::Sound| cat.as_ref().map(|c| s.category.as_deref() == Some(c.as_str())).unwrap_or(true);
477    if let Some(dir) = a.path("path") {
478        ctx.check_allowed(&dir)?;
479        let sounds = resolve::discover_folder(&env, &dir, default_watchdog())?;
480        let list: Vec<Value> = sounds.iter().filter(|s| keep(s)).map(sound_json).collect();
481        let mut by_cat: BTreeMap<String, usize> = BTreeMap::new();
482        for s in sounds.iter().filter(|s| keep(s)) {
483            *by_cat.entry(s.category.clone().unwrap_or_else(|| "unknown".into())).or_default() += 1;
484        }
485        return Ok(
486            json!({"path": dir.display().to_string(), "count": list.len(), "categories": by_cat, "sounds": list}),
487        );
488    }
489    let installed = resolve::read_installed(&env);
490    let sounds = resolve::discover(&env, &ctx.globals.sound_paths, default_watchdog())?;
491    let list: Vec<Value> = sounds
492        .iter()
493        .filter(|s| s.origin != "plugin-scan" || s.instrument)
494        .filter(|s| keep(s))
495        .map(sound_json)
496        .collect();
497    Ok(json!({
498        "installed": installed,
499        "sounds": list,
500        "count": list.len(),
501        "sounds_dir": env.sounds_dir.display().to_string(),
502        "aliases": resolve::read_aliases(&env),
503    }))
504}
505
506fn sounds_remove(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
507    let env = SoundsEnv::from_env();
508    let id = a.str("id").unwrap_or_default();
509    let removed = install::remove(&env, &id, ctx.globals.dry_run, default_watchdog())?;
510    Ok(json!({"id": id, "removed": removed, "dry_run": ctx.globals.dry_run}))
511}
512
513fn sounds_update(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
514    let env = SoundsEnv::from_env();
515    let url = a
516        .str("url")
517        .or_else(|| std::env::var("FERRAUS_SOUNDS_REGISTRY_URL").ok())
518        .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
519    if ctx.globals.offline || std::env::var("FERRAUS_OFFLINE").map(|v| v == "1").unwrap_or(false) {
520        return Err(Error::new(ErrorKind::Network, "offline", "cannot update the sounds registry with --offline"));
521    }
522    let before = load_manifest(&env)?;
523    if ctx.globals.dry_run {
524        return Ok(json!({"url": url, "current": before.updated, "dry_run": true}));
525    }
526    let body = install::http_get_small(&url)?;
527    let sig = install::http_get_small(&format!("{url}.sig"))?;
528    let sig = String::from_utf8_lossy(&sig).to_string();
529    let m = parse_signed(&body, &sig, REGISTRY_PUBLIC_KEY)?;
530    let newer = m.updated > before.updated;
531    if newer {
532        let cp = cached_registry(&env);
533        if let Some(p) = cp.parent() {
534            std::fs::create_dir_all(p)?;
535        }
536        std::fs::write(&cp, &body)?;
537        std::fs::write(cp.with_extension("json.sig"), sig.as_bytes())?;
538    }
539    let installed = resolve::read_installed(&env);
540    let changed: Vec<Value> = installed
541        .iter()
542        .filter_map(|r| {
543            let e = m.get(&r.id)?;
544            let v = e.version.clone().unwrap_or_default();
545            (v != r.version && !v.is_empty()).then(|| json!({"id": r.id, "installed": r.version, "available": v}))
546        })
547        .collect();
548    Ok(
549        json!({"url": url, "previous": before.updated, "updated": m.updated, "replaced": newer, "entries": m.entries.len(), "upgrades": changed}),
550    )
551}
552
553fn sounds_alias(a: &Args, _ctx: &mut OpCtx) -> Result<Value> {
554    let env = SoundsEnv::from_env();
555    let mut aliases = resolve::read_aliases(&env);
556    let mut changed = Vec::new();
557    let mut warnings = Vec::new();
558    let manifest = load_manifest(&env).ok();
559    let installed = resolve::read_installed(&env);
560    // `lead=Ferraus Test Sine` arrives as one argument; words without '=' continue the
561    // previous target so names with spaces work
562    let mut pairs: Vec<String> = Vec::new();
563    for assign in a.all("assignment") {
564        for part in assign.split_whitespace().filter(|s| !s.is_empty()) {
565            match pairs.last_mut() {
566                Some(last) if !part.contains('=') => {
567                    last.push(' ');
568                    last.push_str(part);
569                }
570                _ => pairs.push(part.to_string()),
571            }
572        }
573    }
574    {
575        for part in pairs.iter().map(|s| s.as_str()) {
576            let (k, v) = part.split_once('=').ok_or_else(|| {
577                Error::usage(format!("'{part}' must be name=target, e.g. piano=salamander-grand-piano"))
578            })?;
579            let (k, v) = (k.trim(), v.trim().trim_matches('\''));
580            if k.is_empty() || v.is_empty() {
581                return Err(Error::usage(format!("'{part}' must be name=target")));
582            }
583            let known = v.contains(':')
584                || installed.iter().any(|r| r.id == v)
585                || manifest.as_ref().map(|m| m.get(v).is_some()).unwrap_or(false);
586            if !known {
587                warnings.push(format!("'{v}' is not an installed sound, registry id, or explicit source yet"));
588            }
589            aliases.insert(k.to_string(), v.to_string());
590            changed.push(json!({"name": k, "target": v}));
591        }
592    }
593    let mut removed = Vec::new();
594    for r in a.list("remove") {
595        if aliases.remove(&r).is_some() {
596            removed.push(r);
597        }
598    }
599    if !changed.is_empty() || !removed.is_empty() {
600        resolve::write_aliases(&env, &aliases)?;
601    }
602    for w in &warnings {
603        _ctx.warn("alias_target_unknown", w.clone());
604    }
605    Ok(
606        json!({"aliases": aliases, "set": changed, "removed": removed, "file": env.aliases_file().display().to_string()}),
607    )
608}
609
610/// A cheap fingerprint of a folder tree (paths, sizes, mtimes).
611pub fn tree_stamp(dir: &Path) -> u64 {
612    use std::hash::{Hash, Hasher};
613    let mut h = std::collections::hash_map::DefaultHasher::new();
614    fn rec(d: &Path, depth: usize, h: &mut std::collections::hash_map::DefaultHasher) {
615        if depth > 10 {
616            return;
617        }
618        let Ok(rd) = std::fs::read_dir(d) else { return };
619        let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
620        v.sort();
621        for p in v {
622            p.hash(h);
623            if let Ok(m) = std::fs::metadata(&p) {
624                m.len().hash(h);
625                if let Ok(t) = m.modified() {
626                    t.hash(h);
627                }
628                if m.is_dir() {
629                    rec(&p, depth + 1, h);
630                }
631            }
632        }
633    }
634    rec(dir, 0, &mut h);
635    h.finish()
636}
637
638/// Index a folder into `<cache>/sounds/index/<hash>.json`.
639pub fn index_folder(env: &SoundsEnv, dir: &Path) -> Result<(PathBuf, Vec<resolve::Sound>)> {
640    let sounds = resolve::discover_folder(env, dir, default_watchdog())?;
641    let abs = ferraus_core::ctx::absolutize(dir);
642    use sha2::Digest;
643    let key = hex::encode(&sha2::Sha256::digest(abs.to_string_lossy().as_bytes())[..8]);
644    let f = env.index_dir().join(format!("{key}.json"));
645    std::fs::create_dir_all(env.index_dir())?;
646    std::fs::write(
647        &f,
648        serde_json::to_vec_pretty(
649            &json!({"dir": abs.display().to_string(), "stamp": tree_stamp(dir), "sounds": sounds}),
650        )?,
651    )?;
652    Ok((f, sounds))
653}
654
655fn sounds_scan(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
656    let env = SoundsEnv::from_env();
657    let dir = a.path("dir").ok_or_else(|| Error::usage("name a folder to scan"))?;
658    ctx.check_allowed(&dir)?;
659    if !dir.is_dir() {
660        return Err(Error::new(
661            ErrorKind::InputMissing,
662            "input_not_found",
663            format!("folder not found: {}", dir.display()),
664        ));
665    }
666    let (mut index, mut sounds) = index_folder(&env, &dir)?;
667    let mut reindexed = 0usize;
668    if a.flag("watch") {
669        let interval = std::time::Duration::from_secs_f64(a.seconds("interval")?.max(0.05));
670        let cycles = if a.has("watch-cycles") { Some(a.usize("watch-cycles")?) } else { None };
671        let mut stamp = tree_stamp(&dir);
672        let mut n = 0usize;
673        ctx.progress.log(&format!("watching {} (Ctrl-C to stop)", dir.display()));
674        loop {
675            if cycles.map(|c| n >= c).unwrap_or(false) || ctx.check_cancel().is_err() {
676                break;
677            }
678            std::thread::sleep(interval);
679            n += 1;
680            let s = tree_stamp(&dir);
681            if s != stamp {
682                stamp = s;
683                let r = index_folder(&env, &dir)?;
684                index = r.0;
685                sounds = r.1;
686                reindexed += 1;
687                ctx.progress.log(&format!("re-indexed {}: {} sounds", dir.display(), sounds.len()));
688            }
689        }
690    }
691    Ok(json!({
692        "dir": dir.display().to_string(),
693        "index": index.display().to_string(),
694        "count": sounds.len(),
695        "reindexed": reindexed,
696        "sounds": sounds.iter().map(sound_json).collect::<Vec<_>>(),
697    }))
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn embedded_registry_verifies_and_parses() {
706        let m = parse_signed(EMBEDDED_REGISTRY, EMBEDDED_SIGNATURE, REGISTRY_PUBLIC_KEY).unwrap();
707        assert!(m.entries.len() >= 12);
708        for id in ["surge-xt", "gm-basic", "ir-pack-basic", "salamander-grand-piano", "vital"] {
709            assert!(m.get(id).is_some(), "{id} missing");
710        }
711        // every verified entry really has hashes; unverified ones never claim them
712        for e in &m.entries {
713            match e.status.as_str() {
714                "verified" => assert!(e.downloads.iter().all(|d| d.sha256.is_some()), "{}", e.id),
715                "vendor" => assert!(e.install_from.is_some(), "{}", e.id),
716                _ => {}
717            }
718            assert!(!e.license.spdx.is_empty() && !e.license.text.is_empty(), "{}", e.id);
719        }
720        assert_eq!(m.search("piano")[0].id, "salamander-grand-piano");
721    }
722
723    #[test]
724    fn tampered_registry_is_rejected() {
725        let mut bytes = EMBEDDED_REGISTRY.to_vec();
726        let i = bytes.iter().position(|b| *b == b'G').unwrap();
727        bytes[i] = b'H';
728        let e = parse_signed(&bytes, EMBEDDED_SIGNATURE, REGISTRY_PUBLIC_KEY).unwrap_err();
729        assert_eq!(e.kind, ErrorKind::Verification);
730    }
731}