diff --git a/src/internal/cache/github_release.rs b/src/internal/cache/github_release.rs index 1001fdfe..fda42311 100644 --- a/src/internal/cache/github_release.rs +++ b/src/internal/cache/github_release.rs @@ -1,10 +1,9 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::io; -use std::str::FromStr; use lazy_static::lazy_static; -use node_semver::Version as semverVersion; +use regex::Regex; use serde::Deserialize; use serde::Serialize; use time::OffsetDateTime; @@ -24,6 +23,33 @@ const GITHUB_RELEASE_CACHE_NAME: &str = "github_release_operation"; lazy_static! { static ref GITHUB_RELEASE_OPERATION_NOW: OffsetDateTime = OffsetDateTime::now_utc(); + static ref VERSION_REGEX: Regex = { + let pattern = r"^(?P[^0-9]*)(?P\d+(?:\.\d+)*(?:\-[\w\d_-]+)?)$"; + match Regex::new(pattern) { + Ok(regex) => regex, + Err(err) => panic!("failed to create version regex: {}", err), + } + }; + static ref OS_REGEX: regex::Regex = + match regex::Regex::new(&format!(r"(?i)\b({})\b", compatible_release_os().join("|"))) { + Ok(os_re) => os_re, + Err(err) => panic!("failed to create OS regex: {}", err), + }; + static ref ARCH_REGEX: regex::Regex = match regex::Regex::new(&format!( + r"(?i)\b({})\b", + compatible_release_arch().join("|") + )) { + Ok(arch_re) => arch_re, + Err(err) => panic!("failed to create architecture regex: {}", err), + }; + static ref SEPARATOR_MID_REGEX: regex::Regex = match regex::Regex::new(r"([-_]{2,})") { + Ok(separator_re) => separator_re, + Err(err) => panic!("failed to create separator regex: {}", err), + }; + static ref SEPARATOR_END_REGEX: regex::Regex = match regex::Regex::new(r"(^[-_]+|[-_]+$)") { + Ok(separator_re) => separator_re, + Err(err) => panic!("failed to create separator regex: {}", err), + }; } // TODO: merge this with homebrew_operation_now, maybe up_operation_now? @@ -176,7 +202,8 @@ impl GithubReleases { &self, version: &str, prerelease: bool, - ) -> Option<(semverVersion, GithubReleaseVersion)> { + binary: bool, + ) -> Option<(String, GithubReleaseVersion)> { self.releases .iter() .filter_map(|release| { @@ -191,15 +218,10 @@ impl GithubReleases { } // Parse the version - let release_version = match release.version() { - Ok(release_version) => release_version, - Err(_) => { - return None; - } - }; + let release_version = release.version(); // Make sure the version fits the requested version - if !version_match(version, &release_version.to_string(), prerelease) { + if !version_match(version, &release_version, prerelease) { return None; } @@ -209,11 +231,11 @@ impl GithubReleases { .assets .iter() .filter(|asset| { - let is_tgz = - asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz"); - let is_zip = asset.name.ends_with(".zip"); - - if !is_tgz && !is_zip { + if let Some((asset_type, _)) = asset.file_type() { + if asset_type.is_binary() && !binary { + return false; + } + } else { return false; } @@ -268,14 +290,32 @@ pub struct GithubReleaseVersion { } impl GithubReleaseVersion { - pub fn version(&self) -> Result { - // Get the version as the tag name but ideally without the first v - let version = match self.tag_name.strip_prefix('v') { - Some(version) => version, - None => &self.tag_name, + pub fn version(&self) -> String { + // Try and get the version from the tag name, and keep the prefix + // if it exists, as we will try and add it as build metadata + let captures = match VERSION_REGEX.captures(&self.tag_name) { + Some(captures) => captures, + None => return self.tag_name.clone(), }; - semverVersion::from_str(version).map_err(|err| format!("failed to parse version: {}", err)) + let version = match captures.name("version") { + Some(version) => version.as_str().to_string(), + None => return self.tag_name.clone(), + }; + + // if let Some(prefix) = captures.name("prefix") { + // let prefix = prefix.as_str(); + // if prefix != "v" { + // let prefix = match prefix.strip_suffix('-') { + // Some(prefix) => prefix, + // None => prefix, + // }; + + // version = format!("{}-{}", version, prefix); + // } + // } + + version } } @@ -287,3 +327,63 @@ pub struct GithubReleaseAsset { pub content_type: String, pub size: u64, } + +impl GithubReleaseAsset { + const TAR_GZ_EXTS: [&'static str; 2] = [".tar.gz", ".tgz"]; + const ZIP_EXTS: [&'static str; 1] = [".zip"]; + + pub fn file_type(&self) -> Option<(GithubReleaseAssetType, String)> { + for ext in Self::TAR_GZ_EXTS.iter() { + if let Some(prefix) = self.name.strip_suffix(ext) { + return Some((GithubReleaseAssetType::TarGz, prefix.to_string())); + } + } + + for ext in Self::ZIP_EXTS.iter() { + if let Some(prefix) = self.name.strip_suffix(ext) { + return Some((GithubReleaseAssetType::Zip, prefix.to_string())); + } + } + + if self.name.ends_with(".exe") { + return Some((GithubReleaseAssetType::Binary, self.name.clone())); + } + + if !self.name.contains('.') { + return Some((GithubReleaseAssetType::Binary, self.name.clone())); + } + + None + } + + pub fn clean_name(&self, version: &str) -> String { + let name = self.name.clone(); + let name = OS_REGEX.replace_all(&name, ""); + let name = ARCH_REGEX.replace_all(&name, ""); + let name = name.replace(version, ""); + let name = SEPARATOR_MID_REGEX.replace_all(&name, "-"); + let name = SEPARATOR_END_REGEX.replace_all(&name, ""); + name.to_string() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum GithubReleaseAssetType { + TarGz, + Zip, + Binary, +} + +impl GithubReleaseAssetType { + pub fn is_zip(&self) -> bool { + matches!(self, Self::Zip) + } + + pub fn is_tgz(&self) -> bool { + matches!(self, Self::TarGz) + } + + pub fn is_binary(&self) -> bool { + matches!(self, Self::Binary) + } +} diff --git a/src/internal/cache/utils.rs b/src/internal/cache/utils.rs index 388f6fb3..fd70decb 100644 --- a/src/internal/cache/utils.rs +++ b/src/internal/cache/utils.rs @@ -32,6 +32,14 @@ pub trait Empty { fn is_empty(&self) -> bool; } +pub fn set_true() -> bool { + true +} + +pub fn is_true(value: &bool) -> bool { + *value +} + pub fn set_false() -> bool { false } diff --git a/src/internal/config/up/github_release.rs b/src/internal/config/up/github_release.rs index 33b6675c..abddd2f7 100644 --- a/src/internal/config/up/github_release.rs +++ b/src/internal/config/up/github_release.rs @@ -40,6 +40,15 @@ pub struct UpConfigGithubRelease { #[serde(default, skip_serializing_if = "cache_utils::is_false")] pub prerelease: bool, + /// Whether to install a file that is not currently in an + /// archive. This is useful for tools that are being + /// distributed as a single binary file outside of an archive. + #[serde( + default = "cache_utils::set_true", + skip_serializing_if = "cache_utils::is_true" + )] + pub binary: bool, + /// The URL of the GitHub API; this is only required if downloading /// using Github Enterprise. By default, this is set to the public /// GitHub API URL (https://api.github.com). If you are using @@ -95,6 +104,11 @@ impl UpConfigGithubRelease { .map(|v| v.as_bool()) .unwrap_or(None) .unwrap_or(false); + let binary = table + .get("binary") + .map(|v| v.as_bool()) + .unwrap_or(None) + .unwrap_or(true); let api_url = table .get("api_url") .map(|v| v.as_str_forced()) @@ -104,6 +118,7 @@ impl UpConfigGithubRelease { repository, version, prerelease, + binary, api_url, ..UpConfigGithubRelease::default() } @@ -366,11 +381,13 @@ impl UpConfigGithubRelease { ) -> Result { let version = self.version.clone().unwrap_or_else(|| "latest".to_string()); - let (version, release) = releases.get(&version, self.prerelease).ok_or_else(|| { - let errmsg = "no matching release found".to_string(); - progress_handler.error_with_message(errmsg.clone()); - UpError::Exec(errmsg) - })?; + let (version, release) = releases + .get(&version, self.prerelease, self.binary) + .ok_or_else(|| { + let errmsg = "no matching release found".to_string(); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; self.actual_version.set(version.to_string()).map_err(|_| { let errmsg = "failed to set actual version".to_string(); @@ -421,6 +438,7 @@ impl UpConfigGithubRelease { // Go over each of the assets that matched the current platform // and download them all + let mut binary_found = false; for asset in &release.assets { let asset_name = asset.name.clone(); let asset_url = asset.browser_download_url.clone(); @@ -453,107 +471,139 @@ impl UpConfigGithubRelease { UpError::Exec(errmsg) })?; - progress_handler.progress(format!("extracting {}", asset_name.light_yellow())); - - // Open the downloaded file - let archive_file = std::fs::File::open(&asset_path).map_err(|err| { - let errmsg = format!("failed to open {}: {}", asset_name, err); + // Get the parsed asset name + let (asset_type, target_dir) = asset.file_type().ok_or_else(|| { + let errmsg = format!("file type not supported: {}", asset_name); progress_handler.error_with_message(errmsg.clone()); UpError::Exec(errmsg) })?; + let target_dir = tmp_dir.path().join(target_dir); - // Strip .zip or .tar.gz from the asset name to get the target dir - let target_dir = asset_name - .strip_suffix(".zip") - .map_or_else(|| asset_name.strip_suffix(".tar.gz"), Some) - .ok_or_else(|| { - let errmsg = format!("file extension not supported: {}", asset_name); + if asset_type.is_binary() { + // Make the binary executable + let mut perms = file + .metadata() + .map_err(|err| { + let errmsg = format!("failed to get metadata for {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })? + .permissions(); + perms.set_mode(0o755); + file.set_permissions(perms).map_err(|err| { + let errmsg = format!("failed to set permissions for {}: {}", asset_name, err); progress_handler.error_with_message(errmsg.clone()); UpError::Exec(errmsg) })?; - let target_dir = tmp_dir.path().join(target_dir); - // Perform the extraction - let is_tgz = asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz"); - let is_zip = asset.name.ends_with(".zip"); - if is_zip { - zip_extract::extract(&archive_file, &target_dir, true).map_err(|err| { - let errmsg = format!("failed to extract {}: {}", asset_name, err); + // Rename the file to get rid of the os, architecture + // and version information + let new_path = tmp_dir.path().join(asset.clean_name(&version)); + std::fs::rename(&asset_path, &new_path).map_err(|err| { + let errmsg = format!("failed to rename {}: {}", asset_name, err); progress_handler.error_with_message(errmsg.clone()); UpError::Exec(errmsg) })?; - } else if is_tgz { - let tar = flate2::read::GzDecoder::new(archive_file); - let mut archive = tar::Archive::new(tar); - archive.unpack(&target_dir).map_err(|err| { - let errmsg = format!("failed to extract {}: {}", asset_name, err); + } else { + progress_handler.progress(format!("extracting {}", asset_name.light_yellow())); + + // Open the downloaded file + let archive_file = std::fs::File::open(&asset_path).map_err(|err| { + let errmsg = format!("failed to open {}: {}", asset_name, err); progress_handler.error_with_message(errmsg.clone()); UpError::Exec(errmsg) })?; - } else { - let errmsg = format!("file extension not supported: {}", asset_name); - progress_handler.error_with_message(errmsg.clone()); - return Err(UpError::Exec(errmsg)); + + // Perform the extraction + if asset_type.is_zip() { + zip_extract::extract(&archive_file, &target_dir, true).map_err(|err| { + let errmsg = format!("failed to extract {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + } else if asset_type.is_tgz() { + let tar = flate2::read::GzDecoder::new(archive_file); + let mut archive = tar::Archive::new(tar); + archive.unpack(&target_dir).map_err(|err| { + let errmsg = format!("failed to extract {}: {}", asset_name, err); + progress_handler.error_with_message(errmsg.clone()); + UpError::Exec(errmsg) + })?; + } else { + let errmsg = format!("file extension not supported: {}", asset_name); + progress_handler.error_with_message(errmsg.clone()); + return Err(UpError::Exec(errmsg)); + } } + } - // Locate the binary file(s) in the extracted directory, recursively - // and move them to the workdir data path - for entry in walkdir::WalkDir::new(&target_dir) - .into_iter() - .filter_map(|entry| { - let entry = entry.ok()?; - let entry_path = entry.path(); - if entry_path.is_file() { - let metadata = entry.metadata().ok()?; - let is_executable = metadata.permissions().mode() & 0o111 != 0; - if is_executable { - Some(entry) - } else { - None - } + // Locate the binary file(s) in the extracted directory, recursively + // and move them to the workdir data path + for entry in walkdir::WalkDir::new(tmp_dir.path()) + .into_iter() + .filter_map(|entry| { + let entry = entry.ok()?; + let entry_path = entry.path(); + if entry_path.is_file() { + let metadata = entry.metadata().ok()?; + let is_executable = metadata.permissions().mode() & 0o111 != 0; + if is_executable { + Some(entry) } else { None } - }) - { - let source_path = entry.path(); - let binary_name = source_path - .file_name() - .unwrap_or(source_path.as_os_str()) - .to_string_lossy() - .to_string(); - - progress_handler.progress(format!("found binary {}", binary_name.light_yellow())); - - let target_path = install_path.join(&binary_name); - - // Make sure the target directory exists - if !install_path.exists() { - std::fs::create_dir_all(&install_path).map_err(|err| { - let errmsg = - format!("failed to create {}: {}", install_path.display(), err); - progress_handler.error_with_message(errmsg.clone()); - UpError::Exec(errmsg) - })?; + } else { + None } + }) + { + let source_path = entry.path(); + let binary_name = source_path + .file_name() + .unwrap_or(source_path.as_os_str()) + .to_string_lossy() + .to_string(); - // Copy the binary to the install path - std::fs::copy(source_path, target_path).map_err(|err| { - let errmsg = format!("failed to copy {}: {}", binary_name, err); - progress_handler.error_with_message(errmsg.clone()); + progress_handler.progress(format!("found binary {}", binary_name.light_yellow())); - // Force delete the install path if we fail to copy - // the binary to avoid leaving a partial installation - // behind - let _ = force_remove_dir_all(&install_path); + let target_path = install_path.join(&binary_name); + // Make sure the target directory exists + if !install_path.exists() { + std::fs::create_dir_all(&install_path).map_err(|err| { + let errmsg = format!("failed to create {}: {}", install_path.display(), err); + progress_handler.error_with_message(errmsg.clone()); UpError::Exec(errmsg) })?; } + + // Copy the binary to the install path + std::fs::copy(source_path, target_path).map_err(|err| { + let errmsg = format!("failed to copy {}: {}", binary_name, err); + progress_handler.error_with_message(errmsg.clone()); + + // Force delete the install path if we fail to copy + // the binary to avoid leaving a partial installation + // behind + let _ = force_remove_dir_all(&install_path); + + UpError::Exec(errmsg) + })?; + + binary_found = true; } - progress_handler.progress(format!("downloaded {} {}", self.repository, version)); + if !binary_found { + progress_handler + .error_with_message(format!("no binaries found in {}", self.repository)); + return Err(UpError::Exec("no binaries found".to_string())); + } + progress_handler.progress(format!( + "downloaded {} {}", + self.repository.light_yellow(), + version.light_yellow() + )); Ok(true) } diff --git a/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md b/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md index 16d6a143..cc9d16ad 100644 --- a/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md +++ b/website/contents/reference/01-configuration/0102-parameters/010250-up/010250-github-release.md @@ -29,7 +29,8 @@ This does not support using authentication yet, and thus will only work for publ |------------------|-----------|-------------------------------------------------------| | `repository` | string | The name of the repository to download the release from, in the `/` format; can also be provided as an object with the `owner` and `name` keys | | `version` | string | The version of the tool to install; see [version handling](#version-handling) below for more details. | -| `prerelease` | boolean | Whether to download a prerelease version or only match stable releases | +| `prerelease` | boolean | Whether to download a prerelease version or only match stable releases *(default: `false`)* | +| `binary` | boolean | Whether to download an asset that is not archived and consider it a binary file *(default: `true`)* | | `api_url` | string | The URL of the GitHub API to use, useful to use GitHub Enterprise (e.g. `https://github.example.com/api/v3`); defaults to `https://api.github.com` | ### Version handling