use crate::error::{Error, Result};
use crate::mason::link::{
ResolvedProgram, finalize_install, is_resolved_program_runnable, join_relative_path,
resolve_program,
};
use crate::mason::platform::MasonPlatform;
use crate::mason::registry::MasonPackage;
use crate::mason::source::{SourceId, parse_source_id};
use crate::mason::template::TemplateContext;
use crate::runtime_state::RuntimeState;
use std::path::PathBuf;
use std::process::Command;
#[cfg(test)]
use std::fs;
#[cfg(all(test, unix))]
use std::os::unix::fs::PermissionsExt;
mod artifacts;
#[cfg(test)]
mod tests;
use artifacts::{
download_bytes, ensure_command_success, http_client, install_downloaded_artifact,
parse_archive_file_spec, render_asset_data, render_download_data, select_asset,
select_download,
};
pub(crate) fn resolve_cached_program(
state: &RuntimeState,
package: &MasonPackage,
program: &str,
) -> Result<Option<std::path::PathBuf>> {
match parse_source_id(&package.source.id)? {
SourceId::Npm { .. }
| SourceId::Pypi { .. }
| SourceId::Cargo { .. }
| SourceId::Golang { .. }
| SourceId::Nuget { .. } => {
let resolved_program =
resolve_program(package, program, state, &TemplateContext::empty())?;
Ok(if is_resolved_program_runnable(&resolved_program) {
Some(resolved_program.executable_path().to_path_buf())
} else {
None
})
}
SourceId::Github { version, .. } => {
resolve_cached_github_program(state, package, program, &version)
}
SourceId::Generic { version, .. } => {
resolve_cached_generic_program(state, package, program, &version)
}
SourceId::Unsupported { .. } => Ok(None),
}
}
pub(crate) fn resolve_or_install_program(
state: &RuntimeState,
package: &MasonPackage,
program: &str,
) -> Result<std::path::PathBuf> {
match parse_source_id(&package.source.id)? {
SourceId::Npm {
package_name,
version,
} => install_npm_package(state, package, &package_name, &version, program),
SourceId::Pypi {
package_name,
version,
extras,
} => install_pypi_package(state, package, &package_name, &version, &extras, program),
SourceId::Cargo {
package_name,
version,
} => install_cargo_package(state, package, &package_name, &version, program),
SourceId::Golang {
module_path,
version,
} => install_golang_package(state, package, &module_path, &version, program),
SourceId::Nuget {
package_name,
version,
} => install_nuget_package(state, package, &package_name, &version, program),
SourceId::Github {
repository,
version,
} => install_github_package(state, package, &repository, &version, program),
SourceId::Generic {
package_name,
version,
} => install_generic_package(state, package, &package_name, &version, program),
SourceId::Unsupported { kind } => Err(Error::unexpected(format!(
"cannot install {} automatically yet because its Mason package uses unsupported backend `{kind}`",
package.name
))),
}
}
fn install_npm_package(
state: &RuntimeState,
package: &MasonPackage,
package_name: &str,
version: &str,
program: &str,
) -> Result<PathBuf> {
use_cached_program_or(
package,
program,
state,
&TemplateContext::empty(),
|resolved_program| {
require_command("npm", package, program)?;
let install_dir = prepare_install_dir(state, package)?;
#[cfg(test)]
if fake_npm_install(&install_dir, program)? {
return finalize_install(
state,
package,
program,
&resolved_program,
&TemplateContext::empty(),
"npm did not produce a runnable",
);
}
let install_spec = format!("{package_name}@{version}");
let mut cmd = Command::new("npm");
cmd.arg("install")
.arg("--no-package-lock")
.arg("--prefix")
.arg(&install_dir)
.arg(&install_spec)
.args(&package.source.extra_packages);
run_install_command(&mut cmd, package, "npm")?;
finalize_install(
state,
package,
program,
&resolved_program,
&TemplateContext::empty(),
"npm did not produce a runnable",
)
},
)
}
#[cfg(test)]
use crate::env_vars;
#[cfg(test)]
fn fake_npm_install(install_dir: &std::path::Path, program: &str) -> Result<bool> {
let Some(fake_program) = env_vars::fake_npm_program() else {
return Ok(false);
};
if fake_program != program {
return Ok(false);
}
let executable = install_dir.join("node_modules/.bin").join(program);
let Some(parent) = executable.parent() else {
return Err(Error::unexpected(format!(
"failed to create fake npm output {}: no parent directory",
executable.display()
)));
};
fs::create_dir_all(parent).map_err(|error| {
Error::unexpected(format!(
"failed to create fake npm output {}: {error}",
executable.display()
))
})?;
fs::write(&executable, b"stub\n").map_err(|error| {
Error::unexpected(format!(
"failed to write fake npm output {}: {error}",
executable.display()
))
})?;
#[cfg(unix)]
{
let mut permissions = fs::metadata(&executable)
.map_err(|error| {
Error::unexpected(format!(
"failed to inspect fake npm output {}: {error}",
executable.display()
))
})?
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&executable, permissions).map_err(|error| {
Error::unexpected(format!(
"failed to mark fake npm output {} executable: {error}",
executable.display()
))
})?;
}
Ok(true)
}
fn install_pypi_package(
state: &RuntimeState,
package: &MasonPackage,
package_name: &str,
version: &str,
extras: &[String],
program: &str,
) -> Result<PathBuf> {
use_cached_program_or(
package,
program,
state,
&TemplateContext::empty(),
|resolved_program| {
require_command("python3", package, program)?;
let install_dir = prepare_install_dir(state, package)?;
let mut cmd = pypi_install_command(package_name, version, extras, &install_dir);
run_install_command(&mut cmd, package, "python3 -m pip")?;
finalize_install(
state,
package,
program,
&resolved_program,
&TemplateContext::empty(),
"pip did not produce a runnable",
)
},
)
}
fn pypi_install_command(
package_name: &str,
version: &str,
extras: &[String],
install_dir: &std::path::Path,
) -> Command {
let install_spec = if extras.is_empty() {
format!("{package_name}=={version}")
} else {
format!("{package_name}[{}]=={version}", extras.join(","))
};
let mut command = Command::new("python3");
command
.arg("-m")
.arg("pip")
.arg("install")
.arg("--disable-pip-version-check")
.arg("--ignore-installed")
.arg("--prefix")
.arg(install_dir)
.arg(install_spec);
command
}
fn install_cargo_package(
state: &RuntimeState,
package: &MasonPackage,
package_name: &str,
version: &str,
program: &str,
) -> Result<PathBuf> {
use_cached_program_or(
package,
program,
state,
&TemplateContext::empty(),
|resolved_program| {
require_command("cargo", package, program)?;
let install_dir = prepare_install_dir(state, package)?;
let mut cmd = Command::new("cargo");
cmd.arg("install")
.arg("--root")
.arg(&install_dir)
.arg("--version")
.arg(version)
.arg(package_name);
run_install_command(&mut cmd, package, "cargo")?;
finalize_install(
state,
package,
program,
&resolved_program,
&TemplateContext::empty(),
"cargo did not produce a runnable",
)
},
)
}
fn install_golang_package(
state: &RuntimeState,
package: &MasonPackage,
module_path: &str,
version: &str,
program: &str,
) -> Result<PathBuf> {
use_cached_program_or(
package,
program,
state,
&TemplateContext::empty(),
|resolved_program| {
require_command("go", package, program)?;
state.ensure_dirs()?;
let Some(bin_dir) = resolved_program.executable_path().parent() else {
return Err(Error::unexpected(format!(
"failed to determine installation directory for {}",
package.name
)));
};
crate::fs::create_dir_all(bin_dir)?;
let mut cmd = Command::new("go");
cmd.arg("install")
.arg(format!("{module_path}@{version}"))
.env("GOBIN", bin_dir);
run_install_command(&mut cmd, package, "go")?;
finalize_install(
state,
package,
program,
&resolved_program,
&TemplateContext::empty(),
"go did not produce a runnable",
)
},
)
}
fn install_nuget_package(
state: &RuntimeState,
package: &MasonPackage,
package_name: &str,
version: &str,
program: &str,
) -> Result<PathBuf> {
use_cached_program_or(
package,
program,
state,
&TemplateContext::empty(),
|resolved_program| {
require_command("dotnet", package, program)?;
let install_dir = prepare_install_dir(state, package)?.join("bin");
crate::fs::create_dir_all(&install_dir)?;
let mut cmd = nuget_install_command(package_name, version, &install_dir);
run_install_command(&mut cmd, package, "dotnet tool install")?;
finalize_install(
state,
package,
program,
&resolved_program,
&TemplateContext::empty(),
"dotnet did not produce a runnable",
)
},
)
}
fn nuget_install_command(
package_name: &str,
version: &str,
install_dir: &std::path::Path,
) -> Command {
let mut command = Command::new("dotnet");
command
.arg("tool")
.arg("install")
.arg(package_name)
.arg("--tool-path")
.arg(install_dir)
.arg("--version")
.arg(version);
command
}
fn install_github_package(
state: &RuntimeState,
package: &MasonPackage,
repository: &str,
version: &str,
program: &str,
) -> Result<std::path::PathBuf> {
state.ensure_dirs()?;
let platform = MasonPlatform::detect()?;
let asset = select_asset(package, &platform)?;
let rendered_asset = render_asset_data(asset, version, program, &package.name)?;
let context = rendered_asset.template_context(version);
let resolved_program = resolve_program(package, program, state, &context)?;
if is_resolved_program_runnable(&resolved_program) {
return Ok(resolved_program.executable_path().to_path_buf());
}
let client = http_client()?;
let (download_name, extract_subdir) = parse_archive_file_spec(&rendered_asset.file);
let url =
format!("https://github.com/{repository}/releases/download/{version}/{download_name}");
let archive_bytes = download_bytes(&client, &url, package)?;
let install_dir = match extract_subdir {
Some(path) => join_relative_path(&state.package_dir(&package.name), path)?,
None => state.package_dir(&package.name),
};
install_downloaded_artifact(&install_dir, download_name, &archive_bytes)?;
finalize_install(
state,
package,
program,
&resolved_program,
&context,
"the downloaded asset did not produce a runnable",
)
}
fn install_generic_package(
state: &RuntimeState,
package: &MasonPackage,
_package_name: &str,
version: &str,
program: &str,
) -> Result<std::path::PathBuf> {
state.ensure_dirs()?;
let platform = MasonPlatform::detect()?;
let download = select_download(package, &platform)?;
let rendered_download = render_download_data(download, version);
let context = rendered_download.template_context(version);
let resolved_program = resolve_program(package, program, state, &context)?;
if is_resolved_program_runnable(&resolved_program) {
return Ok(resolved_program.executable_path().to_path_buf());
}
let client = http_client()?;
let install_root = state.package_dir(&package.name);
crate::fs::create_dir_all(&install_root)?;
for (relative_name, url_template) in &download.files {
let relative_name = context.render(relative_name);
let url = context.render(url_template);
let bytes = download_bytes(&client, &url, package)?;
install_downloaded_artifact(&install_root, &relative_name, &bytes)?;
}
finalize_install(
state,
package,
program,
&resolved_program,
&context,
"the downloaded files did not produce a runnable",
)
}
fn resolve_cached_github_program(
state: &RuntimeState,
package: &MasonPackage,
program: &str,
version: &str,
) -> Result<Option<std::path::PathBuf>> {
let platform = MasonPlatform::detect()?;
let asset = select_asset(package, &platform)?;
let rendered_asset = render_asset_data(asset, version, program, &package.name)?;
let context = rendered_asset.template_context(version);
let resolved_program = resolve_program(package, program, state, &context)?;
Ok(if is_resolved_program_runnable(&resolved_program) {
Some(resolved_program.executable_path().to_path_buf())
} else {
None
})
}
fn resolve_cached_generic_program(
state: &RuntimeState,
package: &MasonPackage,
program: &str,
version: &str,
) -> Result<Option<std::path::PathBuf>> {
let platform = MasonPlatform::detect()?;
let download = select_download(package, &platform)?;
let rendered_download = render_download_data(download, version);
let context = rendered_download.template_context(version);
let resolved_program = resolve_program(package, program, state, &context)?;
Ok(if is_resolved_program_runnable(&resolved_program) {
Some(resolved_program.executable_path().to_path_buf())
} else {
None
})
}
fn use_cached_program_or<F>(
package: &MasonPackage,
program: &str,
state: &RuntimeState,
context: &TemplateContext,
install: F,
) -> Result<PathBuf>
where
F: FnOnce(ResolvedProgram) -> Result<PathBuf>,
{
let resolved_program = resolve_program(package, program, state, context)?;
if is_resolved_program_runnable(&resolved_program) {
return Ok(resolved_program.executable_path().to_path_buf());
}
install(resolved_program)
}
fn prepare_install_dir(state: &RuntimeState, package: &MasonPackage) -> Result<PathBuf> {
state.ensure_dirs()?;
let install_dir = state.package_dir(&package.name);
crate::fs::create_dir_all(&install_dir)?;
Ok(install_dir)
}
fn run_install_command(cmd: &mut Command, package: &MasonPackage, tool: &str) -> Result<()> {
let output = cmd.output().map_err(|error| {
Error::unexpected(format!(
"cannot install {} because {tool} could not start: {error}",
package.name
))
})?;
ensure_command_success(&output, package, tool)
}
fn require_command(command: &str, package: &MasonPackage, program: &str) -> Result<()> {
if crate::mason::link::is_command_runnable(command) {
Ok(())
} else {
Err(Error::unexpected(format!(
"cannot install {} because {} is not available in $PATH",
suggestion_server_name(package, program),
command
)))
}
}
fn suggestion_server_name(package: &MasonPackage, program: &str) -> String {
if package.bin.contains_key(program) {
program.to_string()
} else {
package.name.clone()
}
}