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