github-actions-maintainer 0.7.3

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
use std::{thread, time::Duration};

use anyhow::{Context, Result, anyhow, bail};
use reqwest::{
    StatusCode,
    blocking::{Client, RequestBuilder, Response},
    header::{HeaderMap, HeaderValue, RETRY_AFTER, USER_AGENT},
};
use semver::Version;
use serde::Deserialize;

#[derive(Debug, Clone)]
pub struct CratesIoClient {
    base_url: String,
    client: Client,
    max_retries: u32,
    retry_delay: Duration,
    max_retry_delay: Duration,
}

#[derive(Debug, Clone)]
pub struct CratesIoClientOptions {
    pub base_url: String,
    pub timeout: Duration,
    pub max_retries: u32,
    pub retry_delay: Duration,
    pub max_retry_delay: Duration,
}

impl Default for CratesIoClientOptions {
    fn default() -> Self {
        Self {
            base_url: String::from("https://crates.io/api/v1"),
            timeout: Duration::from_secs(30),
            max_retries: 3,
            retry_delay: Duration::from_secs(1),
            max_retry_delay: Duration::from_secs(30),
        }
    }
}

#[derive(Debug, Deserialize)]
struct CrateMetadataResponse {
    #[serde(rename = "crate")]
    metadata: CrateMetadata,
}

#[derive(Debug, Deserialize)]
struct CrateMetadata {
    #[serde(rename = "max_version")]
    latest: Option<String>,
    #[serde(rename = "max_stable_version")]
    latest_stable: Option<String>,
    #[serde(rename = "newest_version")]
    newest: Option<String>,
}

impl CratesIoClient {
    pub fn new(base_url: impl Into<String>) -> Result<Self> {
        Self::with_options(&CratesIoClientOptions {
            base_url: base_url.into(),
            ..CratesIoClientOptions::default()
        })
    }

    pub fn with_options(options: &CratesIoClientOptions) -> Result<Self> {
        let mut headers = HeaderMap::new();
        headers.insert(USER_AGENT, HeaderValue::from_static("github-actions-maintainer"));

        let client = Client::builder()
            .default_headers(headers)
            .timeout(options.timeout)
            .build()
            .context("failed to build crates.io HTTP client")?;

        Ok(Self {
            base_url: options.base_url.trim_end_matches('/').to_owned(),
            client,
            max_retries: options.max_retries,
            retry_delay: options.retry_delay,
            max_retry_delay: options.max_retry_delay,
        })
    }

    pub fn latest_stable_version(&self, crate_name: &str) -> Result<String> {
        let encoded = urlencoding::encode(crate_name);
        let response = self.send_with_retry(
            || self.client.get(format!("{}/crates/{encoded}", self.base_url)),
            || format!("fetch crates.io metadata for {crate_name}"),
        )?;
        let metadata = response
            .json::<CrateMetadataResponse>()
            .with_context(|| format!("failed to decode crates.io metadata for {crate_name}"))?;

        select_stable_version(&metadata.metadata)
            .ok_or_else(|| anyhow!("no stable crates.io release found for {crate_name}"))
    }

    fn send_with_retry<F, D>(&self, mut build_request: F, describe: D) -> Result<Response>
    where
        F: FnMut() -> RequestBuilder,
        D: Fn() -> String,
    {
        let mut attempt = 0u32;

        loop {
            match build_request().send() {
                Ok(response) if response.status().is_success() => return Ok(response),
                Ok(response) => {
                    if should_retry_response(&response) && attempt < self.max_retries {
                        sleep_for_retry(
                            response.headers(),
                            attempt,
                            self.retry_delay,
                            self.max_retry_delay,
                        );
                        attempt += 1;
                        continue;
                    }
                    return error_from_response(response, &describe());
                }
                Err(error) => {
                    if (error.is_timeout() || error.is_connect()) && attempt < self.max_retries {
                        thread::sleep(calculate_backoff(
                            self.retry_delay,
                            self.max_retry_delay,
                            attempt,
                        ));
                        attempt += 1;
                        continue;
                    }
                    return Err(error).with_context(describe);
                }
            }
        }
    }
}

fn select_stable_version(metadata: &CrateMetadata) -> Option<String> {
    [metadata.latest_stable.as_deref(), metadata.latest.as_deref(), metadata.newest.as_deref()]
        .into_iter()
        .flatten()
        .find(|candidate| is_stable_version(candidate))
        .map(ToOwned::to_owned)
}

fn is_stable_version(candidate: &str) -> bool {
    Version::parse(candidate).is_ok_and(|version| version.pre.is_empty())
}

fn should_retry_response(response: &Response) -> bool {
    response.status() == StatusCode::TOO_MANY_REQUESTS || response.status().is_server_error()
}

fn sleep_for_retry(
    headers: &HeaderMap,
    attempt: u32,
    retry_delay: Duration,
    max_retry_delay: Duration,
) {
    let delay = retry_delay_from_headers(headers)
        .filter(|delay| *delay > Duration::ZERO && *delay <= max_retry_delay * 10)
        .unwrap_or_else(|| calculate_backoff(retry_delay, max_retry_delay, attempt));
    thread::sleep(delay);
}

fn calculate_backoff(retry_delay: Duration, max_retry_delay: Duration, attempt: u32) -> Duration {
    let shift = attempt.min(10);
    let candidate = retry_delay.saturating_mul(1u32 << shift);
    candidate.min(max_retry_delay)
}

fn retry_delay_from_headers(headers: &HeaderMap) -> Option<Duration> {
    headers
        .get(RETRY_AFTER)
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.parse::<u64>().ok())
        .map(Duration::from_secs)
}

fn error_from_response(response: Response, context: &str) -> Result<Response> {
    let status = response.status();
    let body = response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));

    if status == StatusCode::NOT_FOUND {
        bail!("{context}: crate not found ({body})");
    }

    bail!("{context}: crates.io API returned {status} ({body})")
}

#[cfg(test)]
#[allow(clippy::significant_drop_tightening)]
mod tests {
    use std::time::Duration;

    use mockito::Server;

    use super::{CratesIoClient, CratesIoClientOptions};

    #[test]
    fn latest_stable_version_prefers_max_stable_version() {
        let mut server = Server::new();
        let _crate = server
            .mock("GET", "/crates/reqwest")
            .match_header("user-agent", "github-actions-maintainer")
            .with_status(200)
            .with_body(
                r#"{
                    "crate": {
                        "id": "reqwest",
                        "name": "reqwest",
                        "max_version": "0.14.0-beta.1",
                        "max_stable_version": "0.13.2",
                        "newest_version": "0.14.0-beta.1"
                    }
                }"#,
            )
            .create();

        let client = CratesIoClient::new(server.url()).expect("crates.io client");
        let latest = client.latest_stable_version("reqwest").expect("latest stable version");

        assert_eq!(latest, "0.13.2");
    }

    #[test]
    fn latest_stable_version_retries_after_rate_limit() {
        let mut server = Server::new();
        let _rate_limited = server
            .mock("GET", "/crates/serde")
            .expect(1)
            .match_header("user-agent", "github-actions-maintainer")
            .with_status(429)
            .with_header("retry-after", "0")
            .with_body(r#"{"errors":[{"detail":"slow down"}]}"#)
            .create();
        let _success = server
            .mock("GET", "/crates/serde")
            .expect(1)
            .match_header("user-agent", "github-actions-maintainer")
            .with_status(200)
            .with_body(
                r#"{
                    "crate": {
                        "id": "serde",
                        "name": "serde",
                        "max_version": "1.0.219",
                        "max_stable_version": "1.0.219",
                        "newest_version": "1.0.219"
                    }
                }"#,
            )
            .create();

        let client = CratesIoClient::with_options(&CratesIoClientOptions {
            base_url: server.url(),
            timeout: Duration::from_secs(5),
            max_retries: 1,
            retry_delay: Duration::from_millis(1),
            max_retry_delay: Duration::from_millis(5),
        })
        .expect("crates.io client");
        let latest = client.latest_stable_version("serde").expect("latest stable version");

        assert_eq!(latest, "1.0.219");
    }
}