diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 91f1a4f1cd..20fcf90a18 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4477,14 +4477,20 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-http-client", "codex-process-hardening", "ctor 0.6.3", + "http 1.4.0", "libc", "pretty_assertions", "reqwest 0.12.28", "serde", "serde_json", "tiny_http", + "tokio", + "tracing", + "tracing-test", + "url", "zeroize", ] @@ -17867,6 +17873,7 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ + "serde", "zeroize_derive", ] diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index 05d638843f..15107789b6 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -19,14 +19,20 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } +codex-http-client = { workspace = true } codex-process-hardening = { workspace = true } ctor = { workspace = true } +http = { workspace = true } libc = { workspace = true } reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } -zeroize = { workspace = true } +tokio = { workspace = true, features = ["rt", "time", "net"] } +url = { workspace = true } +zeroize = { workspace = true, features = ["serde"] } [dev-dependencies] pretty_assertions = { workspace = true } +tracing = { workspace = true } +tracing-test = { workspace = true, features = ["no-env-filter"] } diff --git a/codex-rs/responses-api-proxy/src/auth.rs b/codex-rs/responses-api-proxy/src/auth.rs index c18ca8b882..5e95ce63ce 100644 --- a/codex-rs/responses-api-proxy/src/auth.rs +++ b/codex-rs/responses-api-proxy/src/auth.rs @@ -1,9 +1,76 @@ //! Supplies sensitive request headers without copying the locked API key. +//! Token clients never follow redirects or emit credential-bearing URL diagnostics. use std::fmt; +use std::future::Future; +use std::pin::Pin; use anyhow::Result; -use reqwest::header::HeaderValue; +use anyhow::anyhow; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClient; +use codex_http_client::HttpClientBuilder; +use codex_http_client::HttpClientFactory; +use codex_http_client::HttpResponse; +use codex_http_client::OutboundProxyPolicy; +use codex_http_client::RouteAwareClientPool; +use http::HeaderValue; +use serde::de::DeserializeOwned; +use std::time::Duration; +use zeroize::Zeroizing; + +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024; +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +const MAX_RESPONSE_BYTES: usize = 64 * 1024; +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +pub(crate) const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Supplies a fresh JWT whenever an OpenAI credential needs to be issued. +/// Acquisition stays asynchronous so callers can compose it with the exchange. +/// Implementations must not include tokens, credential-bearing URLs, or upstream +/// response bodies in errors. The exchange deliberately has no issuer-specific config. +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +pub(crate) trait SubjectTokenSource: Send { + fn subject_token<'a>(&'a mut self, client: &'a TokenClient) -> SubjectTokenFuture<'a>; +} + +// Keep the source usable as a trait object while acquisition runs asynchronously. +pub(crate) type SubjectTokenFuture<'a> = + Pin>> + Send + 'a>>; + +/// Credential requests must not follow redirects or log URLs or response headers. +/// The private field prevents other modules from wrapping arbitrary HTTP clients. +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +pub(crate) struct TokenClient(HttpClient); + +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +impl TokenClient { + #[allow( + dead_code, + reason = "federation creates the routed client in the next stack layer" + )] + pub(crate) fn new() -> Self { + // The standalone proxy does not load Codex's session config. For its + // opt-in auth flow, resolve system and environment proxy settings per URL. + let factory = HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy); + Self( + RouteAwareClientPool::with_builder(factory, ClientRouteClass::Auth, Self::builder()) + .into_client(), + ) + } + + fn builder() -> HttpClientBuilder { + HttpClientBuilder::new() + .connect_timeout(Duration::from_secs(10)) + .without_redirects() + .without_request_logging() + } + + pub(crate) fn http(&self) -> &HttpClient { + &self.0 + } +} pub(crate) enum Auth { ApiKey(&'static str), @@ -44,6 +111,35 @@ impl Auth { } } +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +pub(crate) async fn read_response( + mut response: HttpResponse, + source: &str, +) -> Result { + if !response.status().is_success() { + // Never read or log the error body; it may echo a credential. Redirects are + // failures too. HTTP errors can contain the URL, so don't attach them. + return Err(anyhow!( + "{source} token request rejected (HTTP {})", + response.status().as_u16() + )); + } + // Keep secret-bearing response bytes in one allocation, including on errors. + let mut body = Zeroizing::new(Vec::with_capacity(MAX_RESPONSE_BYTES)); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| anyhow!("{source} returned an unreadable token response"))? + { + if chunk.len() > MAX_RESPONSE_BYTES - body.len() { + return Err(anyhow!("{source} returned an oversized token response")); + } + body.extend_from_slice(&chunk); + } + serde_json::from_slice(&body) + .map_err(|_| anyhow!("{source} returned an invalid token response")) +} + #[cfg(test)] #[path = "auth_tests.rs"] -mod tests; +pub(crate) mod tests; diff --git a/codex-rs/responses-api-proxy/src/auth_tests.rs b/codex-rs/responses-api-proxy/src/auth_tests.rs index c55a13ec40..ecc31985f1 100644 --- a/codex-rs/responses-api-proxy/src/auth_tests.rs +++ b/codex-rs/responses-api-proxy/src/auth_tests.rs @@ -1,6 +1,11 @@ -//! Ensures authentication diagnostics do not expose credentials. +//! Credential-safe diagnostics and transport fixtures for the production token-client policy. use super::Auth; +use super::TokenClient; + +pub(crate) fn direct_token_client() -> TokenClient { + TokenClient(TokenClient::builder().build_direct().unwrap()) +} #[test] fn debug_redacts_api_keys_in_both_formats() { diff --git a/codex-rs/responses-api-proxy/src/github_oidc.rs b/codex-rs/responses-api-proxy/src/github_oidc.rs new file mode 100644 index 0000000000..778335818e --- /dev/null +++ b/codex-rs/responses-api-proxy/src/github_oidc.rs @@ -0,0 +1,126 @@ +//! Obtains fresh GitHub Actions OIDC subject tokens for the federated exchange. +//! The stdin request credential and minted tokens must never appear in diagnostics. + +use anyhow::Result; +use anyhow::anyhow; +use http::header::AUTHORIZATION; +use http::header::HeaderValue; +use serde::Deserialize; +use url::Url; +use zeroize::Zeroizing; + +use crate::auth::MAX_TOKEN_BYTES; +use crate::auth::SubjectTokenFuture; +use crate::auth::SubjectTokenSource; +use crate::auth::TOKEN_REQUEST_TIMEOUT; +use crate::auth::TokenClient; +use crate::auth::read_response; +use crate::read_api_key::AUTH_HEADER_PREFIX; +use crate::read_api_key::read_auth_header_with; +use crate::read_api_key::read_from_stdin; + +// Allow space for "Bearer " and a trailing CRLF. +const REQUEST_AUTH_BUFFER_SIZE: usize = AUTH_HEADER_PREFIX.len() + MAX_TOKEN_BYTES + 2; + +// Intentionally no Debug: request_auth contains a credential. +pub(crate) struct GithubOidc { + request_url: Url, + request_auth: HeaderValue, +} + +impl GithubOidc { + #[expect(dead_code, reason = "CLI is wired in a later stack layer")] + pub(crate) fn from_stdin(request_url: &str, audience: &str) -> Result { + // Avoid echoing caller-supplied URL parameters in parsing errors. + let mut request_url = + Url::parse(request_url).map_err(|_| anyhow!("invalid GitHub OIDC request URL"))?; + if request_url.scheme() != "https" + || request_url.host_str().is_none() + || !request_url.username().is_empty() + || request_url.password().is_some() + || request_url.fragment().is_some() + { + return Err(anyhow!( + "GitHub OIDC request URL must be HTTPS without user info or fragment" + )); + } + // Replace any existing audience; preserve GitHub's other query parameters. + let pairs: Vec<(String, String)> = request_url + .query_pairs() + .filter(|(key, _)| key != "audience") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + request_url + .query_pairs_mut() + .clear() + .extend_pairs(pairs) + .append_pair("audience", audience); + Ok(Self { + request_url, + request_auth: read_request_auth(read_from_stdin)?, + }) + } +} + +impl SubjectTokenSource for GithubOidc { + fn subject_token<'a>(&'a mut self, client: &'a TokenClient) -> SubjectTokenFuture<'a> { + Box::pin(async move { + let response = client + .http() + .get(self.request_url.clone()) + .timeout(TOKEN_REQUEST_TIMEOUT) + .header(AUTHORIZATION, self.request_auth.clone()) + .send() + .await + .map_err(|_| anyhow!("GitHub OIDC token request failed"))?; + let response: GithubResponse = read_response(response, "GitHub OIDC").await?; + Ok(response.value) + }) + } +} + +#[derive(Deserialize)] +struct GithubResponse { + value: Zeroizing, +} + +// The launcher must remove ACTIONS_ID_TOKEN_REQUEST_TOKEN from its environment +// before exec. This header is retained for the lifetime of the proxy. +fn read_request_auth(mut read: F) -> Result +where + F: FnMut(&mut [u8]) -> std::io::Result, +{ + let read_credential = |buf: &mut [u8]| { + read(buf).map_err(|_| { + std::io::Error::other("could not read GitHub OIDC request credential from stdin") + }) + }; + let value = read_auth_header_with::( + read_credential, + validate_github_request_token, + || anyhow!("GitHub OIDC request credential too large"), + )?; + let mut header = HeaderValue::from_static(value); + header.set_sensitive(true); + Ok(header) +} + +fn validate_github_request_token(token: &[u8]) -> Result { + let token = token.strip_suffix(b"\n").unwrap_or(token); + let token = token.strip_suffix(b"\r").unwrap_or(token); + // RFC 6750 permits JWT periods; reject whitespace and control bytes. + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || !token.iter().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/' | b'=') + }) + { + return Err(anyhow!("invalid workload identity bearer credential")); + } + Ok(token.len()) +} + +#[cfg(test)] +#[path = "github_oidc_tests.rs"] +mod tests; diff --git a/codex-rs/responses-api-proxy/src/github_oidc_tests.rs b/codex-rs/responses-api-proxy/src/github_oidc_tests.rs new file mode 100644 index 0000000000..b029defb1c --- /dev/null +++ b/codex-rs/responses-api-proxy/src/github_oidc_tests.rs @@ -0,0 +1,184 @@ +use super::*; +use crate::auth::tests::direct_token_client; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::io::Cursor; +use std::io::Read; +use std::thread; +use std::time::Duration; +use tiny_http::Header; +use tiny_http::Server; +use tiny_http::StatusCode; +use tokio::runtime::Runtime; + +#[test] +fn stdin_accepts_bounded_bearer_tokens_in_short_reads_and_rejects_injection() { + let token = format!("{}.payload.sig", "a".repeat(2000)); + let input = format!("{token}\r\n"); + let mut cursor = Cursor::new(input.as_bytes()); + let header = read_request_auth(|buf| { + let len = buf.len().min(3); + cursor.read(&mut buf[..len]) + }) + .unwrap(); + assert_eq!(header.to_str().unwrap(), format!("Bearer {token}")); + assert!(header.is_sensitive()); + for token in ["", "a\rb", "a b", "a\0b", "x:y", "💥", "a\r\r", "a\r\r\n"] { + let mut cursor = Cursor::new(token.as_bytes()); + assert!(read_request_auth(|buf| cursor.read(buf)).is_err()); + } + for suffix in ["", "\n", "\r\n"] { + let max_token = "a".repeat(MAX_TOKEN_BYTES); + let input = format!("{max_token}{suffix}"); + let mut cursor = Cursor::new(input.as_bytes()); + let header = read_request_auth(|buf| cursor.read(buf)).unwrap(); + assert_eq!(header.to_str().unwrap(), format!("Bearer {max_token}")); + + let input = format!("a{max_token}{suffix}"); + let mut cursor = Cursor::new(input.as_bytes()); + assert!(read_request_auth(|buf| cursor.read(buf)).is_err()); + } +} + +#[test] +fn stdin_read_error_cannot_echo_a_github_credential() { + let mut sent = false; + let error = read_request_auth(|buf| { + if sent { + return Err(std::io::Error::other("private.github.request")); + } + sent = true; + buf[..3].copy_from_slice(b"abc"); + Ok(3) + }) + .unwrap_err(); + assert_eq!( + format!("{error:#}"), + "could not read GitHub OIDC request credential from stdin" + ); +} + +fn server() -> (Server, Url) { + let server = Server::http("127.0.0.1:0").unwrap(); + let url = Url::parse(&format!("http://{}", server.server_addr())).unwrap(); + (server, url) +} + +fn source_at(url: &Url) -> GithubOidc { + let mut cursor = Cursor::new(b"private.github.request"); + GithubOidc { + request_url: url.join("/github").unwrap(), + request_auth: read_request_auth(|buf| cursor.read(buf)).unwrap(), + } +} + +fn client() -> (TokenClient, Runtime) { + let client = direct_token_client(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + (client, runtime) +} + +fn recv(server: &Server) -> tiny_http::Request { + server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .expect("expected HTTP request") +} + +#[test] +fn obtains_fresh_subject_tokens_using_github_request_credential() { + let (server, url) = server(); + let worker = thread::spawn(move || { + for token in ["github.first.jwt", "github.second.jwt"] { + let req = recv(&server); + assert_eq!(req.url(), "/github"); + assert_eq!(req.method(), &tiny_http::Method::Get); + assert!(req.headers().iter().any(|h| h.field.equiv("Authorization") + && h.value.as_str() == "Bearer private.github.request")); + req.respond( + tiny_http::Response::from_string(json!({"value": token}).to_string()) + .with_header(Header::from_bytes("Content-Type", "application/json").unwrap()), + ) + .unwrap(); + } + }); + let mut source: Box = Box::new(source_at(&url)); + let (client, runtime) = client(); + runtime.block_on(async { + assert_eq!( + source.subject_token(&client).await.unwrap().as_str(), + "github.first.jwt" + ); + assert_eq!( + source.subject_token(&client).await.unwrap().as_str(), + "github.second.jwt" + ); + }); + worker.join().unwrap(); +} + +#[test] +#[tracing_test::traced_test] +fn rejected_subject_does_not_log_response_or_follow_redirect() { + tracing::debug!(target: "codex_http_client::client", "credential log capture sentinel"); + let (server, url) = server(); + let target = url.join("/credential-leak").unwrap(); + let worker = thread::spawn(move || { + let req = recv(&server); + assert_eq!(req.url(), "/github?opaque=private.github.url"); + req.respond( + tiny_http::Response::from_string("private.github.request") + .with_status_code(StatusCode(302)) + .with_header(Header::from_bytes("Location", target.as_str()).unwrap()), + ) + .unwrap(); + assert!( + server + .recv_timeout(Duration::from_millis(200)) + .unwrap() + .is_none() + ); + }); + let (client, runtime) = client(); + let mut source = source_at(&url); + source + .request_url + .set_query(Some("opaque=private.github.url")); + let error = format!( + "{:#}", + runtime.block_on(source.subject_token(&client)).unwrap_err() + ); + assert!(error.contains("302"), "{error}"); + assert!(!error.contains("private.github.request"), "{error}"); + assert!(!error.contains("private.github.url"), "{error}"); + assert!(!error.contains(url.as_str()), "{error}"); + worker.join().unwrap(); + assert!(logs_contain("credential log capture sentinel")); + assert!(!logs_contain("private.github.url")); + assert!(!logs_contain("private.github.request")); +} + +#[test] +fn malformed_json_cannot_echo_a_secret_in_diagnostics() { + let (server, url) = server(); + let worker = thread::spawn(move || { + recv(&server) + .respond(tiny_http::Response::from_string( + r#"{"value": private.github.request}"#, + )) + .unwrap(); + }); + let (client, runtime) = client(); + let error = format!( + "{:#}", + runtime + .block_on(source_at(&url).subject_token(&client)) + .unwrap_err() + ); + assert!(error.contains("invalid token response"), "{error}"); + assert!(!error.contains("private.github.request"), "{error}"); + worker.join().unwrap(); +} diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index d096d8bf68..5cc423d8e3 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -30,6 +30,8 @@ use tiny_http::StatusCode; mod auth; mod dump; +#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))] +mod github_oidc; mod read_api_key; use auth::Auth; use dump::ExchangeDumper; diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index e9e9034c81..a406ea4f84 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -6,14 +6,22 @@ use zeroize::Zeroize; /// Use a generous buffer size to avoid truncation and to allow for longer API /// keys in the future. const BUFFER_SIZE: usize = 1024; -const AUTH_HEADER_PREFIX: &[u8] = b"Bearer "; +pub(crate) const AUTH_HEADER_PREFIX: &[u8] = b"Bearer "; /// Reads the auth token from stdin and returns a static `Authorization` header /// value with the auth token used with `Bearer`. The header value is returned /// as a `&'static str` whose bytes are locked in memory to avoid accidental /// exposure. pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { - read_auth_header_with(read_from_stdin) + read_api_key_with(read_from_stdin) +} + +fn read_api_key_with( + read_fn: impl FnMut(&mut [u8]) -> std::io::Result, +) -> Result<&'static str> { + read_auth_header_with::(read_fn, validate_auth_header_bytes, || { + anyhow!("API key is too large to fit in the {BUFFER_SIZE}-byte buffer") + }) } #[cfg(windows)] @@ -68,18 +76,23 @@ pub(crate) fn read_from_stdin(buffer: &mut [u8]) -> std::io::Result { } } -fn read_auth_header_with(mut read_fn: F) -> Result<&'static str> -where - F: FnMut(&mut [u8]) -> std::io::Result, -{ +/// Reads and locks one permanent credential. The validator receives the raw +/// token line and returns the length of its validated prefix after trimming. +/// The buffer size includes the Bearer prefix and any line terminators. +/// Do not use for renewable tokens: the allocation lives until process exit. +pub(crate) fn read_auth_header_with( + mut read_fn: impl FnMut(&mut [u8]) -> std::io::Result, + validate: impl FnOnce(&[u8]) -> Result, + buffer_full_error: impl FnOnce() -> anyhow::Error, +) -> Result<&'static str> { // TAKE CARE WHEN MODIFYING THIS CODE!!! // - // This function goes to great lengths to avoid leaving the API key in + // This function goes to great lengths to avoid leaving the credential in // memory longer than necessary and to avoid copying it around. We read // directly into a stack buffer so the only heap allocation should be the // one to create the String (with the exact size) for the header value, // which we then immediately protect with mlock(2). - let mut buf = [0u8; BUFFER_SIZE]; + let mut buf = [0u8; N]; buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX); let prefix_len = AUTH_HEADER_PREFIX.len(); @@ -119,32 +132,21 @@ where // If buffer filled and we did not see newline or EOF, error out. if total_read == capacity && !saw_newline && !saw_eof { buf.zeroize(); - return Err(anyhow!( - "API key is too large to fit in the {BUFFER_SIZE}-byte buffer" - )); + return Err(buffer_full_error()); } - let mut total = prefix_len + total_read; - while total > prefix_len && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') { - total -= 1; - } - - if total == AUTH_HEADER_PREFIX.len() { - buf.zeroize(); - return Err(anyhow!( - "API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" - )); - } - - if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) { - buf.zeroize(); - return Err(err); - } + let total = match validate(&buf[prefix_len..prefix_len + total_read]) { + Ok(len) => prefix_len + len, + Err(err) => { + buf.zeroize(); + return Err(err); + } + }; let header_str = match std::str::from_utf8(&buf[..total]) { Ok(value) => value, Err(err) => { - // In theory, validate_auth_header_bytes() should have caught + // In theory, the validator should have caught // any invalid UTF-8 sequences, but just in case... buf.zeroize(); return Err(err).context("reading Authorization header from stdin as UTF-8"); @@ -161,7 +163,7 @@ where } #[cfg(unix)] -fn mlock_str(value: &str) { +fn mlock_str(value: &'static str) { use libc::_SC_PAGESIZE; use libc::c_void; use libc::mlock; @@ -200,16 +202,27 @@ fn mlock_str(value: &str) { } #[cfg(not(unix))] -fn mlock_str(_value: &str) {} +fn mlock_str(_value: &'static str) {} /// The key should match /^[A-Za-z0-9\-_]+$/. Ensure there is no funny business /// with NUL characters and whatnot. -fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { +fn validate_auth_header_bytes(mut key_bytes: &[u8]) -> Result { + while key_bytes + .last() + .is_some_and(|byte| matches!(byte, b'\n' | b'\r')) + { + key_bytes = &key_bytes[..key_bytes.len() - 1]; + } + if key_bytes.is_empty() { + return Err(anyhow!( + "API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" + )); + } if key_bytes .iter() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) { - return Ok(()); + return Ok(key_bytes.len()); } Err(anyhow!( @@ -226,7 +239,7 @@ mod tests { #[test] fn reads_key_with_no_newlines() { let mut sent = false; - let result = read_auth_header_with(|buf| { + let result = read_api_key_with(|buf| { if sent { return Ok(0); } @@ -244,7 +257,7 @@ mod tests { fn reads_key_with_short_reads() { let mut chunks: VecDeque<&[u8]> = VecDeque::from(vec![b"sk-".as_ref(), b"abc".as_ref(), b"123\n".as_ref()]); - let result = read_auth_header_with(|buf| match chunks.pop_front() { + let result = read_api_key_with(|buf| match chunks.pop_front() { Some(chunk) if !chunk.is_empty() => { buf[..chunk.len()].copy_from_slice(chunk); Ok(chunk.len()) @@ -259,7 +272,7 @@ mod tests { #[test] fn reads_key_and_trims_newlines() { let mut sent = false; - let result = read_auth_header_with(|buf| { + let result = read_api_key_with(|buf| { if sent { return Ok(0); } @@ -275,14 +288,14 @@ mod tests { #[test] fn errors_when_no_input_provided() { - let err = read_auth_header_with(|_| Ok(0)).unwrap_err(); + let err = read_api_key_with(|_| Ok(0)).unwrap_err(); let message = format!("{err:#}"); assert!(message.contains("must be provided")); } #[test] fn errors_when_buffer_filled() { - let err = read_auth_header_with(|buf| { + let err = read_api_key_with(|buf| { let data = vec![b'a'; BUFFER_SIZE - AUTH_HEADER_PREFIX.len()]; buf[..data.len()].copy_from_slice(&data); Ok(data.len()) @@ -296,7 +309,7 @@ mod tests { #[test] fn propagates_io_error() { - let err = read_auth_header_with(|_| Err(io::Error::other("boom"))).unwrap_err(); + let err = read_api_key_with(|_| Err(io::Error::other("boom"))).unwrap_err(); let io_error = err.downcast_ref::().unwrap(); assert_eq!(io_error.kind(), io::ErrorKind::Other); @@ -306,7 +319,7 @@ mod tests { #[test] fn errors_on_invalid_utf8() { let mut sent = false; - let err = read_auth_header_with(|buf| { + let err = read_api_key_with(|buf| { if sent { return Ok(0); } @@ -323,19 +336,26 @@ mod tests { #[test] fn errors_on_invalid_characters() { - let mut sent = false; - let err = read_auth_header_with(|buf| { - if sent { - return Ok(0); - } - let data = b"sk-abc!23"; - buf[..data.len()].copy_from_slice(data); - sent = true; - Ok(data.len()) - }) - .unwrap_err(); - - let message = format!("{err:#}"); - assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); + for data in [ + b"sk-abc!23".as_slice(), + b"sk.abc123", + b"sk-abc+123", + b"sk-abc=123", + ] { + let mut sent = false; + let err = read_api_key_with(|buf| { + if sent { + return Ok(0); + } + buf[..data.len()].copy_from_slice(data); + sent = true; + Ok(data.len()) + }) + .unwrap_err(); + let message = format!("{err:#}"); + assert!( + message.contains("API key may only contain ASCII letters, numbers, '-' or '_'") + ); + } } }