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