aion-cli 0.30.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
//! Asking the registry which version of Aion is current.
//!
//! One question, one endpoint: `https://crates.io/api/v1/crates/aion-cli`,
//! whose `crate.max_stable_version` is the version a plain `cargo install
//! aion-cli` would fetch. The whole workspace shares one version, so the CLI
//! crate's answer is the estate's answer.
//!
//! Every failure here is a REFUSAL naming the URL, never a fallback to a
//! guessed version: an update that quietly installed something other than what
//! the operator asked for is worse than one that did not run.

use std::time::Duration;

use serde::Deserialize;

/// The registry endpoint that answers "what is the current aion-cli".
pub const INDEX_URL: &str = "https://crates.io/api/v1/crates/aion-cli";

/// How long the registry query may take before it is reported as unreachable.
/// A version lookup is one small GET; a query that has not answered in this
/// span is a network problem the operator needs told about, not something to
/// keep waiting on before a long install.
const QUERY_TIMEOUT: Duration = Duration::from_secs(15);

/// crates.io identifies the caller by User-Agent and refuses anonymous ones.
const USER_AGENT: &str = concat!("aion-cli/", env!("CARGO_PKG_VERSION"), " (aion update)");

/// Why the registry could not answer which version is current.
///
/// Two faces, kept apart because they want different actions: a network that
/// is down will come back, while an answer this build cannot read will not
/// change by retrying. `Display` is written out by hand rather than derived —
/// `aion-cli` is the binary at the top of the stack and carries `anyhow` for
/// its reporting, not `thiserror` (the workspace rule); a hand-written
/// implementation keeps the type honest without widening the published
/// crate's dependency graph for two sentences.
#[derive(Debug)]
pub enum IndexError {
    /// The request never completed: no network, DNS, TLS, or a timeout.
    Unreachable {
        /// The transport failure, verbatim.
        source: String,
    },
    /// The index answered, but not with something this verb can read.
    Unreadable {
        /// The HTTP status the index answered with.
        status: u16,
        /// What went wrong reading the body.
        detail: String,
    },
}

impl std::fmt::Display for IndexError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Unreachable { source } => write!(
                formatter,
                "could not reach the package index at {INDEX_URL}: {source}. Nothing \
                 was installed and nothing running was touched; retry when the network \
                 is back, or name the version yourself with `--version <x.y.z>`"
            ),
            Self::Unreadable { status, detail } => write!(
                formatter,
                "the package index at {INDEX_URL} answered {status}, which is not a \
                 version record this build can read ({detail}). Nothing was installed; \
                 name the version yourself with `--version <x.y.z>` if you know it"
            ),
        }
    }
}

impl std::error::Error for IndexError {}

/// The registry's answer, as far as this verb reads it.
#[derive(Debug, Deserialize)]
struct IndexAnswer {
    #[serde(rename = "crate")]
    crate_record: CrateRecord,
}

#[derive(Debug, Deserialize)]
struct CrateRecord {
    /// The newest version that is not a pre-release and not yanked — exactly
    /// what a plain `cargo install aion-cli` resolves to.
    max_stable_version: String,
}

/// The latest stable `aion-cli` version the registry publishes.
///
/// # Errors
///
/// Returns [`IndexError`] when the registry cannot be reached or its answer
/// cannot be read as a version record. There is deliberately no fallback: a
/// guessed version is not an answer to "what is current".
pub async fn latest_stable() -> Result<String, IndexError> {
    let client = reqwest::Client::builder()
        .timeout(QUERY_TIMEOUT)
        .user_agent(USER_AGENT)
        .build()
        .map_err(|error| IndexError::Unreachable {
            source: error.to_string(),
        })?;
    let response = client
        .get(INDEX_URL)
        .send()
        .await
        .map_err(|error| IndexError::Unreachable {
            source: error.to_string(),
        })?;
    let status = response.status();
    let body = response
        .text()
        .await
        .map_err(|error| IndexError::Unreadable {
            status: status.as_u16(),
            detail: error.to_string(),
        })?;
    parse_latest_stable(status.as_u16(), &body)
}

/// Read the registry's answer. Split from the request so the parsing contract
/// is provable against fixtures rather than against the network.
///
/// # Errors
///
/// Returns [`IndexError::Unreadable`] for any status other than 200, and for a
/// 200 whose body does not carry `crate.max_stable_version` as a non-empty
/// string.
pub fn parse_latest_stable(status: u16, body: &str) -> Result<String, IndexError> {
    if status != 200 {
        return Err(IndexError::Unreadable {
            status,
            detail: format!(
                "expected 200; the body begins `{}`",
                body.chars().take(120).collect::<String>()
            ),
        });
    }
    let answer: IndexAnswer =
        serde_json::from_str(body).map_err(|error| IndexError::Unreadable {
            status,
            detail: error.to_string(),
        })?;
    let version = answer.crate_record.max_stable_version;
    if version.trim().is_empty() {
        return Err(IndexError::Unreadable {
            status,
            detail: "the record carries an empty max_stable_version".to_owned(),
        });
    }
    Ok(version)
}

#[cfg(test)]
mod tests {
    use super::{IndexError, parse_latest_stable};

    /// The green path, against the shape crates.io actually answers with:
    /// the record carries far more than this verb reads, and the extra fields
    /// must not make it unreadable — a strict decoder would break the update
    /// verb the first time the registry added a field.
    #[test]
    fn the_registry_record_yields_its_max_stable_version() -> Result<(), IndexError> {
        let body = r#"{
            "crate": {
                "id": "aion-cli",
                "name": "aion-cli",
                "max_version": "0.26.0-rc.1",
                "newest_version": "0.26.0-rc.1",
                "max_stable_version": "0.25.1",
                "description": "The aion binary",
                "downloads": 1234
            },
            "versions": [{"num": "0.25.1"}]
        }"#;
        assert_eq!(parse_latest_stable(200, body)?, "0.25.1");
        Ok(())
    }

    /// 🔴 The STABLE version, not the newest. A pre-release published to the
    /// registry must never become what `aion update` installs by default: the
    /// operator asked for "current", and `cargo install aion-cli` with no
    /// version resolves the stable line.
    #[test]
    fn a_prerelease_is_never_the_default_target() -> Result<(), IndexError> {
        let body = r#"{"crate":{"max_version":"0.26.0-rc.1","newest_version":"0.26.0-rc.1","max_stable_version":"0.25.1"}}"#;
        assert_eq!(parse_latest_stable(200, body)?, "0.25.1");
        Ok(())
    }

    /// A non-200 is reported as unreadable with the status AND a slice of the
    /// body: a 404 from a renamed crate and a 503 from an outage want
    /// different actions, and the status alone does not distinguish an HTML
    /// error page from a JSON one.
    #[test]
    fn a_non_success_status_is_reported_with_its_body() -> Result<(), Box<dyn std::error::Error>> {
        let Err(IndexError::Unreadable { status, detail }) =
            parse_latest_stable(503, "<html>upstream busy</html>")
        else {
            return Err("a 503 must not read as a version".into());
        };
        assert_eq!(status, 503);
        assert!(detail.contains("upstream busy"), "{detail}");
        Ok(())
    }

    /// A 200 whose body is not a version record refuses rather than
    /// defaulting: a captive-portal HTML page answers 200, and installing
    /// "whatever" because the body did not parse is exactly the silent
    /// wrong-version failure this refusal exists to prevent.
    #[test]
    fn a_two_hundred_that_is_not_a_version_record_refuses() {
        assert!(matches!(
            parse_latest_stable(200, "<html>sign in to the wifi</html>"),
            Err(IndexError::Unreadable { .. })
        ));
        assert!(matches!(
            parse_latest_stable(200, r#"{"crate":{"max_stable_version":""}}"#),
            Err(IndexError::Unreadable { .. })
        ));
        assert!(matches!(
            parse_latest_stable(200, r#"{"crate":{}}"#),
            Err(IndexError::Unreadable { .. })
        ));
    }
}