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