Skip to main content

vs_installer/
install.rs

1//! Artifact download and runtime installation helpers.
2
3use std::fs;
4use std::io::{Cursor, Read};
5use std::path::{Path, PathBuf};
6
7use indicatif::{ProgressBar, ProgressStyle};
8use md5::Md5;
9use reqwest::Proxy;
10use reqwest::blocking::Client;
11use sha1::Sha1;
12use sha2::{Digest, Sha256, Sha512};
13use tar::Archive;
14use tempfile::Builder;
15use vs_plugin_api::{
16    Checksum, InstallArtifact, InstallPlan, InstallSource, InstalledArtifact, InstalledRuntime,
17};
18use xz2::read::XzDecoder;
19use zip::ZipArchive;
20
21use crate::InstallerError;
22use crate::InstallerOptions;
23use crate::fs::copy_dir_all;
24use crate::receipt::InstallReceipt;
25
26/// Handles transactional runtime installs.
27#[derive(Debug, Clone)]
28pub struct Installer {
29    home: PathBuf,
30    runtime_root: PathBuf,
31    proxy_url: Option<String>,
32}
33
34impl Installer {
35    /// Creates a new installer rooted at the active home.
36    pub fn new(home: impl Into<PathBuf>) -> Self {
37        Self::with_options(home, InstallerOptions::default())
38    }
39
40    /// Creates a new installer with explicit runtime settings.
41    pub fn with_options(home: impl Into<PathBuf>, options: InstallerOptions) -> Self {
42        let home = home.into();
43        let runtime_root = options.runtime_root.unwrap_or_else(|| home.join("cache"));
44        Self {
45            home,
46            runtime_root,
47            proxy_url: options.proxy_url,
48        }
49    }
50
51    fn versions_root(&self, plugin: &str) -> PathBuf {
52        self.runtime_root.join(plugin).join("versions")
53    }
54
55    fn receipt_path(install_dir: &Path) -> PathBuf {
56        install_dir.join(".vs-receipt.json")
57    }
58
59    /// Returns the final install directory for a plugin version.
60    pub fn install_dir(&self, plugin: &str, version: &str) -> PathBuf {
61        self.versions_root(plugin).join(version)
62    }
63
64    /// Lists installed versions for a plugin.
65    pub fn installed_versions(&self, plugin: &str) -> Result<Vec<String>, InstallerError> {
66        let root = self.versions_root(plugin);
67        if !root.exists() {
68            return Ok(Vec::new());
69        }
70        let mut versions = fs::read_dir(root)?
71            .filter_map(|entry| {
72                let entry = entry.ok()?;
73                let file_type = entry.file_type().ok()?;
74                if file_type.is_dir() {
75                    entry.file_name().into_string().ok()
76                } else {
77                    None
78                }
79            })
80            .collect::<Vec<_>>();
81        versions.sort_by(|left, right| compare_versions_desc(left, right));
82        Ok(versions)
83    }
84
85    /// Installs a version using a staging directory and atomic rename.
86    pub fn install(
87        &self,
88        plan: &InstallPlan,
89        progress: Option<&crate::ProgressFn<'_>>,
90    ) -> Result<InstalledRuntime, InstallerError> {
91        let destination = self.install_dir(&plan.plugin, &plan.version);
92        if destination.exists() {
93            return self
94                .read_receipt(&plan.plugin, &plan.version)?
95                .ok_or_else(|| {
96                    InstallerError::Validation(String::from("install receipt is missing"))
97                });
98        }
99
100        println!("Preinstalling {}@{}...", plan.plugin, plan.version);
101
102        let staging_root = self.home.join("cache").join(&plan.plugin).join(".staging");
103        fs::create_dir_all(&staging_root)?;
104        let temp_dir = Builder::new().prefix("install-").tempdir_in(staging_root)?;
105        let staged_install = temp_dir.path().join("runtime");
106        fs::create_dir_all(&staged_install)?;
107
108        let main = self.materialize_artifact(&plan.main, &staged_install, true, progress)?;
109        let mut additions = Vec::new();
110        for artifact in &plan.additions {
111            additions.push(self.materialize_artifact(
112                artifact,
113                &staged_install,
114                false,
115                progress,
116            )?);
117        }
118        self.validate_staged_install(&staged_install)?;
119
120        if let Some(parent) = destination.parent() {
121            fs::create_dir_all(parent)?;
122        }
123        fs::rename(&staged_install, &destination)?;
124
125        let receipt = InstallReceipt {
126            plugin: plan.plugin.clone(),
127            version: plan.version.clone(),
128            root_dir: destination.clone(),
129            main: InstalledArtifact {
130                name: main.name,
131                version: main.version,
132                path: destination.join(main.relative_path),
133                note: main.note,
134            },
135            additions: additions
136                .into_iter()
137                .map(|artifact| InstalledArtifact {
138                    name: artifact.name,
139                    version: artifact.version,
140                    path: destination.join(artifact.relative_path),
141                    note: artifact.note,
142                })
143                .collect(),
144        };
145        self.write_receipt(&destination, &receipt)?;
146        Ok(receipt)
147    }
148
149    /// Uninstalls a version from the local cache.
150    pub fn uninstall(&self, plugin: &str, version: &str) -> Result<bool, InstallerError> {
151        let path = self.install_dir(plugin, version);
152        if !path.exists() {
153            return Ok(false);
154        }
155        fs::remove_dir_all(path)?;
156        Ok(true)
157    }
158
159    /// Reads the install receipt for a version.
160    pub fn read_receipt(
161        &self,
162        plugin: &str,
163        version: &str,
164    ) -> Result<Option<InstallReceipt>, InstallerError> {
165        let path = Self::receipt_path(&self.install_dir(plugin, version));
166        if !path.exists() {
167            return Ok(None);
168        }
169        let content = fs::read_to_string(&path)?;
170        let receipt = serde_json::from_str(&content).map_err(|error| InstallerError::Json {
171            path,
172            message: error.to_string(),
173        })?;
174        Ok(Some(receipt))
175    }
176
177    fn materialize_artifact(
178        &self,
179        artifact: &InstallArtifact,
180        version_root: &Path,
181        is_main: bool,
182        progress: Option<&crate::ProgressFn<'_>>,
183    ) -> Result<ArtifactPlacement, InstallerError> {
184        let relative_path = runtime_dir_name(artifact, is_main);
185        let target_path = version_root.join(&relative_path);
186
187        match &artifact.source {
188            InstallSource::Directory { path } => {
189                if !path.exists() {
190                    return Err(InstallerError::MissingSource(path.clone()));
191                }
192                copy_dir_all(path, &target_path)?;
193            }
194            InstallSource::File { path } => {
195                if !path.exists() {
196                    return Err(InstallerError::MissingSource(path.clone()));
197                }
198                self.install_from_file(path, artifact.checksum.as_ref(), &target_path)?;
199            }
200            InstallSource::Url { url, headers } => {
201                let bytes = self.download_bytes(url, headers, progress)?;
202                let temp_dir = self.home.join("downloads");
203                fs::create_dir_all(&temp_dir)?;
204                let temp_file = Builder::new().prefix("artifact-").tempfile_in(temp_dir)?;
205                fs::write(temp_file.path(), &bytes)?;
206                if let Some(checksum) = artifact.checksum.as_ref() {
207                    verify_checksum(temp_file.path(), checksum)?;
208                }
209                self.install_from_download(url, &bytes, &target_path)?;
210            }
211        }
212
213        Ok(ArtifactPlacement {
214            name: artifact.name.clone(),
215            version: artifact.version.clone(),
216            relative_path,
217            note: artifact.note.clone(),
218        })
219    }
220
221    fn validate_staged_install(&self, staged_install: &Path) -> Result<(), InstallerError> {
222        let has_failure_marker = walkdir::WalkDir::new(staged_install)
223            .into_iter()
224            .filter_map(Result::ok)
225            .any(|entry| entry.file_name() == ".vs-fail-install");
226        if has_failure_marker {
227            return Err(InstallerError::Validation(String::from(
228                "staged runtime requested a simulated install failure",
229            )));
230        }
231        Ok(())
232    }
233
234    fn write_receipt(
235        &self,
236        install_dir: &Path,
237        receipt: &InstallReceipt,
238    ) -> Result<(), InstallerError> {
239        let path = Self::receipt_path(install_dir);
240        let rendered =
241            serde_json::to_string_pretty(receipt).map_err(|error| InstallerError::Json {
242                path: path.clone(),
243                message: error.to_string(),
244            })?;
245        fs::write(path, rendered)?;
246        Ok(())
247    }
248
249    fn install_from_file(
250        &self,
251        source_path: &Path,
252        checksum: Option<&Checksum>,
253        target_path: &Path,
254    ) -> Result<(), InstallerError> {
255        if let Some(checksum) = checksum {
256            verify_checksum(source_path, checksum)?;
257        }
258        let bytes = fs::read(source_path)?;
259        self.install_from_download(&source_path.display().to_string(), &bytes, target_path)
260    }
261
262    fn install_from_download(
263        &self,
264        source_name: &str,
265        bytes: &[u8],
266        target_path: &Path,
267    ) -> Result<(), InstallerError> {
268        match detect_archive_kind(source_name) {
269            ArchiveKind::Zip => extract_zip(bytes, target_path)?,
270            ArchiveKind::TarGz => extract_tar_gz(bytes, target_path)?,
271            ArchiveKind::TarXz => extract_tar_xz(bytes, target_path)?,
272            ArchiveKind::Tar => extract_tar(bytes, target_path)?,
273            ArchiveKind::PlainFile => {
274                fs::create_dir_all(target_path)?;
275                let file_name =
276                    artifact_file_name(source_name).unwrap_or_else(|| String::from("artifact"));
277                fs::write(target_path.join(file_name), bytes)?;
278            }
279        }
280        Ok(())
281    }
282
283    fn http_client(&self) -> Result<Client, InstallerError> {
284        let mut builder = Client::builder().user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")));
285        if let Some(proxy_url) = self.proxy_url.as_deref() {
286            builder = builder.proxy(
287                Proxy::all(proxy_url)
288                    .map_err(|error| InstallerError::Download(error.to_string()))?,
289            );
290        }
291        builder
292            .build()
293            .map_err(|error| InstallerError::Download(error.to_string()))
294    }
295
296    fn download_bytes(
297        &self,
298        url: &str,
299        headers: &std::collections::BTreeMap<String, String>,
300        progress: Option<&crate::ProgressFn<'_>>,
301    ) -> Result<Vec<u8>, InstallerError> {
302        let client = self.http_client()?;
303        let mut request = client.get(url);
304        for (key, value) in headers {
305            request = request.header(key, value);
306        }
307        let response = request
308            .send()
309            .and_then(reqwest::blocking::Response::error_for_status)
310            .map_err(|error| InstallerError::Download(error.to_string()))?;
311        let total_size = response.content_length();
312        // When a progress callback is supplied (GUI), skip the console bar to
313        // avoid stray stderr output; otherwise keep the indicatif bar (CLI).
314        let progress_bar = match progress {
315            Some(_) => None,
316            None => Some(create_download_progress_bar(total_size)),
317        };
318        let mut response = response;
319        let mut bytes = Vec::new();
320        let mut buffer = [0_u8; 8192];
321
322        loop {
323            let read = response
324                .read(&mut buffer)
325                .map_err(|error| InstallerError::Download(error.to_string()))?;
326            if read == 0 {
327                break;
328            }
329            bytes.extend_from_slice(&buffer[..read]);
330            if let Some(bar) = progress_bar.as_ref() {
331                bar.inc(read as u64);
332            }
333            if let Some(callback) = progress {
334                callback(bytes.len() as u64, total_size);
335            }
336        }
337
338        if let Some(bar) = progress_bar.as_ref() {
339            bar.finish_and_clear();
340        }
341        Ok(bytes)
342    }
343}
344
345#[derive(Debug, Clone)]
346struct ArtifactPlacement {
347    name: String,
348    version: String,
349    relative_path: PathBuf,
350    note: Option<String>,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354enum ArchiveKind {
355    Zip,
356    TarGz,
357    TarXz,
358    Tar,
359    PlainFile,
360}
361
362fn runtime_dir_name(artifact: &InstallArtifact, is_main: bool) -> PathBuf {
363    let directory_name = if is_main {
364        if artifact.version.is_empty() {
365            artifact.name.clone()
366        } else {
367            format!("{}-{}", artifact.name, artifact.version)
368        }
369    } else if artifact.version.is_empty() {
370        format!("add-{}", artifact.name)
371    } else {
372        format!("add-{}-{}", artifact.name, artifact.version)
373    };
374    PathBuf::from(directory_name)
375}
376
377fn detect_archive_kind(source_name: &str) -> ArchiveKind {
378    let name = archive_name_hint(source_name);
379    if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
380        ArchiveKind::TarGz
381    } else if name.ends_with(".tar.xz") {
382        ArchiveKind::TarXz
383    } else if name.ends_with(".tar") {
384        ArchiveKind::Tar
385    } else if name.ends_with(".zip") {
386        ArchiveKind::Zip
387    } else {
388        ArchiveKind::PlainFile
389    }
390}
391
392fn archive_name_hint(source_name: &str) -> String {
393    if let Some((_, fragment)) = source_name.rsplit_once("#/") {
394        return fragment.to_string();
395    }
396    source_name
397        .rsplit('/')
398        .next()
399        .unwrap_or(source_name)
400        .to_string()
401}
402
403fn artifact_file_name(source_name: &str) -> Option<String> {
404    let hint = archive_name_hint(source_name);
405    let candidate = hint.split('?').next().unwrap_or(&hint).trim();
406    if candidate.is_empty() {
407        None
408    } else {
409        Some(candidate.to_string())
410    }
411}
412
413fn compare_versions_desc(left: &str, right: &str) -> std::cmp::Ordering {
414    compare_version_components(left, right).reverse()
415}
416
417fn compare_version_components(left: &str, right: &str) -> std::cmp::Ordering {
418    let left_parts = version_components(left);
419    let right_parts = version_components(right);
420    for (left_part, right_part) in left_parts.iter().zip(right_parts.iter()) {
421        let ordering = match (left_part.parse::<u64>(), right_part.parse::<u64>()) {
422            (Ok(left_num), Ok(right_num)) => left_num.cmp(&right_num),
423            _ => left_part.cmp(right_part),
424        };
425        if ordering != std::cmp::Ordering::Equal {
426            return ordering;
427        }
428    }
429    left_parts.len().cmp(&right_parts.len())
430}
431
432fn version_components(version: &str) -> Vec<&str> {
433    version
434        .trim_start_matches('v')
435        .split(|ch: char| !ch.is_ascii_alphanumeric())
436        .filter(|part| !part.is_empty())
437        .collect()
438}
439
440fn create_download_progress_bar(total_size: Option<u64>) -> ProgressBar {
441    let progress_bar = match total_size {
442        Some(total_size) => ProgressBar::new(total_size),
443        None => ProgressBar::new_spinner(),
444    };
445
446    let style = ProgressStyle::with_template(
447        "Downloading... {wide_bar} {bytes}/{total_bytes} ({bytes_per_sec})",
448    )
449    .unwrap_or_else(|_| ProgressStyle::default_bar())
450    .progress_chars("=> ");
451    progress_bar.set_style(style);
452    progress_bar
453}
454
455fn verify_checksum(path: &Path, checksum: &Checksum) -> Result<(), InstallerError> {
456    println!("Verifying checksum {}...", checksum.value);
457    let bytes = fs::read(path)?;
458    let actual = match checksum.algorithm.as_str() {
459        "sha256" => format!("{:x}", Sha256::digest(&bytes)),
460        "sha512" => format!("{:x}", Sha512::digest(&bytes)),
461        "sha1" => format!("{:x}", Sha1::digest(&bytes)),
462        "md5" => format!("{:x}", Md5::digest(&bytes)),
463        other => {
464            return Err(InstallerError::Validation(format!(
465                "unsupported checksum algorithm: {other}"
466            )));
467        }
468    };
469    if actual.eq_ignore_ascii_case(&checksum.value) {
470        Ok(())
471    } else {
472        Err(InstallerError::Validation(format!(
473            "checksum mismatch for {}",
474            path.display()
475        )))
476    }
477}
478
479fn extract_zip(bytes: &[u8], target_path: &Path) -> Result<(), InstallerError> {
480    println!("Unpacking {}...", target_path.display());
481    fs::create_dir_all(target_path)?;
482    let mut archive = ZipArchive::new(Cursor::new(bytes))?;
483    for index in 0..archive.len() {
484        let mut file = archive.by_index(index)?;
485        let Some(relative_path) = file.enclosed_name() else {
486            continue;
487        };
488        let output_path = target_path.join(relative_path);
489        if file.name().ends_with('/') {
490            fs::create_dir_all(&output_path)?;
491            continue;
492        }
493        if let Some(parent) = output_path.parent() {
494            fs::create_dir_all(parent)?;
495        }
496        let mut output = fs::File::create(output_path)?;
497        std::io::copy(&mut file, &mut output)?;
498    }
499    flatten_extracted_root(target_path)?;
500    Ok(())
501}
502
503fn extract_tar(bytes: &[u8], target_path: &Path) -> Result<(), InstallerError> {
504    println!("Unpacking {}...", target_path.display());
505    fs::create_dir_all(target_path)?;
506    extract_tar_archive(Archive::new(Cursor::new(bytes)), target_path)
507}
508
509fn extract_tar_gz(bytes: &[u8], target_path: &Path) -> Result<(), InstallerError> {
510    println!("Unpacking {}...", target_path.display());
511    fs::create_dir_all(target_path)?;
512    let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
513    extract_tar_archive(Archive::new(decoder), target_path)
514}
515
516fn extract_tar_xz(bytes: &[u8], target_path: &Path) -> Result<(), InstallerError> {
517    println!("Unpacking {}...", target_path.display());
518    fs::create_dir_all(target_path)?;
519    let decoder = XzDecoder::new(Cursor::new(bytes));
520    extract_tar_archive(Archive::new(decoder), target_path)
521}
522
523fn extract_tar_archive<R: Read>(
524    mut archive: Archive<R>,
525    target_path: &Path,
526) -> Result<(), InstallerError> {
527    for entry in archive.entries()? {
528        let mut entry = entry?;
529        entry.unpack_in(target_path)?;
530    }
531    flatten_extracted_root(target_path)?;
532    Ok(())
533}
534
535fn flatten_extracted_root(target_path: &Path) -> Result<(), InstallerError> {
536    let mut entries = fs::read_dir(target_path)?.collect::<Result<Vec<_>, _>>()?;
537    if entries.len() != 1 {
538        return Ok(());
539    }
540
541    let root = entries.swap_remove(0);
542    if !root.file_type()?.is_dir() {
543        return Ok(());
544    }
545
546    let root_path = root.path();
547    let root_name = root.file_name();
548    if !should_flatten_archive_root(root_name.to_string_lossy().as_ref()) {
549        return Ok(());
550    }
551
552    for child in fs::read_dir(&root_path)? {
553        let child = child?;
554        let destination = target_path.join(child.file_name());
555        fs::rename(child.path(), destination)?;
556    }
557    fs::remove_dir(&root_path)?;
558    Ok(())
559}
560
561fn should_flatten_archive_root(root_name: &str) -> bool {
562    !matches!(
563        root_name,
564        "bin"
565            | "lib"
566            | "lib64"
567            | "include"
568            | "share"
569            | "etc"
570            | "usr"
571            | "opt"
572            | "Scripts"
573            | "script"
574            | "cmd"
575            | "completions"
576    )
577}