aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Abrir direcciones y archivos con el sistema: navegador, gestor de archivos y papelera.

use std::path::Path;
use std::process::Child;

/// Solo HTTP(S) y sin caracteres que un intérprete o el propio `open` puedan tomar por otra cosa: un esquema `file:`
/// o una opción que empiece por `-` ejecutaría o abriría algo distinto a una web. `&` queda permitido: las URL de
/// OAuth llevan varios parámetros y ningún destino pasa por un intérprete.
pub fn is_safe_url(url: &str) -> bool {
    (url.starts_with("https://") || url.starts_with("http://"))
        && !url.chars().any(|c| c.is_control() || c.is_whitespace() || c == '"')
}

/// Abre `url` en el navegador predeterminado. Con `background` no le quita el foco a la app (solo macOS puede).
pub fn open_url(url: &str, background: bool) -> Result<(), String> {
    if !is_safe_url(url) {
        return Err("solo se abren direcciones http(s) sin caracteres especiales".into());
    }
    super::sys::open_url(url, background)
}

/// Muestra `path` en el gestor de archivos: seleccionado en macOS y Windows; en Linux, su carpeta.
pub fn reveal(path: &Path) -> Result<(), String> {
    ensure_exists(path)?;
    super::sys::reveal(path)
}

/// Manda `path` (archivo o carpeta) a la papelera del sistema, de donde se puede restaurar.
pub fn trash(path: &Path) -> Result<(), String> {
    ensure_exists(path)?;
    super::sys::trash(path).map_err(|error| format!("no se pudo mandar a la papelera: {error}"))
}

fn ensure_exists(path: &Path) -> Result<(), String> {
    if path.exists() { Ok(()) } else { Err(format!("«{}» ya no existe", path.display())) }
}

/// Recoge el proceso en un hilo mínimo para no dejar zombis; no espera su resultado.
#[cfg_attr(any(windows, target_os = "ios", target_os = "android"), allow(dead_code))]
pub(super) fn reap(mut child: Child) -> Result<(), String> {
    std::thread::Builder::new()
        .name("aura-reap".into())
        .stack_size(64 * 1024)
        .spawn(move || drop(child.wait()))
        .map(drop)
        .map_err(|error| format!("no se pudo vigilar el proceso: {error}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn acepta_urls_http_normales_de_oauth() {
        assert!(is_safe_url("https://auth.example.com/oauth/authorize?client_id=a&state=b"));
    }

    #[test]
    fn rechaza_esquemas_distintos_y_metacaracteres() {
        let bad = ["file:///etc/passwd", "-a Calculator", "javascript:alert(1)", "https://a.com/ b", "ftp://a.com"];
        for url in bad.into_iter().chain(["https://a.com/\nx", "https://a.com/\"x"]) {
            assert!(!is_safe_url(url), "{url}");
            assert!(open_url(url, false).is_err());
        }
    }

    #[test]
    fn lo_que_no_existe_no_lanza_nada() {
        let missing = std::env::temp_dir().join("aura-no-existe-xyz");
        assert!(reveal(&missing).unwrap_err().contains("ya no existe"));
        assert!(trash(&missing).unwrap_err().contains("ya no existe"));
    }
}