Skip to content

Commit 1dc8a0c

Browse files
committed
Add support for the .AppImage extension
This also updates extension handling in a few other ways: * We only consider assets with `.exe` extensions on Windows. * We only consider assets with `.AppImage` extensions on Linux. * We make sure to preserve `.exe`, `.pytz`, and `.AppImage` extensions when installing assets with these extensions.
1 parent a40fee1 commit 1dc8a0c

File tree

7 files changed

+202
-39
lines changed

7 files changed

+202
-39
lines changed

Changes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
includes things like a version number of platform information. Based on discussion in #86.
77
- Added support for release artifacts with a `.pyz` extension. These are zip files containing Python
88
code, and they can be directly executed.
9+
- Added support for release artifacts with a `.AppImage` extension. These will only be picked when
10+
running Linux. Requested by @saulh (Saul Reynolds-Haertle). GH #86.
11+
- Fixed a bug where `ubi` would consider an asset with `.exe` extension on non-Windows platforms. In
12+
practice, this would probably only have been an issue for projects with exactly one release
13+
artifact, where that artifact had a `.exe` extension.
914
- The `--extract-all` CLI option added in the previous release did not have any description in the
1015
help output. This has been fixed.
1116

ubi-cli/tests/ubi.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,16 @@ fn integration_tests() -> Result<()> {
492492
make_exe_pathbuf(&["bin", "glab"]),
493493
)?;
494494

495+
#[cfg(target_os = "linux")]
496+
{
497+
run_test(
498+
td.path(),
499+
ubi.as_ref(),
500+
&["--project", "hzeller/timg", "--tag", "v1.6.1"],
501+
make_exe_pathbuf(&["bin", "timg.AppImage"]),
502+
)?;
503+
}
504+
495505
Ok(())
496506
}
497507

ubi/src/extension.rs

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,24 @@ use anyhow::Result;
44
use itertools::Itertools;
55
use lazy_regex::{regex, Lazy};
66
use log::debug;
7+
use platforms::{Platform, OS};
78
use regex::Regex;
8-
use std::{ffi::OsStr, path::Path};
9+
use std::{
10+
ffi::OsStr,
11+
path::{Path, PathBuf},
12+
};
913
use strum::{EnumIter, IntoEnumIterator};
1014
use thiserror::Error;
1115

1216
#[derive(Debug, Error)]
1317
pub(crate) enum ExtensionError {
14-
#[error("{path:} has unknown extension {ext:}")]
15-
UnknownExtension { path: String, ext: String },
18+
#[error("{} has unknown extension {ext:}", path.display())]
19+
UnknownExtension { path: PathBuf, ext: String },
1620
}
1721

1822
#[derive(Debug, EnumIter, PartialEq, Eq)]
1923
pub(crate) enum Extension {
24+
AppImage,
2025
Bz,
2126
Bz2,
2227
Exe,
@@ -37,6 +42,7 @@ pub(crate) enum Extension {
3742
impl Extension {
3843
pub(crate) fn extension(&self) -> &'static str {
3944
match self {
45+
Extension::AppImage => ".AppImage",
4046
Extension::Bz => ".bz",
4147
Extension::Bz2 => ".bz2",
4248
Extension::Exe => ".exe",
@@ -55,9 +61,14 @@ impl Extension {
5561
}
5662
}
5763

64+
pub(crate) fn extension_without_dot(&self) -> &str {
65+
self.extension().strip_prefix('.').unwrap()
66+
}
67+
5868
pub(crate) fn is_archive(&self) -> bool {
5969
match self {
60-
Extension::Bz
70+
Extension::AppImage
71+
| Extension::Bz
6172
| Extension::Bz2
6273
| Extension::Exe
6374
| Extension::Gz
@@ -75,41 +86,71 @@ impl Extension {
7586
}
7687
}
7788

78-
pub(crate) fn from_path<S: AsRef<str>>(path: S) -> Result<Option<Extension>> {
79-
let path = path.as_ref();
80-
let Some(ext_str) = Path::new(path).extension() else {
89+
pub(crate) fn should_preserve_extension_on_install(&self) -> bool {
90+
match self {
91+
Extension::AppImage | Extension::Exe | Extension::Pyz => true,
92+
Extension::Xz
93+
| Extension::Gz
94+
| Extension::Bz
95+
| Extension::Bz2
96+
| Extension::Tar
97+
| Extension::TarBz
98+
| Extension::TarBz2
99+
| Extension::TarGz
100+
| Extension::TarXz
101+
| Extension::Tbz
102+
| Extension::Tgz
103+
| Extension::Txz
104+
| Extension::Zip => false,
105+
}
106+
}
107+
108+
pub(crate) fn matches_platform(&self, platform: &Platform) -> bool {
109+
match self {
110+
Extension::AppImage => platform.target_os == OS::Linux,
111+
Extension::Exe => platform.target_os == OS::Windows,
112+
_ => true,
113+
}
114+
}
115+
116+
pub(crate) fn from_path(path: &Path) -> Result<Option<Extension>> {
117+
let Some(ext_str_from_path) = path.extension() else {
81118
return Ok(None);
82119
};
120+
let path_str = path.to_string_lossy();
83121

84122
// We need to try the longest extensions first so that ".tar.gz" matches before ".gz" and so
85123
// on for other compression formats.
86124
if let Some(ext) = Extension::iter()
87125
.sorted_by(|a, b| Ord::cmp(&a.extension().len(), &b.extension().len()))
88126
.rev()
89-
.find(|e| path.ends_with(e.extension()))
127+
// This is intentionally using a string comparison instead of looking at
128+
// path.extension(). That's because the `.extension()` method returns `"bz"` for paths
129+
// like "foo.tar.bz", instead of "tar.bz".
130+
.find(|e| path_str.ends_with(e.extension()))
90131
{
91132
return Ok(Some(ext));
92133
}
93134

94-
if extension_is_part_of_version(path, ext_str) {
95-
debug!("the extension {ext_str:?} is part of the version, ignoring");
135+
if extension_is_part_of_version(path, ext_str_from_path) {
136+
debug!("the extension {ext_str_from_path:?} is part of the version, ignoring");
96137
return Ok(None);
97138
}
98139

99-
if extension_is_platform(ext_str) {
100-
debug!("the extension {ext_str:?} is a platform name, ignoring");
140+
if extension_is_platform(ext_str_from_path) {
141+
debug!("the extension {ext_str_from_path:?} is a platform name, ignoring");
101142
return Ok(None);
102143
}
103144

104145
Err(ExtensionError::UnknownExtension {
105-
path: path.to_string(),
106-
ext: ext_str.to_string_lossy().to_string(),
146+
path: path.to_path_buf(),
147+
ext: ext_str_from_path.to_string_lossy().to_string(),
107148
}
108149
.into())
109150
}
110151
}
111152

112-
fn extension_is_part_of_version(path: &str, ext_str: &OsStr) -> bool {
153+
fn extension_is_part_of_version(path: &Path, ext_str: &OsStr) -> bool {
113154
let ext_str = ext_str.to_string_lossy().to_string();
114155

115156
let version_number_ext_re = regex!(r"^[0-9]+");
@@ -119,7 +160,9 @@ fn extension_is_part_of_version(path: &str, ext_str: &OsStr) -> bool {
119160

120161
// This matches something like "foo_3.2.1_linux_amd64" and captures "1_linux_amd64".
121162
let version_number_re = regex!(r"[0-9]+\.([0-9]+[^.]*)$");
122-
let Some(caps) = version_number_re.captures(path) else {
163+
let Some(caps) = version_number_re.captures(path.to_str().expect(
164+
"this path came from a UTF-8 string originally so it should always convert back to one",
165+
)) else {
123166
return false;
124167
};
125168
let Some(dot_num) = caps.get(1) else {
@@ -149,7 +192,9 @@ fn extension_is_platform(ext_str: &OsStr) -> bool {
149192
mod test {
150193
use super::*;
151194
use test_case::test_case;
195+
use test_log::test;
152196

197+
#[test_case("foo.AppImage", Ok(Some(Extension::AppImage)))]
153198
#[test_case("foo.bz", Ok(Some(Extension::Bz)))]
154199
#[test_case("foo.bz2", Ok(Some(Extension::Bz2)))]
155200
#[test_case("foo.exe", Ok(Some(Extension::Exe)))]
@@ -166,11 +211,11 @@ mod test {
166211
#[test_case("foo_3.9.1.linux.amd64", Ok(None))]
167212
#[test_case("i386-linux-ghcup-0.1.30.0", Ok(None))]
168213
#[test_case("i386-linux-ghcup-0.1.30.0-linux_amd64", Ok(None))]
169-
#[test_case("foo.bar", Err(ExtensionError::UnknownExtension { path: "foo.bar".to_string(), ext: "bar".to_string() }.into()))]
214+
#[test_case("foo.bar", Err(ExtensionError::UnknownExtension { path: PathBuf::from("foo.bar"), ext: "bar".to_string() }.into()))]
170215
fn from_path(path: &str, expect: Result<Option<Extension>>) {
171216
crate::test_case::init_logging();
172217

173-
let ext = Extension::from_path(path);
218+
let ext = Extension::from_path(Path::new(path));
174219
if expect.is_ok() {
175220
assert!(ext.is_ok());
176221
assert_eq!(ext.unwrap(), expect.unwrap());
@@ -181,4 +226,37 @@ mod test {
181226
);
182227
}
183228
}
229+
230+
#[test]
231+
fn matches_platform() -> Result<()> {
232+
let freebsd = Platform::find("x86_64-unknown-freebsd").unwrap().clone();
233+
let linux = Platform::find("x86_64-unknown-linux-gnu").unwrap().clone();
234+
let macos = Platform::find("aarch64-apple-darwin").unwrap().clone();
235+
let windows = Platform::find("x86_64-pc-windows-msvc").unwrap().clone();
236+
237+
let ext = Extension::from_path(Path::new("foo.exe"))?.unwrap();
238+
assert!(
239+
ext.matches_platform(&windows),
240+
"foo.exe is valid on {windows}"
241+
);
242+
for p in [&freebsd, &linux, &macos] {
243+
assert!(!ext.matches_platform(p), "foo.exe is not valid on {p}");
244+
}
245+
246+
let ext = Extension::from_path(Path::new("foo.AppImage"))?.unwrap();
247+
assert!(
248+
ext.matches_platform(&linux),
249+
"foo.exe is valid on {windows}"
250+
);
251+
for p in [&freebsd, &macos, &windows] {
252+
assert!(!ext.matches_platform(p), "foo.AppImage is not valid on {p}");
253+
}
254+
255+
let ext = Extension::from_path(Path::new("foo.tar.gz"))?.unwrap();
256+
for p in [&freebsd, &linux, &macos, &windows] {
257+
assert!(ext.matches_platform(p), "foo.tar.gz is valid on {p}");
258+
}
259+
260+
Ok(())
261+
}
184262
}

ubi/src/installer.rs

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ impl ExeInstaller {
4040
ExeInstaller { install_path, exe }
4141
}
4242

43-
fn extract_executable(&self, downloaded_file: &Path) -> Result<()> {
44-
match Extension::from_path(downloaded_file.to_string_lossy())? {
43+
fn extract_executable(&self, downloaded_file: &Path) -> Result<Option<PathBuf>> {
44+
match Extension::from_path(downloaded_file)? {
4545
Some(
4646
Extension::Tar
4747
| Extension::TarBz
@@ -51,12 +51,29 @@ impl ExeInstaller {
5151
| Extension::Tbz
5252
| Extension::Tgz
5353
| Extension::Txz,
54-
) => self.extract_executable_from_tarball(downloaded_file),
55-
Some(Extension::Bz | Extension::Bz2) => self.unbzip(downloaded_file),
56-
Some(Extension::Gz) => self.ungzip(downloaded_file),
57-
Some(Extension::Xz) => self.unxz(downloaded_file),
58-
Some(Extension::Zip) => self.extract_executable_from_zip(downloaded_file),
59-
Some(Extension::Exe | Extension::Pyz) | None => self.copy_executable(downloaded_file),
54+
) => {
55+
self.extract_executable_from_tarball(downloaded_file)?;
56+
Ok(None)
57+
}
58+
Some(Extension::Bz | Extension::Bz2) => {
59+
self.unbzip(downloaded_file)?;
60+
Ok(None)
61+
}
62+
Some(Extension::Gz) => {
63+
self.ungzip(downloaded_file)?;
64+
Ok(None)
65+
}
66+
Some(Extension::Xz) => {
67+
self.unxz(downloaded_file)?;
68+
Ok(None)
69+
}
70+
Some(Extension::Zip) => {
71+
self.extract_executable_from_zip(downloaded_file)?;
72+
Ok(None)
73+
}
74+
Some(Extension::AppImage | Extension::Exe | Extension::Pyz) | None => {
75+
self.copy_executable(downloaded_file)
76+
}
6077
}
6178
}
6279

@@ -146,12 +163,24 @@ impl ExeInstaller {
146163
Ok(())
147164
}
148165

149-
fn copy_executable(&self, exe_file: &Path) -> Result<()> {
166+
fn copy_executable(&self, exe_file: &Path) -> Result<Option<PathBuf>> {
150167
debug!("copying executable to final location");
151168
self.create_install_dir()?;
152-
std::fs::copy(exe_file, &self.install_path)?;
153169

154-
Ok(())
170+
let mut install_path = self.install_path.clone();
171+
if let Some(ext) = Extension::from_path(exe_file)? {
172+
if ext.should_preserve_extension_on_install() {
173+
debug!("preserving the {} extension on install", ext.extension());
174+
install_path.set_extension(ext.extension_without_dot());
175+
}
176+
}
177+
std::fs::copy(exe_file, &install_path).context(format!(
178+
"error copying file from {} to {}",
179+
exe_file.display(),
180+
install_path.display()
181+
))?;
182+
183+
Ok(Some(install_path))
155184
}
156185

157186
fn create_install_dir(&self) -> Result<()> {
@@ -167,12 +196,12 @@ impl ExeInstaller {
167196
.with_context(|| format!("could not create a directory at {}", path.display()))
168197
}
169198

170-
fn chmod_executable(&self) -> Result<()> {
199+
fn chmod_executable(exe: &Path) -> Result<()> {
171200
#[cfg(target_family = "windows")]
172201
return Ok(());
173202

174203
#[cfg(target_family = "unix")]
175-
match set_permissions(&self.install_path, Permissions::from_mode(0o755)) {
204+
match set_permissions(exe, Permissions::from_mode(0o755)) {
176205
Ok(()) => Ok(()),
177206
Err(e) => Err(anyhow::Error::new(e)),
178207
}
@@ -181,9 +210,10 @@ impl ExeInstaller {
181210

182211
impl Installer for ExeInstaller {
183212
fn install(&self, download: &Download) -> Result<()> {
184-
self.extract_executable(&download.archive_path)?;
185-
self.chmod_executable()?;
186-
info!("Installed executable into {}", self.install_path.display());
213+
let exe = self.extract_executable(&download.archive_path)?;
214+
let real_exe = exe.as_deref().unwrap_or(&self.install_path);
215+
Self::chmod_executable(real_exe)?;
216+
info!("Installed executable into {}", real_exe.display());
187217

188218
Ok(())
189219
}
@@ -197,7 +227,7 @@ impl ArchiveInstaller {
197227
}
198228

199229
fn extract_entire_archive(&self, downloaded_file: &Path) -> Result<()> {
200-
match Extension::from_path(downloaded_file.to_string_lossy())? {
230+
match Extension::from_path(downloaded_file)? {
201231
Some(
202232
Extension::Tar
203233
| Extension::TarBz
@@ -370,6 +400,7 @@ mod tests {
370400
use test_case::test_case;
371401
use test_log::test;
372402

403+
#[test_case("test-data/project.AppImage")]
373404
#[test_case("test-data/project.bz")]
374405
#[test_case("test-data/project.bz2")]
375406
#[test_case("test-data/project.exe")]
@@ -394,6 +425,14 @@ mod tests {
394425
let mut path_with_subdir = td.path().to_path_buf();
395426
path_with_subdir.extend(&["subdir", "project"]);
396427

428+
// The installer special-cases this extension and preserves it for the installed file.
429+
if let Some(ext) = Extension::from_path(Path::new(archive_path))? {
430+
if ext.should_preserve_extension_on_install() {
431+
path_without_subdir.set_extension(ext.extension_without_dot());
432+
path_with_subdir.set_extension(ext.extension_without_dot());
433+
}
434+
}
435+
397436
for install_path in [path_without_subdir, path_with_subdir] {
398437
let installer = ExeInstaller::new(install_path.clone(), exe.to_string());
399438
installer.install(&Download {

ubi/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@
3232
//! with the same name as the project. In either case, the file will be installed with the name it
3333
//! has in the archive file.
3434
//!
35-
//! If the release is in the form of a bare executable or a compressed executable, then the installed
36-
//! executable will use the name of the project instead.
35+
//! If the release is in the form of a bare executable or a compressed executable, then the
36+
//! installed executable will use the name of the project instead. For files with a `.exe`, `.pyz`
37+
//! or `.AppImage`, the installed executable will be `$project_name.$extension`.
3738
//!
3839
//! This is a bit inconsistent, but it's how `ubi` has behaved since it was created, and I find this
3940
//! to be the sanest behavior. Some projects, for example `rust-analyzer`, provide releases as

0 commit comments

Comments
 (0)