Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ pub(crate) struct Cli {
pub update: bool,

/// Build and install from a remote GitHub repo (uses default branch if no other options)
#[arg(short = 'r', long)]
#[arg(short = 'r', long, env = "FOUNDRYUP_REPO", hide_env = true)]
pub repo: Option<String>,

/// Build and install a specific branch
#[arg(short = 'b', long, conflicts_with = "pr")]
#[arg(short = 'b', long, conflicts_with = "pr", env = "FOUNDRYUP_BRANCH", hide_env = true)]
pub branch: Option<String>,

/// Install a specific version from built binaries (e.g., stable, nightly, 0.3.0)
#[arg(id = "ver", short = 'i', long = "install", value_name = "VERSION")]
#[arg(
id = "ver",
short = 'i',
long = "install",
value_name = "VERSION",
env = "FOUNDRYUP_VERSION",
hide_env = true
)]
pub version: Option<String>,

/// List installed versions
Expand All @@ -33,19 +40,19 @@ pub(crate) struct Cli {
pub use_version: Option<String>,

/// Build and install a local repository
#[arg(short = 'p', long)]
#[arg(short = 'p', long, env = "FOUNDRYUP_LOCAL_REPO", hide_env = true)]
pub path: Option<std::path::PathBuf>,

/// Build and install a specific Pull Request
#[arg(short = 'P', long, conflicts_with = "branch")]
#[arg(short = 'P', long, conflicts_with = "branch", env = "FOUNDRYUP_PR", hide_env = true)]
pub pr: Option<u64>,

/// Build and install a specific commit
#[arg(short = 'C', long)]
#[arg(short = 'C', long, env = "FOUNDRYUP_COMMIT", hide_env = true)]
pub commit: Option<String>,

/// Number of CPUs to use for building (default: all)
#[arg(short = 'j', long)]
#[arg(short = 'j', long, env = "FOUNDRYUP_JOBS", hide_env = true)]
pub jobs: Option<u32>,

/// Cargo profile to use for building
Expand All @@ -57,19 +64,19 @@ pub(crate) struct Cli {
pub cargo_features: Option<String>,

/// [deprecated] Install binaries for a specific network
#[arg(short = 'n', long, hide = true)]
#[arg(short = 'n', long, hide = true, env = "FOUNDRYUP_NETWORK", hide_env = true)]
pub network: Option<String>,

/// Skip SHA verification (INSECURE)
#[arg(short = 'f', long)]
pub force: bool,

/// Install a specific architecture (amd64, arm64)
#[arg(long)]
#[arg(long, env = "FOUNDRYUP_ARCH", hide_env = true)]
pub arch: Option<String>,

/// Install a specific platform (win32, linux, darwin, alpine)
#[arg(long)]
#[arg(long, env = "FOUNDRYUP_PLATFORM", hide_env = true)]
pub platform: Option<String>,

/// Generate shell completions
Expand Down
96 changes: 78 additions & 18 deletions src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,16 @@ async fn install_prebuilt(config: &Config, args: &Cli) -> Result<()> {
format!("https://github.com/{}/releases/download/{tag}/", config.network.repo);

let hashes = if config.network.has_attestation && !args.force {
fetch_and_verify_attestation(config, repo, &downloader, &release_url, &version, &target)
.await?
fetch_and_verify_attestation(
config,
repo,
&downloader,
&release_url,
&version,
&tag,
&target,
)
.await?
} else if args.force {
say!("skipped SHA verification due to --force flag");
None
Expand All @@ -76,6 +84,12 @@ async fn install_from_local(config: &Config, local_path: &Path, args: &Cli) -> R
warn!("--branch, --install, --use, and --repo arguments are ignored during local install");
}

// Resolve to an absolute path so the symlinks below point at the build
// artifacts regardless of the current working directory.
let local_path = fs::canonicalize(local_path)
.wrap_err_with(|| format!("local repository not found: {}", local_path.display()))?;
let local_path = local_path.as_path();

say!("installing from {}", local_path.display());

let mut cmd = tokio::process::Command::new("cargo");
Expand Down Expand Up @@ -103,9 +117,7 @@ async fn install_from_local(config: &Config, local_path: &Path, args: &Cli) -> R
let src = local_path.join("target").join(target_dir).join(bin_name(bin));
let dest = config.bin_path(bin);

if dest.exists() {
fs::remove_file(&dest)?;
}
remove_if_exists(&dest)?;

#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dest)?;
Expand Down Expand Up @@ -211,10 +223,18 @@ async fn install_from_source(config: &Config, repo: &str, args: &Cli) -> Result<
fs::create_dir_all(&version_dir)?;

let target_dir = profile_target_dir(&args.cargo_profile);
let build_dir = repo_path.join("target").join(target_dir);
for bin in config.network.bins {
let src = repo_path.join("target").join(target_dir).join(bin_name(bin));
if src.exists() {
fs::rename(&src, version_dir.join(bin_name(bin)))?;
// Move whichever artifact cargo produced (with or without `.exe`).
for candidate in [bin.to_string(), format!("{bin}.exe")] {
let src = build_dir.join(&candidate);
if src.is_file() {
// Remove any existing destination first; `fs::rename` fails on
// Windows if the target already exists.
let dest = version_dir.join(&candidate);
remove_if_exists(&dest)?;
fs::rename(&src, &dest)?;
}
}
}

Expand Down Expand Up @@ -264,10 +284,11 @@ async fn fetch_and_verify_attestation(
downloader: &Downloader,
release_url: &str,
version: &str,
tag: &str,
target: &Target,
) -> Result<Option<HashMap<String, String>>> {
let bins = config.network.bins;
say!("checking if {} for {version} version are already installed", bins.join(", "));
say!("checking if {} for {tag} version are already installed", bins.join(", "));

let attestation_url = format!(
"{release_url}foundry_{version}_{platform}_{arch}.attestation.txt",
Expand All @@ -290,14 +311,14 @@ async fn fetch_and_verify_attestation(
}
};

say!("found attestation for {version} version, downloading attestation artifact, checking...");
say!("found attestation for {tag} version, downloading attestation artifact, checking...");

let artifact_url = format!("{attestation_link}/download");
let artifact_json = downloader.download_to_string(&artifact_url).await?;

let hashes = parse_attestation_payload(&artifact_json)?;

let version_dir = config.version_dir(repo, version);
let version_dir = config.version_dir(repo, tag);

if version_dir.exists() {
let mut all_match = true;
Expand All @@ -322,8 +343,8 @@ async fn fetch_and_verify_attestation(
}

if all_match {
say!("version {version} already installed and verified, activating...");
use_version(config, repo, version)?;
say!("version {tag} already installed and verified, activating...");
use_version(config, repo, tag)?;
say!("done!");
std::process::exit(0);
}
Expand Down Expand Up @@ -578,9 +599,7 @@ pub(crate) fn use_version(config: &Config, repo: &str, version: &str) -> Result<

let old_version = if dest.exists() { get_bin_version(&dest).ok() } else { None };

if dest.exists() {
fs::remove_file(&dest)?;
}
remove_if_exists(&dest)?;

#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dest)?;
Expand Down Expand Up @@ -685,7 +704,9 @@ fn latest_nightly_release_tag(json: &str, repo: &str) -> Result<String> {
}

fn normalize_version(version: &str) -> (String, String) {
if version.starts_with("nightly") {
// Only concrete `nightly-<sha>` tags map to the nightly channel here; the
// bare `nightly` channel is resolved earlier.
if version.starts_with("nightly-") {
("nightly".to_string(), version.to_string())
} else if version.starts_with(|c: char| c.is_ascii_digit()) {
let s = format!("v{version}");
Expand All @@ -699,14 +720,31 @@ fn bin_name(name: &str) -> String {
if cfg!(windows) { format!("{name}.exe") } else { name.to_string() }
}

/// Removes `path` if it exists, including dangling symlinks.
///
/// Uses `symlink_metadata` so a broken symlink is still detected and removed;
/// `Path::exists()` follows symlinks and reports `false` for one, which would
/// leave it in place and make a following `symlink`/`copy` fail.
fn remove_if_exists(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(_) => fs::remove_file(path).map_err(Into::into),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}

fn get_bin_version(path: &Path) -> Result<String> {
let output = std::process::Command::new(path).arg("-V").output()?;
let version = String::from_utf8_lossy(&output.stdout);
Ok(version.trim().to_string())
}

fn rustflags() -> String {
std::env::var("RUSTFLAGS").unwrap_or_else(|_| "-C target-cpu=native".to_string())
// Treat an empty `RUSTFLAGS` the same as unset.
std::env::var("RUSTFLAGS")
.ok()
.filter(|flags| !flags.is_empty())
.unwrap_or_else(|| "-C target-cpu=native".to_string())
}

#[cfg(test)]
Expand Down Expand Up @@ -751,6 +789,28 @@ mod tests {
assert!(err.to_string().contains("could not find a nightly release tag"));
}

#[test]
fn normalize_version_cases() {
// `nightly-<sha>` -> asset version "nightly", tag is the literal sha tag.
assert_eq!(
normalize_version("nightly-abc123"),
("nightly".to_string(), "nightly-abc123".to_string())
);
// Bare semver gets a `v` prefix for both asset version and tag.
assert_eq!(normalize_version("1.5.0"), ("v1.5.0".to_string(), "v1.5.0".to_string()));
assert_eq!(normalize_version("v1.5.0"), ("v1.5.0".to_string(), "v1.5.0".to_string()));
// Arbitrary `nightly*` strings are not the nightly channel.
assert_eq!(
normalize_version("nightlyfoo"),
("nightlyfoo".to_string(), "nightlyfoo".to_string())
);
// Branch/custom names pass through untouched.
assert_eq!(
normalize_version("foundry-rs-branch-master"),
("foundry-rs-branch-master".to_string(), "foundry-rs-branch-master".to_string())
);
}

#[test]
fn attestation_de() {
let s = r#"{
Expand Down
20 changes: 19 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,24 @@ mod self_update;
use cli::Cli;
use config::Config;

/// Removes empty `FOUNDRYUP_*` variables before clap parses them.
///
/// clap's `env` support captures `Some("")` and parses it as a real value (e.g.
/// an empty version, or a non-numeric `FOUNDRYUP_JOBS` that fails parsing), so an
/// empty variable is cleared here and treated as unset.
fn clear_empty_foundryup_env() {
// `vars_os` is a snapshot, so removing during iteration is safe.
for (key, value) in std::env::vars_os() {
if value.is_empty() && key.to_str().is_some_and(|key| key.starts_with("FOUNDRYUP_")) {
// SAFETY: runs at startup before any threads are spawned.
unsafe { std::env::remove_var(&key) };
}
}
}

fn main() -> Result<()> {
clear_empty_foundryup_env();

color_eyre::install()?;
tracing_subscriber::fmt()
.with_env_filter(
Expand Down Expand Up @@ -70,7 +87,8 @@ async fn run(cli: Cli) -> Result<()> {
if cli.list {
install::list(&config)?;
} else if let Some(ref version) = cli.use_version {
install::use_version_resolved(&config, config.network.repo, version).await?;
let repo = cli.repo.as_deref().unwrap_or(config.network.repo);
install::use_version_resolved(&config, repo, version).await?;
} else {
print_banner();
process::check_bins_in_use(&config)?;
Expand Down
33 changes: 30 additions & 3 deletions src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ impl Platform {
match s.to_lowercase().as_str() {
"linux" => Ok(Self::Linux),
"alpine" => Ok(Self::Alpine),
"darwin" | "macos" | "mac" => Ok(Self::Darwin),
"win32" | "windows" => Ok(Self::Win32),
s if s.starts_with("mingw") => Ok(Self::Win32),
"darwin" => Ok(Self::Darwin),
s if s.starts_with("mac") => Ok(Self::Darwin),
s if s.starts_with("mingw") || s.starts_with("win") => Ok(Self::Win32),
_ => bail!("unsupported platform: {s}"),
}
}
Expand Down Expand Up @@ -134,3 +134,30 @@ fn is_rosetta() -> bool {
#[cfg(not(target_os = "macos"))]
false
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn platform_from_str_cases() {
assert_eq!(Platform::from_str("darwin").unwrap(), Platform::Darwin);
assert_eq!(Platform::from_str("Darwin").unwrap(), Platform::Darwin);
assert_eq!(Platform::from_str("macos").unwrap(), Platform::Darwin);
assert_eq!(Platform::from_str("mac").unwrap(), Platform::Darwin);
assert_eq!(Platform::from_str("linux").unwrap(), Platform::Linux);
assert_eq!(Platform::from_str("alpine").unwrap(), Platform::Alpine);
assert_eq!(Platform::from_str("win32").unwrap(), Platform::Win32);
assert_eq!(Platform::from_str("windows").unwrap(), Platform::Win32);
assert_eq!(Platform::from_str("mingw64_nt").unwrap(), Platform::Win32);
assert!(Platform::from_str("solaris").is_err());
}

#[test]
fn arch_from_str_cases() {
assert_eq!(Arch::from_str("amd64").unwrap(), Arch::Amd64);
assert_eq!(Arch::from_str("x86_64").unwrap(), Arch::Amd64);
assert_eq!(Arch::from_str("arm64").unwrap(), Arch::Arm64);
assert_eq!(Arch::from_str("aarch64").unwrap(), Arch::Arm64);
}
}
16 changes: 16 additions & 0 deletions tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,22 @@ fn use_version_creates_symlink_on_unix() {
}
}

// Empty `FOUNDRYUP_*` env vars are treated as unset, so an empty `FOUNDRYUP_JOBS`
// must not fail clap's `u32` parsing.
#[test]
fn empty_foundryup_env_is_ignored() {
let temp_dir = tempfile::Builder::new().tempdir().unwrap();

foundryup()
.env("FOUNDRY_DIR", temp_dir.path().join(".foundry"))
.env("FOUNDRYUP_JOBS", "")
.env("FOUNDRYUP_VERSION", "")
.env("FOUNDRYUP_PR", "")
.arg("--list")
.assert()
.success();
}

#[test]
fn list_empty() {
let temp_dir = tempfile::Builder::new().tempdir().unwrap();
Expand Down
Loading