// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2021 The Gleam contributors use camino::Utf8Path; use clap::ValueEnum; use debug_ignore::DebugIgnore; use flate2::read::GzDecoder; use futures::future; use hexpm::{ApiError, WriteActionCredentials, version::Version}; use strum::{EnumString, VariantNames}; use tar::Archive; use crate::{ Error, Result, io::{FileSystemReader, FileSystemWriter, HttpClient, TarUnpacker}, manifest::{ManifestPackage, ManifestPackageSource}, paths::{self, ProjectPaths}, }; pub const HEXPM_PUBLIC_KEY: &[u8] = b"-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApqREcFDt5vV21JVe2QNB Edvzk6w36aNFhVGWN5toNJRjRJ6m4hIuG4KaXtDWVLjnvct6MYMfqhC79HAGwyF+ IqR6Q6a5bbFSsImgBJwz1oadoVKD6ZNetAuCIK84cjMrEFRkELtEIPNHblCzUkkM 3rS9+DPlnfG8hBvGi6tvQIuZmXGCxF/73hU0/MyGhbmEjIKRtG6b0sJYKelRLTPW XgK7s5pESgiwf2YC/2MGDXjAJfpfCd0RpLdvd4eRiXtVlE9qO9bND94E7PgQ/xqZ J1i2xWFndWa6nfFnRxZmCStCOZWYYPlaxr+FZceFbpMwzTNs4g3d4tLNUcbKAIH4 0wIDAQAB -----END PUBLIC KEY----- "; fn key_name(hostname: &str) -> String { format!("gleam-{hostname}") } pub async fn publish_package( release_tarball: Vec, version: String, name: &str, api_key: &WriteActionCredentials, config: &hexpm::Config, replace: bool, http: &Http, ) -> Result<()> { tracing::info!("Publishing package, replace: {}", replace); let request = hexpm::api_publish_package_request(release_tarball, api_key, config, replace); let response = http.send(request).await?; hexpm::api_publish_package_response(response).map_err(|e| match e { ApiError::NotReplacing => Error::HexPublishReplaceRequired { version }, ApiError::Forbidden => Error::HexPublishAccessDenied { name: name.into(), version, }, ApiError::Json(_) | ApiError::Io(_) | ApiError::RateLimited | ApiError::InvalidCredentials | ApiError::UnexpectedResponse(..) | ApiError::InvalidPackageNameFormat(_) | ApiError::IncorrectPayloadSignature | ApiError::InvalidProtobuf(_) | ApiError::InvalidVersionFormat(_) | ApiError::NotFound | ApiError::InvalidVersionRequirementFormat(_) | ApiError::IncorrectChecksum | ApiError::OAuthTimeout | ApiError::OAuthAccessDenied | ApiError::ExpiredToken | ApiError::OAuthRefreshTokenRejected | ApiError::IncorrectOneTimePassword | ApiError::LateDeletion | ApiError::LateModification => Error::hex(e), }) } pub async fn add_owner( api_key: &WriteActionCredentials, package_name: String, new_owner_username_or_email: String, level: hexpm::OwnerLevel, config: &hexpm::Config, http: &Http, ) -> Result<()> { tracing::info!( "Adding {} as owner of `{}`", new_owner_username_or_email, package_name ); let request = hexpm::api_add_owner_request( &package_name, &new_owner_username_or_email, level, api_key, config, ); let response = http.send(request).await?; hexpm::api_add_owner_response(response).map_err(Error::hex) } pub async fn transfer_owner( api_key: &WriteActionCredentials, package_name: String, new_owner_username_or_email: String, config: &hexpm::Config, http: &Http, ) -> Result<()> { tracing::info!( "Transferring ownership of `{}` to {}", package_name, new_owner_username_or_email ); let request = hexpm::api_transfer_owner_request( &package_name, &new_owner_username_or_email, api_key, config, ); let response = http.send(request).await?; hexpm::api_transfer_owner_response(response).map_err(Error::hex) } #[derive(Debug, EnumString, VariantNames, ValueEnum, Clone, Copy, PartialEq, Eq)] #[strum(serialize_all = "lowercase")] pub enum RetirementReason { Other, Invalid, Security, Deprecated, Renamed, } impl RetirementReason { pub fn to_library_enum(&self) -> hexpm::RetirementReason { match self { RetirementReason::Other => hexpm::RetirementReason::Other, RetirementReason::Invalid => hexpm::RetirementReason::Invalid, RetirementReason::Security => hexpm::RetirementReason::Security, RetirementReason::Deprecated => hexpm::RetirementReason::Deprecated, RetirementReason::Renamed => hexpm::RetirementReason::Renamed, } } } pub async fn retire_release( package: &str, version: &str, reason: RetirementReason, message: Option<&str>, api_key: &WriteActionCredentials, config: &hexpm::Config, http: &Http, ) -> Result<()> { tracing::info!(package=%package, version=%version, "retiring_hex_release"); let request = hexpm::api_retire_release_request( package, version, reason.to_library_enum(), message, api_key, config, ); let response = http.send(request).await?; hexpm::api_retire_release_response(response).map_err(Error::hex) } pub async fn unretire_release( package: &str, version: &str, api_key: &WriteActionCredentials, config: &hexpm::Config, http: &Http, ) -> Result<()> { tracing::info!(package=%package, version=%version, "retiring_hex_release"); let request = hexpm::api_unretire_release_request(package, version, api_key, config); let response = http.send(request).await?; hexpm::api_unretire_release_response(response).map_err(Error::hex) } pub async fn remove_api_key( hostname: &str, config: &hexpm::Config, auth_key: &WriteActionCredentials, http: &Http, ) -> Result<()> { tracing::info!("Deleting API key from Hex"); let request = hexpm::api_remove_api_key_request(&key_name(hostname), auth_key, config); let response = http.send(request).await?; hexpm::api_remove_api_key_response(response).map_err(Error::hex) } #[derive(Debug)] pub struct Downloader { fs_reader: DebugIgnore>, fs_writer: DebugIgnore>, http: DebugIgnore>, untar: DebugIgnore>, hex_config: hexpm::Config, credentials: DebugIgnore>, paths: ProjectPaths, } impl Downloader { pub fn new( fs_reader: Box, fs_writer: Box, http: Box, untar: Box, credentials: Option, paths: ProjectPaths, ) -> Self { Self { fs_reader: DebugIgnore(fs_reader), fs_writer: DebugIgnore(fs_writer), http: DebugIgnore(http), untar: DebugIgnore(untar), hex_config: hexpm::Config::new(), credentials: DebugIgnore(credentials), paths, } } pub async fn ensure_package_downloaded( &self, package: &ManifestPackage, ) -> Result { let outer_checksum = match &package.source { ManifestPackageSource::Hex { outer_checksum } => outer_checksum, ManifestPackageSource::Git { .. } | ManifestPackageSource::Local { .. } => { panic!("Attempt to download non-hex package from hex") } }; let tarball_path = paths::global_package_cache_package_tarball(outer_checksum); if self.fs_reader.is_file(&tarball_path) { tracing::info!( package = package.name.as_str(), version = %package.version, "package_in_cache" ); return Ok(false); } tracing::info!( package = &package.name.as_str(), version = %package.version, "downloading_package_to_cache" ); let request = hexpm::repository_get_package_tarball_request( &package.name, &package.version.to_string(), self.credentials.as_ref(), &self.hex_config, ); let response = self.http.send(request).await?; let tarball = hexpm::repository_get_package_tarball_response(response, &outer_checksum.0) .map_err(|error| Error::DownloadPackageError { package_name: package.name.to_string(), package_version: package.version.to_string(), error: error.to_string(), })?; self.fs_writer.write_bytes(&tarball_path, &tarball)?; Ok(true) } pub async fn ensure_package_in_build_directory( &self, package: &ManifestPackage, ) -> Result { let _ = self.ensure_package_downloaded(package).await?; self.extract_package_from_cache(package) } // It would be really nice if this was async but the library is sync pub fn extract_package_from_cache(&self, package: &ManifestPackage) -> Result { let contents_path = Utf8Path::new("contents.tar.gz"); let destination = self.paths.build_packages_package(&package.name); let outer_checksum = match &package.source { ManifestPackageSource::Hex { outer_checksum } => outer_checksum, ManifestPackageSource::Git { .. } | ManifestPackageSource::Local { .. } => { panic!("Attempt to download non-hex package from hex") } }; // If the directory already exists then there's nothing for us to do if self.fs_reader.is_directory(&destination) { tracing::info!( package = package.name.as_str(), "Package already in build directory" ); return Ok(false); } tracing::info!(package = package.name.as_str(), "writing_package_to_target"); let tarball = paths::global_package_cache_package_tarball(outer_checksum); let reader = self.fs_reader.reader(&tarball)?; let mut archive = Archive::new(reader); // Find the source code from within the outer tarball for entry in self.untar.entries(&mut archive)? { let file = entry.map_err(Error::expand_tar)?; let path = file.header().path().map_err(Error::expand_tar)?; if path.as_ref() == contents_path { // Expand this inner source code and write to the file system let archive = Archive::new(GzDecoder::new(file)); let result = self.untar.unpack(&destination, archive); // If we failed to expand the tarball remove any source code // that was partially written so that we don't mistakenly think // the operation succeeded next time we run. return match result { Ok(()) => Ok(true), Err(err) => { self.fs_writer.delete_directory(&destination)?; Err(err) } }; } } Err(Error::ExpandTar { error: "Unable to locate Hex package contents.tar.gz".into(), }) } pub async fn download_hex_packages<'a, Packages: Iterator>( &self, packages: Packages, project_name: &str, ) -> Result<()> { let futures = packages .filter(|package| project_name != package.name) .map(|package| self.ensure_package_in_build_directory(package)); // Run the futures to download the packages concurrently let results = future::join_all(futures).await; // Count the number of packages downloaded while checking for errors for result in results { let _ = result?; } Ok(()) } } pub async fn publish_documentation( name: &str, version: &Version, archive: Vec, api_key: &WriteActionCredentials, config: &hexpm::Config, http: &Http, ) -> Result<()> { tracing::info!("publishing_documentation"); let request = hexpm::api_publish_docs_request(name, &version.to_string(), archive, api_key, config) .map_err(Error::hex)?; let response = http.send(request).await?; hexpm::api_publish_docs_response(response).map_err(Error::hex) } pub async fn get_package_release( name: &str, version: &Version, credentials: Option<&hexpm::Credentials>, config: &hexpm::Config, http: &Http, ) -> Result> { let version = version.to_string(); tracing::info!( name = name, version = version.as_str(), "looking_up_package_release" ); let request = hexpm::api_get_package_release_request(name, &version, credentials, config); let response = http.send(request).await?; hexpm::api_get_package_release_response(response).map_err(Error::hex) } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; /// A fake `HttpClient` that records the `authorization` header of the last /// request it was sent and replies with a canned package release. #[derive(Default)] struct AuthorizationCapturingHttpClient { last_authorization: Mutex>, } #[async_trait::async_trait] impl HttpClient for AuthorizationCapturingHttpClient { async fn send( &self, request: http::Request>, ) -> Result>, Error> { let authorization = request .headers() .get("authorization") .map(|value| value.to_str().unwrap().to_string()); *self.last_authorization.lock().unwrap() = authorization; let body = serde_json::json!({ "version": "1.0.0", "checksum": "960090c2fb391784bb34267b099dc9315cc1b1f6013e7415bc763cef1905d7d3", "requirements": {}, "meta": { "app": "gleam_stdlib", "build_tools": ["gleam"] } }) .to_string() .into_bytes(); Ok(http::Response::builder().status(200).body(body).unwrap()) } } #[test] fn get_package_release_authenticates_with_api_key() { let http = AuthorizationCapturingHttpClient::default(); let credentials = hexpm::Credentials::ApiKey("secret-key".into()); let _ = futures::executor::block_on(get_package_release( "gleam_stdlib", &Version::new(1, 0, 0), Some(&credentials), &hexpm::Config::new(), &http, )) .unwrap(); assert_eq!( http.last_authorization.lock().unwrap().as_deref(), Some("secret-key") ); } #[test] fn get_package_release_is_anonymous_without_api_key() { let http = AuthorizationCapturingHttpClient::default(); let _ = futures::executor::block_on(get_package_release( "gleam_stdlib", &Version::new(1, 0, 0), None, &hexpm::Config::new(), &http, )) .unwrap(); assert_eq!(http.last_authorization.lock().unwrap().as_deref(), None); } }