use std::collections::BTreeMap;
use std::{
ffi::OsString,
path::{Path, PathBuf},
sync::Arc,
};
use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::platform_target::PlatformTarget;
use crate::cli::args::BackendArg;
use crate::config::{CompilePurpose, Config, Settings};
#[cfg(unix)]
use crate::file::ExtractOptions;
use crate::file::display_path;
use crate::http::{HTTP, HTTP_FETCH};
use crate::install_context::InstallContext;
use crate::lock_file::LockFile;
use crate::lockfile::PlatformInfo;
use crate::platform::{Platform, linux_os_release};
use crate::toolset::{ToolRequest, ToolVersion};
use crate::{file, github, plugins};
use async_trait::async_trait;
use eyre::{Result, bail};
use indexmap::IndexMap;
use xx::regex;
#[cfg(linux)]
use crate::cmd::CmdLineRunner;
#[cfg(linux)]
use std::fs;
#[derive(Debug)]
pub struct ErlangPlugin {
ba: Arc<BackendArg>,
}
const KERL_VERSION: &str = "4.4.0";
const ERLANG_PRECOMPILED_OS_OPTION: &str = "precompiled_os";
impl ErlangPlugin {
pub fn new() -> Self {
Self {
ba: Arc::new(plugins::core::new_backend_arg("erlang")),
}
}
fn kerl_path(&self) -> PathBuf {
self.ba.cache_path.join(format!("kerl-{KERL_VERSION}"))
}
fn kerl_base_dir(&self) -> PathBuf {
self.ba.cache_path.join("kerl")
}
fn lock_build_tool(&self) -> Result<fslock::LockFile> {
LockFile::new(&self.kerl_path())
.with_callback(|l| {
trace!("install_or_update_kerl {}", l.display());
})
.lock()
}
async fn update_kerl(&self) -> Result<()> {
let _lock = self.lock_build_tool();
if self.kerl_path().exists() {
file::remove_all(self.kerl_base_dir())?;
return Ok(());
}
self.install_kerl().await?;
let output = cmd!(self.kerl_path(), "update", "releases")
.env("KERL_BASE_DIR", self.kerl_base_dir())
.stdout_capture()
.stderr_capture()
.run()?;
trace!("kerl stdout: {}", String::from_utf8_lossy(&output.stdout));
trace!("kerl stderr: {}", String::from_utf8_lossy(&output.stderr));
Ok(())
}
async fn install_kerl(&self) -> Result<()> {
debug!("Installing kerl to {}", display_path(self.kerl_path()));
HTTP_FETCH
.download_file(
format!("https://raw.githubusercontent.com/kerl/kerl/{KERL_VERSION}/kerl"),
&self.kerl_path(),
None,
)
.await?;
file::make_executable(self.kerl_path())?;
Ok(())
}
fn precompiled_unavailable(&self, reason: impl Into<String>) -> Result<Option<ToolVersion>> {
let reason = reason.into();
if Settings::get().erlang_compile(CompilePurpose::Inspect) == Some(false) {
bail!("precompiled erlang is not available: {reason}");
}
debug!("{reason}");
Ok(None)
}
fn release_tag(version: &str) -> String {
format!("OTP-{version}")
}
fn lockfile_url(&self, locked: bool, tv: &ToolVersion) -> Option<String> {
locked_platform_url(locked, &tv.lock_platforms, &self.get_platform_key())
}
fn set_lockfile_info(
&self,
tv: &mut ToolVersion,
install: Option<&str>,
url: &str,
checksum: Option<String>,
url_api: Option<String>,
) {
let platform_info = tv
.lock_platforms
.entry(self.get_platform_key())
.or_default();
let artifact_changed = platform_info.install.as_deref() != install
|| platform_info.url.as_deref() != Some(url);
if artifact_changed {
platform_info.checksum = None;
platform_info.size = None;
platform_info.url_api = None;
platform_info.provenance = None;
platform_info.github_attestations = None;
}
platform_info.install = install.map(str::to_string);
platform_info.url = Some(url.to_string());
if let Some(checksum) = checksum {
platform_info.checksum = Some(checksum);
}
if let Some(url_api) = url_api {
platform_info.url_api = Some(url_api);
}
}
fn source_asset_name(version: &str) -> String {
format!("otp_src_{version}.tar.gz")
}
fn source_asset_url(version: &str) -> String {
format!(
"https://github.com/erlang/otp/releases/download/{}/{}",
Self::release_tag(version),
Self::source_asset_name(version)
)
}
fn source_archive_url(version: &str) -> String {
format!(
"https://github.com/erlang/otp/archive/{}.tar.gz",
Self::release_tag(version)
)
}
fn source_cache_name(url: &str) -> String {
format!("otp-source-{}.tar.gz", crate::hash::hash_sha256_to_str(url))
}
fn linux_precompiled_base_url(target: &PlatformTarget) -> Result<String> {
if target.libc() == Some("musl") {
bail!("precompiled erlang is not supported on musl linux");
}
let arch = match target.arch_name() {
"x64" => "amd64",
"arm64" => "arm64",
other => bail!("unsupported architecture: {other}"),
};
let os_ver = Self::linux_precompiled_os_version()?;
Ok(format!("https://builds.hex.pm/builds/otp/{arch}/{os_ver}"))
}
async fn linux_precompiled_lock_info(
version: &str,
target: &PlatformTarget,
) -> Result<PlatformInfo> {
let base_url = Self::linux_precompiled_base_url(target)?;
let release_tag = Self::release_tag(version);
let builds = HTTP_FETCH
.get_text_cached(format!("{base_url}/builds.txt"))
.await?;
let checksum = parse_hex_build_checksum(&builds, &release_tag)?;
Ok(PlatformInfo {
url: Some(format!("{base_url}/{release_tag}.tar.gz")),
checksum: Some(checksum),
..Default::default()
})
}
#[cfg(linux)]
fn linux_precompiled_cache_name(url: &str) -> String {
url.strip_prefix("https://builds.hex.pm/builds/otp/")
.unwrap_or(url)
.replace('/', "__")
.replace(':', "_")
}
fn lockfile_precompiled_os_option(target: &PlatformTarget) -> Option<String> {
if target.os_name() == "linux" && target.libc() != Some("musl") {
Self::linux_precompiled_os_version().ok()
} else {
None
}
}
fn linux_precompiled_os_version() -> Result<String> {
let os_ver = if Platform::current().is_linux() {
if let Ok(os) = std::env::var("ImageOS") {
match os.as_str() {
"ubuntu24" => "ubuntu-24.04".to_string(),
"ubuntu22" => "ubuntu-22.04".to_string(),
"ubuntu20" => "ubuntu-20.04".to_string(),
_ => os,
}
} else if let Some(os_release) = linux_os_release() {
format!("{}-{}", os_release.id, os_release.version_id)
} else {
bail!("could not determine OS release");
}
} else {
"ubuntu-24.04".to_string()
};
if !["ubuntu-20.04", "ubuntu-22.04", "ubuntu-24.04"].contains(&os_ver.as_str()) {
bail!("unsupported OS version: {os_ver}");
}
Ok(os_ver)
}
fn macos_asset_name(target: &PlatformTarget) -> Result<String> {
let arch = match target.arch_name() {
"x64" => "x86_64",
"arm64" => "aarch64",
other => bail!("unsupported architecture: {other}"),
};
Ok(format!("otp-{arch}-apple-darwin.tar.gz"))
}
fn windows_asset_name(version: &str, target: &PlatformTarget) -> Result<String> {
let os = match target.arch_name() {
"x64" => "win64",
"x86" => "win32",
other => bail!("unsupported architecture: {other}"),
};
Ok(format!("otp_{os}_{version}.zip"))
}
async fn github_asset_lock_info(repo: &str, tag: &str, name: &str) -> Result<PlatformInfo> {
let release = github::get_release(repo, tag).await?;
let asset = release
.assets
.iter()
.find(|a| a.name == name)
.ok_or_else(|| eyre::eyre!("no asset found for {name} in {tag}"))?;
Ok(PlatformInfo {
checksum: asset.digest.clone(),
url: Some(asset.browser_download_url.clone()),
url_api: Some(asset.url.clone()),
..Default::default()
})
}
async fn resolve_source_lock_info(&self, version: &str) -> Result<PlatformInfo> {
let release_tag = Self::release_tag(version);
let asset_name = Self::source_asset_name(version);
let info = match Self::github_asset_lock_info("erlang/otp", &release_tag, &asset_name).await
{
Ok(info) => info,
Err(err) => {
let asset_url = Self::source_asset_url(version);
match HTTP.head(&asset_url).await {
Ok(_) => {
debug!(
"failed to resolve metadata for Erlang/OTP source release asset {asset_name}: {err}; using the reachable release asset without a checksum"
);
PlatformInfo {
url: Some(asset_url),
..Default::default()
}
}
Err(_) => {
debug!(
"failed to resolve Erlang/OTP source release asset {asset_name}: {err}; using tag archive"
);
PlatformInfo {
url: Some(Self::source_archive_url(version)),
..Default::default()
}
}
}
}
};
Ok(PlatformInfo {
install: Some("source".to_string()),
..info
})
}
#[cfg(linux)]
async fn install_precompiled(
&self,
ctx: &InstallContext,
mut tv: ToolVersion,
) -> Result<Option<ToolVersion>> {
if !ctx.locked && Settings::get().erlang_compile(CompilePurpose::Inspect) == Some(true) {
return Ok(None);
}
let (url, checksum) = if let Some(url) = self.lockfile_url(ctx.locked, &tv) {
let checksum = require_locked_precompiled_checksum(
&tv.version,
tv.lock_platforms
.get(&self.get_platform_key())
.and_then(|info| info.checksum.as_deref()),
)?;
(url, checksum)
} else {
match Self::linux_precompiled_lock_info(&tv.version, &PlatformTarget::from_current())
.await
{
Ok(info) => (info.url.unwrap(), info.checksum.unwrap()),
Err(e) => {
return self.precompiled_unavailable(e.to_string());
}
}
};
let filename = url.split('/').next_back().unwrap();
let tarball_path = tv
.download_path()
.join(Self::linux_precompiled_cache_name(&url));
ctx.pr.set_message(format!("Downloading {filename}"));
if !tarball_path.exists() {
HTTP.download_file(&url, &tarball_path, Some(ctx.pr.as_ref()))
.await?;
}
self.set_lockfile_info(&mut tv, None, &url, Some(checksum), None);
self.verify_checksum(ctx, &mut tv, &tarball_path)?;
ctx.pr.set_message(format!("Extracting {filename}"));
file::untar(
&tarball_path,
&tv.download_path(),
file::ExtractionFormat::TarGz,
&ExtractOptions {
pr: Some(ctx.pr.as_ref()),
..Default::default()
},
)?;
self.move_to_install_path(&tv)?;
CmdLineRunner::new(tv.install_path().join("Install"))
.with_pr(ctx.pr.as_ref())
.arg("-minimal")
.arg(tv.install_path())
.env_values(tv.install_env())
.execute()?;
Ok(Some(tv))
}
#[cfg(linux)]
fn move_to_install_path(&self, tv: &ToolVersion) -> Result<()> {
let base_dir = tv
.download_path()
.read_dir()?
.find(|e| e.as_ref().unwrap().file_type().unwrap().is_dir())
.unwrap()?
.path();
file::remove_all(tv.install_path())?;
file::create_dir_all(tv.install_path())?;
for entry in fs::read_dir(base_dir)? {
let entry = entry?;
let dest = tv.install_path().join(entry.file_name());
trace!("moving {:?} to {:?}", entry.path(), &dest);
file::move_file(entry.path(), dest)?;
}
Ok(())
}
#[cfg(macos)]
async fn install_precompiled(
&self,
ctx: &InstallContext,
mut tv: ToolVersion,
) -> Result<Option<ToolVersion>> {
if !ctx.locked && Settings::get().erlang_compile(CompilePurpose::Inspect) == Some(true) {
return Ok(None);
}
let release_tag = Self::release_tag(&tv.version);
let (url, checksum) = if let Some(url) = self.lockfile_url(ctx.locked, &tv) {
(url, None)
} else {
let tarball_name = match Self::macos_asset_name(&PlatformTarget::from_current()) {
Ok(tarball_name) => tarball_name,
Err(e) => return self.precompiled_unavailable(e.to_string()),
};
let gh_release = match github::get_release("erlef/otp_builds", &release_tag).await {
Ok(release) => release,
Err(e) => {
return self.precompiled_unavailable(format!(
"failed to get release {release_tag}: {e}"
));
}
};
let asset = match gh_release.assets.iter().find(|a| a.name == tarball_name) {
Some(asset) => asset,
None => {
return self.precompiled_unavailable(format!(
"no asset found for {tarball_name} in {release_tag}"
));
}
};
(asset.browser_download_url.clone(), asset.digest.clone())
};
let tarball_name = url.split('/').next_back().unwrap();
ctx.pr.set_message(format!("Downloading {tarball_name}"));
let tarball_path = tv.download_path().join(tarball_name);
if !tarball_path.exists() {
HTTP.download_file(&url, &tarball_path, Some(ctx.pr.as_ref()))
.await?;
}
self.set_lockfile_info(&mut tv, None, &url, checksum, None);
self.verify_checksum(ctx, &mut tv, &tarball_path)?;
ctx.pr.set_message(format!("Extracting {tarball_name}"));
file::untar(
&tarball_path,
&tv.install_path(),
file::ExtractionFormat::TarGz,
&ExtractOptions {
pr: Some(ctx.pr.as_ref()),
..Default::default()
},
)?;
Ok(Some(tv))
}
#[cfg(windows)]
async fn install_precompiled(
&self,
ctx: &InstallContext,
mut tv: ToolVersion,
) -> Result<Option<ToolVersion>> {
if !ctx.locked && Settings::get().erlang_compile(CompilePurpose::Inspect) == Some(true) {
return Ok(None);
}
let release_tag = Self::release_tag(&tv.version);
let (url, checksum) = if let Some(url) = self.lockfile_url(ctx.locked, &tv) {
(url, None)
} else {
let zip_name =
match Self::windows_asset_name(&tv.version, &PlatformTarget::from_current()) {
Ok(zip_name) => zip_name,
Err(e) => return self.precompiled_unavailable(e.to_string()),
};
let gh_release = match github::get_release("erlang/otp", &release_tag).await {
Ok(release) => release,
Err(e) => {
return self.precompiled_unavailable(format!(
"failed to get release {release_tag}: {e}"
));
}
};
let asset = match gh_release.assets.iter().find(|a| a.name == zip_name) {
Some(asset) => asset,
None => {
return self.precompiled_unavailable(format!(
"no asset found for {zip_name} in {release_tag}"
));
}
};
(asset.browser_download_url.clone(), asset.digest.clone())
};
let zip_name = url.split('/').next_back().unwrap();
ctx.pr.set_message(format!("Downloading {}", zip_name));
let zip_path = tv.download_path().join(zip_name);
if !zip_path.exists() {
HTTP.download_file(&url, &zip_path, Some(ctx.pr.as_ref()))
.await?;
}
self.set_lockfile_info(&mut tv, None, &url, checksum, None);
self.verify_checksum(ctx, &mut tv, &zip_path)?;
ctx.pr.set_message(format!("Extracting {}", zip_name));
file::unzip(&zip_path, &tv.install_path(), &Default::default())?;
Ok(Some(tv))
}
#[cfg(not(any(linux, macos, windows)))]
async fn install_precompiled(
&self,
ctx: &InstallContext,
_tv: ToolVersion,
) -> Result<Option<ToolVersion>> {
if !ctx.locked && Settings::get().erlang_compile(CompilePurpose::Inspect) == Some(true) {
Ok(None)
} else {
self.precompiled_unavailable("precompiled erlang is not supported on this platform")
}
}
async fn install_via_kerl(
&self,
ctx: &InstallContext,
mut tv: ToolVersion,
) -> Result<ToolVersion> {
self.update_kerl().await?;
let platform_key = self.get_platform_key();
let source_info = if ctx.locked {
tv.lock_platforms
.get(&platform_key)
.cloned()
.ok_or_else(|| {
eyre::eyre!("missing locked Erlang source info for {platform_key}")
})?
} else {
self.resolve_source_lock_info(&tv.version).await?
};
let source_url = source_info
.url
.as_deref()
.ok_or_else(|| eyre::eyre!("missing Erlang source URL for {platform_key}"))?;
self.set_lockfile_info(
&mut tv,
Some("source"),
source_url,
source_info.checksum.clone(),
source_info.url_api.clone(),
);
let source_path = tv.download_path().join(Self::source_cache_name(source_url));
let display_name = source_url
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or("Erlang/OTP source");
ctx.pr.set_message(format!("Downloading {display_name}"));
if !source_path.exists() {
HTTP.download_file(source_url, &source_path, Some(ctx.pr.as_ref()))
.await?;
}
self.verify_checksum(ctx, &mut tv, &source_path)?;
let kerl_base_dir = self.kerl_base_dir();
let kerl_source_path = kerl_base_dir
.join("archives")
.join(format!("{}.tar.gz", Self::release_tag(&tv.version)));
file::create_dir_all(kerl_source_path.parent().unwrap())?;
file::copy(&source_path, &kerl_source_path)?;
file::remove_all(tv.install_path())?;
match &tv.request {
ToolRequest::Ref { .. } => {
unimplemented!("erlang does not yet support refs");
}
_ => {
let mut cmd = cmd!(
self.kerl_path(),
"build-install",
&tv.version,
&tv.version,
tv.install_path()
)
.env("MAKEFLAGS", format!("-j{}", num_cpus::get()));
for (key, value) in source_build_kerl_env(tv.install_env(), &kerl_base_dir) {
cmd = match value {
Some(value) => cmd.env(key, value),
None => cmd.env_remove(key),
};
}
cmd.run()?;
}
}
Ok(tv)
}
}
#[async_trait]
impl Backend for ErlangPlugin {
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<VersionInfo>> {
let versions = if Settings::get().erlang_compile(CompilePurpose::Inspect) == Some(false) {
github::list_releases("erlef/otp_builds")
.await?
.into_iter()
.filter_map(|r| {
let released_at = r.released_at().to_string();
r.tag_name
.strip_prefix("OTP-")
.map(|s| (s.to_string(), Some(released_at)))
})
.map(|(version, created_at)| VersionInfo {
version,
created_at,
..Default::default()
})
.collect()
} else {
self.update_kerl().await?;
let kerl_path = self.kerl_path().to_string_lossy().to_string();
let kerl_base_dir = self.ba.cache_path.join("kerl");
plugins::core::run_fetch_task_with_timeout_async(async move || {
let output = crate::cmd::cmd_read_async_inherited_env(
&kerl_path,
&["list", "releases", "all"],
[("KERL_BASE_DIR", kerl_base_dir.as_os_str())],
)
.await?;
let versions = output
.split('\n')
.filter(|s| regex!(r"^[0-9].+$").is_match(s))
.map(|s| VersionInfo {
version: s.to_string(),
..Default::default()
})
.collect();
Ok(versions)
})
.await?
};
Ok(versions)
}
async fn install_version_(&self, ctx: &InstallContext, tv: ToolVersion) -> Result<ToolVersion> {
let platform_key = self.get_platform_key();
let settings = Settings::get();
let erlang_compile = if ctx.locked {
settings.erlang_compile(CompilePurpose::Inspect)
} else {
settings.erlang_compile(CompilePurpose::Install)
};
if should_install_from_source(
ctx.locked,
&tv.lock_platforms,
&platform_key,
erlang_compile,
) {
return self.install_via_kerl(ctx, tv).await;
}
if let Some(tv) = self.install_precompiled(ctx, tv.clone()).await? {
return Ok(tv);
}
self.install_via_kerl(ctx, tv).await
}
fn resolve_lockfile_options(
&self,
_request: &ToolRequest,
target: &PlatformTarget,
) -> Result<BTreeMap<String, String>> {
let mut opts = BTreeMap::new();
let settings = Settings::get();
match settings.erlang_compile(CompilePurpose::Inspect) {
Some(true) => {
opts.insert("compile".to_string(), "true".to_string());
}
Some(false) => {
opts.insert("compile".to_string(), "false".to_string());
if let Some(os_version) = Self::lockfile_precompiled_os_option(target) {
opts.insert(ERLANG_PRECOMPILED_OS_OPTION.to_string(), os_version);
}
}
None => {
if let Some(os_version) = Self::lockfile_precompiled_os_option(target) {
opts.insert(ERLANG_PRECOMPILED_OS_OPTION.to_string(), os_version);
}
}
}
Ok(opts)
}
async fn resolve_lock_info(
&self,
tv: &ToolVersion,
target: &PlatformTarget,
) -> Result<PlatformInfo> {
let compile = Settings::get().erlang_compile(CompilePurpose::Inspect);
if compile == Some(true) {
return self.resolve_source_lock_info(&tv.version).await;
}
let release_tag = Self::release_tag(&tv.version);
match target.os_name() {
"linux" => match Self::linux_precompiled_lock_info(&tv.version, target).await {
Ok(info) => Ok(info),
Err(err) if compile == Some(false) => Err(err),
Err(_) => self.resolve_source_lock_info(&tv.version).await,
},
"macos" => {
let info = match Self::macos_asset_name(target) {
Ok(asset_name) => {
Self::github_asset_lock_info("erlef/otp_builds", &release_tag, &asset_name)
.await
}
Err(err) => Err(err),
};
match info {
Ok(info) => Ok(info),
Err(err) if compile == Some(false) => Err(err),
Err(_) => self.resolve_source_lock_info(&tv.version).await,
}
}
"windows" => {
let info = match Self::windows_asset_name(&tv.version, target) {
Ok(asset_name) => {
Self::github_asset_lock_info("erlang/otp", &release_tag, &asset_name).await
}
Err(err) => Err(err),
};
match info {
Ok(info) => Ok(info),
Err(err) if compile == Some(false) => Err(err),
Err(_) => self.resolve_source_lock_info(&tv.version).await,
}
}
os if compile == Some(false) => {
bail!("precompiled erlang is not supported on {os}")
}
_ => self.resolve_source_lock_info(&tv.version).await,
}
}
}
fn source_build_kerl_env(
install_env: IndexMap<String, crate::config::env_directive::EnvValue>,
kerl_base_dir: &Path,
) -> IndexMap<String, Option<OsString>> {
let mut env = install_env
.into_iter()
.map(|(key, value)| (key, value.into_string().map(OsString::from)))
.collect::<IndexMap<_, _>>();
env.insert(
"KERL_BASE_DIR".to_string(),
Some(kerl_base_dir.as_os_str().to_owned()),
);
env.insert(
"KERL_DOWNLOAD_DIR".to_string(),
Some(kerl_base_dir.join("archives").into_os_string()),
);
env.insert(
"KERL_BUILD_BACKEND".to_string(),
Some(OsString::from("git")),
);
env
}
fn locked_platform_url(
locked: bool,
lock_platforms: &BTreeMap<String, PlatformInfo>,
platform_key: &str,
) -> Option<String> {
if !locked {
return None;
}
lock_platforms
.get(platform_key)
.and_then(|platform_info| platform_info.url.clone())
}
fn should_install_from_source(
locked: bool,
lock_platforms: &BTreeMap<String, PlatformInfo>,
platform_key: &str,
erlang_compile: Option<bool>,
) -> bool {
if locked {
lock_platforms
.get(platform_key)
.is_some_and(|pi| pi.install.as_deref() == Some("source"))
} else {
erlang_compile == Some(true)
}
}
fn parse_hex_build_checksum(builds: &str, release_tag: &str) -> Result<String> {
let matching = builds
.lines()
.filter_map(|line| {
let parts = line.split_whitespace().collect::<Vec<_>>();
(parts.first() == Some(&release_tag)).then_some(parts)
})
.collect::<Vec<_>>();
if matching.len() != 1 {
bail!(
"expected exactly one Hex OTP build record for {release_tag}, found {}",
matching.len()
);
}
let checksum = matching[0]
.get(3)
.ok_or_else(|| eyre::eyre!("Hex OTP build record for {release_tag} has no checksum"))?;
if !regex!(r"^[0-9a-f]{64}$").is_match(checksum) {
bail!("invalid Hex OTP checksum for {release_tag}: {checksum}");
}
Ok(format!("sha256:{checksum}"))
}
#[cfg(any(linux, test))]
fn require_locked_precompiled_checksum(version: &str, checksum: Option<&str>) -> Result<String> {
let checksum = checksum.ok_or_else(|| {
eyre::eyre!(
"No lockfile checksum found for precompiled Erlang/OTP {version}; regenerate mise.lock with `mise lock`"
)
})?;
if !regex!(r"^sha256:[0-9a-f]{64}$").is_match(checksum) {
bail!(
"Invalid lockfile checksum for precompiled Erlang/OTP {version}; regenerate mise.lock with `mise lock`"
);
}
Ok(checksum.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_hex_build_checksum_requires_exact_publisher_record() {
let builds = "OTP-28.5.0.4 abc 2026-07-27T13:55:40Z 34498e6287e1fbc31250d36dd88bcdb1d286cedbdd9b1a66bbc4284738d2d2eb\n\
OTP-28.5.0.5 def 2026-08-04T10:35:03Z e3476633cae6fef8e1bb53576832b15823f715e70e3d4d1e66a6be908804f967\n";
assert_eq!(
parse_hex_build_checksum(builds, "OTP-28.5.0.5").unwrap(),
"sha256:e3476633cae6fef8e1bb53576832b15823f715e70e3d4d1e66a6be908804f967"
);
assert!(parse_hex_build_checksum(builds, "OTP-28.5.0.6").is_err());
}
#[test]
fn test_parse_hex_build_checksum_rejects_missing_malformed_and_duplicate_evidence() {
let missing = "OTP-28.5.0.5 def 2026-08-04T10:35:03Z\n";
let malformed = "OTP-28.5.0.5 def 2026-08-04T10:35:03Z abc123\n";
let duplicate = "OTP-28.5.0.5 def 2026-08-04T10:35:03Z e3476633cae6fef8e1bb53576832b15823f715e70e3d4d1e66a6be908804f967\n\
OTP-28.5.0.5 def 2026-08-04T10:35:03Z e3476633cae6fef8e1bb53576832b15823f715e70e3d4d1e66a6be908804f967\n";
assert!(parse_hex_build_checksum(missing, "OTP-28.5.0.5").is_err());
assert!(parse_hex_build_checksum(malformed, "OTP-28.5.0.5").is_err());
assert!(parse_hex_build_checksum(duplicate, "OTP-28.5.0.5").is_err());
}
#[test]
fn test_locked_precompiled_checksum_is_mandatory_and_well_formed() {
let valid = "sha256:e3476633cae6fef8e1bb53576832b15823f715e70e3d4d1e66a6be908804f967";
assert_eq!(
require_locked_precompiled_checksum("28.5.0.5", Some(valid)).unwrap(),
valid
);
assert!(require_locked_precompiled_checksum("28.5.0.5", None).is_err());
assert!(require_locked_precompiled_checksum("28.5.0.5", Some("sha256:abc123")).is_err());
assert!(
require_locked_precompiled_checksum(
"28.5.0.5",
Some("sha512:e3476633cae6fef8e1bb53576832b15823f715e70e3d4d1e66a6be908804f967")
)
.is_err()
);
}
#[test]
fn test_source_build_kerl_env_pins_staged_archive() {
let install_env = IndexMap::from([
(
"KERL_BUILD_BACKEND".to_string(),
crate::config::env_directive::EnvValue::from("tarball"),
),
(
"KERL_DOWNLOAD_DIR".to_string(),
crate::config::env_directive::EnvValue::from("/custom/downloads"),
),
(
"KERL_CONFIGURE_OPTIONS".to_string(),
crate::config::env_directive::EnvValue::from("--without-javac"),
),
]);
let kerl_base_dir = Path::new("/mise/kerl");
let env = source_build_kerl_env(install_env, kerl_base_dir);
assert_eq!(
env["KERL_BUILD_BACKEND"].as_deref(),
Some(std::ffi::OsStr::new("git"))
);
assert_eq!(
env["KERL_BASE_DIR"].as_deref(),
Some(kerl_base_dir.as_os_str())
);
assert_eq!(
env["KERL_DOWNLOAD_DIR"].as_deref(),
Some(kerl_base_dir.join("archives").as_os_str())
);
assert_eq!(
env["KERL_CONFIGURE_OPTIONS"].as_deref(),
Some(std::ffi::OsStr::new("--without-javac"))
);
}
#[test]
fn test_locked_erlang_install_uses_source_marker() {
let platform_key = Platform::current().to_key();
let lock_platforms = BTreeMap::from([(
platform_key.clone(),
PlatformInfo {
install: Some("source".to_string()),
url: Some("https://github.com/erlang/otp/source.tar.gz".to_string()),
..Default::default()
},
)]);
assert!(should_install_from_source(
true,
&lock_platforms,
&platform_key,
Some(false)
));
}
#[test]
fn test_locked_erlang_install_ignores_compile_setting_for_precompiled_lock() {
let platform_key = Platform::current().to_key();
let lock_platforms = BTreeMap::from([(
platform_key.clone(),
PlatformInfo {
url: Some("https://builds.hex.pm/builds/otp/precompiled.tar.gz".to_string()),
..Default::default()
},
)]);
assert!(!should_install_from_source(
true,
&lock_platforms,
&platform_key,
Some(true)
));
}
#[test]
fn test_unlocked_erlang_install_uses_compile_setting() {
assert!(should_install_from_source(
false,
&BTreeMap::new(),
"linux-x64",
Some(true)
));
assert!(!should_install_from_source(
false,
&BTreeMap::new(),
"linux-x64",
None
));
}
#[test]
fn test_unlocked_erlang_install_ignores_lockfile_url() {
let platform_key = Platform::current().to_key();
let lock_platforms = BTreeMap::from([(
platform_key.clone(),
PlatformInfo {
install: Some("source".to_string()),
url: Some("https://github.com/erlang/otp/source.tar.gz".to_string()),
..Default::default()
},
)]);
assert_eq!(
locked_platform_url(false, &lock_platforms, &platform_key),
None
);
assert_eq!(
locked_platform_url(true, &lock_platforms, &platform_key).as_deref(),
Some("https://github.com/erlang/otp/source.tar.gz")
);
}
}