oyo 0.1.66

CLI TUI for oyo - Step-through diff viewer
use super::{loading, run_command};
use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::io::{self, BufRead, Read};
use std::process::Command;
use std::sync::{Mutex, OnceLock};

#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadTransport {
    AuthenticatedCli,
    PublicHttps,
}

impl ReadTransport {
    pub(crate) fn for_host(host: &str) -> Self {
        if host != "github.com" {
            return Self::AuthenticatedCli;
        }
        static AUTH: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
        let mut hosts = AUTH
            .get_or_init(Default::default)
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let authenticated = hosts.get(host).copied().unwrap_or_else(|| {
            let mut command = Command::new("gh");
            command.args([
                "auth",
                "status",
                "--hostname",
                host,
                "--json",
                "hosts",
                "--jq",
                ".hosts | length > 0",
            ]);
            let authenticated = match run_command(command, None) {
                Ok(output) => output.trim().parse::<bool>().unwrap_or(true),
                Err(error)
                    if error.chain().any(|e| {
                        e.downcast_ref::<std::io::Error>()
                            .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
                    }) =>
                {
                    false
                }
                Err(_) => {
                    let mut command = Command::new("gh");
                    command.args(["auth", "status", "--hostname", host]);
                    match run_command(command, None) {
                        Ok(_) => true,
                        Err(error) => !error
                            .to_string()
                            .contains("You are not logged into any GitHub hosts."),
                    }
                }
            };
            if !loading::cancelled() {
                hosts.insert(host.to_owned(), authenticated);
            }
            authenticated
        });
        if authenticated {
            Self::AuthenticatedCli
        } else {
            Self::PublicHttps
        }
    }
}

pub(crate) struct ResponseBody<R> {
    reader: R,
    headers_done: bool,
}

impl<R: BufRead> ResponseBody<R> {
    pub(crate) fn new(reader: R, transport: ReadTransport) -> Self {
        Self {
            reader,
            headers_done: transport == ReadTransport::AuthenticatedCli,
        }
    }

    fn read_headers(&mut self) -> io::Result<()> {
        if self.headers_done {
            return Ok(());
        }
        let mut total = 0usize;
        loop {
            let mut block = Vec::new();
            loop {
                let start = block.len();
                loop {
                    let bytes = self.reader.fill_buf()?;
                    if bytes.is_empty() {
                        return Err(io::Error::new(
                            io::ErrorKind::UnexpectedEof,
                            "Public GitHub response headers stopped early",
                        ));
                    }
                    let end = bytes
                        .iter()
                        .position(|b| *b == b'\n')
                        .map_or(bytes.len(), |n| n + 1);
                    total += end;
                    if total > 65536 {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            "Public GitHub response headers exceed the 64 KiB loading limit",
                        ));
                    }
                    let newline = bytes[end - 1] == b'\n';
                    block.extend_from_slice(&bytes[..end]);
                    self.reader.consume(end);
                    if newline {
                        break;
                    }
                }
                if matches!(&block[start..], b"\r\n" | b"\n") {
                    break;
                }
            }
            let text = std::str::from_utf8(&block)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
            let status = text
                .lines()
                .next()
                .filter(|line| line.starts_with("HTTP/"))
                .and_then(|line| line.split_whitespace().nth(1))
                .and_then(|v| v.parse::<u16>().ok())
                .ok_or_else(|| {
                    io::Error::new(
                        io::ErrorKind::InvalidData,
                        "Invalid public GitHub HTTP status",
                    )
                })?;
            if (100..200).contains(&status) {
                continue;
            }
            if let Some(error) = response_failure(text) {
                return Err(io::Error::other(error));
            }
            self.headers_done = true;
            return Ok(());
        }
    }
}

impl<R: BufRead> Read for ResponseBody<R> {
    fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
        self.read_headers()?;
        self.reader.read(output)
    }
}

impl<R: BufRead> BufRead for ResponseBody<R> {
    fn fill_buf(&mut self) -> io::Result<&[u8]> {
        self.read_headers()?;
        self.reader.fill_buf()
    }
    fn consume(&mut self, count: usize) {
        self.reader.consume(count)
    }
}

fn response_failure(headers: &str) -> Option<String> {
    let mut status = 0u16;
    let mut fields = HashMap::new();
    for line in headers.lines() {
        if line.starts_with("HTTP/") {
            status = line
                .split_whitespace()
                .nth(1)
                .and_then(|v| v.parse().ok())
                .unwrap_or(0);
            fields.clear();
        } else if let Some((name, value)) = line.split_once(':') {
            fields.insert(name.to_ascii_lowercase(), value.trim());
        }
    }
    if (200..300).contains(&status) || status == 0 {
        return None;
    }
    if status == 429
        || status == 403
            && (fields.get("x-ratelimit-remaining") == Some(&"0")
                || fields.contains_key("retry-after"))
    {
        let retry = fields
            .get("retry-after")
            .map(|v| format!(" Retry-After: {}.", oyo_core::patch::terminal_text(v)))
            .unwrap_or_default();
        let reset = fields
            .get("x-ratelimit-reset")
            .map(|v| {
                format!(
                    " Rate limit resets at Unix time {}.",
                    oyo_core::patch::terminal_text(v)
                )
            })
            .unwrap_or_default();
        return Some(format!("Public GitHub rate limit reached (HTTP {status}).{retry}{reset} Wait before retrying. An existing gh login can provide higher limits."));
    }
    Some(match status {
        404 => "Public GitHub resource is unavailable or private (HTTP 404). Private repositories require an existing gh login.".to_owned(),
        _ => format!("Public GitHub read failed (HTTP {status})."),
    })
}

fn public_request(url: &str, accept: &str) -> Command {
    let mut command = Command::new("curl");
    command.args([
        "--disable",
        "--no-buffer",
        "--silent",
        "--show-error",
        "--dump-header",
        "-",
        "--suppress-connect-headers",
        "--proto",
        "=https",
        "--connect-timeout",
        "10",
        "--max-time",
        "30",
        "--user-agent",
        "Oyo",
        "--header",
        accept,
        url,
    ]);
    command
}

pub(crate) fn json(host: &str, endpoint: &str) -> Result<Value> {
    let transport = ReadTransport::for_host(host);
    let command = match transport {
        ReadTransport::AuthenticatedCli => {
            let mut command = Command::new("gh");
            command.args(["api", "--hostname", host, endpoint]);
            command
        }
        ReadTransport::PublicHttps => public_request(
            &format!("https://api.github.com/{endpoint}"),
            "Accept: application/vnd.github+json",
        ),
    };
    let data = match transport {
        ReadTransport::AuthenticatedCli => run_command(command, None)?,
        ReadTransport::PublicHttps => super::run_public_http(command)?,
    };
    Ok(serde_json::from_str(&data)?)
}

pub(crate) fn patch(
    host: &str,
    repo: &str,
    base_tip: &str,
    head: &str,
) -> (Command, ReadTransport) {
    let transport = ReadTransport::for_host(host);
    let command = match transport {
        ReadTransport::AuthenticatedCli => {
            let mut command = Command::new("gh");
            command.args([
                "api",
                "--hostname",
                host,
                "--allow-escape-sequences",
                "-H",
                "Accept: application/vnd.github.diff",
                &format!("repos/{repo}/compare/{base_tip}...{head}"),
            ]);
            command
        }
        ReadTransport::PublicHttps => public_request(
            &format!("https://github.com/{repo}/compare/{base_tip}...{head}.diff"),
            "Accept: text/plain",
        ),
    };
    (command, transport)
}

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

    #[test]
    fn metadata_http_error_stops_a_slow_body_and_keeps_retry_details() {
        let mut command = Command::new("sh");
        command.args([
            "-c",
            "printf 'HTTP/2 429\r\nRetry-After: 90\r\n\r\n'; sleep 60",
        ]);
        let start = std::time::Instant::now();
        let error = super::super::run_public_http(command)
            .unwrap_err()
            .to_string();
        assert!(
            error.contains("rate limit reached") && error.contains("Retry-After: 90"),
            "{error}"
        );
        assert!(start.elapsed() < std::time::Duration::from_secs(2));
    }

    #[test]
    fn public_http_errors_preserve_rate_limits_without_automatic_retries() {
        for status in [403, 429] {
            let error=response_failure(&format!("HTTP/1.1 200 Connection established\r\n\r\nHTTP/2 {status}\r\nX-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: 1793833862\r\nRetry-After: 90\r\n\r\n")).unwrap();
            assert!(error.contains("rate limit reached"));
            assert!(error.contains("1793833862"));
            assert!(error.contains("Retry-After: 90"));
        }
        assert!(response_failure("HTTP/2 200\r\nx-ratelimit-remaining: 0\r\n\r\n").is_none());
        assert!(response_failure("HTTP/2 404\r\n\r\n")
            .unwrap()
            .contains("unavailable or private"));
        assert!(!response_failure("HTTP/2 403\r\n\r\n")
            .unwrap()
            .contains("rate limit reached"));
        assert!(
            response_failure("HTTP/2 302\r\nRetry-After: 90\r\n\r\nHTTP/2 200\r\n\r\n").is_none()
        );
    }

    #[test]
    fn public_response_headers_are_bounded_before_allocation_and_body_parsing() {
        for capacity in 1..32 {
            let data=b"HTTP/1.1 103 Early Hints\r\nLink: fixture\r\n\r\nHTTP/2 200\r\nX-RateLimit-Remaining: 0\r\n\r\n{\"ok\":true}";
            let reader = std::io::BufReader::with_capacity(capacity, data.as_slice());
            let value: Value =
                serde_json::from_reader(ResponseBody::new(reader, ReadTransport::PublicHttps))
                    .unwrap();
            assert_eq!(value["ok"], true);
        }
        for data in [
            b"HTTP/2 200\r\nX: "
                .iter()
                .copied()
                .chain(std::iter::repeat_n(b'a', 65537))
                .collect::<Vec<_>>(),
            b"HTTP/2 302\r\nLocation: https://other.example\r\n\r\n".to_vec(),
            b"HTTP/2 200\r\nX: truncated".to_vec(),
        ] {
            let mut body =
                ResponseBody::new(std::io::Cursor::new(data), ReadTransport::PublicHttps);
            assert!(body.read_to_end(&mut Vec::new()).is_err());
        }
        let command = public_request(
            "https://api.github.com/repos/o/r/pulls/1",
            "Accept: application/vnd.github+json",
        );
        let args = command
            .get_args()
            .map(|v| v.to_string_lossy().into_owned())
            .collect::<Vec<_>>();
        assert_eq!(args[0], "--disable");
        assert!(args.iter().any(|v| v == "--no-buffer"));
        assert!(args.windows(2).any(|v| v == ["--dump-header", "-"]));
        assert!(!args
            .iter()
            .any(|v| v == "--location" || v.contains("Authorization") || v.contains("netrc")));
    }
}