Skip to main content

vs_installer/
install.rs

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