ugi 0.2.1

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation
/// Encode `bytes` as standard Base64 with `=` padding (RFC 4648 §4).
pub(crate) fn encode_base64(bytes: &[u8]) -> String {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    encode_base64_impl(bytes, TABLE, true)
}

/// Encode `bytes` as URL-safe Base64 *without* padding (RFC 4648 §5).
pub(crate) fn encode_base64url_no_pad(bytes: &[u8]) -> String {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    encode_base64_impl(bytes, TABLE, false)
}

fn encode_base64_impl(bytes: &[u8], table: &[u8; 64], pad: bool) -> String {
    let mut output = String::with_capacity((bytes.len() + 2) / 3 * 4);
    let mut chunks = bytes.chunks_exact(3);
    for chunk in &mut chunks {
        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
        output.push(table[((n >> 18) & 0x3F) as usize] as char);
        output.push(table[((n >> 12) & 0x3F) as usize] as char);
        output.push(table[((n >> 6) & 0x3F) as usize] as char);
        output.push(table[(n & 0x3F) as usize] as char);
    }
    let rem = chunks.remainder();
    if !rem.is_empty() {
        let first = rem[0] as u32;
        let second = rem.get(1).copied().unwrap_or_default() as u32;
        let n = (first << 16) | (second << 8);
        output.push(table[((n >> 18) & 0x3F) as usize] as char);
        output.push(table[((n >> 12) & 0x3F) as usize] as char);
        if rem.len() == 2 {
            output.push(table[((n >> 6) & 0x3F) as usize] as char);
            if pad {
                output.push('=');
            }
        } else if pad {
            output.push('=');
            output.push('=');
        }
    }
    output
}

// ── HTTP Semantics ────────────────────────────────────────────────────────────

use crate::decode::{CompressionMode, DEFAULT_ACCEPT_ENCODING};
use crate::error::{Error, ErrorKind, Result};
use crate::header::HeaderMap;
use crate::request::Method;
use crate::response::StatusCode;

/// Build the HTTP/2 and HTTP/3 pseudo-header + regular-header list for a request.
///
/// Strips connection-specific headers that are forbidden in HTTP/2+
/// (RFC 9113 §8.2.2: `Connection`, `Keep-Alive`, `Transfer-Encoding`, etc.)
/// and merges cookies into a single `cookie` field as required by RFC 9113 §8.2.3.
///
/// Auto-appends `accept-encoding` if compression is enabled and the caller has
/// not already set one explicitly.
#[cfg_attr(not(any(feature = "h2", feature = "h3")), allow(dead_code))]
pub(crate) fn build_httpx_regular_headers(
    headers: &HeaderMap,
    cookies: &[(String, String)],
    compression_mode: CompressionMode,
    body_len: Option<u64>,
) -> Vec<(String, String)> {
    let mut header_list = Vec::new();
    let mut cookie_values = Vec::new();
    let mut has_content_length = false;
    let mut has_accept_encoding = false;

    for (name, value) in headers.iter() {
        let name = name.as_str().to_ascii_lowercase();
        let value = value.as_str().to_owned();
        if matches!(
            name.as_str(),
            "connection" | "keep-alive" | "proxy-connection" | "transfer-encoding" | "upgrade"
        ) {
            continue;
        }
        if name == "host" {
            continue;
        }
        if name == "te" && !value.eq_ignore_ascii_case("trailers") {
            continue;
        }
        if name == "cookie" {
            cookie_values.push(value);
            continue;
        }
        if name == "content-length" {
            has_content_length = true;
        }
        if name == "accept-encoding" {
            has_accept_encoding = true;
        }
        header_list.push((name, value));
    }

    if !cookies.is_empty() {
        let mut formatted = String::new();
        for (index, (name, value)) in cookies.iter().enumerate() {
            if index > 0 {
                formatted.push_str("; ");
            }
            formatted.push_str(name);
            formatted.push('=');
            formatted.push_str(value);
        }
        cookie_values.push(formatted);
    }

    if !cookie_values.is_empty() {
        header_list.push(("cookie".to_owned(), cookie_values.join("; ")));
    }

    if let Some(length) = body_len {
        if !has_content_length {
            header_list.push(("content-length".to_owned(), length.to_string()));
        }
    }

    if !has_accept_encoding && compression_mode.should_add_accept_encoding() {
        header_list.push((
            "accept-encoding".to_owned(),
            DEFAULT_ACCEPT_ENCODING.to_owned(),
        ));
    }

    header_list
}

/// Returns `true` if the response for this `method` and `status` may carry a body.
///
/// Implements RFC 9110 §6.4.1 and §9.3.2: HEAD responses and 204/205/304
/// status codes never have a message body regardless of what headers say.
pub(crate) fn response_body_allowed(method: Method, status: StatusCode) -> bool {
    method != Method::Head && !matches!(status.as_u16(), 204 | 205 | 304)
}

/// Parse the `Content-Length` header into a byte count.
///
/// Returns `Ok(None)` when the header is absent.  Returns an error if the
/// value is not a valid decimal integer or if multiple `Content-Length` headers
/// are present with conflicting values (RFC 9110 §8.6).  `protocol` is used
/// only for error messages (e.g. `"http1"` or `"http2"`).
pub(crate) fn parse_content_length(
    headers: &HeaderMap,
    protocol: &'static str,
) -> Result<Option<usize>> {
    let values = headers.get_all("content-length");
    if values.is_empty() {
        return Ok(None);
    }

    let mut parsed = None;
    for value in values {
        let length = value.parse::<usize>().map_err(|err| {
            Error::with_source(
                ErrorKind::Transport,
                format!("invalid {protocol} content-length header value"),
                err,
            )
        })?;
        if let Some(existing) = parsed {
            if existing != length {
                return Err(Error::new(
                    ErrorKind::Transport,
                    format!("conflicting {protocol} content-length header values"),
                ));
            }
        } else {
            parsed = Some(length);
        }
    }

    Ok(parsed)
}

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

    #[test]
    fn encode_base64_empty() {
        assert_eq!(encode_base64(b""), "");
    }

    #[test]
    fn encode_base64_one_byte() {
        // 'M' → 0x4D → 010011 01 → "TQ=="
        assert_eq!(encode_base64(b"M"), "TQ==");
    }

    #[test]
    fn encode_base64_two_bytes() {
        // 'Ma' → 0x4D 0x61 → "TWE="
        assert_eq!(encode_base64(b"Ma"), "TWE=");
    }

    #[test]
    fn encode_base64_three_bytes() {
        assert_eq!(encode_base64(b"Man"), "TWFu");
    }

    #[test]
    fn encode_base64_rfc_example() {
        // RFC 4648 test vector
        assert_eq!(encode_base64(b"foobar"), "Zm9vYmFy");
    }

    #[test]
    fn encode_base64url_no_pad_basic() {
        // Standard "Man" encodes to "TWFu" in both alphabets
        assert_eq!(encode_base64url_no_pad(b"Man"), "TWFu");
    }

    #[test]
    fn encode_base64url_no_pad_uses_url_safe_chars() {
        // bytes that produce + and / in standard base64 should produce - and _
        // 0xFF 0xFF → standard: "//8=" → url-safe no-pad: "__8"
        assert_eq!(encode_base64url_no_pad(&[0xFF, 0xFF]), "__8");
    }
}