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