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.
//! Receptor local de la redirección OAuth: un puerto de loopback que atiende una sola petición y se cierra.

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::{Duration, Instant};

const PAGE: &str = "<!doctype html><meta charset=utf-8><title>Aura</title>\
<body style=\"font-family:system-ui;text-align:center;margin-top:20vh\"><h2>Listo</h2>\
<p>Ya puedes volver a Aura y cerrar esta pestaña.</p>";

/// Lo que trajo la redirección: `code` y `state`.
#[derive(Debug, PartialEq, Eq)]
pub struct Redirect {
    pub code: String,
    pub state: String,
}

pub struct CallbackServer {
    listener: TcpListener,
    pub port: u16,
}

impl CallbackServer {
    /// Intenta el puerto preferido y, si está ocupado, cualquiera libre de loopback.
    pub fn bind(preferred: u16) -> Result<Self, String> {
        let listener = TcpListener::bind(("127.0.0.1", preferred))
            .or_else(|_| TcpListener::bind(("127.0.0.1", 0)))
            .map_err(|error| format!("no se pudo abrir el puerto local: {error}"))?;
        listener.set_nonblocking(true).map_err(|error| error.to_string())?;
        let port = listener.local_addr().map_err(|error| error.to_string())?.port();
        Ok(Self { listener, port })
    }

    /// Espera la redirección hasta `timeout`; `cancelled` permite abandonar antes.
    pub fn wait(&self, path: &str, timeout: Duration, cancelled: &dyn Fn() -> bool) -> Result<Redirect, String> {
        let started = Instant::now();
        loop {
            if cancelled() {
                return Err("inicio de sesión cancelado".into());
            }
            if started.elapsed() > timeout {
                return Err("se agotó el tiempo para iniciar sesión en el navegador".into());
            }
            match self.listener.accept() {
                Ok((stream, _)) => {
                    if let Some(redirect) = answer(stream, path) {
                        return Ok(redirect);
                    }
                }
                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
                    std::thread::sleep(Duration::from_millis(100));
                }
                Err(error) => return Err(error.to_string()),
            }
        }
    }
}

/// Responde la petición; devuelve la redirección solo si es la esperada (el favicon y similares se ignoran).
fn answer(mut stream: TcpStream, path: &str) -> Option<Redirect> {
    let _ = stream.set_nonblocking(false);
    let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
    let mut buffer = [0u8; 4096];
    let read = stream.read(&mut buffer).ok()?;
    let request = String::from_utf8_lossy(&buffer[..read]);
    let target = request.lines().next()?.split_whitespace().nth(1)?;
    let redirect = parse_target(target, path);
    let (status, body) = if redirect.is_some() { ("200 OK", PAGE) } else { ("404 Not Found", "") };
    let _ = write!(
        stream,
        "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    redirect
}

pub fn percent_decode(text: &str) -> String {
    let bytes = text.as_bytes();
    let (mut out, mut index) = (Vec::new(), 0);
    while index < bytes.len() {
        match bytes[index] {
            b'%' if index + 2 < bytes.len() => {
                let hex =
                    std::str::from_utf8(&bytes[index + 1..index + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
                match hex {
                    Some(byte) => {
                        out.push(byte);
                        index += 3;
                    }
                    None => {
                        out.push(b'%');
                        index += 1;
                    }
                }
            }
            b'+' => {
                out.push(b' ');
                index += 1;
            }
            byte => {
                out.push(byte);
                index += 1;
            }
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Lee `code` y `state` de `/ruta?code=…&state=…`.
pub fn parse_target(target: &str, path: &str) -> Option<Redirect> {
    let (route, query) = target.split_once('?')?;
    if route != path {
        return None;
    }
    let value = |name: &str| {
        query
            .split('&')
            .find_map(|pair| pair.split_once('=').filter(|(key, _)| *key == name).map(|(_, v)| percent_decode(v)))
    };
    Some(Redirect { code: value("code")?, state: value("state").unwrap_or_default() })
}

/// Lo que el usuario pegó a mano: una URL de redirección, `code#state` o solo el código.
pub fn parse_pasted(input: &str) -> Option<Redirect> {
    let input = input.trim();
    if input.is_empty() {
        return None;
    }
    if let Some((_, query)) = input.split_once('?') {
        return parse_target(&format!("/x?{query}"), "/x");
    }
    if input.contains("code=") {
        return parse_target(&format!("/x?{input}"), "/x");
    }
    let (code, state) = input.split_once('#').unwrap_or((input, ""));
    Some(Redirect { code: code.to_owned(), state: state.to_owned() })
}

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

    #[test]
    fn lee_codigo_y_estado_de_la_redireccion() {
        let redirect = parse_target("/callback?code=a%2Fb&state=xyz", "/callback").unwrap();
        assert_eq!(redirect, Redirect { code: "a/b".into(), state: "xyz".into() });
        assert!(parse_target("/favicon.ico", "/callback").is_none());
        assert!(parse_target("/otra?code=1", "/callback").is_none());
    }

    #[test]
    fn acepta_lo_que_el_usuario_pega() {
        assert_eq!(parse_pasted("http://localhost:1/callback?code=c&state=s").unwrap().state, "s");
        assert_eq!(parse_pasted("c#s").unwrap(), Redirect { code: "c".into(), state: "s".into() });
        assert_eq!(parse_pasted("solo").unwrap().code, "solo");
        assert!(parse_pasted("  ").is_none());
    }

    #[test]
    fn el_servidor_entrega_la_redireccion_por_un_socket_real() {
        let server = CallbackServer::bind(0).unwrap();
        let port = server.port;
        let client = std::thread::spawn(move || {
            let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
            stream.write_all(b"GET /callback?code=K&state=S HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
            let mut reply = String::new();
            stream.read_to_string(&mut reply).unwrap();
            reply
        });
        let got = server.wait("/callback", Duration::from_secs(5), &|| false).unwrap();
        assert_eq!(got, Redirect { code: "K".into(), state: "S".into() });
        assert!(client.join().unwrap().starts_with("HTTP/1.1 200"));
    }

    #[test]
    fn cancelar_y_el_plazo_cortan_la_espera() {
        let server = CallbackServer::bind(0).unwrap();
        assert!(server.wait("/c", Duration::from_secs(5), &|| true).unwrap_err().contains("cancelado"));
        assert!(server.wait("/c", Duration::ZERO, &|| false).unwrap_err().contains("tiempo"));
    }
}