geam-cli 0.2.1

Standalone command implementation for Geam
Documentation
use super::archive::MAX_COMPRESSED_SIZE;
use super::{ProviderRegistry, RegistryAccessError};
use std::time::Duration;

const SEARCH_BODY_LIMIT: usize = 1024 * 1024;
const INDEX_BODY_LIMIT: usize = 16 * 1024 * 1024;
const CONFIG_BODY_LIMIT: usize = 64 * 1024;
const USER_AGENT: &str = concat!("geam/", env!("CARGO_PKG_VERSION"));

pub(in crate::provider) struct CratesIoRegistry {
    agent: ureq::Agent,
}

impl Default for CratesIoRegistry {
    fn default() -> Self {
        let config = ureq::Agent::config_builder()
            .timeout_global(Some(Duration::from_secs(30)))
            .timeout_connect(Some(Duration::from_secs(10)))
            .https_only(true)
            .build();
        Self {
            agent: config.into(),
        }
    }
}

impl ProviderRegistry for CratesIoRegistry {
    fn search(&self, query: &str) -> Result<Vec<u8>, RegistryAccessError> {
        access(
            "search crates.io",
            self.request::<SEARCH_BODY_LIMIT>(&search_url(query)),
        )
    }

    fn index(&self, crate_name: &str) -> Result<Vec<u8>, RegistryAccessError> {
        access(
            format!("read crates.io sparse index for {crate_name}"),
            self.request::<INDEX_BODY_LIMIT>(&index_url(crate_name)),
        )
    }

    fn configuration(&self) -> Result<Vec<u8>, RegistryAccessError> {
        access(
            "read crates.io registry configuration",
            self.request::<CONFIG_BODY_LIMIT>("https://index.crates.io/config.json"),
        )
    }

    fn download(&self, url: &str) -> Result<Vec<u8>, RegistryAccessError> {
        access(
            format!("download provider archive from {url}"),
            self.request::<{ MAX_COMPRESSED_SIZE + 1 }>(url),
        )
    }
}

impl CratesIoRegistry {
    fn request<const LIMIT: usize>(&self, url: &str) -> Result<Vec<u8>, ureq::Error> {
        self.agent
            .get(url)
            .header("User-Agent", USER_AGENT)
            .call()
            .and_then(read_response::<LIMIT>)
    }
}

fn access(
    operation: impl Into<String>,
    result: Result<Vec<u8>, ureq::Error>,
) -> Result<Vec<u8>, RegistryAccessError> {
    result.map_err(|error| RegistryAccessError::new(operation, error.to_string()))
}

fn read_response<const LIMIT: usize>(
    mut response: ureq::http::Response<ureq::Body>,
) -> Result<Vec<u8>, ureq::Error> {
    response
        .body_mut()
        .with_config()
        .limit(LIMIT as u64)
        .read_to_vec()
}

fn search_url(query: &str) -> String {
    format!("https://crates.io/api/v1/crates?q={query}&per_page=100")
}

fn index_url(crate_name: &str) -> String {
    format!("https://index.crates.io/{}", sparse_index_path(crate_name))
}

pub(super) fn sparse_index_path(crate_name: &str) -> String {
    let crate_name = crate_name.to_ascii_lowercase();
    match crate_name.len() {
        1 => format!("1/{crate_name}"),
        2 => format!("2/{crate_name}"),
        3 => format!("3/{}/{crate_name}", &crate_name[..1]),
        _ => format!("{}/{}/{crate_name}", &crate_name[..2], &crate_name[2..4]),
    }
}

#[cfg(test)]
mod tests {
    use super::{USER_AGENT, access, index_url, read_response, search_url, sparse_index_path};
    use crate::provider::registry::{CratesIoRegistry, ProviderRegistry, RegistryAccessError};
    use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
    use ureq::unversioned::{
        resolver::{ResolvedSocketAddrs, Resolver},
        transport::{ConnectionDetails, Connector, NextTimeout},
    };
    use ureq::{Agent, Error, config::Config, http::Uri};

    #[test]
    fn constructs_exact_crates_io_endpoints_and_user_agent() {
        assert_eq!(
            search_url("geam-company-image"),
            "https://crates.io/api/v1/crates?q=geam-company-image&per_page=100",
        );
        assert_eq!(
            index_url("Geam-Images"),
            "https://index.crates.io/ge/am/geam-images"
        );
        assert_eq!(
            ["a", "ab", "abc", "abcd"].map(sparse_index_path),
            ["1/a", "2/ab", "3/a/abc", "ab/cd/abcd"],
        );
        assert_eq!(USER_AGENT, format!("geam/{}", env!("CARGO_PKG_VERSION")));
    }

    #[test]
    fn bounds_response_bodies_and_preserves_request_failures() {
        let response = ureq::http::Response::new(ureq::Body::builder().data(b"body".to_vec()));
        assert_eq!(
            read_response::<5>(response).expect("bounded response should read"),
            b"body",
        );

        let response = ureq::http::Response::new(ureq::Body::builder().data(b"large".to_vec()));
        let oversized = read_response::<4>(response).expect_err("oversized response should fail");
        assert_eq!(format!("{oversized:?}"), "BodyExceedsLimit(4)");
        assert_eq!(
            oversized.to_string(),
            "the response body is larger than request limit: 4",
        );
        let invalid_url = CratesIoRegistry::default()
            .request::<4>("not a URL")
            .expect_err("invalid request URL should fail");
        assert!(matches!(
            invalid_url,
            Error::Http(error) if error.to_string().contains("invalid uri character")
        ));
        assert_eq!(
            access("request", Ok(b"body".to_vec()))
                .expect("successful response should pass through"),
            b"body",
        );
        assert_eq!(
            access("request", Err(ureq::Error::ConnectionFailed))
                .expect_err("request failure should become an owned registry error"),
            RegistryAccessError::new("request", "connection failed"),
        );
    }

    #[test]
    fn maps_each_crates_io_request_boundary_without_network_access() {
        let registry = CratesIoRegistry {
            agent: Agent::with_parts(Config::default(), FailingConnector, FixedResolver),
        };

        assert_eq!(
            registry.search("geam-images"),
            Err(RegistryAccessError::new(
                "search crates.io",
                "connection failed",
            )),
        );
        assert_eq!(
            registry.index("geam-images"),
            Err(RegistryAccessError::new(
                "read crates.io sparse index for geam-images",
                "connection failed",
            )),
        );
        assert_eq!(
            registry.configuration(),
            Err(RegistryAccessError::new(
                "read crates.io registry configuration",
                "connection failed",
            )),
        );
        assert_eq!(
            registry.download("https://downloads.example/archive"),
            Err(RegistryAccessError::new(
                "download provider archive from https://downloads.example/archive",
                "connection failed",
            )),
        );
    }

    #[derive(Debug)]
    struct FixedResolver;

    impl Resolver for FixedResolver {
        fn resolve(
            &self,
            _: &Uri,
            _: &Config,
            _: NextTimeout,
        ) -> Result<ResolvedSocketAddrs, Error> {
            let mut addresses = self.empty();
            addresses.push(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 443)));
            Ok(addresses)
        }
    }

    #[derive(Debug)]
    struct FailingConnector;

    impl Connector for FailingConnector {
        type Out = ();

        fn connect(
            &self,
            _: &ConnectionDetails<'_>,
            _: Option<()>,
        ) -> Result<Option<Self::Out>, Error> {
            Err(Error::ConnectionFailed)
        }
    }
}