#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use cratefield_core::{Clock, HttpClient, HttpError};
use cratefield_push_auth::{CachedToken, Rs256Signer};
use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LINK, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, Uri};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
pub const DEFAULT_API_BASE: &str = "https://api.github.com";
pub const DEFAULT_WEB_BASE: &str = "https://github.com";
const API_VERSION: &str = "2022-11-28";
const ACCEPT_VALUE: &str = "application/vnd.github+json";
const USER_AGENT_VALUE: &str = "cratefield-adapter-github-app";
const API_VERSION_HEADER: &str = "x-github-api-version";
const TOKEN_TTL: Duration = Duration::from_mins(55);
const TOKEN_SAFETY_MARGIN: time::Duration = time::Duration::minutes(5);
const JWT_BACKDATE_SECS: i64 = 60;
const JWT_LIFETIME_SECS: i64 = 540;
const INSTALLATIONS_PAGE_CAP: usize = 10;
pub struct GithubApp {
app_id: u64,
key: AppKey,
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
api_base: String,
web_base: String,
tokens: CachedToken<(u64, String)>,
exchange: futures_util::lock::Mutex<()>,
}
enum AppKey {
Absent,
Invalid,
Signer(Box<Rs256Signer>),
}
impl AppKey {
fn is_signer(&self) -> bool {
matches!(self, AppKey::Signer(_))
}
}
impl std::fmt::Debug for GithubApp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GithubApp")
.field("app_id", &self.app_id)
.field("api_base", &self.api_base)
.field("web_base", &self.web_base)
.field("configured", &self.key.is_signer())
.finish_non_exhaustive()
}
}
impl GithubApp {
#[must_use]
pub fn new(
app_id: u64,
private_key_pem: Option<String>,
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
api_base: impl Into<String>,
) -> Self {
let api_base = api_base.into();
let key = match private_key_pem {
Some(pem) if !pem.trim().is_empty() => match Rs256Signer::from_pem(&pem) {
Ok(signer) => AppKey::Signer(Box::new(signer)),
Err(_) => AppKey::Invalid,
},
_ => AppKey::Absent,
};
Self {
app_id,
key,
http,
clock,
api_base: trim_base(&api_base),
web_base: DEFAULT_WEB_BASE.to_owned(),
tokens: CachedToken::new(TOKEN_TTL),
exchange: futures_util::lock::Mutex::new(()),
}
}
#[must_use]
pub fn with_web_base(mut self, web_base: impl Into<String>) -> Self {
let web_base = web_base.into();
self.web_base = trim_base(&web_base);
self
}
#[must_use]
pub fn app_id(&self) -> u64 {
self.app_id
}
#[must_use]
pub fn is_configured(&self) -> bool {
self.key.is_signer()
}
fn signer(&self) -> Result<&Rs256Signer, GithubAppError> {
match &self.key {
AppKey::Signer(signer) => Ok(signer),
AppKey::Absent => Err(GithubAppError::NotConfigured),
AppKey::Invalid => Err(GithubAppError::InvalidKey),
}
}
pub fn app_jwt(&self) -> Result<String, GithubAppError> {
let signer = self.signer()?;
let now = self.clock.now().unix_timestamp();
let header = serde_json::json!({ "alg": "RS256", "typ": "JWT" });
let claims = serde_json::json!({
"iat": now - JWT_BACKDATE_SECS,
"exp": now + JWT_LIFETIME_SECS,
"iss": self.app_id.to_string(),
});
Ok(signer.sign_jwt(&header, &claims))
}
pub async fn installation_token(
&self,
installation_id: u64,
permissions: Option<&BTreeMap<String, String>>,
repositories: Option<&[String]>,
) -> Result<InstallationToken, GithubAppError> {
self.signer()?;
let key = (installation_id, scope_key(permissions, repositories));
let _single_flight = self.exchange.lock().await;
if let Some(cached) = self.tokens.cached(self.clock.as_ref(), &key) {
if let Ok(wire) = serde_json::from_str::<CachedTokenWire>(&cached) {
return Ok(wire.into_token());
}
self.tokens.invalidate(&key);
}
let token = self
.exchange_installation_token(installation_id, permissions, repositories)
.await?;
let now = self.clock.now();
let usable = token.expires_at - TOKEN_SAFETY_MARGIN - now;
if let Ok(lifetime) = std::time::Duration::try_from(usable)
&& lifetime > Duration::ZERO
&& let Ok(json) = serde_json::to_string(&CachedTokenWire::from(&token))
{
self.tokens.store(self.clock.as_ref(), &key, json, lifetime);
}
tracing::info!(
provider = "github-app",
installation = installation_id,
outcome = "token-minted",
"github app token"
);
Ok(token)
}
async fn exchange_installation_token(
&self,
installation_id: u64,
permissions: Option<&BTreeMap<String, String>>,
repositories: Option<&[String]>,
) -> Result<InstallationToken, GithubAppError> {
let jwt = self.app_jwt()?;
let body = serde_json::to_vec(&TokenRequest {
permissions,
repositories,
})
.map_err(|err| GithubAppError::Decode(err.to_string()))?;
let uri: Uri = format!(
"{}/app/installations/{installation_id}/access_tokens",
self.api_base
)
.parse()
.map_err(|_| GithubAppError::InvalidHeader)?;
let auth = HeaderValue::from_str(&format!("Bearer {jwt}"))
.map_err(|_| GithubAppError::InvalidHeader)?;
let request = Request::builder()
.method(http::Method::POST)
.uri(uri)
.header(AUTHORIZATION, auth)
.header(ACCEPT, ACCEPT_VALUE)
.header(CONTENT_TYPE, "application/json")
.header(API_VERSION_HEADER, API_VERSION)
.header(USER_AGENT, USER_AGENT_VALUE)
.body(Bytes::from(body))
.map_err(|_| GithubAppError::InvalidHeader)?;
let response = self.http.send(request).await?;
let status = response.status();
let rate_limit = RateLimit::from_headers(response.headers());
if !status.is_success() {
tracing::warn!(
provider = "github-app",
installation = installation_id,
code = status.as_u16(),
outcome = "token-refused",
"github app token"
);
return Err(GithubAppError::Status { status, rate_limit });
}
let wire: InstallationTokenWire = serde_json::from_slice(response.body())
.map_err(|err| GithubAppError::Decode(err.to_string()))?;
wire.into_token()
}
pub async fn request(
&self,
installation_id: u64,
request: Request<Bytes>,
) -> Result<GithubResponse, GithubAppError> {
self.signer()?;
let caller_authenticated = request.headers().contains_key(AUTHORIZATION);
if !caller_authenticated {
let target = resolve_uri(&self.api_base, request.uri())?;
if !same_origin(&self.api_base, &target.to_string()) {
tracing::warn!(
provider = "github-app",
installation = installation_id,
outcome = "foreign-origin-refused",
"github app request"
);
return Err(GithubAppError::ForeignOrigin);
}
}
let mut credential = if caller_authenticated {
None
} else {
Some(
self.installation_token(installation_id, None, None)
.await?
.token,
)
};
let first = self.prepare(request.clone(), credential.as_deref())?;
let response = self.http.send(first).await?;
if response.status() != StatusCode::UNAUTHORIZED || credential.is_none() {
return Ok(GithubResponse::new(response));
}
let key = (installation_id, default_scope_key());
self.tokens.invalidate(&key);
let fresh = self.installation_token(installation_id, None, None).await?;
credential = Some(fresh.token);
tracing::warn!(
provider = "github-app",
installation = installation_id,
status = 401,
outcome = "token-refreshed",
"github app request"
);
let replay = self.prepare(request, credential.as_deref())?;
Ok(GithubResponse::new(self.http.send(replay).await?))
}
fn prepare(
&self,
mut request: Request<Bytes>,
credential: Option<&str>,
) -> Result<Request<Bytes>, GithubAppError> {
*request.uri_mut() = resolve_uri(&self.api_base, request.uri())?;
let headers = request.headers_mut();
if let Some(token) = credential
&& !headers.contains_key(AUTHORIZATION)
{
let value = HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|_| GithubAppError::InvalidHeader)?;
headers.insert(AUTHORIZATION, value);
}
insert_static(headers, ACCEPT, ACCEPT_VALUE);
insert_static(
headers,
HeaderName::from_static(API_VERSION_HEADER),
API_VERSION,
);
insert_static(headers, USER_AGENT, USER_AGENT_VALUE);
Ok(request)
}
pub async fn paginate(
&self,
installation_id: u64,
first_url: impl Into<String>,
max_pages: usize,
) -> Result<Vec<GithubResponse>, GithubAppError> {
let mut pages = Vec::new();
let mut next = Some(first_url.into());
while let Some(url) = next {
if pages.len() >= max_pages {
break;
}
let request = Request::builder()
.method(http::Method::GET)
.uri(url)
.body(Bytes::new())
.map_err(|_| GithubAppError::InvalidHeader)?;
let response = self.request(installation_id, request).await?;
next = next_page(response.response.headers())
.filter(|url| same_origin(&self.api_base, url));
pages.push(response);
}
Ok(pages)
}
pub async fn exchange_user_code(
&self,
client_id: &str,
client_secret: &str,
code: &str,
redirect_uri: Option<&str>,
) -> Result<UserToken, GithubAppError> {
let body = serde_json::to_vec(&OAuthRequest {
client_id,
client_secret,
code,
redirect_uri,
})
.map_err(|err| GithubAppError::Decode(err.to_string()))?;
let uri: Uri = format!("{}/login/oauth/access_token", self.web_base)
.parse()
.map_err(|_| GithubAppError::InvalidHeader)?;
let request = Request::builder()
.method(http::Method::POST)
.uri(uri)
.header(ACCEPT, "application/json")
.header(CONTENT_TYPE, "application/json")
.header(USER_AGENT, USER_AGENT_VALUE)
.body(Bytes::from(body))
.map_err(|_| GithubAppError::InvalidHeader)?;
let response = self.http.send(request).await?;
let status = response.status();
let rate_limit = RateLimit::from_headers(response.headers());
let value = serde_json::from_slice::<serde_json::Value>(response.body()).ok();
if let Some(error) = value
.as_ref()
.and_then(|value| value.get("error"))
.and_then(serde_json::Value::as_str)
{
tracing::warn!(
provider = "github-app",
code = status.as_u16(),
outcome = "oauth-refused",
"github app oauth"
);
return Err(GithubAppError::OAuth(error.to_owned()));
}
if !status.is_success() {
return Err(GithubAppError::Status { status, rate_limit });
}
let value = value
.ok_or_else(|| GithubAppError::Decode("the OAuth answer was not JSON".to_owned()))?;
let wire: UserTokenWire =
serde_json::from_value(value).map_err(|err| GithubAppError::Decode(err.to_string()))?;
Ok(wire.into_token())
}
pub async fn user_can_access_installation(
&self,
user_token: &str,
installation_id: u64,
) -> Result<bool, GithubAppError> {
let mut next = Some(format!("{}/user/installations?per_page=100", self.api_base));
let mut visited = 0;
while let Some(url) = next {
if visited >= INSTALLATIONS_PAGE_CAP {
break;
}
visited += 1;
let auth = HeaderValue::from_str(&format!("Bearer {user_token}"))
.map_err(|_| GithubAppError::InvalidHeader)?;
let request = Request::builder()
.method(http::Method::GET)
.uri(url)
.header(AUTHORIZATION, auth)
.header(ACCEPT, ACCEPT_VALUE)
.header(API_VERSION_HEADER, API_VERSION)
.header(USER_AGENT, USER_AGENT_VALUE)
.body(Bytes::new())
.map_err(|_| GithubAppError::InvalidHeader)?;
let response = self.http.send(request).await?;
let status = response.status();
if !status.is_success() {
return Err(GithubAppError::Status {
status,
rate_limit: RateLimit::from_headers(response.headers()),
});
}
let page: InstallationsPage = serde_json::from_slice(response.body())
.map_err(|err| GithubAppError::Decode(err.to_string()))?;
if page
.installations
.iter()
.any(|one| one.id == installation_id)
{
return Ok(true);
}
next = next_page(response.headers()).filter(|url| same_origin(&self.api_base, url));
}
Ok(false)
}
}
#[must_use]
pub fn with_etag(mut request: Request<Bytes>, etag: &str) -> Request<Bytes> {
if let Ok(value) = HeaderValue::from_str(etag) {
request.headers_mut().insert(IF_NONE_MATCH, value);
}
request
}
pub struct InstallationToken {
pub token: String,
pub expires_at: OffsetDateTime,
pub permissions: BTreeMap<String, String>,
}
impl std::fmt::Debug for InstallationToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InstallationToken")
.field("token", &"<redacted>")
.field("expires_at", &self.expires_at)
.field("permissions", &self.permissions)
.finish()
}
}
impl InstallationToken {
#[must_use]
pub fn expose(&self) -> &str {
&self.token
}
}
pub struct UserToken {
pub access_token: String,
pub expires_in: Option<u64>,
pub refresh_token: Option<String>,
pub refresh_token_expires_in: Option<u64>,
pub token_type: Option<String>,
}
impl std::fmt::Debug for UserToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserToken")
.field("access_token", &"<redacted>")
.field("expires_in", &self.expires_in)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("refresh_token_expires_in", &self.refresh_token_expires_in)
.field("token_type", &self.token_type)
.finish()
}
}
impl UserToken {
#[must_use]
pub fn expose(&self) -> &str {
&self.access_token
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RateLimit {
pub remaining: Option<u32>,
pub reset: Option<OffsetDateTime>,
pub retry_after: Option<Duration>,
}
impl RateLimit {
#[must_use]
pub fn from_headers(headers: &HeaderMap) -> Self {
let remaining = headers
.get("x-ratelimit-remaining")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u32>().ok());
let reset = headers
.get("x-ratelimit-reset")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.and_then(|secs| OffsetDateTime::from_unix_timestamp(secs).ok());
let retry_after = headers
.get(http::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_secs);
Self {
remaining,
reset,
retry_after,
}
}
}
pub struct GithubResponse {
pub response: Response<Bytes>,
pub rate_limit: RateLimit,
}
impl GithubResponse {
fn new(response: Response<Bytes>) -> Self {
let rate_limit = RateLimit::from_headers(response.headers());
Self {
response,
rate_limit,
}
}
#[must_use]
pub fn status(&self) -> StatusCode {
self.response.status()
}
#[must_use]
pub fn not_modified(&self) -> bool {
self.response.status() == StatusCode::NOT_MODIFIED
}
#[must_use]
pub fn headers(&self) -> &HeaderMap {
self.response.headers()
}
#[must_use]
pub fn body(&self) -> &Bytes {
self.response.body()
}
#[must_use]
pub fn etag(&self) -> Option<&str> {
self.response
.headers()
.get(ETAG)
.and_then(|value| value.to_str().ok())
}
}
#[must_use]
pub fn next_page(headers: &HeaderMap) -> Option<String> {
headers.get_all(LINK).iter().find_map(|value| {
links(value.to_str().ok()?).into_iter().find_map(|link| {
let open = link.find('<')?;
let rest = &link[open + 1..];
let close = rest.find('>')?;
is_next_link(&rest[close + 1..]).then(|| rest[..close].to_owned())
})
})
}
fn links(text: &str) -> Vec<&str> {
let (mut links, mut start, mut in_url) = (Vec::new(), 0, false);
for (at, ch) in text.char_indices() {
match ch {
'<' => in_url = true,
'>' => in_url = false,
',' if !in_url => {
links.push(&text[start..at]);
start = at + 1;
}
_ => {}
}
}
links.push(&text[start..]);
links
}
fn is_next_link(params: &str) -> bool {
params.split(';').any(|param| {
let Some((name, value)) = param.split_once('=') else {
return false;
};
name.trim().eq_ignore_ascii_case("rel") && value.trim().trim_matches('"') == "next"
})
}
fn same_origin(api_base: &str, url: &str) -> bool {
match (api_base.parse::<Uri>(), url.parse::<Uri>()) {
(Ok(base), Ok(target)) => origin(&base).is_some() && origin(&base) == origin(&target),
_ => false,
}
}
fn origin(uri: &Uri) -> Option<(&str, &str, u16)> {
let scheme = uri.scheme_str()?;
let default_port = match scheme {
"https" => 443,
"http" => 80,
_ => return None,
};
Some((scheme, uri.host()?, uri.port_u16().unwrap_or(default_port)))
}
fn scope_key(
permissions: Option<&BTreeMap<String, String>>,
repositories: Option<&[String]>,
) -> String {
let permissions = permissions.map_or_else(
|| "-".to_owned(),
|map| {
map.iter()
.map(|(name, level)| format!("{name}:{level}"))
.collect::<Vec<_>>()
.join(",")
},
);
let repositories = repositories.map_or_else(
|| "-".to_owned(),
|list| {
let mut sorted = list.to_vec();
sorted.sort();
sorted.join(",")
},
);
format!("p={permissions};r={repositories}")
}
fn default_scope_key() -> String {
scope_key(None, None)
}
fn trim_base(base: &str) -> String {
base.trim_end_matches('/').to_owned()
}
fn resolve_uri(api_base: &str, uri: &Uri) -> Result<Uri, GithubAppError> {
if uri.scheme().is_some() {
return Ok(uri.clone());
}
let path = uri.path_and_query().map_or("/", |pq| pq.as_str());
let separator = if path.starts_with('/') { "" } else { "/" };
format!("{api_base}{separator}{path}")
.parse()
.map_err(|_| GithubAppError::InvalidHeader)
}
fn insert_static(headers: &mut HeaderMap, name: HeaderName, value: &'static str) {
if !headers.contains_key(&name) {
headers.insert(name, HeaderValue::from_static(value));
}
}
#[derive(serde::Serialize)]
struct TokenRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
permissions: Option<&'a BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
repositories: Option<&'a [String]>,
}
#[derive(serde::Serialize)]
struct OAuthRequest<'a> {
client_id: &'a str,
client_secret: &'a str,
code: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
redirect_uri: Option<&'a str>,
}
#[derive(serde::Deserialize)]
struct InstallationTokenWire {
token: String,
expires_at: String,
#[serde(default)]
permissions: BTreeMap<String, String>,
}
impl InstallationTokenWire {
fn into_token(self) -> Result<InstallationToken, GithubAppError> {
let expires_at = OffsetDateTime::parse(&self.expires_at, &Rfc3339)
.map_err(|err| GithubAppError::Decode(err.to_string()))?;
Ok(InstallationToken {
token: self.token,
expires_at,
permissions: self.permissions,
})
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedTokenWire {
token: String,
expires_at_unix: i64,
permissions: BTreeMap<String, String>,
}
impl From<&InstallationToken> for CachedTokenWire {
fn from(token: &InstallationToken) -> Self {
Self {
token: token.token.clone(),
expires_at_unix: token.expires_at.unix_timestamp(),
permissions: token.permissions.clone(),
}
}
}
impl CachedTokenWire {
fn into_token(self) -> InstallationToken {
let expires_at = OffsetDateTime::from_unix_timestamp(self.expires_at_unix)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
InstallationToken {
token: self.token,
expires_at,
permissions: self.permissions,
}
}
}
#[derive(serde::Deserialize)]
struct UserTokenWire {
access_token: String,
#[serde(default)]
expires_in: Option<u64>,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
refresh_token_expires_in: Option<u64>,
#[serde(default)]
token_type: Option<String>,
}
impl UserTokenWire {
fn into_token(self) -> UserToken {
UserToken {
access_token: self.access_token,
expires_in: self.expires_in,
refresh_token: self.refresh_token,
refresh_token_expires_in: self.refresh_token_expires_in,
token_type: self.token_type,
}
}
}
#[derive(serde::Deserialize)]
struct InstallationsPage {
#[serde(default)]
installations: Vec<InstallationRef>,
}
#[derive(serde::Deserialize)]
struct InstallationRef {
id: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum GithubAppError {
#[error("the GitHub App is not configured: no private key")]
NotConfigured,
#[error("the GitHub App private key is not a valid RSA PEM")]
InvalidKey,
#[error("a request header value is not valid")]
InvalidHeader,
#[error("refusing to send the installation token outside the GitHub API origin")]
ForeignOrigin,
#[error("github app request failed: {0}")]
Http(#[from] HttpError),
#[error("github app request was refused with {status}")]
Status {
status: StatusCode,
rate_limit: RateLimit,
},
#[error("github app answer could not be decoded: {0}")]
Decode(String),
#[error("github oauth error: {0}")]
OAuth(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_keys_are_order_independent_and_scope_sensitive() {
let a = BTreeMap::from([
("contents".to_owned(), "read".to_owned()),
("issues".to_owned(), "write".to_owned()),
]);
let b = BTreeMap::from([
("issues".to_owned(), "write".to_owned()),
("contents".to_owned(), "read".to_owned()),
]);
assert_eq!(scope_key(Some(&a), None), scope_key(Some(&b), None));
let unsorted = vec!["acme/zeta".to_owned(), "acme/alpha".to_owned()];
let sorted = vec!["acme/alpha".to_owned(), "acme/zeta".to_owned()];
assert_eq!(
scope_key(None, Some(&unsorted)),
scope_key(None, Some(&sorted))
);
assert_ne!(scope_key(None, None), scope_key(Some(&a), None));
assert_eq!(scope_key(None, None), default_scope_key());
let empty_map = BTreeMap::new();
let empty_list: Vec<String> = Vec::new();
assert_ne!(scope_key(None, None), scope_key(Some(&empty_map), None));
assert_ne!(scope_key(None, None), scope_key(None, Some(&empty_list)));
assert_ne!(
scope_key(None, None),
scope_key(Some(&empty_map), Some(&empty_list))
);
assert_eq!(scope_key(None, None), default_scope_key());
}
#[test]
fn a_link_header_yields_its_next_url() {
let mut headers = HeaderMap::new();
headers.insert(
LINK,
HeaderValue::from_static(
"<https://api.github.test/user/installations?per_page=100&page=2>; rel=\"next\", \
<https://api.github.test/user/installations?per_page=100&page=5>; rel=\"last\"",
),
);
assert_eq!(
next_page(&headers).as_deref(),
Some("https://api.github.test/user/installations?per_page=100&page=2")
);
}
#[test]
fn a_link_header_without_next_is_none() {
let mut headers = HeaderMap::new();
headers.insert(
LINK,
HeaderValue::from_static("<https://api.github.test/x?page=1>; rel=\"prev\""),
);
assert_eq!(next_page(&headers), None);
assert_eq!(next_page(&HeaderMap::new()), None);
}
#[test]
fn a_comma_inside_a_link_url_is_not_a_separator() {
let mut headers = HeaderMap::new();
headers.insert(
LINK,
HeaderValue::from_static("<https://api.github.test/a,b>; rel=\"next\""),
);
assert_eq!(
next_page(&headers).as_deref(),
Some("https://api.github.test/a,b")
);
assert_eq!(
links("<https://x/a,b>; rel=\"next\""),
vec!["<https://x/a,b>; rel=\"next\""]
);
}
#[test]
fn a_next_link_is_only_same_origin() {
assert!(same_origin(
"https://api.github.test",
"https://api.github.test/user/installations?page=2"
));
assert!(same_origin(
"https://api.github.test",
"https://api.github.test:443/x"
));
assert!(!same_origin(
"https://api.github.test",
"http://api.github.test/x"
));
assert!(!same_origin(
"https://api.github.test",
"https://evil.example.com/x"
));
assert!(!same_origin(
"https://api.github.test",
"https://api.github.test:8443/x"
));
assert!(!same_origin(
"https://api.github.test",
"/user/installations?page=2"
));
assert!(!same_origin(
"https://api.github.test",
"ftp://api.github.test/x"
));
assert!(!same_origin("not a base", "https://api.github.test/x"));
}
#[test]
fn rate_limit_headers_are_parsed_and_garbage_is_ignored() {
let mut headers = HeaderMap::new();
headers.insert("x-ratelimit-remaining", HeaderValue::from_static("4999"));
headers.insert("x-ratelimit-reset", HeaderValue::from_static("1700000000"));
headers.insert(http::header::RETRY_AFTER, HeaderValue::from_static("42"));
let limit = RateLimit::from_headers(&headers);
assert_eq!(limit.remaining, Some(4999));
assert_eq!(limit.reset.unwrap().unix_timestamp(), 1_700_000_000);
assert_eq!(limit.retry_after, Some(Duration::from_secs(42)));
let mut bad = HeaderMap::new();
bad.insert("x-ratelimit-remaining", HeaderValue::from_static("soon"));
bad.insert("x-ratelimit-reset", HeaderValue::from_static("not-a-time"));
bad.insert(http::header::RETRY_AFTER, HeaderValue::from_static("-1"));
assert_eq!(RateLimit::from_headers(&bad), RateLimit::default());
}
#[test]
fn a_relative_uri_resolves_against_the_api_base() {
let absolute: Uri = "https://elsewhere.test/repos".parse().unwrap();
assert_eq!(
resolve_uri("https://api.github.test", &absolute).unwrap(),
absolute
);
let relative: Uri = "/repos/acme/widgets".parse().unwrap();
assert_eq!(
resolve_uri("https://api.github.test", &relative)
.unwrap()
.to_string(),
"https://api.github.test/repos/acme/widgets"
);
}
#[test]
fn the_debug_of_an_installation_token_hides_the_token() {
let token = InstallationToken {
token: "ghs_secretvalue".to_owned(),
expires_at: OffsetDateTime::UNIX_EPOCH,
permissions: BTreeMap::new(),
};
let printed = format!("{token:?}");
assert!(!printed.contains("ghs_"), "{printed}");
assert!(printed.contains("redacted"), "{printed}");
assert_eq!(token.expose(), "ghs_secretvalue");
}
}