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.
//! Llavero de macOS mediante la herramienta `security` del sistema.

use std::io::Write;
use std::process::{Command, Stdio};

/// Ruta absoluta: `security` recibe las claves y no debe resolverse por un `PATH` heredado que alguien controle.
const SECURITY: &str = "/usr/bin/security";
/// `security -w` devuelve en hexadecimal todo valor con caracteres no ASCII: esos valores se guardan ya en hex.
const HEX: &str = "aura-hex:";

fn encode(value: &str) -> String {
    if value.is_ascii() {
        value.into()
    } else {
        format!("{HEX}{}", value.bytes().map(|b| format!("{b:02x}")).collect::<String>())
    }
}

fn decode(stored: &str) -> String {
    let Some(hex) = stored.strip_prefix(HEX) else { return stored.into() };
    let bytes: Option<Vec<u8>> =
        (0..hex.len() / 2).map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
    bytes.and_then(|bytes| String::from_utf8(bytes).ok()).unwrap_or_else(|| stored.into())
}

/// La clave viaja por la entrada estándar de `security -i`, no como argumento: los argumentos de un proceso los
/// puede leer cualquier otro proceso del mismo usuario (`ps`) mientras dura el comando.
pub fn secret_set(service: &str, account: &str, value: &str) -> Result<(), String> {
    let command = add_command(service, account, &encode(value))?;
    let mut child = Command::new(SECURITY)
        .arg("-i")
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| format!("no se pudo ejecutar security: {error}"))?;
    let written = child.stdin.take().ok_or("security no abrió su entrada estándar")?.write_all(command.as_bytes());
    let output = child.wait_with_output().map_err(|error| format!("security no terminó: {error}"))?;
    written.map_err(|error| format!("no se pudo enviar la clave a security: {error}"))?;
    // En modo interactivo `security` informa los errores de cada comando por stderr y puede salir con 0.
    if output.status.success() && output.stderr.is_empty() {
        Ok(())
    } else {
        // El mensaje de `security` no contiene la clave; la línea de comandos sí, por eso no se incluye.
        Err(String::from_utf8_lossy(&output.stderr).trim().to_owned())
    }
}

/// Línea de `security -i`. Un salto de línea o un NUL en el valor terminaría el comando y permitiría encadenar otro
/// (borrar o leer entradas ajenas), así que se rechazan en vez de escaparlos.
fn add_command(service: &str, account: &str, value: &str) -> Result<String, String> {
    let quote = |text: &str| -> Result<String, String> {
        if text.chars().any(char::is_control) {
            return Err("la clave y el nombre del proveedor no pueden contener caracteres de control".into());
        }
        Ok(format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")))
    };
    Ok(format!("add-generic-password -U -s {} -a {} -w {}\n", quote(service)?, quote(account)?, quote(value)?))
}

pub fn secret_get(service: &str, account: &str) -> Option<String> {
    let output = Command::new(SECURITY)
        .args(["find-generic-password", "-s", service, "-a", account, "-w"])
        .stdin(Stdio::null())
        .stderr(Stdio::null())
        .output()
        .ok()?;
    output.status.success().then(|| decode(String::from_utf8_lossy(&output.stdout).trim()))
}

pub fn secret_remove(service: &str, account: &str) -> Result<(), String> {
    let status = Command::new(SECURITY)
        .args(["delete-generic-password", "-s", service, "-a", account])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map_err(|error| format!("no se pudo ejecutar security: {error}"))?;
    // 44 = «item no encontrado»: borrar algo que no existe no es un error.
    if status.success() || status.code() == Some(44) { Ok(()) } else { Err(format!("security terminó con {status}")) }
}

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

    #[test]
    fn los_valores_no_ascii_viajan_en_hex_y_vuelven_iguales() {
        let value = "tokén€\"x";
        let stored = encode(value);
        assert!(stored.is_ascii());
        assert_eq!(decode(&stored), value);
        assert_eq!(encode("abc"), "abc");
        assert_eq!(decode("abc"), "abc");
        assert_eq!(decode("aura-hex:zz"), "aura-hex:zz");
    }

    #[test]
    fn la_linea_de_security_escapa_comillas_y_barras() {
        let line = add_command("aura-ai", "anthropic", r#"p"a\ss w0rd$`x"#).unwrap();
        assert_eq!(line, "add-generic-password -U -s \"aura-ai\" -a \"anthropic\" -w \"p\\\"a\\\\ss w0rd$`x\"\n");
        assert_eq!(line.matches('\n').count(), 1);
    }

    #[test]
    fn un_salto_de_linea_no_puede_encadenar_otro_comando() {
        assert!(add_command("aura-ai", "openai", "abc\ndelete-generic-password -s x").is_err());
        assert!(add_command("aura-ai", "openai", "abc\rx").is_err());
        assert!(add_command("aura-ai", "openai", "abc\0x").is_err());
        assert!(add_command("aura-ai", "open\nai", "abc").is_err());
    }
}