use crate::backend::platform_target::PlatformTarget;
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
use crate::config::Settings;
use crate::http::HTTP;
use crate::install_context::InstallContext;
use crate::lockfile::PlatformInfo;
use crate::platform::linux_os_release;
use crate::toolset::{ToolRequest, ToolVersion};
use crate::ui::progress_report::SingleReport;
use crate::{backend::Backend, backend::VersionInfo, config::Config};
use crate::{file, github, gpg, plugins};
use async_trait::async_trait;
use eyre::{Result, bail};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
use tempfile::tempdir_in;
const SWIFT_PLATFORM_OPTION: &str = "swift_platform";
#[derive(Debug)]
pub struct SwiftPlugin {
ba: Arc<BackendArg>,
}
impl SwiftPlugin {
pub fn new() -> Self {
Self {
ba: Arc::new(plugins::core::new_backend_arg("swift")),
}
}
fn swift_bin(&self, tv: &ToolVersion) -> PathBuf {
tv.install_path().join("bin").join(swift_bin_name())
}
fn test_swift(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> {
ctx.pr.set_message("swift --version".into());
CmdLineRunner::new(self.swift_bin(tv))
.with_pr(ctx.pr.as_ref())
.arg("--version")
.env_values(tv.install_env())
.execute()
}
async fn download(&self, tv: &ToolVersion, pr: &dyn SingleReport) -> Result<PathBuf> {
let url = url(tv, &PlatformTarget::from_current());
let filename = url.split('/').next_back().unwrap();
let tarball_path = tv.download_path().join(filename);
if !tarball_path.exists() {
pr.set_message(format!("download {filename}"));
HTTP.download_file(&url, &tarball_path, Some(pr)).await?;
}
Ok(tarball_path)
}
fn install(&self, ctx: &InstallContext, tv: &ToolVersion, tarball_path: &Path) -> Result<()> {
let filename = tarball_path.file_name().unwrap().to_string_lossy();
let version = &tv.version;
ctx.pr.set_message(format!("extract {filename}"));
if cfg!(macos) {
let tmp = {
tempdir_in(tv.install_path().parent().unwrap())?
.path()
.to_path_buf()
};
CmdLineRunner::new(pkgutil_path())
.arg("--expand-full")
.arg(tarball_path)
.arg(&tmp)
.with_pr(ctx.pr.as_ref())
.env_values(tv.install_env())
.execute()?;
file::remove_all(tv.install_path())?;
file::rename(
tmp.join(format!("swift-{version}-RELEASE-osx-package.pkg"))
.join("Payload"),
tv.install_path(),
)?;
} else if cfg!(windows) {
todo!("install from exe");
} else {
file::untar(
tarball_path,
&tv.install_path(),
file::ExtractionFormat::TarGz,
&file::ExtractOptions {
strip_components: 1,
pr: Some(ctx.pr.as_ref()),
..Default::default()
},
)?;
}
Ok(())
}
fn symlink_bins(&self, tv: &ToolVersion) -> Result<()> {
let usr_bin = tv.install_path().join("usr").join("bin");
let bin_dir = tv.install_path().join("bin");
file::create_dir_all(&bin_dir)?;
for bin in file::ls(&usr_bin)? {
if !file::is_executable(&bin) {
continue;
}
let file_name = bin.file_name().unwrap().to_string_lossy().to_string();
if file_name.contains("swift") || file_name.contains("sourcekit") {
file::make_symlink_or_copy(&bin, &bin_dir.join(file_name))?;
}
}
Ok(())
}
async fn verify_gpg(
&self,
ctx: &InstallContext,
tv: &ToolVersion,
tarball_path: &Path,
) -> Result<()> {
let sig_path = PathBuf::from(format!("{}.sig", tarball_path.to_string_lossy()));
HTTP.download_file(
format!("{}.sig", url(tv, &PlatformTarget::from_current())),
&sig_path,
Some(ctx.pr.as_ref()),
)
.await?;
let signature = file::read(&sig_path)?;
gpg::verify_swift(tarball_path, &signature)?;
Ok(())
}
fn verify(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> {
self.test_swift(ctx, tv)
}
}
#[cfg(macos)]
fn pkgutil_path() -> PathBuf {
resolve_pkgutil_path(file::which("pkgutil"))
}
#[cfg(not(macos))]
fn pkgutil_path() -> PathBuf {
PathBuf::from("pkgutil")
}
#[cfg(macos)]
fn resolve_pkgutil_path(which_result: Option<PathBuf>) -> PathBuf {
if let Some(path) = which_result {
return path;
}
let fallback = PathBuf::from("/usr/sbin/pkgutil");
if file::is_executable(&fallback) {
fallback
} else {
PathBuf::from("pkgutil")
}
}
#[cfg(all(test, macos))]
mod tests {
use super::resolve_pkgutil_path;
use crate::file;
use std::path::PathBuf;
#[test]
fn resolve_pkgutil_path_prefers_discovered_path() {
let discovered = PathBuf::from("/tmp/custom/pkgutil");
assert_eq!(resolve_pkgutil_path(Some(discovered.clone())), discovered);
}
#[test]
fn resolve_pkgutil_path_falls_back_to_system_location() {
let resolved = resolve_pkgutil_path(None);
let fallback = PathBuf::from("/usr/sbin/pkgutil");
if file::is_executable(&fallback) {
assert_eq!(resolved, fallback);
} else {
assert_eq!(resolved, PathBuf::from("pkgutil"));
}
}
}
#[async_trait]
impl Backend for SwiftPlugin {
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
fn supports_lockfile_url(&self) -> bool {
false
}
fn resolve_lockfile_options(
&self,
_request: &ToolRequest,
target: &PlatformTarget,
) -> Result<BTreeMap<String, String>> {
let mut opts = BTreeMap::new();
if target.os_name() == "linux" {
opts.insert(SWIFT_PLATFORM_OPTION.to_string(), platform(target));
}
Ok(opts)
}
fn lockfile_options_are_host_specific(&self) -> bool {
true
}
async fn resolve_lock_info(
&self,
tv: &ToolVersion,
target: &PlatformTarget,
) -> Result<PlatformInfo> {
if target.libc() == Some("musl") {
bail!("swift does not publish musl builds");
}
let url = url(tv, target);
if let Err(err) = HTTP.head(&url).await {
bail!("swift does not publish {url}: {err}");
}
Ok(PlatformInfo {
url: Some(url),
..Default::default()
})
}
async fn security_info(&self) -> Vec<crate::backend::SecurityFeature> {
use crate::backend::SecurityFeature;
let mut features = vec![SecurityFeature::Checksum {
algorithm: Some("sha256".to_string()),
}];
if cfg!(target_os = "linux") && Settings::get().swift.gpg_verify != Some(false) {
features.push(SecurityFeature::Gpg);
}
features
}
async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<VersionInfo>> {
let versions = github::list_releases("swiftlang/swift")
.await?
.into_iter()
.filter_map(|r| {
r.tag_name
.strip_prefix("swift-")
.and_then(|v| v.strip_suffix("-RELEASE"))
.map(|v| (v.to_string(), r.created_at))
})
.rev()
.map(|(version, created_at)| VersionInfo {
version,
created_at: Some(created_at),
..Default::default()
})
.collect();
Ok(versions)
}
async fn _idiomatic_filenames(&self) -> Result<Vec<String>> {
Ok(vec![".swift-version".into()])
}
async fn install_version_(
&self,
ctx: &InstallContext,
mut tv: ToolVersion,
) -> Result<ToolVersion> {
let tarball_path = self.download(&tv, ctx.pr.as_ref()).await?;
if cfg!(target_os = "linux") && Settings::get().swift.gpg_verify != Some(false) {
self.verify_gpg(ctx, &tv, &tarball_path).await?;
}
self.verify_checksum(ctx, &mut tv, &tarball_path)?;
self.install(ctx, &tv, &tarball_path)?;
self.symlink_bins(&tv)?;
self.verify(ctx, &tv)?;
Ok(tv)
}
}
fn swift_bin_name() -> &'static str {
if cfg!(windows) { "swift.exe" } else { "swift" }
}
fn platform_directory(target: &PlatformTarget) -> String {
match target.os_name() {
"macos" => "xcode".into(),
"windows" => "windows10".into(),
_ => {
let platform = platform(target);
if platform.starts_with("ubuntu") && target.arch_name() == "arm64" {
format!("{platform}-aarch64").replace(".", "")
} else {
platform.replace(".", "")
}
}
}
}
fn platform(target: &PlatformTarget) -> String {
match target.os_name() {
"macos" => "osx".to_string(),
"windows" => "windows10".to_string(),
_ => match &Settings::get().swift.platform {
Some(platform) => platform.clone(),
None => linux_platform(target),
},
}
}
fn linux_platform(target: &PlatformTarget) -> String {
if !target.is_current() {
return format!("ubuntu{}", DEFAULT_UBUNTU_VERSION);
}
let Some(os_release) = linux_os_release() else {
return "ubi9".to_string();
};
if os_release.id == "amzn" {
format!("amazonlinux{}", os_release.version_id)
} else if os_release.id == "ubi" {
"ubi9".to_string() } else if os_release.id == "fedora" {
"fedora39".to_string() } else if os_release.id == "ubuntu" {
format!("ubuntu{}", ubuntu_swift_version(&os_release.version_id))
} else {
format!("{}{}", os_release.id, os_release.version_id)
}
}
fn extension(target: &PlatformTarget) -> &'static str {
match target.os_name() {
"macos" => "pkg",
"windows" => "exe",
_ => "tar.gz",
}
}
fn architecture(target: &PlatformTarget) -> Option<&str> {
let arch = target.arch_name();
match target.os_name() {
"linux" => match arch {
"x64" => None,
"arm64" => Some("aarch64"),
_ => Some(arch),
},
"windows" if arch == "arm64" => Some("arm64"),
_ => None,
}
}
const DEFAULT_UBUNTU_VERSION: &str = "24.04";
fn ubuntu_swift_version(version_id: &str) -> &str {
match version_id {
"20.04" | "22.04" | "24.04" => version_id,
_ => DEFAULT_UBUNTU_VERSION,
}
}
fn url(tv: &ToolVersion, target: &PlatformTarget) -> String {
format!(
"https://download.swift.org/swift-{version}-release/{platform_directory}/swift-{version}-RELEASE/swift-{version}-RELEASE-{platform}{architecture}.{extension}",
version = tv.version,
platform = platform(target),
platform_directory = platform_directory(target),
extension = extension(target),
architecture = match architecture(target) {
Some(arch) => format!("-{arch}"),
None => "".into(),
}
)
}
#[cfg(test)]
mod lockfile_tests {
use super::*;
use crate::config::settings::SettingsPartial;
use crate::platform::Platform;
use crate::toolset::ToolSource;
use confique::Layer;
static TEST_SETTINGS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct SettingsResetGuard {
_lock: std::sync::MutexGuard<'static, ()>,
}
impl Drop for SettingsResetGuard {
fn drop(&mut self) {
Settings::reset(None);
}
}
fn pin_platform(platform: Option<&str>) -> SettingsResetGuard {
let lock = TEST_SETTINGS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let guard = SettingsResetGuard { _lock: lock };
let mut settings = SettingsPartial::empty();
settings.swift.platform = platform.map(str::to_string);
Settings::reset(Some(settings));
guard
}
fn target(platform: &str) -> PlatformTarget {
PlatformTarget::new(Platform::parse(platform).expect("valid platform"))
}
fn tool_version(backend: &SwiftPlugin, version: &str) -> ToolVersion {
let request = ToolRequest::new(backend.ba().clone(), version, ToolSource::Unknown)
.expect("valid swift request");
ToolVersion::new(request, version.to_string())
}
fn options(
backend: &SwiftPlugin,
tv: &ToolVersion,
platform: &str,
) -> BTreeMap<String, String> {
backend
.resolve_lockfile_options(&tv.request, &target(platform))
.expect("swift lockfile options")
}
#[test]
fn lockfile_options_record_the_pinned_distro() {
let _guard = pin_platform(Some("ubi9"));
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
assert_eq!(
options(&backend, &tv, "linux-x64"),
BTreeMap::from([("swift_platform".to_string(), "ubi9".to_string())])
);
}
#[test]
fn lockfile_options_differ_between_distros() {
let ubuntu = {
let _guard = pin_platform(Some("ubuntu24.04"));
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
options(&backend, &tv, "linux-x64")
};
let fedora = {
let _guard = pin_platform(Some("fedora39"));
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
options(&backend, &tv, "linux-x64")
};
assert_ne!(ubuntu, fedora);
}
#[test]
fn lockfile_options_are_empty_off_linux() {
let _guard = pin_platform(None);
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
assert!(options(&backend, &tv, "macos-arm64").is_empty());
assert!(options(&backend, &tv, "windows-x64").is_empty());
}
#[test]
fn url_is_built_for_the_target_platform() {
let _guard = pin_platform(Some("ubuntu24.04"));
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
assert_eq!(
url(&tv, &target("linux-arm64")),
"https://download.swift.org/swift-6.3.1-release/ubuntu2404-aarch64/swift-6.3.1-RELEASE/swift-6.3.1-RELEASE-ubuntu24.04-aarch64.tar.gz"
);
}
#[test]
fn pinned_distro_does_not_apply_off_linux() {
let _guard = pin_platform(Some("ubi9"));
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
assert_eq!(
url(&tv, &target("macos-arm64")),
"https://download.swift.org/swift-6.3.1-release/xcode/swift-6.3.1-RELEASE/swift-6.3.1-RELEASE-osx.pkg"
);
assert_eq!(
url(&tv, &target("windows-x64")),
"https://download.swift.org/swift-6.3.1-release/windows10/swift-6.3.1-RELEASE/swift-6.3.1-RELEASE-windows10.exe"
);
}
#[tokio::test]
async fn musl_targets_have_nothing_to_lock() {
let _guard = pin_platform(None);
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
assert!(
backend
.resolve_lock_info(&tv, &target("linux-x64-musl"))
.await
.is_err()
);
}
#[test]
fn foreign_linux_targets_fall_back_to_ubuntu() {
let _guard = pin_platform(None);
let backend = SwiftPlugin::new();
let tv = tool_version(&backend, "6.3.1");
let foreign = target("linux-riscv64");
assert!(!foreign.is_current());
assert_eq!(
options(&backend, &tv, "linux-riscv64"),
BTreeMap::from([(
"swift_platform".to_string(),
format!("ubuntu{DEFAULT_UBUNTU_VERSION}")
)])
);
assert!(url(&tv, &foreign).ends_with("-ubuntu24.04-riscv64.tar.gz"));
}
}