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