Skip to main content

dfx_core/extension/manager/
install.rs

1use crate::error::extension::{
2    DownloadAndInstallExtensionToTempdirError, FinalizeInstallationError,
3    GetExtensionArchiveNameError, GetExtensionDownloadUrlError, GetExtensionManifestError,
4    GetHighestCompatibleVersionError, GetTopLevelDirectoryError, InstallExtensionError,
5};
6use crate::extension::{
7    catalog::ExtensionCatalog,
8    manager::ExtensionManager,
9    manifest::{ExtensionDependencies, ExtensionManifest},
10    url::ExtensionJsonUrl,
11};
12use crate::http::get::get_with_retries;
13use backoff::exponential::ExponentialBackoff;
14use flate2::read::GzDecoder;
15use reqwest::Url;
16use semver::{BuildMetadata, Prerelease, Version};
17use std::io::Cursor;
18#[cfg(unix)]
19use std::os::unix::fs::PermissionsExt;
20use std::path::{Path, PathBuf};
21use std::time::Duration;
22use tar::Archive;
23use tempfile::{TempDir, tempdir_in};
24
25pub enum InstallOutcome {
26    Installed(String, Version),
27    AlreadyInstalled(String, Version),
28}
29
30impl ExtensionManager {
31    pub async fn install_extension(
32        &self,
33        name: &str,
34        catalog_url: Option<&Url>,
35        install_as: Option<&str>,
36        version: Option<&Version>,
37    ) -> Result<InstallOutcome, InstallExtensionError> {
38        let url = if let Ok(url) = Url::parse(name) {
39            ExtensionJsonUrl::new(url)
40        } else {
41            ExtensionCatalog::fetch(catalog_url)
42                .await?
43                .lookup(name)
44                .ok_or(InstallExtensionError::ExtensionNotFound(name.to_string()))?
45        };
46
47        let manifest = Self::get_extension_manifest(&url).await?;
48        let extension_name: &str = &manifest.name;
49
50        let effective_extension_name = install_as.unwrap_or(extension_name);
51
52        if self
53            .get_extension_directory(effective_extension_name)
54            .exists()
55        {
56            let installed_manifest = ExtensionManifest::load(effective_extension_name, &self.dir)?;
57
58            return if matches!(version, Some(v) if *v != *installed_manifest.version) {
59                Err(InstallExtensionError::OtherVersionAlreadyInstalled(
60                    extension_name.to_string(),
61                    installed_manifest.version.clone(),
62                ))
63            } else {
64                Ok(InstallOutcome::AlreadyInstalled(
65                    extension_name.to_string(),
66                    installed_manifest.version.clone(),
67                ))
68            };
69        }
70
71        let extension_version = match version {
72            Some(version) => version.clone(),
73            None => self.get_highest_compatible_version(&url).await?,
74        };
75        let github_release_tag = get_git_release_tag(extension_name, &extension_version);
76        let extension_archive = get_extension_archive_name(extension_name)?;
77        let archive_url = get_extension_download_url(
78            &manifest.download_url_template(),
79            &github_release_tag,
80            &extension_archive,
81        )?;
82
83        let temp_dir = self
84            .download_and_unpack_extension_to_tempdir(archive_url)
85            .await?;
86
87        self.finalize_installation(extension_name, effective_extension_name, temp_dir)?;
88
89        Ok(InstallOutcome::Installed(
90            extension_name.to_string(),
91            extension_version,
92        ))
93    }
94
95    /// Removing the prerelease tag and build metadata, because they should
96    /// not be allowed in extension manifests, and semver crate won't match
97    /// a semver with a prerelease tag or build metadata against a semver without.
98    fn dfx_version_strip_semver(&self) -> Version {
99        let mut dfx_version = self.dfx_version.clone();
100        dfx_version.pre = Prerelease::EMPTY;
101        dfx_version.build = BuildMetadata::EMPTY;
102        dfx_version
103    }
104
105    async fn get_highest_compatible_version(
106        &self,
107        url: &ExtensionJsonUrl,
108    ) -> Result<Version, GetHighestCompatibleVersionError> {
109        let dependencies = ExtensionDependencies::fetch(url).await?;
110        let dfx_version = self.dfx_version_strip_semver();
111        dependencies
112            .find_highest_compatible_version(&dfx_version)?
113            .ok_or(GetHighestCompatibleVersionError::NoCompatibleVersionFound())
114    }
115
116    async fn get_extension_manifest(
117        url: &ExtensionJsonUrl,
118    ) -> Result<ExtensionManifest, GetExtensionManifestError> {
119        let retry_policy = ExponentialBackoff {
120            max_elapsed_time: Some(Duration::from_secs(60)),
121            ..Default::default()
122        };
123        let resp = get_with_retries(url.as_url().clone(), retry_policy)
124            .await
125            .map_err(GetExtensionManifestError::Get)?;
126
127        resp.json()
128            .await
129            .map_err(GetExtensionManifestError::ParseJson)
130    }
131
132    async fn download_and_unpack_extension_to_tempdir(
133        &self,
134        download_url: Url,
135    ) -> Result<TempDir, DownloadAndInstallExtensionToTempdirError> {
136        let retry_policy = ExponentialBackoff {
137            max_elapsed_time: Some(Duration::from_secs(60)),
138            ..Default::default()
139        };
140        let response = get_with_retries(download_url.clone(), retry_policy)
141            .await
142            .map_err(DownloadAndInstallExtensionToTempdirError::ExtensionDownloadFailed)?;
143
144        let bytes = response
145            .bytes()
146            .await
147            .map_err(DownloadAndInstallExtensionToTempdirError::ExtensionDownloadFailed)?;
148
149        crate::fs::composite::ensure_dir_exists(&self.dir)
150            .map_err(DownloadAndInstallExtensionToTempdirError::EnsureExtensionDirExistsFailed)?;
151
152        let temp_dir = tempdir_in(&self.dir).map_err(|e| {
153            DownloadAndInstallExtensionToTempdirError::CreateTemporaryDirectoryFailed(
154                self.dir.to_path_buf(),
155                e,
156            )
157        })?;
158
159        let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
160        archive.unpack(temp_dir.path()).map_err(|e| {
161            DownloadAndInstallExtensionToTempdirError::DecompressFailed(download_url, e)
162        })?;
163
164        Ok(temp_dir)
165    }
166
167    #[allow(clippy::collapsible_if)]
168    fn finalize_installation(
169        &self,
170        extension_name: &str,
171        effective_extension_name: &str,
172        temp_dir: TempDir,
173    ) -> Result<(), FinalizeInstallationError> {
174        let effective_extension_dir = &self.get_extension_directory(effective_extension_name);
175        let top_level_dir = get_top_level_directory(temp_dir.path())?;
176        crate::fs::rename(&top_level_dir, effective_extension_dir)?;
177
178        let installed_manifest = ExtensionManifest::load(effective_extension_name, &self.dir)?;
179
180        if matches!(installed_manifest.subcommands, Some(subcommands) if !subcommands.0.is_empty())
181        {
182            if extension_name != effective_extension_name {
183                // rename the binary
184                crate::fs::rename(
185                    &effective_extension_dir.join(extension_name),
186                    &effective_extension_dir.join(effective_extension_name),
187                )?;
188            }
189            #[cfg(unix)]
190            {
191                let bin = effective_extension_dir.join(effective_extension_name);
192                crate::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o500))?;
193            }
194        }
195        Ok(())
196    }
197}
198
199fn get_top_level_directory(dir: &Path) -> Result<PathBuf, GetTopLevelDirectoryError> {
200    // the archive will have a single top-level subdirectory
201    // return that subdirectory
202    Ok(crate::fs::read_dir(dir)?
203        .next()
204        .ok_or(GetTopLevelDirectoryError::NoTopLevelDirectoryEntry)?
205        .map_err(GetTopLevelDirectoryError::ReadDirEntry)?
206        .path())
207}
208
209fn get_extension_download_url(
210    download_url_template: &str,
211    github_release_tag: &str,
212    extension_archive_name: &str,
213) -> Result<Url, GetExtensionDownloadUrlError> {
214    let download_url = download_url_template
215        .replace("{{tag}}", github_release_tag)
216        .replace("{{basename}}", extension_archive_name)
217        .replace("{{archive-format}}", "tar.gz");
218
219    Url::parse(&download_url).map_err(|source| GetExtensionDownloadUrlError {
220        url: download_url,
221        source,
222    })
223}
224
225fn get_git_release_tag(extension_name: &str, extension_verion: &Version) -> String {
226    format!("{extension_name}-v{extension_verion}",)
227}
228
229fn get_extension_archive_name(
230    extension_name: &str,
231) -> Result<String, GetExtensionArchiveNameError> {
232    Ok(format!(
233        "{extension_name}-{arch}-{platform}",
234        platform = match std::env::consts::OS {
235            "linux" => "unknown-linux-gnu",
236            "macos" => "apple-darwin",
237            // "windows" => "pc-windows-msvc",
238            unsupported_platform =>
239                return Err(GetExtensionArchiveNameError::PlatformNotSupported(
240                    unsupported_platform.to_string()
241                )),
242        },
243        arch = std::env::consts::ARCH,
244    ))
245}