use std::io::Read;
use std::process::{ExitStatus, Stdio};
use std::time::{Duration, Instant};
use trusty_common::content::TAG_PREFIX;
pub const CONTENT_REPO: &str = "bobmatnyc/trusty-tools";
pub(crate) const MAX_LISTING_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchError {
pub url: String,
pub reason: String,
pub status: Option<u16>,
}
impl FetchError {
pub fn is_server_error(&self) -> bool {
self.status.is_some_and(|code| (500..600).contains(&code))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Release {
pub tag: String,
pub draft: bool,
pub prerelease: bool,
}
pub trait ReleaseSource {
fn asset_url(&self, tag: &str, file: &str) -> String;
fn asset(&self, tag: &str, file: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, FetchError>;
fn content_tags(&self) -> Result<Vec<String>, FetchError>;
fn release(&self, tag: &str) -> Result<Release, FetchError>;
}
#[derive(Clone)]
pub struct GithubReleases {
client: reqwest::blocking::Client,
download_base: String,
api_base: String,
token: Option<(String, TokenOrigin)>,
}
impl std::fmt::Debug for GithubReleases {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GithubReleases")
.field("download_base", &self.download_base)
.field("api_base", &self.api_base)
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.finish_non_exhaustive()
}
}
fn github_token() -> Option<(String, TokenOrigin)> {
github_token_with(|name| std::env::var(name).ok(), run_gh_auth_token)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenOrigin {
Given,
Var(&'static str),
Gh,
}
const GH_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const GH_AUTH_TOKEN_ARGS: [&str; 4] = ["auth", "token", "--hostname", "github.com"];
#[derive(Debug)]
pub(crate) struct GhOutput {
pub(crate) success: bool,
pub(crate) stdout: Vec<u8>,
}
pub(crate) fn github_token_with(
var: impl Fn(&str) -> Option<String>,
gh: impl FnOnce() -> std::io::Result<GhOutput>,
) -> Option<(String, TokenOrigin)> {
if let Some((name, token)) = github_token_from(var) {
return Some((token, TokenOrigin::Var(name)));
}
match gh() {
Ok(out) if out.success => String::from_utf8(out.stdout)
.ok()
.and_then(normalize_token)
.map(|token| (token, TokenOrigin::Gh)),
Ok(_) => {
tracing::debug!("`gh auth token` exited non-zero; reading GitHub unauthenticated");
None
}
Err(e) => {
tracing::debug!(kind = ?e.kind(), "`gh auth token` did not run; reading GitHub unauthenticated");
None
}
}
}
pub(crate) fn gh_auth_token_command() -> std::process::Command {
let mut command = trusty_common::gh::GhCommand::new(GH_AUTH_TOKEN_ARGS)
.env_remove("GH_HOST")
.to_std_command();
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
command
}
fn run_gh_auth_token() -> std::io::Result<GhOutput> {
let mut child = gh_auth_token_command().spawn()?;
let status = wait_bounded(&mut child, GH_TIMEOUT)?;
let mut stdout = Vec::new();
if let Some(out) = child.stdout.take() {
out.take(4096).read_to_end(&mut stdout)?;
}
Ok(GhOutput {
success: status.success(),
stdout,
})
}
pub(crate) trait Reap {
fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>>;
fn kill(&mut self) -> std::io::Result<()>;
fn wait(&mut self) -> std::io::Result<ExitStatus>;
}
impl Reap for std::process::Child {
fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
std::process::Child::try_wait(self)
}
fn kill(&mut self) -> std::io::Result<()> {
std::process::Child::kill(self)
}
fn wait(&mut self) -> std::io::Result<ExitStatus> {
std::process::Child::wait(self)
}
}
pub(crate) fn wait_bounded(
child: &mut impl Reap,
timeout: Duration,
) -> std::io::Result<ExitStatus> {
let deadline = Instant::now() + timeout;
let failure = loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status),
Ok(None) if Instant::now() >= deadline => {
break std::io::Error::new(
std::io::ErrorKind::TimedOut,
"`gh auth token` did not finish",
);
}
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(e) => break e,
}
};
let _ = child.kill();
let _ = child.wait();
Err(failure)
}
pub(crate) fn rate_limited_reason(
status: &str,
remaining: Option<&str>,
retry_after: Option<&str>,
reset: Option<&str>,
) -> String {
let mut facts = Vec::new();
if let Some(left) = remaining {
facts.push(format!("x-ratelimit-remaining: {left}"));
}
if let Some(secs) = retry_after {
facts.push(format!("retry-after: {secs} s"));
}
if let Some(at) = reset {
facts.push(format!("x-ratelimit-reset: {at}"));
}
let facts = if facts.is_empty() {
String::new()
} else {
format!(" ({})", facts.join(", "))
};
format!(
"HTTP {status}: GitHub's API rate limit refused the request{facts}; set GITHUB_TOKEN \
or GH_TOKEN, or run `gh auth login`, to read GitHub authenticated"
)
}
fn normalize_token(token: String) -> Option<String> {
let trimmed = token.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
pub(super) fn github_token_from(
var: impl Fn(&str) -> Option<String>,
) -> Option<(&'static str, String)> {
[trusty_common::env_vars::ENV_GITHUB_TOKEN, "GH_TOKEN"]
.into_iter()
.find_map(|name| var(name).and_then(normalize_token).map(|t| (name, t)))
}
fn refused_token_reason(origin: TokenOrigin) -> String {
match origin {
TokenOrigin::Var(name) => format!(
"HTTP 401 Unauthorized: GitHub refused the token in `{name}`, which is revoked or \
expired; replace it, or unset `{name}` to read GitHub unauthenticated"
),
TokenOrigin::Given | TokenOrigin::Gh => "HTTP 401 Unauthorized: GitHub refused the \
configured token, which is revoked or expired"
.to_owned(),
}
}
impl GithubReleases {
pub fn new() -> Result<Self, FetchError> {
Ok(Self::with_bases(
&format!("https://github.com/{CONTENT_REPO}/releases/download"),
&format!("https://api.github.com/repos/{CONTENT_REPO}"),
)?
.with_token_from(github_token()))
}
pub fn with_bases(download_base: &str, api_base: &str) -> Result<Self, FetchError> {
let client = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.user_agent(concat!("trusty-mpm/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| FetchError {
url: download_base.to_owned(),
reason: e.to_string(),
status: None,
})?;
Ok(Self {
client,
download_base: download_base.trim_end_matches('/').to_owned(),
api_base: api_base.trim_end_matches('/').to_owned(),
token: None,
})
}
pub fn with_token(self, token: Option<String>) -> Self {
self.with_token_from(token.map(|t| (t, TokenOrigin::Given)))
}
pub fn with_token_from(mut self, token: Option<(String, TokenOrigin)>) -> Self {
self.token = token.and_then(|(t, origin)| normalize_token(t).map(|t| (t, origin)));
self
}
fn send(&self, url: &str, auth: bool) -> Result<reqwest::blocking::Response, FetchError> {
let fail = |reason: String| FetchError {
url: url.to_owned(),
reason,
status: None,
};
let mut request = self.client.get(url);
if let (true, Some((token, _))) = (auth, &self.token) {
let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|_| fail("the GitHub token is not a valid header value".to_owned()))?;
value.set_sensitive(true);
request = request.header(reqwest::header::AUTHORIZATION, value);
}
request.send().map_err(|e| fail(e.to_string()))
}
fn get(&self, url: &str, max_bytes: u64, auth: bool) -> Result<Option<Vec<u8>>, FetchError> {
let fail = |reason: String| FetchError {
url: url.to_owned(),
reason,
status: None,
};
let mut response = self.send(url, auth)?;
if let (true, reqwest::StatusCode::UNAUTHORIZED, Some((_, origin))) =
(auth, response.status(), &self.token)
{
if *origin != TokenOrigin::Gh {
return Err(FetchError {
status: Some(401),
..fail(refused_token_reason(*origin))
});
}
tracing::warn!(
"GitHub refused the `gh auth token` token (HTTP 401); reading unauthenticated"
);
response = self.send(url, false)?;
}
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !status.is_success() {
let header = |name: &str| {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
};
let reason = if matches!(status.as_u16(), 403 | 429) {
rate_limited_reason(
&status.to_string(),
header("x-ratelimit-remaining").as_deref(),
header("retry-after").as_deref(),
header("x-ratelimit-reset").as_deref(),
)
} else {
format!("HTTP {status}")
};
return Err(FetchError {
status: Some(status.as_u16()),
..fail(reason)
});
}
let mut body = Vec::new();
response
.take(max_bytes.saturating_add(1))
.read_to_end(&mut body)
.map_err(|e| fail(e.to_string()))?;
if body.len() as u64 > max_bytes {
return Err(fail(format!("the response is over {max_bytes} bytes")));
}
Ok(Some(body))
}
fn api(&self, url: &str) -> Result<Vec<u8>, FetchError> {
self.get(url, MAX_LISTING_BYTES, true)?
.ok_or_else(|| FetchError {
url: url.to_owned(),
reason: "HTTP 404 Not Found".to_owned(),
status: Some(404),
})
}
}
impl ReleaseSource for GithubReleases {
fn asset_url(&self, tag: &str, file: &str) -> String {
format!("{}/{tag}/{file}", self.download_base)
}
fn asset(&self, tag: &str, file: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, FetchError> {
self.get(&self.asset_url(tag, file), max_bytes, false)
}
fn content_tags(&self) -> Result<Vec<String>, FetchError> {
#[derive(serde::Deserialize)]
struct ApiRef {
r#ref: String,
}
let url = format!("{}/git/matching-refs/tags/{TAG_PREFIX}", self.api_base);
let refs: Vec<ApiRef> =
serde_json::from_slice(&self.api(&url)?).map_err(|e| FetchError {
url: url.clone(),
reason: format!("unexpected response: {e}"),
status: None,
})?;
refs.into_iter()
.map(|entry| {
entry
.r#ref
.strip_prefix("refs/tags/")
.map(str::to_owned)
.ok_or_else(|| FetchError {
url: url.clone(),
reason: format!("unexpected ref {:?}", entry.r#ref),
status: None,
})
})
.collect()
}
fn release(&self, tag: &str) -> Result<Release, FetchError> {
#[derive(serde::Deserialize)]
struct ApiRelease {
tag_name: String,
draft: bool,
prerelease: bool,
}
let url = format!("{}/releases/tags/{tag}", self.api_base);
let release: ApiRelease =
serde_json::from_slice(&self.api(&url)?).map_err(|e| FetchError {
url: url.clone(),
reason: format!("unexpected response: {e}"),
status: None,
})?;
if release.tag_name != tag {
return Err(FetchError {
url,
reason: format!("asked for release {tag}, got {}", release.tag_name),
status: None,
});
}
Ok(Release {
tag: release.tag_name,
draft: release.draft,
prerelease: release.prerelease,
})
}
}
pub const MAX_RETRIES: u32 = 3;
const FIRST_BACKOFF: Duration = Duration::from_secs(1);
pub fn github_source() -> Result<Retrying<GithubReleases>, FetchError> {
Ok(Retrying::new(GithubReleases::new()?))
}
pub struct Retrying<S> {
inner: S,
sleep: Box<dyn Fn(Duration) + Send + Sync>,
}
impl<S: ReleaseSource> Retrying<S> {
pub fn new(inner: S) -> Self {
Self::with_sleeper(inner, std::thread::sleep)
}
pub fn with_sleeper(inner: S, sleep: impl Fn(Duration) + Send + Sync + 'static) -> Self {
Self {
inner,
sleep: Box::new(sleep),
}
}
fn retry<T>(&self, op: impl Fn(&S) -> Result<T, FetchError>) -> Result<T, FetchError> {
let mut delay = FIRST_BACKOFF;
for _ in 0..MAX_RETRIES {
match op(&self.inner) {
Err(e) if e.is_server_error() => {
tracing::debug!(url = %e.url, reason = %e.reason, "retrying a server error");
(self.sleep)(delay);
delay = delay.saturating_mul(2);
}
other => return other,
}
}
op(&self.inner).map_err(|mut e| {
if e.is_server_error() {
e.reason = format!("{} (after {MAX_RETRIES} retries)", e.reason);
}
e
})
}
}
impl<S: ReleaseSource> ReleaseSource for Retrying<S> {
fn asset_url(&self, tag: &str, file: &str) -> String {
self.inner.asset_url(tag, file)
}
fn asset(&self, tag: &str, file: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, FetchError> {
self.retry(|s| s.asset(tag, file, max_bytes))
}
fn content_tags(&self) -> Result<Vec<String>, FetchError> {
self.retry(|s| s.content_tags())
}
fn release(&self, tag: &str) -> Result<Release, FetchError> {
self.retry(|s| s.release(tag))
}
}