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.
//! Secretos (claves de API, tokens de sesión) en el almacén seguro del sistema: llavero de macOS, Administrador de
//! credenciales de Windows o Secret Service de Linux (`secret-tool`). Nunca en archivos ni en el log.

/// Máximo por entrada: `security -i` rechaza líneas de ~4 KB (con escapes se duplica) y una credencial de Windows
/// admite 2560 bytes. Un token de OAuth puede superarlo, así que lo largo se reparte en `cuenta`, `cuenta#1`…
const CHUNK: usize = 1800;
/// Marca del primer fragmento de un valor repartido. Un valor corto se guarda tal cual, sin marca.
const MARK: &str = "aura-chunks:";

fn slot(account: &str, index: usize) -> String {
    if index == 0 { account.into() } else { format!("{account}#{index}") }
}

/// Parte `value` en trozos de hasta `CHUNK` bytes sin cortar un carácter por la mitad.
fn split(value: &str) -> Vec<&str> {
    let mut parts = Vec::new();
    let mut rest = value;
    while rest.len() > CHUNK {
        let cut = (0..=CHUNK).rev().find(|&at| rest.is_char_boundary(at)).unwrap_or(CHUNK);
        let (head, tail) = rest.split_at(cut);
        parts.push(head);
        rest = tail;
    }
    parts.push(rest);
    parts
}

/// Guarda o reemplaza `value` bajo (`service`, `account`).
pub fn set(service: &str, account: &str, value: &str) -> Result<(), String> {
    if service.chars().chain(account.chars()).any(char::is_control) {
        return Err("el servicio y la cuenta no pueden contener caracteres de control".into());
    }
    let parts = split(value);
    for (index, part) in parts.iter().enumerate() {
        let text = if parts.len() > 1 && index == 0 { format!("{MARK}{}:{part}", parts.len()) } else { (*part).into() };
        super::sys::secret_set(service, &slot(account, index), &text)?;
    }
    // Fragmentos sobrantes de un valor anterior más largo.
    remove_from(service, account, parts.len())
}

/// Valor guardado, o `None` si no existe o el sistema no tiene almacén.
pub fn get(service: &str, account: &str) -> Option<String> {
    let first = super::sys::secret_get(service, account)?;
    let Some(header) = first.strip_prefix(MARK) else { return Some(first) };
    let (count, head) = header.split_once(':')?;
    let mut value = String::from(head);
    for index in 1..count.parse::<usize>().ok()? {
        value.push_str(&super::sys::secret_get(service, &slot(account, index))?);
    }
    Some(value)
}

/// Borra el valor; borrar algo que no existe no es un error.
pub fn remove(service: &str, account: &str) -> Result<(), String> {
    super::sys::secret_remove(service, account)?;
    remove_from(service, account, 1)
}

fn remove_from(service: &str, account: &str, first: usize) -> Result<(), String> {
    let mut index = first;
    while super::sys::secret_get(service, &slot(account, index)).is_some() {
        super::sys::secret_remove(service, &slot(account, index))?;
        index += 1;
    }
    Ok(())
}

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

    #[test]
    fn un_valor_largo_se_parte_sin_cortar_caracteres_y_se_une_igual() {
        let long = "ñé€".repeat(2000);
        let parts = split(&long);
        assert!(parts.len() > 1 && parts.iter().all(|part| part.len() <= CHUNK));
        assert_eq!(parts.concat(), long);
        assert_eq!(split("corto"), ["corto"]);
        assert_eq!(split(""), [""]);
    }

    #[test]
    fn rechaza_caracteres_de_control_en_los_nombres() {
        assert!(set("aura\n", "x", "v").is_err());
        assert!(set("aura", "x\0", "v").is_err());
    }

    /// Escribe y lee el almacén real con un servicio desechable: `cargo test -- --ignored`.
    #[test]
    #[ignore]
    fn un_valor_largo_y_con_comillas_ida_y_vuelta() {
        let service = format!("aura-test-{}", std::process::id());
        let long = "tok\u{e9}n\"&'\\$`".repeat(300);
        set(&service, "prueba", &long).unwrap();
        assert_eq!(get(&service, "prueba").as_deref(), Some(long.as_str()));
        set(&service, "prueba", "corto").unwrap();
        assert_eq!(get(&service, "prueba").as_deref(), Some("corto"));
        remove(&service, "prueba").unwrap();
        assert_eq!(get(&service, "prueba"), None);
        remove(&service, "prueba").unwrap();
    }
}