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#[derive(Debug, Clone)]
24pub struct Installer {
25 home: PathBuf,
26}
27
28impl Installer {
29 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 pub fn install_dir(&self, plugin: &str, version: &str) -> PathBuf {
44 self.versions_root(plugin).join(version)
45 }
46
47 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 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 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 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}