wp-self-update 0.1.6

Self-update execution library for wp-labs binaries
Documentation
use crate::{
    parse_v2_release, updates_manifest_path, updates_manifest_url, GithubRepo, ResolvedRelease,
    SourceConfig, SourceKind, UpdateChannel,
};
use orion_error::{ToStructError, UvsFrom};
use reqwest::StatusCode;
use serde::Deserialize;
use std::time::Duration;
use wp_error::run_error::{RunReason, RunResult};

const FETCH_CONNECT_TIMEOUT_SECS: u64 = 5;
const FETCH_REQUEST_TIMEOUT_SECS: u64 = 10;
const FETCH_RETRY_MAX_ATTEMPTS: usize = 3;

pub(crate) async fn load_release(
    source: &SourceConfig,
    channel: UpdateChannel,
) -> RunResult<(ResolvedRelease, String)> {
    let client = reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(FETCH_CONNECT_TIMEOUT_SECS))
        .timeout(Duration::from_secs(FETCH_REQUEST_TIMEOUT_SECS))
        .build()
        .map_err(|e| {
            RunReason::from_conf()
                .to_err()
                .with_detail(format!("failed to build HTTP client: {}", e))
        })?;

    match &source.kind {
        SourceKind::Manifest {
            updates_base_url,
            updates_root,
        } => {
            if let Some(root) = updates_root.as_deref() {
                let path = updates_manifest_path(root, channel);
                let raw = std::fs::read_to_string(&path).map_err(|e| {
                    RunReason::from_conf().to_err().with_detail(format!(
                        "failed to read manifest {}: {}",
                        path.display(),
                        e
                    ))
                })?;
                let release = parse_v2_release(&raw, &path.display().to_string(), channel)?;
                return Ok((release, path.display().to_string()));
            }

            let url = updates_manifest_url(updates_base_url, channel);
            let raw = fetch_text(&client, &url, true).await?;
            let release = parse_v2_release(&raw, &url, channel)?;
            Ok((release, url))
        }
        SourceKind::GithubLatest { repo } => {
            let url = repo.latest_release_api_url();
            let raw = fetch_github_latest_release_text(&client, &url).await?;
            let release = parse_github_latest_release(&raw, repo, &url)?;
            Ok((release, url))
        }
    }
}

#[derive(Debug, Deserialize)]
struct GithubLatestRelease {
    tag_name: String,
    assets: Vec<GithubReleaseAsset>,
}

#[derive(Debug, Deserialize)]
struct GithubReleaseAsset {
    name: String,
    browser_download_url: String,
    digest: Option<String>,
}

async fn fetch_github_latest_release_text(
    client: &reqwest::Client,
    url: &str,
) -> RunResult<String> {
    let request = client
        .get(url)
        .header("accept", "application/vnd.github+json")
        .header("x-github-api-version", "2022-11-28")
        .header("user-agent", "wp-inst");
    fetch_text_from_request(request, url, false).await
}

async fn fetch_text(
    client: &reqwest::Client,
    url: &str,
    not_found_is_terminal: bool,
) -> RunResult<String> {
    fetch_text_from_request(client.get(url), url, not_found_is_terminal).await
}

async fn fetch_text_from_request(
    request: reqwest::RequestBuilder,
    url: &str,
    not_found_is_terminal: bool,
) -> RunResult<String> {
    let mut last_error: Option<String> = None;
    for attempt in 1..=FETCH_RETRY_MAX_ATTEMPTS {
        let Some(request) = request.try_clone() else {
            return Err(RunReason::from_conf()
                .to_err()
                .with_detail(format!("failed to clone HTTP request for {}", url)));
        };
        match request.send().await {
            Ok(rsp) => {
                let status = rsp.status();
                if status.is_success() {
                    return rsp.text().await.map_err(|e| {
                        RunReason::from_conf()
                            .to_err()
                            .with_detail(format!("failed to read response {}: {}", url, e))
                    });
                }
                if not_found_is_terminal && status == StatusCode::NOT_FOUND {
                    return Err(RunReason::from_conf()
                        .to_err()
                        .with_detail(format!("manifest not found: {}", url)));
                }
                if is_retryable_status(status) && attempt < FETCH_RETRY_MAX_ATTEMPTS {
                    tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await;
                    continue;
                }
                return Err(RunReason::from_conf()
                    .to_err()
                    .with_detail(format!("request failed {}: HTTP {}", url, status)));
            }
            Err(e) => {
                last_error = Some(e.to_string());
                if attempt < FETCH_RETRY_MAX_ATTEMPTS {
                    tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await;
                    continue;
                }
            }
        }
    }
    Err(RunReason::from_conf().to_err().with_detail(format!(
        "failed to fetch manifest {} after {} attempts: {}",
        url,
        FETCH_RETRY_MAX_ATTEMPTS,
        last_error.unwrap_or_else(|| "unknown error".to_string())
    )))
}

fn parse_github_latest_release(
    raw: &str,
    repo: &GithubRepo,
    source: &str,
) -> RunResult<ResolvedRelease> {
    let release = serde_json::from_str::<GithubLatestRelease>(raw).map_err(|e| {
        RunReason::from_conf().to_err().with_detail(format!(
            "invalid GitHub latest release JSON {}: {}",
            source, e
        ))
    })?;

    let target = crate::platform::detect_target_triple_v2()?;
    let asset = select_github_release_asset(&release.assets, target).ok_or_else(|| {
        let mut names: Vec<&str> = release
            .assets
            .iter()
            .map(|asset| asset.name.as_str())
            .collect();
        names.sort_unstable();
        RunReason::from_conf().to_err().with_detail(format!(
            "GitHub release missing asset for target '{}': {} (available: {})",
            target,
            repo.url,
            names.join(", ")
        ))
    })?;

    let digest = asset.digest.as_deref().ok_or_else(|| {
        RunReason::from_conf().to_err().with_detail(format!(
            "GitHub release asset '{}' is missing sha256 digest metadata: {}",
            asset.name, repo.url
        ))
    })?;
    let sha256 = parse_github_asset_digest(digest, &asset.name, source)?;

    Ok(ResolvedRelease {
        version: release.tag_name,
        target: target.to_string(),
        artifact: asset.browser_download_url.clone(),
        sha256,
    })
}

fn select_github_release_asset<'a>(
    assets: &'a [GithubReleaseAsset],
    target: &str,
) -> Option<&'a GithubReleaseAsset> {
    let mut exact_raw = None;
    let mut raw = None;
    let mut archive = None;

    for asset in assets {
        if !asset.name.contains(target) {
            continue;
        }
        if asset.name.ends_with(target) {
            exact_raw = Some(asset);
            continue;
        }
        if asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz") {
            archive = Some(asset);
            continue;
        }
        raw = Some(asset);
    }

    exact_raw.or(raw).or(archive)
}

fn parse_github_asset_digest(raw: &str, asset_name: &str, source: &str) -> RunResult<String> {
    let Some(value) = raw.strip_prefix("sha256:") else {
        return Err(RunReason::from_conf().to_err().with_detail(format!(
            "unsupported GitHub asset digest '{}' for {} ({})",
            raw, asset_name, source
        )));
    };

    let normalized = value.trim().to_ascii_lowercase();
    let is_hex_64 = normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit());
    if is_hex_64 {
        return Ok(normalized);
    }
    Err(RunReason::from_conf().to_err().with_detail(format!(
        "invalid GitHub asset sha256 '{}' for {} ({})",
        raw, asset_name, source
    )))
}

pub(crate) fn is_retryable_status(status: StatusCode) -> bool {
    status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
}

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

    #[test]
    fn retryable_status_rules_ok() {
        assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR));
        assert!(is_retryable_status(StatusCode::BAD_GATEWAY));
        assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
        assert!(!is_retryable_status(StatusCode::NOT_FOUND));
        assert!(!is_retryable_status(StatusCode::BAD_REQUEST));
    }

    #[test]
    fn parse_github_asset_digest_accepts_sha256_prefix() {
        let value = parse_github_asset_digest(
            "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
            "wpl-check",
            "test",
        )
        .unwrap();
        assert_eq!(
            value,
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        );
    }

    #[test]
    fn select_github_release_asset_prefers_raw_binary() {
        let assets = vec![
            GithubReleaseAsset {
                name: "wpl-check-v0.1.7-aarch64-apple-darwin.tar.gz".to_string(),
                browser_download_url: "https://example.com/archive".to_string(),
                digest: Some(
                    "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
                        .to_string(),
                ),
            },
            GithubReleaseAsset {
                name: "wpl-check-v0.1.7-aarch64-apple-darwin".to_string(),
                browser_download_url: "https://example.com/raw".to_string(),
                digest: Some(
                    "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
                        .to_string(),
                ),
            },
        ];

        let selected = select_github_release_asset(&assets, "aarch64-apple-darwin").unwrap();
        assert_eq!(selected.browser_download_url, "https://example.com/raw");
    }
}