Skip to main content

ferraus_plugins/sounds/
install.rs

1//! Download (with resume), SHA-256 verification, archive extraction, and placement of
2//! registry sounds. Plugins go to the per-user plugin folders; SoundFonts, SFZ libraries,
3//! and impulse responses to `<data>/ferraus/sounds/<id>/`.
4
5use super::{Download, Entry};
6use crate::client::PluginFormat;
7use crate::resolve::{read_installed, write_installed, InstalledRecord, SoundKind, SoundsEnv};
8use crate::scan::{self, ScanOptions};
9use ferraus_core::{Error, ErrorKind, Result};
10use sha2::{Digest, Sha256};
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15const INSTALLERS: &[&str] = &["dmg", "pkg", "exe", "msi", "deb", "rpm", "appimage"];
16
17#[derive(Clone, Debug)]
18pub struct InstallOptions {
19    /// Downloads allowed (`--yes` or auto-download in the config).
20    pub yes: bool,
21    pub offline: bool,
22    /// `FERRAUS_ALLOW_UNVERIFIED=1`: allow entries without a pinned SHA-256.
23    pub allow_unverified: bool,
24    pub dry_run: bool,
25    /// `macos`, `linux`, or `windows`.
26    pub os: String,
27    pub watchdog: Duration,
28}
29
30fn agent() -> ureq::Agent {
31    ureq::Agent::config_builder()
32        .timeout_connect(Some(Duration::from_secs(30)))
33        .timeout_recv_response(Some(Duration::from_secs(60)))
34        .http_status_as_error(false)
35        .user_agent(concat!("ferraus/", env!("CARGO_PKG_VERSION")))
36        .build()
37        .into()
38}
39
40fn net_err(url: &str, e: impl std::fmt::Display) -> Error {
41    Error::new(ErrorKind::Network, "download_failed", format!("cannot download {url}: {e}"))
42        .with_hint("check the network connection and retry; partial downloads resume")
43}
44
45/// GET a small resource (manifests, signatures) into memory (limit 16 MB).
46pub fn http_get_small(url: &str) -> Result<Vec<u8>> {
47    let mut resp = agent().get(url).call().map_err(|e| net_err(url, e))?;
48    let status = resp.status().as_u16();
49    if status != 200 {
50        return Err(net_err(url, format!("HTTP {status}")));
51    }
52    resp.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| net_err(url, e))
53}
54
55/// Download `url` to `dest`, resuming from an existing partial file with a Range request.
56pub fn download(url: &str, dest: &Path, progress: &mut dyn FnMut(u64, Option<u64>)) -> Result<()> {
57    if let Some(p) = dest.parent() {
58        std::fs::create_dir_all(p)?;
59    }
60    let have = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
61    let mut req = agent().get(url);
62    if have > 0 {
63        req = req.header("Range", format!("bytes={have}-"));
64    }
65    let resp = req.call().map_err(|e| net_err(url, e))?;
66    let status = resp.status().as_u16();
67    let len = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
68    let (mut file, mut done, total) = match status {
69        206 if have > 0 => (std::fs::OpenOptions::new().append(true).open(dest)?, have, len.map(|l| l + have)),
70        200 => (std::fs::File::create(dest)?, 0, len),
71        416 if have > 0 => return Ok(()), // already complete
72        s => return Err(net_err(url, format!("HTTP {s}"))),
73    };
74    let mut reader = resp.into_body().into_reader();
75    let mut buf = vec![0u8; 1 << 16];
76    loop {
77        let n = reader.read(&mut buf).map_err(|e| net_err(url, e))?;
78        if n == 0 {
79            break;
80        }
81        file.write_all(&buf[..n])?;
82        done += n as u64;
83        progress(done, total);
84    }
85    file.flush()?;
86    if let Some(t) = total {
87        if done < t {
88            return Err(net_err(url, format!("connection closed after {done} of {t} bytes")));
89        }
90    }
91    Ok(())
92}
93
94pub fn sha256_file(p: &Path) -> Result<String> {
95    let mut f = std::fs::File::open(p)?;
96    let mut h = Sha256::new();
97    let mut buf = vec![0u8; 1 << 16];
98    loop {
99        let n = f.read(&mut buf)?;
100        if n == 0 {
101            break;
102        }
103        h.update(&buf[..n]);
104    }
105    Ok(hex::encode(h.finalize()))
106}
107
108fn url_file_name(url: &str) -> String {
109    let path = url.split(['?', '#']).next().unwrap_or(url);
110    let name = path.rsplit('/').next().unwrap_or("download");
111    let name: String = name.chars().map(|c| if c.is_alphanumeric() || "._-+ ".contains(c) { c } else { '_' }).collect();
112    if name.is_empty() || name == "content" {
113        "download".into()
114    } else {
115        name
116    }
117}
118
119/// Unpack `archive` (of type `kind`) into `dest`. Raw files are copied as `file_name`.
120pub fn extract(archive: &Path, kind: &str, dest: &Path, file_name: &str) -> Result<()> {
121    std::fs::create_dir_all(dest)?;
122    let bad = |e: &dyn std::fmt::Display| {
123        Error::new(ErrorKind::Unsupported, "bad_archive", format!("cannot unpack {}: {e}", archive.display()))
124    };
125    match kind {
126        "zip" => {
127            let f = std::fs::File::open(archive)?;
128            let mut z = zip::ZipArchive::new(f).map_err(|e| bad(&e))?;
129            for i in 0..z.len() {
130                let mut entry = z.by_index(i).map_err(|e| bad(&e))?;
131                let Some(rel) = entry.enclosed_name() else {
132                    continue; // path traversal attempt: skip
133                };
134                let out = dest.join(rel);
135                if entry.is_dir() {
136                    std::fs::create_dir_all(&out)?;
137                    continue;
138                }
139                if let Some(p) = out.parent() {
140                    std::fs::create_dir_all(p)?;
141                }
142                let mut w = std::fs::File::create(&out)?;
143                std::io::copy(&mut entry, &mut w)?;
144                #[cfg(unix)]
145                if let Some(mode) = entry.unix_mode() {
146                    use std::os::unix::fs::PermissionsExt;
147                    let _ = std::fs::set_permissions(&out, std::fs::Permissions::from_mode(mode & 0o777));
148                }
149            }
150        }
151        "tar.gz" | "tgz" | "tar.xz" | "txz" | "tar" => {
152            let f = std::fs::File::open(archive)?;
153            let r: Box<dyn Read> = match kind {
154                "tar.gz" | "tgz" => Box::new(flate2::read::GzDecoder::new(f)),
155                "tar.xz" | "txz" => Box::new(lzma_rust2::XzReader::new(f, true)),
156                _ => Box::new(f),
157            };
158            let mut t = tar::Archive::new(r);
159            for entry in t.entries().map_err(|e| bad(&e))? {
160                let mut entry = entry.map_err(|e| bad(&e))?;
161                // unpack_in refuses paths that escape `dest`
162                entry.unpack_in(dest).map_err(|e| bad(&e))?;
163            }
164        }
165        k if INSTALLERS.contains(&k) => {
166            return Err(Error::unsupported(format!(
167                "{} is an installer ({k}); ferraus never runs installers",
168                archive.display()
169            )))
170        }
171        _ => {
172            std::fs::copy(archive, dest.join(file_name))?;
173        }
174    }
175    Ok(())
176}
177
178fn move_path(from: &Path, to: &Path) -> Result<()> {
179    if let Some(p) = to.parent() {
180        std::fs::create_dir_all(p)?;
181    }
182    if to.exists() {
183        remove_path(to)?;
184    }
185    if std::fs::rename(from, to).is_ok() {
186        return Ok(());
187    }
188    copy_recursive(from, to)?;
189    remove_path(from)
190}
191
192fn copy_recursive(from: &Path, to: &Path) -> Result<()> {
193    if from.is_dir() {
194        std::fs::create_dir_all(to)?;
195        for e in std::fs::read_dir(from)? {
196            let e = e?;
197            copy_recursive(&e.path(), &to.join(e.file_name()))?;
198        }
199    } else {
200        std::fs::copy(from, to)?;
201    }
202    Ok(())
203}
204
205fn remove_path(p: &Path) -> Result<()> {
206    if p.is_dir() {
207        std::fs::remove_dir_all(p)?;
208    } else if p.exists() || p.symlink_metadata().is_ok() {
209        std::fs::remove_file(p)?;
210    }
211    Ok(())
212}
213
214/// If `dir` holds exactly one folder and nothing else, that folder (archives usually wrap
215/// their content in one top-level directory).
216fn content_root(dir: &Path) -> PathBuf {
217    let entries: Vec<PathBuf> =
218        std::fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default();
219    let visible: Vec<&PathBuf> = entries
220        .iter()
221        .filter(|p| {
222            let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
223            !n.starts_with('.') && n != "__MACOSX"
224        })
225        .collect();
226    if visible.len() == 1 && visible[0].is_dir() && PluginFormat::from_path(visible[0]).is_none() {
227        visible[0].clone()
228    } else {
229        dir.to_path_buf()
230    }
231}
232
233/// Move unpacked content from `staging` into place; returns the created paths.
234fn place(env: &SoundsEnv, kind: SoundKind, id: &str, staging: &Path) -> Result<Vec<PathBuf>> {
235    let mut paths = Vec::new();
236    if kind.is_plugin() {
237        let bundles = scan::find_bundles(staging, &[PluginFormat::Clap, PluginFormat::Vst3]);
238        if bundles.is_empty() {
239            return Err(Error::new(
240                ErrorKind::Unsupported,
241                "no_plugin_in_download",
242                format!("the download for '{id}' contains no .clap or .vst3 bundle"),
243            ));
244        }
245        for (f, b) in bundles {
246            let dest = env.plugin_dir(f).join(b.file_name().unwrap());
247            move_path(&b, &dest)?;
248            paths.push(dest);
249        }
250    } else {
251        let root = content_root(staging);
252        let dest = env.sounds_dir.join(id);
253        move_path(&root, &dest)?;
254        paths.push(dest);
255    }
256    Ok(paths)
257}
258
259fn now() -> u64 {
260    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
261}
262
263fn record(env: &SoundsEnv, rec: InstalledRecord) -> Result<()> {
264    let mut all = read_installed(env);
265    all.retain(|r| r.id != rec.id);
266    all.push(rec);
267    all.sort_by(|a, b| a.id.cmp(&b.id));
268    write_installed(env, &all)
269}
270
271fn rescan(env: &SoundsEnv, watchdog: Duration) {
272    let opts = ScanOptions {
273        extra: vec![env.clap_dir.clone(), env.vst3_dir.clone()],
274        standard: false,
275        watchdog,
276        cache_path: env.scan_cache(),
277        ..Default::default()
278    };
279    let _ = scan::scan(&opts);
280}
281
282/// Install a registry entry. Returns `None` for a dry run (nothing downloaded).
283pub fn install_entry(
284    env: &SoundsEnv,
285    e: &Entry,
286    opts: &InstallOptions,
287    progress: &mut dyn FnMut(f64),
288) -> Result<Option<InstalledRecord>> {
289    if e.is_vendor_only() {
290        let url = e.install_from.as_ref().map(|i| i.url.clone()).unwrap_or_default();
291        return Err(Error::new(
292            ErrorKind::Usage,
293            "vendor_install_required",
294            format!("{} is only distributed by its vendor ({url}); download it there, then register the file", e.name),
295        )
296        .with_hint(format!("ferraus sounds install {} --from <downloaded file>", e.id)));
297    }
298    let parts: Vec<&Download> = e.downloads_for(&opts.os);
299    if parts.is_empty() {
300        let mut err = Error::unsupported(format!("{} has no download for {}", e.name, opts.os));
301        if let Some(i) = &e.install_from {
302            err =
303                err.with_hint(format!("get it from {} and run: ferraus sounds install {} --from <file>", i.url, e.id));
304        }
305        return Err(err);
306    }
307    if let Some(d) = parts.iter().find(|d| INSTALLERS.contains(&d.archive.as_str())) {
308        return Err(Error::unsupported(format!(
309            "{} is only available as an installer ({}) on {}; ferraus never runs installers",
310            e.name, d.archive, opts.os
311        ))
312        .with_hint(format!(
313            "install it yourself from {} , or download an archive and run: ferraus sounds install {} --from <file>",
314            if e.homepage.is_empty() { &d.url } else { &e.homepage },
315            e.id
316        )));
317    }
318    if let Some(existing) = read_installed(env).into_iter().find(|r| r.id == e.id) {
319        if existing.paths.iter().all(|p| p.exists()) {
320            return Ok(Some(existing));
321        }
322    }
323    let unverified = parts.iter().any(|d| d.sha256.as_deref().map(|s| s.len() != 64).unwrap_or(true));
324    if unverified && !opts.allow_unverified {
325        return Err(Error::new(
326            ErrorKind::Verification,
327            "sound_unverified",
328            format!("{} has no pinned SHA-256 for {} in the registry, so ferraus will not install it", e.name, opts.os),
329        )
330        .with_hint(format!("FERRAUS_ALLOW_UNVERIFIED=1 ferraus sounds install {} -y", e.id)));
331    }
332    if opts.dry_run {
333        return Ok(None);
334    }
335    if opts.offline {
336        return Err(Error::new(
337            ErrorKind::Network,
338            "offline",
339            format!("{} must be downloaded, but --offline is set", e.name),
340        ));
341    }
342    if !opts.yes {
343        let size: Option<u64> = e.size_for(&opts.os);
344        return Err(Error::new(
345            ErrorKind::Usage,
346            "download_needs_consent",
347            format!(
348                "installing {} downloads {}; pass --yes to allow downloads (or enable auto_download in the config)",
349                e.name,
350                size.map(|s| format!("{:.1} MB", s as f64 / 1048576.0)).unwrap_or_else(|| "an unknown amount".into())
351            ),
352        )
353        .with_hint(format!("ferraus sounds install {} -y", e.id)));
354    }
355    let total: u64 = parts.iter().filter_map(|d| d.size_bytes).sum();
356    let mut base = 0u64;
357    let staging = env.downloads_dir().join(format!("staging-{}-{}", e.id, std::process::id()));
358    let _ = remove_path(&staging);
359    let result = (|| -> Result<Vec<PathBuf>> {
360        let mut files = Vec::new();
361        for (i, d) in parts.iter().enumerate() {
362            let tag = d.sha256.clone().unwrap_or_else(|| format!("{}-{i}", e.id));
363            let part = env.downloads_dir().join(format!("{tag}.part"));
364            let mut last_err = None;
365            let mut ok = false;
366            for url in std::iter::once(&d.url).chain(d.mirrors.iter()) {
367                let mut cb = |done: u64, t: Option<u64>| {
368                    let denom = if total > 0 { total } else { t.unwrap_or(0) };
369                    if denom > 0 {
370                        progress(((base + done) as f64 / denom as f64).min(1.0));
371                    }
372                };
373                match download(url, &part, &mut cb) {
374                    Ok(()) => {
375                        ok = true;
376                        break;
377                    }
378                    Err(err) => last_err = Some(err),
379                }
380            }
381            if !ok {
382                return Err(last_err.unwrap_or_else(|| net_err(&d.url, "no URL")));
383            }
384            let got = sha256_file(&part)?;
385            if let Some(want) = &d.sha256 {
386                if !got.eq_ignore_ascii_case(want) {
387                    let _ = std::fs::remove_file(&part);
388                    return Err(Error::new(
389                        ErrorKind::Verification,
390                        "checksum_mismatch",
391                        format!(
392                            "SHA-256 of {} does not match the registry (expected {want}, got {got}); the file was deleted and nothing was installed",
393                            d.url
394                        ),
395                    ));
396                }
397            }
398            base += std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
399            extract(&part, &d.archive, &staging, &url_file_name(&d.url))?;
400            files.push(part);
401        }
402        let placed = place(env, e.kind, &e.id, &staging)?;
403        for f in files {
404            let _ = std::fs::remove_file(f);
405        }
406        Ok(placed)
407    })();
408    let _ = remove_path(&staging);
409    let paths = result?;
410    let rec = InstalledRecord {
411        id: e.id.clone(),
412        name: e.name.clone(),
413        kind: e.kind,
414        version: e.version.clone().unwrap_or_default(),
415        license: e.license.spdx.clone(),
416        paths,
417        tags: e.tags.clone(),
418        category: e.category.clone(),
419        source: "registry".into(),
420        installed_at: now(),
421        sha256: parts.first().and_then(|d| d.sha256.clone()),
422    };
423    record(env, rec.clone())?;
424    if e.kind.is_plugin() {
425        rescan(env, opts.watchdog);
426    }
427    Ok(Some(rec))
428}
429
430fn infer_kind(p: &Path) -> Option<SoundKind> {
431    let ext = p.extension()?.to_string_lossy().to_ascii_lowercase();
432    match ext.as_str() {
433        "clap" => Some(SoundKind::Clap),
434        "vst3" => Some(SoundKind::Vst3),
435        "sf2" | "sf3" => Some(SoundKind::Sf2),
436        "sfz" => Some(SoundKind::Sfz),
437        "wav" | "aif" | "aiff" | "flac" => Some(SoundKind::Ir),
438        _ => None,
439    }
440}
441
442fn archive_kind(p: &Path) -> Option<&'static str> {
443    let n = p.file_name()?.to_string_lossy().to_ascii_lowercase();
444    if n.ends_with(".zip") {
445        Some("zip")
446    } else if n.ends_with(".tar.gz") || n.ends_with(".tgz") {
447        Some("tar.gz")
448    } else if n.ends_with(".tar.xz") || n.ends_with(".txz") {
449        Some("tar.xz")
450    } else if n.ends_with(".tar") {
451        Some("tar")
452    } else {
453        None
454    }
455}
456
457fn slug(s: &str) -> String {
458    let mut out = String::new();
459    for c in s.chars() {
460        if c.is_alphanumeric() {
461            out.extend(c.to_lowercase());
462        } else if !out.ends_with('-') && !out.is_empty() {
463            out.push('-');
464        }
465    }
466    out.trim_end_matches('-').to_string()
467}
468
469/// What a staged folder contains.
470fn detect_kind(dir: &Path) -> Option<SoundKind> {
471    let b = scan::find_bundles(dir, &[PluginFormat::Clap, PluginFormat::Vst3]);
472    if let Some((f, _)) = b.first() {
473        return Some(if *f == PluginFormat::Clap { SoundKind::Clap } else { SoundKind::Vst3 });
474    }
475    let files = crate::resolve::discover_files(dir, "user");
476    if files.iter().any(|s| s.kind == SoundKind::Sf2) {
477        Some(SoundKind::Sf2)
478    } else if files.iter().any(|s| s.kind == SoundKind::Sfz) {
479        Some(SoundKind::Sfz)
480    } else {
481        let any_audio = walkdir_has_audio(dir, 0);
482        any_audio.then_some(SoundKind::Ir)
483    }
484}
485
486fn walkdir_has_audio(d: &Path, depth: usize) -> bool {
487    if depth > 6 {
488        return false;
489    }
490    std::fs::read_dir(d)
491        .map(|rd| {
492            rd.flatten().any(|e| {
493                let p = e.path();
494                if p.is_dir() {
495                    walkdir_has_audio(&p, depth + 1)
496                } else {
497                    infer_kind(&p) == Some(SoundKind::Ir)
498                }
499            })
500        })
501        .unwrap_or(false)
502}
503
504/// Register a file or folder the user downloaded: a bundle, `.sf2`, `.sfz` library folder,
505/// IR folder, or a zip/tar archive of any of these. Never runs installers.
506pub fn install_from(
507    env: &SoundsEnv,
508    from: &Path,
509    entry: Option<&Entry>,
510    name: Option<&str>,
511    dry_run: bool,
512) -> Result<InstalledRecord> {
513    if !from.exists() {
514        return Err(Error::new(ErrorKind::InputMissing, "input_not_found", format!("not found: {}", from.display())));
515    }
516    let ext = from.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
517    if INSTALLERS.contains(&ext.as_str()) {
518        return Err(Error::unsupported(format!(
519            "{} is an installer; ferraus never runs installers. Run it yourself, then 'ferraus plugin scan' finds the plugin",
520            from.display()
521        ))
522        .with_hint("ferraus plugin scan"));
523    }
524    let display = name.map(|s| s.to_string()).or_else(|| entry.map(|e| e.name.clone())).unwrap_or_else(|| {
525        let n = from.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
526        n.split('.').next().unwrap_or(&n).to_string()
527    });
528    let id = entry.map(|e| e.id.clone()).unwrap_or_else(|| slug(&display));
529    let staging = env.downloads_dir().join(format!("staging-from-{id}-{}", std::process::id()));
530    let _ = remove_path(&staging);
531    std::fs::create_dir_all(&staging)?;
532    let result = (|| -> Result<(SoundKind, Vec<PathBuf>)> {
533        if let Some(k) = archive_kind(from) {
534            extract(from, k, &staging, "")?;
535        } else {
536            let target = staging.join(from.file_name().unwrap());
537            copy_recursive(from, &target)?;
538        }
539        let kind = entry
540            .map(|e| e.kind)
541            .filter(|k| !k.is_plugin() || detect_kind(&staging).map(|d| d.is_plugin()).unwrap_or(false))
542            .or_else(|| detect_kind(&staging))
543            .ok_or_else(|| {
544                Error::unsupported(format!(
545                    "{} contains no .clap/.vst3 bundle, .sf2, .sfz, or audio files",
546                    from.display()
547                ))
548            })?;
549        if dry_run {
550            return Ok((kind, Vec::new()));
551        }
552        let paths = place(env, kind, &id, &staging)?;
553        Ok((kind, paths))
554    })();
555    let _ = remove_path(&staging);
556    let (kind, paths) = result?;
557    let rec = InstalledRecord {
558        id: id.clone(),
559        name: display,
560        kind,
561        version: entry.and_then(|e| e.version.clone()).unwrap_or_default(),
562        license: entry.map(|e| e.license.spdx.clone()).unwrap_or_else(|| "unknown".into()),
563        paths,
564        tags: entry.map(|e| e.tags.clone()).unwrap_or_default(),
565        category: entry
566            .and_then(|e| e.category.clone())
567            .or_else(|| crate::resolve::infer_category(&id).map(|s| s.to_string())),
568        source: "user".into(),
569        installed_at: now(),
570        sha256: if from.is_file() { sha256_file(from).ok() } else { None },
571    };
572    if !dry_run {
573        record(env, rec.clone())?;
574        if kind.is_plugin() {
575            rescan(env, crate::client::default_watchdog());
576        }
577    }
578    Ok(rec)
579}
580
581/// Remove an installed sound. Only paths inside ferraus' sounds folder or the per-user
582/// plugin folders are deleted.
583pub fn remove(env: &SoundsEnv, id: &str, dry_run: bool, watchdog: Duration) -> Result<Vec<PathBuf>> {
584    let mut all = read_installed(env);
585    let Some(i) = all.iter().position(|r| r.id == id || r.name.eq_ignore_ascii_case(id)) else {
586        return Err(Error::usage(format!("'{id}' is not installed")).with_hint("ferraus sounds list"));
587    };
588    let rec = all[i].clone();
589    let roots = [&env.sounds_dir, &env.clap_dir, &env.vst3_dir];
590    let mut removed = Vec::new();
591    for p in &rec.paths {
592        let abs = ferraus_core::ctx::absolutize(p);
593        if !roots.iter().any(|r| abs.starts_with(ferraus_core::ctx::absolutize(r))) {
594            return Err(Error::usage(format!(
595                "refusing to delete {}: it is outside ferraus' sounds and plugin folders",
596                p.display()
597            )));
598        }
599        if !dry_run {
600            remove_path(p)?;
601        }
602        removed.push(p.clone());
603    }
604    if !dry_run {
605        all.remove(i);
606        write_installed(env, &all)?;
607        if rec.kind.is_plugin() {
608            rescan(env, watchdog);
609        }
610    }
611    Ok(removed)
612}