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