diff --git a/Cargo.lock b/Cargo.lock index a31753c..af49973 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,6 +378,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "url", ] [[package]] @@ -2739,9 +2740,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", diff --git a/axoupdater/Cargo.toml b/axoupdater/Cargo.toml index 3f8b6d2..146e66c 100644 --- a/axoupdater/Cargo.toml +++ b/axoupdater/Cargo.toml @@ -25,6 +25,7 @@ camino = { version = "1.1.6", features = ["serde1"] } homedir = "0.3.3" serde = "1.0.197" tempfile = "3.10.1" +url = "2.5.2" # axo releases gazenot = { version = "0.3.3", features = ["client_lib"], optional = true } diff --git a/axoupdater/src/errors.rs b/axoupdater/src/errors.rs index 7251a7a..2a5eea2 100644 --- a/axoupdater/src/errors.rs +++ b/axoupdater/src/errors.rs @@ -46,6 +46,10 @@ pub enum AxoupdateError { #[error(transparent)] Version(#[from] axotag::semver::Error), + /// Failed to parse a URL + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + /// Failure when converting a PathBuf to a Utf8PathBuf #[error("An internal error occurred when decoding path `{:?}' to utf8", path)] #[diagnostic(help("This probably isn't your fault; please open an issue!"))] @@ -166,4 +170,26 @@ pub enum AxoupdateError { )] #[diagnostic(help("This probably isn't your fault; please open an issue at https://github.com/axodotdev/axoupdater!"))] CleanupFailed {}, + + /// User passed conflicting GitHub API environment variables + #[error("Both {ghe_env_var} and {github_env_var} have been set in the environment")] + #[diagnostic(help("These variables are mutually exclusive; please pick one."))] + MultipleGitHubAPIs { + /// The GitHub Enterprise env var + ghe_env_var: String, + /// The GitHub env var + github_env_var: String, + }, + + /// Couldn't parse the text domain (could be an IP, etc.) + #[error("Unable to parse the domain from the passed url: {url}")] + #[diagnostic(help("The {env_var} variable only takes domains. If you're using an IP, we recommend the GitHub Enterprise-style variable: {ghe_env_var}"))] + GitHubDomainParseError { + /// The GitHub env var + env_var: String, + /// The GitHub Enterprise env var + ghe_env_var: String, + /// The supplied URL + url: String, + }, } diff --git a/axoupdater/src/lib.rs b/axoupdater/src/lib.rs index dce07fe..817b351 100644 --- a/axoupdater/src/lib.rs +++ b/axoupdater/src/lib.rs @@ -512,7 +512,7 @@ impl AxoUpdater { // Also set the app-specific name for this; in the future, the // CARGO_DIST_ version may be removed. let app_name = self.name.clone().unwrap_or_default(); - let app_name_env_var = app_name.to_ascii_uppercase().replace('-', "_"); + let app_name_env_var = app_name_to_env_var(&app_name); let app_specific_env_var = format!("{app_name_env_var}_INSTALL_DIR"); command.env(app_specific_env_var, &install_prefix); @@ -621,6 +621,11 @@ fn get_app_name() -> Option { } } +/// Returns an environment variable-compatible version of the app name. +pub fn app_name_to_env_var(app_name: &str) -> String { + app_name.to_ascii_uppercase().replace('-', "_") +} + fn root_without_bin(path: &Utf8PathBuf) -> Utf8PathBuf { if path.file_name() == Some("bin") { if let Some(parent) = path.parent() { diff --git a/axoupdater/src/release/github.rs b/axoupdater/src/release/github.rs index a610371..e1adf4a 100644 --- a/axoupdater/src/release/github.rs +++ b/axoupdater/src/release/github.rs @@ -1,7 +1,7 @@ //! Fetching and processing from GitHub Releases use super::{Asset, Release}; -use crate::errors::*; +use crate::{app_name_to_env_var, errors::*}; use axoasset::reqwest::{ self, header::{ACCEPT, USER_AGENT}, @@ -9,9 +9,36 @@ use axoasset::reqwest::{ use axotag::{parse_tag, Version}; use serde::{Deserialize, Serialize}; use std::env; +use url::Url; + +fn github_api(app_name: &str) -> AxoupdateResult { + let formatted_app_name = app_name_to_env_var(app_name); + let ghe_env_var = format!("{}_INSTALLER_GHE_BASE_URL", formatted_app_name); + let github_env_var = format!("{}_INSTALLER_GITHUB_BASE_URL", formatted_app_name); + + if env::var(&ghe_env_var).is_ok() && env::var(&github_env_var).is_ok() { + return Err(AxoupdateError::MultipleGitHubAPIs { + ghe_env_var, + github_env_var, + }); + } -fn github_api() -> String { - env::var("INSTALLER_BASE_URL").unwrap_or_else(|_| "https://api.github.com".to_string()) + if let Ok(value) = env::var(&ghe_env_var) { + let parsed = Url::parse(&value)?; + Ok(parsed.join("api/v3")?.to_string()) + } else if let Ok(value) = env::var(&github_env_var) { + let parsed = Url::parse(&value)?; + let Some(domain) = parsed.domain() else { + return Err(AxoupdateError::GitHubDomainParseError { + env_var: github_env_var, + ghe_env_var, + url: value, + }); + }; + Ok(format!("{}://api.{}", parsed.scheme(), domain)) + } else { + Ok("https://api.github.com".to_string()) + } } /// A struct representing a specific GitHub Release @@ -47,7 +74,7 @@ pub(crate) async fn get_latest_github_release( token: &Option, ) -> AxoupdateResult> { let client = reqwest::Client::new(); - let api: String = github_api(); + let api: String = github_api(app_name)?; let mut request = client .get(format!("{api}/repos/{owner}/{name}/releases/latest")) .header(ACCEPT, "application/json") @@ -93,7 +120,7 @@ pub(crate) async fn get_specific_github_tag( token: &Option, ) -> AxoupdateResult { let client = reqwest::Client::new(); - let api: String = github_api(); + let api: String = github_api(app_name)?; let mut request = client .get(format!("{api}/repos/{owner}/{name}/releases/tags/{tag}")) .header(ACCEPT, "application/json") @@ -147,7 +174,7 @@ pub(crate) async fn get_github_releases( token: &Option, ) -> AxoupdateResult> { let client = reqwest::Client::new(); - let api: String = github_api(); + let api: String = github_api(app_name)?; let mut url = format!("{api}/repos/{owner}/{name}/releases"); let mut pages_remain = true; let mut data: Vec = vec![]; @@ -324,8 +351,8 @@ mod test { #[test] #[serial] // modifying the global state environment variables fn test_github_api_no_env_var() { - env::remove_var("INSTALLER_BASE_URL"); - let result = github_api(); + env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); + let result = github_api("dist").unwrap(); assert_eq!(result, "https://api.github.com"); } @@ -333,21 +360,70 @@ mod test { #[test] #[serial] // modifying the global state environment variables fn test_github_api_overwrite() { - env::set_var("INSTALLER_BASE_URL", "https://magic.com"); - let result = github_api(); + env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "https://magic.com"); + let result = github_api("dist").unwrap(); + env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); + + assert_eq!(result, "https://api.magic.com"); + } + + #[test] + #[serial] // modifying the global state environment variables + fn test_github_api_overwrite_ip() { + env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "https://127.0.0.1"); + let result = github_api("dist"); + env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); + assert!(result.is_err()); + } + + #[test] + #[serial] // modifying the global state environment variables + fn test_github_api_overwrite_bad_value() { + env::set_var("DIST_INSTALLER_GITHUB_BASE_URL", "this is not a url"); + let result = github_api("dist"); + env::remove_var("DIST_INSTALLER_GITHUB_BASE_URL"); + assert!(result.is_err()); + } - assert_eq!(result, "https://magic.com"); + #[test] + #[serial] // modifying the global state environment variables + fn test_ghe_api_no_env_var() { + env::remove_var("DIST_INSTALLER_GHE_BASE_URL"); + let result = github_api("dist").unwrap(); + + assert_eq!(result, "https://api.github.com"); + } + + #[test] + #[serial] // modifying the global state environment variables + fn test_ghe_api_overwrite() { + env::set_var("DIST_INSTALLER_GHE_BASE_URL", "https://magic.com"); + let result = github_api("dist").unwrap(); + env::remove_var("DIST_INSTALLER_GHE_BASE_URL"); + + assert_eq!(result, "https://magic.com/api/v3"); + } + + #[test] + #[serial] // modifying the global state environment variables + fn test_ghe_ip_api_overwrite() { + env::set_var("DIST_INSTALLER_GHE_BASE_URL", "https://127.0.0.1"); + let result = github_api("dist").unwrap(); + env::remove_var("DIST_INSTALLER_GHE_BASE_URL"); + + assert_eq!(result, "https://127.0.0.1/api/v3"); } #[tokio::test] #[serial] // modifying the global state environment variables async fn test_get_latest_github_release_custom_endpoint() { let server = MockServer::start_async().await; - env::set_var("INSTALLER_BASE_URL", server.base_url()); + env::set_var("APP_INSTALLER_GHE_BASE_URL", server.base_url()); let latest_release_http_call = server .mock_async(|when, then| { - when.method("GET").path("/repos/owner/name/releases/latest"); + when.method("GET") + .path("/api/v3/repos/owner/name/releases/latest"); then.status(StatusCode::OK.as_u16()) .header("content-type", "application/json") .json_body(json!(build_test_git_hub_release())); @@ -355,6 +431,7 @@ mod test { .await; let result = get_latest_github_release("name", "owner", "app", &None).await; + env::remove_var("APP_INSTALLER_GHE_BASE_URL"); assert!(result.is_ok()); assert!(result.unwrap().is_some()); @@ -380,12 +457,12 @@ mod test { #[serial] // modifying the global state environment variables async fn test_get_specific_github_tag_custom_endpoint() { let server = MockServer::start_async().await; - env::set_var("INSTALLER_BASE_URL", server.base_url()); + env::set_var("APP_INSTALLER_GHE_BASE_URL", server.base_url()); let release_tag_http_call = server .mock_async(|when, then| { when.method("GET") - .path("/repos/owner/name/releases/tags/1.0.0"); + .path("/api/v3/repos/owner/name/releases/tags/1.0.0"); then.status(StatusCode::OK.as_u16()) .header("content-type", "application/json") .json_body(json!(build_test_git_hub_release())); @@ -393,6 +470,7 @@ mod test { .await; let result = get_specific_github_tag("name", "owner", "app", "1.0.0", &None).await; + env::remove_var("APP_INSTALLER_GHE_BASE_URL"); assert!(result.is_ok()); @@ -403,11 +481,11 @@ mod test { #[serial] // modifying the global state environment variables async fn test_get_github_releases_custom_endpoint() { let server = MockServer::start_async().await; - env::set_var("INSTALLER_BASE_URL", server.base_url()); + env::set_var("APP_INSTALLER_GHE_BASE_URL", server.base_url()); let releases_http_call = server .mock_async(|when, then| { - when.method("GET").path("/repos/owner/name/releases"); + when.method("GET").path("/api/v3/repos/owner/name/releases"); then.status(StatusCode::OK.as_u16()) .header("content-type", "application/json") .json_body(json!(vec![build_test_git_hub_release()])); @@ -415,6 +493,7 @@ mod test { .await; let result = get_github_releases("name", "owner", "app", &None).await; + env::remove_var("APP_INSTALLER_GHE_BASE_URL"); assert!(result.is_ok());