Skip to content

Commit 20ce381

Browse files
committed
feat(install): install packages from a local file path
1 parent caf1ba6 commit 20ce381

6 files changed

Lines changed: 409 additions & 23 deletions

File tree

crates/soar-cli/src/install.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,37 @@ async fn install_with_show(
149149
let mut install_targets = Vec::new();
150150

151151
for package in packages {
152+
// Local files and remote URLs/GHCR refs aren't registry queries and
153+
// have nothing to select; resolve them directly.
154+
if soar_core::package::local::LocalPackage::is_local(package)
155+
|| soar_core::package::url::UrlPackage::is_remote(package)
156+
{
157+
let results =
158+
install::resolve_packages(ctx, std::slice::from_ref(package), options).await?;
159+
for result in results {
160+
match result {
161+
ResolveResult::Resolved(targets) => install_targets.extend(targets),
162+
ResolveResult::AlreadyInstalled {
163+
pkg_name,
164+
pkg_id,
165+
repo_name,
166+
version,
167+
} => {
168+
warn!(
169+
"{}#{}:{} ({}) is already installed - skipping",
170+
pkg_name, pkg_id, repo_name, version,
171+
);
172+
if !force {
173+
info!("Hint: Use --force to reinstall");
174+
}
175+
}
176+
ResolveResult::NotFound(name) => error!("Package {} not found", name),
177+
ResolveResult::Ambiguous(_) => {}
178+
}
179+
}
180+
continue;
181+
}
182+
152183
let query = PackageQuery::try_from(package.as_str())?;
153184

154185
// --show requires a name and no pkg_id

crates/soar-core/src/package/install.rs

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::{
22
env, fs,
3-
io::Write,
3+
io::{Read, Write},
4+
os::unix::fs::PermissionsExt,
45
path::{Path, PathBuf},
56
process::Command,
67
thread::sleep,
@@ -35,10 +36,23 @@ use crate::{
3536
constants::INSTALL_MARKER_FILE,
3637
database::{connection::DieselDatabase, models::Package},
3738
error::{ErrorContext, SoarError},
39+
package::local::local_path_from_url,
3840
utils::get_extract_dir,
3941
SoarResult,
4042
};
4143

44+
/// Returns `true` if the file at `path` starts with the ELF magic bytes.
45+
///
46+
/// AppImages and plain binaries are ELF and need the executable bit; archives
47+
/// are not and are extracted instead.
48+
fn is_elf(path: &Path) -> bool {
49+
let mut magic = [0u8; 4];
50+
fs::File::open(path)
51+
.and_then(|mut f| f.read_exact(&mut magic))
52+
.is_ok()
53+
&& magic == *b"\x7fELF"
54+
}
55+
4256
/// Early validation of relative paths before download.
4357
/// Rejects paths containing `..` or absolute paths.
4458
fn validate_relative_path(relative_path: &str, path_type: &str) -> SoarResult<()> {
@@ -527,6 +541,67 @@ impl PackageInstaller {
527541
Ok(())
528542
}
529543

544+
/// Install a package from a local file by copying it into the install
545+
/// directory (and extracting it when it is an archive), mirroring the
546+
/// relevant post-download steps of [`Download::execute`].
547+
fn copy_local_source(
548+
&self,
549+
src: &Path,
550+
dest: &Path,
551+
extract: bool,
552+
extract_dir: &Path,
553+
) -> SoarResult<PathBuf> {
554+
if !src.is_file() {
555+
return Err(SoarError::Custom(format!(
556+
"Local source is not a file: {}",
557+
src.display()
558+
)));
559+
}
560+
561+
if let Some(parent) = dest.parent() {
562+
fs::create_dir_all(parent)
563+
.with_context(|| format!("creating directory {}", parent.display()))?;
564+
}
565+
566+
fs::copy(src, dest).with_context(|| {
567+
format!("copying {} to {}", src.display(), dest.display())
568+
})?;
569+
570+
// Honor checksum pinning the same way the direct-download path does.
571+
if let Some(ref bsum) = self.package.bsum {
572+
let actual = calculate_checksum(dest)?;
573+
if &actual != bsum {
574+
fs::remove_file(dest).ok();
575+
return Err(SoarError::Custom(format!(
576+
"Checksum mismatch for {}: expected {}, got {}",
577+
src.display(),
578+
bsum,
579+
actual
580+
)));
581+
}
582+
}
583+
584+
// ELF binaries (including AppImages) need the executable bit; archives
585+
// are extracted below instead of being run directly.
586+
if is_elf(dest) {
587+
fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))
588+
.with_context(|| format!("setting permissions on {}", dest.display()))?;
589+
}
590+
591+
if extract {
592+
debug!(archive = %dest.display(), dest = %extract_dir.display(), "extracting local archive");
593+
compak::extract_archive(dest, extract_dir).map_err(|e| {
594+
SoarError::Custom(format!(
595+
"Failed to extract archive {}: {}",
596+
dest.display(),
597+
e
598+
))
599+
})?;
600+
}
601+
602+
Ok(dest.to_path_buf())
603+
}
604+
530605
pub async fn download_package(&self) -> SoarResult<Option<String>> {
531606
debug!(
532607
pkg_name = self.package.pkg_name,
@@ -621,7 +696,6 @@ impl PackageInstaller {
621696

622697
Ok(None)
623698
} else {
624-
trace!(url = url.as_str(), "using direct download");
625699
let extract_dir = get_extract_dir(&self.install_dir);
626700

627701
let should_extract = self
@@ -630,24 +704,30 @@ impl PackageInstaller {
630704
.as_deref()
631705
.is_some_and(|t| t == "archive");
632706

633-
let mut dl = Download::new(url.as_str())
634-
.output(output_path.to_string_lossy())
635-
.overwrite(OverwriteMode::Skip)
636-
.extract(should_extract)
637-
.extract_to(&extract_dir);
638-
639-
if let Some(ref bsum) = self.package.bsum {
640-
dl = dl.checksum(bsum);
641-
}
707+
let file_path = if let Some(local_src) = local_path_from_url(url) {
708+
trace!(source = %local_src.display(), "installing from local file");
709+
self.copy_local_source(local_src, output_path, should_extract, &extract_dir)?
710+
} else {
711+
trace!(url = url.as_str(), "using direct download");
712+
let mut dl = Download::new(url.as_str())
713+
.output(output_path.to_string_lossy())
714+
.overwrite(OverwriteMode::Skip)
715+
.extract(should_extract)
716+
.extract_to(&extract_dir);
717+
718+
if let Some(ref bsum) = self.package.bsum {
719+
dl = dl.checksum(bsum);
720+
}
642721

643-
if let Some(ref cb) = self.progress_callback {
644-
let cb = cb.clone();
645-
dl = dl.progress(move |p| {
646-
cb(p);
647-
});
648-
}
722+
if let Some(ref cb) = self.progress_callback {
723+
let cb = cb.clone();
724+
dl = dl.progress(move |p| {
725+
cb(p);
726+
});
727+
}
649728

650-
let file_path = dl.execute()?;
729+
dl.execute()?
730+
};
651731

652732
self.run_post_download_hook()?;
653733

0 commit comments

Comments
 (0)