Skip to main content

vs_installer/
lib.rs

1//! Transactional runtime installs for `vs`.
2
3mod fs;
4mod install;
5mod receipt;
6
7use std::path::PathBuf;
8
9use thiserror::Error;
10
11pub use install::Installer;
12pub use receipt::InstallReceipt;
13
14/// Reports download progress as `(bytes_downloaded_so_far, total_bytes_if_known)`.
15///
16/// Invoked synchronously on whatever thread runs the download. Optional — when
17/// `None` the installer falls back to its console progress bar.
18pub type ProgressFn<'a> = dyn Fn(u64, Option<u64>) + 'a;
19
20/// Runtime installer configuration resolved by the application layer.
21#[derive(Debug, Clone, Default)]
22pub struct InstallerOptions {
23    /// Alternative runtime root directory.
24    pub runtime_root: Option<PathBuf>,
25    /// Optional explicit proxy URL for outbound downloads.
26    pub proxy_url: Option<String>,
27}
28
29/// Errors returned by installer services.
30#[derive(Debug, Error)]
31pub enum InstallerError {
32    /// An I/O operation failed.
33    #[error(transparent)]
34    Io(#[from] std::io::Error),
35    /// A download failed.
36    #[error("artifact download failed: {0}")]
37    Download(String),
38    /// A directory walk failed.
39    #[error("failed to walk directory tree: {0}")]
40    Walk(String),
41    /// The source directory was not found.
42    #[error("install source does not exist: {0}")]
43    MissingSource(PathBuf),
44    /// JSON data could not be parsed.
45    #[error("failed to parse JSON file at {path}: {message}")]
46    Json { path: PathBuf, message: String },
47    /// The install validation step failed.
48    #[error("install validation failed: {0}")]
49    Validation(String),
50    /// An archive could not be unpacked.
51    #[error(transparent)]
52    Archive(#[from] zip::result::ZipError),
53}
54
55#[cfg(test)]
56mod tests {
57    use std::error::Error;
58    use std::fs;
59
60    use tempfile::TempDir;
61    use vs_plugin_api::{InstallArtifact, InstallPlan, InstallSource};
62    use zip::ZipWriter;
63    use zip::write::SimpleFileOptions;
64
65    use super::Installer;
66
67    #[test]
68    fn install_should_rollback_when_validation_fails() -> Result<(), Box<dyn Error>> {
69        let temp_dir = TempDir::new()?;
70        let source = temp_dir.path().join("source");
71        fs::create_dir_all(&source)?;
72        fs::write(source.join(".vs-fail-install"), "")?;
73
74        let installer = Installer::new(temp_dir.path().join("home"));
75        let plan = InstallPlan {
76            plugin: String::from("nodejs"),
77            version: String::from("20.11.1"),
78            main: InstallArtifact {
79                name: String::from("nodejs"),
80                version: String::from("20.11.1"),
81                source: InstallSource::Directory { path: source },
82                note: None,
83                checksum: None,
84            },
85            additions: Vec::new(),
86            legacy_filenames: Vec::new(),
87        };
88
89        let error = match installer.install(&plan, None) {
90            Ok(_) => {
91                return Err(Box::new(std::io::Error::other(
92                    "install unexpectedly succeeded",
93                )));
94            }
95            Err(error) => error,
96        };
97        assert!(error.to_string().contains("validation failed"));
98        assert!(!installer.install_dir("nodejs", "20.11.1").exists());
99        Ok(())
100    }
101
102    #[test]
103    fn install_should_preserve_flat_archive_layouts() -> Result<(), Box<dyn Error>> {
104        let temp_dir = TempDir::new()?;
105        let archive = write_zip(
106            &temp_dir,
107            "flat.zip",
108            &[("bin/node", b"#!/bin/sh\necho node\n".as_slice())],
109        )?;
110
111        let installer = Installer::new(temp_dir.path().join("home"));
112        let plan = install_plan_from_archive(&archive);
113        let installed = installer.install(&plan, None)?;
114
115        assert!(installed.main.path.join("bin/node").exists());
116        Ok(())
117    }
118
119    #[test]
120    fn install_should_collapse_single_wrapped_archive_root() -> Result<(), Box<dyn Error>> {
121        let temp_dir = TempDir::new()?;
122        let archive = write_zip(
123            &temp_dir,
124            "wrapped.zip",
125            &[("package/bin/node", b"#!/bin/sh\necho node\n".as_slice())],
126        )?;
127
128        let installer = Installer::new(temp_dir.path().join("home"));
129        let plan = install_plan_from_archive(&archive);
130        let installed = installer.install(&plan, None)?;
131
132        assert!(installed.main.path.join("bin/node").exists());
133        assert!(!installed.main.path.join("package").exists());
134        Ok(())
135    }
136
137    fn install_plan_from_archive(path: &std::path::Path) -> InstallPlan {
138        InstallPlan {
139            plugin: String::from("nodejs"),
140            version: String::from("20.11.1"),
141            main: InstallArtifact {
142                name: String::from("nodejs"),
143                version: String::from("20.11.1"),
144                source: InstallSource::File {
145                    path: path.to_path_buf(),
146                },
147                note: None,
148                checksum: None,
149            },
150            additions: Vec::new(),
151            legacy_filenames: Vec::new(),
152        }
153    }
154
155    fn write_zip(
156        temp_dir: &TempDir,
157        file_name: &str,
158        entries: &[(&str, &[u8])],
159    ) -> Result<std::path::PathBuf, Box<dyn Error>> {
160        let path = temp_dir.path().join(file_name);
161        let file = fs::File::create(&path)?;
162        let mut zip = ZipWriter::new(file);
163        let options = SimpleFileOptions::default();
164
165        for (name, contents) in entries {
166            zip.start_file(name, options)?;
167            std::io::Write::write_all(&mut zip, contents)?;
168        }
169
170        zip.finish()?;
171        Ok(path)
172    }
173}