Skip to main content

Mountain/ExtensionManagement/
VsixInstaller.rs

1//! # VSIX Installer
2//!
3//! Unpacks a `.vsix` file (ZIP with `extension/` as the payload prefix) into
4//! Land's user-extensions directory and produces an
5//! `ExtensionDescriptionStateDTO` ready for insertion into the application
6//! state's `ScannedExtensionCollection`.
7//!
8//! ## Flow
9//!
10//! 1. `InstallVsix(VsixPath, InstallRoot)`:
11//!    - Open the `.vsix` as a zip archive.
12//!    - Read `extension/package.json`, parse minimal fields (publisher, name,
13//!      version). These three determine the install directory.
14//!    - Compute target: `<InstallRoot>/<publisher>.<name>-<version>/`.
15//!    - If target already exists with a readable manifest, treat the install as
16//!      idempotent - return the existing outcome instead of re-extracting.
17//!      Matches VS Code's reinstall-is-a-no-op semantics and prevents the
18//!      renderer crash where `ExtensionsWorkbenchService` dereferences a null
19//!      result from a rejected install.
20//!    - Stream every entry whose path begins with `extension/` into the target,
21//!      stripping that prefix.
22//!    - Re-parse the extracted `package.json` as a full
23//!      `ExtensionDescriptionStateDTO`, stamp `ExtensionLocation`,
24//!      `Identifier`, and `IsBuiltin=false`.
25//! 2. `UninstallExtension(InstallDir)`:
26//!    - Recursively delete the install directory.
27//!
28//! The caller (`WindServiceHandlers::extensions:install`) is responsible for
29//! `ScannedExtensionCollection::AddOrUpdate` and for broadcasting the
30//! `extensions:installed` Tauri event so Wind re-fetches the extension list.
31//!
32//! ## Why the minimal two-pass read?
33//!
34//! The first pass reads only `extension/package.json` to compute the install
35//! path (we need publisher+name+version *before* writing any files, so we can
36//! reject collisions without partial writes). The second pass streams
37//! everything to disk. This keeps memory low - we never hold the full archive
38//! in RAM, and we don't unpack to a temp dir just to move it.
39//!
40//! ## Why no gallery API?
41//!
42//! `extensions:install` in `WindServiceHandlers.rs` previously responded to
43//! both `install` (gallery) and `install-vsix` (local file). This installer
44//! handles the local-file case - VS Code's gallery contract requires an
45//! online marketplace which Land does not currently host. Gallery support
46//! can layer on later by resolving a publisher identifier + version to a
47//! VSIX URL, downloading to a temp file, and calling `InstallVsix`.
48
49#![allow(non_snake_case)]
50
51use std::{
52	fs::{self, File},
53	io::{self, Read},
54	path::{Path, PathBuf},
55};
56
57use serde_json::Value;
58use zip::ZipArchive;
59
60use crate::{ApplicationState::DTO::ExtensionDescriptionStateDTO::ExtensionDescriptionStateDTO, dev_log};
61
62/// Everything an IPC handler needs after a successful install.
63#[derive(Debug)]
64pub struct InstallOutcome {
65	/// `<publisher>.<name>` - the canonical identifier string.
66	pub Identifier:String,
67
68	/// Semver string from the manifest.
69	pub Version:String,
70
71	/// Extracted target directory on disk.
72	pub InstalledAt:PathBuf,
73
74	/// Fully-populated DTO, ready to `AddOrUpdate` in ScannedExtensions.
75	pub Description:ExtensionDescriptionStateDTO,
76}
77
78/// Manifest facts we need before we start writing files.
79struct ManifestFacts {
80	Publisher:String,
81
82	Name:String,
83
84	Version:String,
85}
86
87/// Errors distinct enough that the IPC handler can produce useful messages
88/// without a `CommonError` cast. Flattened to String at the handler boundary.
89#[derive(Debug, thiserror::Error)]
90pub enum InstallError {
91	#[error("VSIX path '{0}' does not exist")]
92	SourceMissing(PathBuf),
93
94	#[error("VSIX archive read failure: {0}")]
95	ArchiveRead(String),
96
97	#[error("VSIX manifest missing or unreadable: {0}")]
98	ManifestMissing(String),
99
100	#[error("VSIX manifest missing required field '{0}'")]
101	ManifestFieldMissing(&'static str),
102
103	#[error("Filesystem error during install: {0}")]
104	FilesystemIO(String),
105}
106
107const MANIFEST_ENTRY:&str = "extension/package.json";
108
109const PAYLOAD_PREFIX:&str = "extension/";
110
111/// Open `VsixPath` and install its payload under `InstallRoot`. On success the
112/// caller receives the new identifier, install directory, and a DTO ready
113/// for `ScannedExtensionCollection::AddOrUpdate`.
114pub fn InstallVsix(VsixPath:&Path, InstallRoot:&Path) -> Result<InstallOutcome, InstallError> {
115	if !VsixPath.exists() {
116		return Err(InstallError::SourceMissing(VsixPath.to_path_buf()));
117	}
118
119	let Facts = ReadManifestFacts(VsixPath)?;
120
121	let InstalledAt = InstallRoot.join(format!("{}.{}-{}", Facts.Publisher, Facts.Name, Facts.Version));
122
123	let Identifier = format!("{}.{}", Facts.Publisher, Facts.Name);
124
125	// Idempotent reinstall: if the target directory already holds the same
126	// <publisher>.<name>-<version>, skip extraction and surface the existing
127	// install as a success. Reading the on-disk manifest handles the edge
128	// case where the directory was left in a half-written state by an earlier
129	// crash - BuildDescription will Err, and we fall through to re-extract.
130	if InstalledAt.exists() {
131		if let Ok(Description) = BuildDescription(&InstalledAt) {
132			// Retroactively heal exec bits on existing installs. Older
133			// VSIX installs predating the magic-number / bin-path
134			// promotion left native binaries (rust-analyzer's
135			// `server/rust-analyzer`, openai.chatgpt's
136			// `bin/<triple>/codex`, etc.) at 0o644 - the extension's
137			// own `child_process.spawn(...)` then fails with EACCES
138			// even though the file is intact on disk. Walk the install
139			// tree once and chmod +x anything matching the same
140			// heuristic ExtractPayload uses for fresh installs.
141			#[cfg(unix)]
142			HealExecutableBits(&InstalledAt);
143
144			dev_log!(
145				"extensions",
146				"[VsixInstaller] Reinstall no-op - '{}' v{} already present at {}",
147				Identifier,
148				Facts.Version,
149				InstalledAt.display()
150			);
151
152			return Ok(InstallOutcome { Identifier, Version:Facts.Version, InstalledAt, Description });
153		}
154
155		// Corrupt / partial previous install - wipe and re-extract below.
156		dev_log!(
157			"extensions",
158			"[VsixInstaller] Existing install at {} is unreadable - wiping and reinstalling",
159			InstalledAt.display()
160		);
161
162		fs::remove_dir_all(&InstalledAt).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
163	}
164
165	CreateParent(&InstalledAt)?;
166
167	ExtractPayload(VsixPath, &InstalledAt)?;
168
169	let Description = BuildDescription(&InstalledAt)?;
170
171	dev_log!(
172		"extensions",
173		"[VsixInstaller] Installed '{}' v{} at {}",
174		Identifier,
175		Facts.Version,
176		InstalledAt.display()
177	);
178
179	Ok(InstallOutcome { Identifier, Version:Facts.Version, InstalledAt, Description })
180}
181
182/// Delete the install directory. Returns `Ok` if the path was already absent.
183pub fn UninstallExtension(InstallDir:&Path) -> Result<(), InstallError> {
184	if !InstallDir.exists() {
185		dev_log!(
186			"extensions",
187			"[VsixInstaller] Uninstall skipped - {} already absent",
188			InstallDir.display()
189		);
190
191		return Ok(());
192	}
193
194	fs::remove_dir_all(InstallDir).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
195
196	dev_log!("extensions", "[VsixInstaller] Uninstalled {}", InstallDir.display());
197
198	Ok(())
199}
200
201// --- Internals ----------------------------------------------------------
202
203fn ReadManifestFacts(VsixPath:&Path) -> Result<ManifestFacts, InstallError> {
204	let Manifest = ReadFullManifest(VsixPath)?;
205
206	let Publisher = ReadStringField(&Manifest, "publisher")?;
207
208	let Name = ReadStringField(&Manifest, "name")?;
209
210	let Version = ReadStringField(&Manifest, "version")?;
211
212	Ok(ManifestFacts { Publisher, Name, Version })
213}
214
215/// Read the full `extension/package.json` from a `.vsix` without extracting
216/// the archive to disk. Used by the IPC `extensions:getManifest` handler so
217/// the "Install from VSIX…" preview dialog and drag-and-drop flow can inspect
218/// a manifest before the user confirms installation.
219///
220/// The returned value is the raw parsed JSON (`serde_json::Value`) - callers
221/// can project it into VS Code's `IExtensionManifest` shape. No NLS bundle
222/// resolution is performed here (the renderer only needs publisher/name/
223/// version/displayName for the preview UI, and NLS keys would require
224/// unpacking `package.nls.json` from the archive too).
225pub fn ReadFullManifest(VsixPath:&Path) -> Result<Value, InstallError> {
226	let Archive = File::open(VsixPath).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
227
228	let mut Archive = ZipArchive::new(Archive).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
229
230	let mut Entry = Archive
231		.by_name(MANIFEST_ENTRY)
232		.map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
233
234	let mut Raw = String::new();
235
236	Entry
237		.read_to_string(&mut Raw)
238		.map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
239
240	serde_json::from_str(&Raw).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))
241}
242
243fn ReadStringField(Manifest:&Value, Field:&'static str) -> Result<String, InstallError> {
244	Manifest
245		.get(Field)
246		.and_then(|Value| Value.as_str())
247		.filter(|Value| !Value.is_empty())
248		.map(str::to_owned)
249		.ok_or(InstallError::ManifestFieldMissing(Field))
250}
251
252fn CreateParent(InstalledAt:&Path) -> Result<(), InstallError> {
253	if let Some(Parent) = InstalledAt.parent() {
254		fs::create_dir_all(Parent).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
255	}
256
257	Ok(())
258}
259
260fn ExtractPayload(VsixPath:&Path, InstalledAt:&Path) -> Result<(), InstallError> {
261	let Archive = File::open(VsixPath).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
262
263	let mut Archive = ZipArchive::new(Archive).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
264
265	fs::create_dir_all(InstalledAt).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
266
267	for Index in 0..Archive.len() {
268		let mut Entry = Archive
269			.by_index(Index)
270			.map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?;
271
272		let EntryName = Entry.name().to_string();
273
274		// Only the `extension/...` subtree is the addon payload. Manifest-level
275		// files (`[Content_Types].xml`, `extension.vsixmanifest`, `assets/`,
276		// etc.) are VSIX packaging metadata and are not needed at runtime.
277		let Stripped = match EntryName.strip_prefix(PAYLOAD_PREFIX) {
278			Some(Path) if !Path.is_empty() => Path,
279
280			_ => continue,
281		};
282
283		// Guard against zip-slip: the archive must not reference `..` segments
284		// that escape the install dir. Reject any entry whose resolved path is
285		// outside `InstalledAt`.
286		let Target = InstalledAt.join(Stripped);
287
288		let CanonicalInstall = InstalledAt.to_path_buf();
289
290		let RejectTraversal = !Target.starts_with(&CanonicalInstall);
291
292		if RejectTraversal {
293			return Err(InstallError::ArchiveRead(format!("zip-slip entry rejected: {}", EntryName)));
294		}
295
296		if Entry.is_dir() {
297			fs::create_dir_all(&Target).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
298
299			continue;
300		}
301
302		if let Some(Parent) = Target.parent() {
303			fs::create_dir_all(Parent).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
304		}
305
306		let mut Output = File::create(&Target).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
307
308		io::copy(&mut Entry, &mut Output).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
309
310		// Preserve Unix executable bits recorded in the VSIX. Extensions
311		// that ship platform-native binaries (openai.chatgpt's `codex`,
312		// language-server launchers, etc.) rely on the `0o755` mode being
313		// carried through the zip. Without this, the child `spawn()`
314		// inside the extension fails with `EACCES` because the freshly
315		// written file has only the default `0o644` read/write mode.
316		#[cfg(unix)]
317		{
318			use std::os::unix::fs::PermissionsExt;
319
320			let PermissionBits = Entry.unix_mode().map(|Mode| Mode & 0o777).unwrap_or(0);
321
322			// Promote executable bit whenever the payload is a native
323			// binary the extension will spawn. Heuristics, in order:
324			//   1. Zip already recorded any exec bit (user/group/other).
325			//   2. Path lives under a `bin/` segment (vscode convention for shipped CLI
326			//      tools: openai.chatgpt's `bin/<triple>/codex`, rust-analyzer's
327			//      `bin/ra_lsp`, Dart-Code's `bin/dart`, …).
328			//   3. First two bytes match a known executable magic number: Mach-O
329			//      (`\xCF\xFA\xED\xFE` / `\xCE\xFA\xED\xFE` / fat `\xCA\xFE\xBA\xBE`), ELF
330			//      (`\x7FELF`), or shebang (`#!`). Some zip creators drop all mode bits;
331			//      the magic-number probe is the only way to tell before the extension
332			//      tries to spawn the file.
333			// Directory segments that conventionally hold spawnable
334			// binaries: VS Code's `bin/`, language-server `server/`
335			// (rust-analyzer, ruby-lsp, jdt-ls, gopls), .NET's
336			// `tools/`, OmniSharp's `omnisharp/`, debug-adapter
337			// `adapter/`, native-host `native/`. Match any path
338			// segment, not just the leading one - many VSIXes nest
339			// like `out/server/...` or `dist/bin/...`.
340			let IsBinPath = Stripped
341				.split('/')
342				.any(|Segment| matches!(Segment, "bin" | "server" | "tools" | "omnisharp" | "adapter" | "native"));
343
344			let HasExecBit = PermissionBits & 0o111 != 0;
345
346			let LooksExecutable = if HasExecBit || IsBinPath {
347				true
348			} else {
349				let mut Probe = [0u8; 4];
350
351				match std::fs::File::open(&Target).and_then(|mut Handle| {
352					use std::io::Read as IoRead;
353					IoRead::read(&mut Handle, &mut Probe).map(|BytesRead| (BytesRead, Probe))
354				}) {
355					Ok((BytesRead, Bytes)) if BytesRead >= 2 => {
356						let Shebang = &Bytes[..2] == b"#!";
357
358						let ElfMagic = BytesRead >= 4 && &Bytes[..4] == b"\x7FELF";
359
360						let MachMagic = BytesRead >= 4
361							&& matches!(
362								&Bytes[..4],
363								b"\xCF\xFA\xED\xFE"
364									| b"\xCE\xFA\xED\xFE" | b"\xFE\xED\xFA\xCF"
365									| b"\xFE\xED\xFA\xCE" | b"\xCA\xFE\xBA\xBE"
366									| b"\xBE\xBA\xFE\xCA"
367							);
368
369						Shebang || ElfMagic || MachMagic
370					},
371
372					_ => false,
373				}
374			};
375
376			let FinalMode = if LooksExecutable {
377				(PermissionBits | 0o755) & 0o755
378			} else {
379				(PermissionBits | 0o644) & 0o755
380			};
381
382			let _ = fs::set_permissions(&Target, fs::Permissions::from_mode(FinalMode));
383		}
384	}
385
386	Ok(())
387}
388
389/// Walk an installed extension directory and chmod +x any file that
390/// matches the same executable heuristic as fresh installs. Used on the
391/// idempotent reinstall path so users who installed extensions before
392/// the exec-bit promotion landed don't need to manually `chmod` shipped
393/// binaries (`rust-analyzer/server/rust-analyzer`,
394/// `openai.chatgpt/bin/<triple>/codex`, `Dart-Code/bin/dart`, etc.).
395///
396/// Errors are swallowed - this is a best-effort heal, never the reason
397/// an install fails. A file we can't open or stat just keeps its
398/// existing mode and the extension's `spawn` will surface the same
399/// EACCES it would have anyway.
400#[cfg(unix)]
401pub fn HealExecutableBits(InstalledAt:&Path) {
402	use std::{io::Read, os::unix::fs::PermissionsExt};
403
404	fn IsBinSegment(Segment:&std::ffi::OsStr) -> bool {
405		let Some(Name) = Segment.to_str() else {
406			return false;
407		};
408
409		matches!(Name, "bin" | "server" | "tools" | "omnisharp" | "adapter" | "native")
410	}
411
412	fn LooksExecutable(Target:&Path, RelativeFromRoot:&Path) -> bool {
413		let IsBinPath = RelativeFromRoot
414			.components()
415			.any(|Component| IsBinSegment(Component.as_os_str()));
416
417		if IsBinPath {
418			return true;
419		}
420
421		let Ok(mut Handle) = std::fs::File::open(Target) else {
422			return false;
423		};
424
425		let mut Probe = [0u8; 4];
426
427		let Ok(BytesRead) = Handle.read(&mut Probe) else {
428			return false;
429		};
430
431		if BytesRead < 2 {
432			return false;
433		}
434
435		let Shebang = &Probe[..2] == b"#!";
436
437		let ElfMagic = BytesRead >= 4 && &Probe[..4] == b"\x7FELF";
438
439		let MachMagic = BytesRead >= 4
440			&& matches!(
441				&Probe[..4],
442				b"\xCF\xFA\xED\xFE"
443					| b"\xCE\xFA\xED\xFE"
444					| b"\xFE\xED\xFA\xCF"
445					| b"\xFE\xED\xFA\xCE"
446					| b"\xCA\xFE\xBA\xBE"
447					| b"\xBE\xBA\xFE\xCA"
448			);
449
450		Shebang || ElfMagic || MachMagic
451	}
452
453	fn Walk(Dir:&Path, Root:&Path, Healed:&mut usize) {
454		let Ok(Entries) = std::fs::read_dir(Dir) else {
455			return;
456		};
457
458		for Entry in Entries.flatten() {
459			let Path = Entry.path();
460
461			let Ok(Metadata) = Entry.metadata() else {
462				continue;
463			};
464
465			if Metadata.is_dir() {
466				// Skip the bundled-deps tree by name - chmod-ing every
467				// file under node_modules is wasteful and chmod-ing
468				// `.bin` shims is what the npm install lifecycle
469				// already handles. If an extension genuinely needs a
470				// binary inside node_modules executable, its postinstall
471				// will mark it.
472				if Entry.file_name() == "node_modules" {
473					continue;
474				}
475
476				Walk(&Path, Root, Healed);
477
478				continue;
479			}
480
481			let Ok(Relative) = Path.strip_prefix(Root) else {
482				continue;
483			};
484
485			let Mode = Metadata.permissions().mode() & 0o777;
486
487			if Mode & 0o100 != 0 {
488				// Owner-exec already set; trust it.
489				continue;
490			}
491
492			if !LooksExecutable(&Path, Relative) {
493				continue;
494			}
495
496			let Promoted = (Mode | 0o755) & 0o755;
497
498			if std::fs::set_permissions(&Path, std::fs::Permissions::from_mode(Promoted)).is_ok() {
499				*Healed += 1;
500			}
501		}
502	}
503
504	let mut Healed:usize = 0;
505
506	Walk(InstalledAt, InstalledAt, &mut Healed);
507
508	if Healed > 0 {
509		dev_log!(
510			"extensions",
511			"[VsixInstaller] Healed {} executable bit(s) under {}",
512			Healed,
513			InstalledAt.display()
514		);
515	}
516}
517
518fn BuildDescription(InstalledAt:&Path) -> Result<ExtensionDescriptionStateDTO, InstallError> {
519	let ManifestPath = InstalledAt.join("package.json");
520
521	let Raw = fs::read_to_string(&ManifestPath).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
522
523	let mut ManifestValue:Value =
524		serde_json::from_str(&Raw).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
525
526	let mut Description:ExtensionDescriptionStateDTO = serde_json::from_value(ManifestValue.clone())
527		.map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?;
528
529	Description.ExtensionLocation = serde_json::to_value(
530		url::Url::from_directory_path(InstalledAt)
531			.unwrap_or_else(|_| url::Url::parse("file:///").expect("file:/// is a valid URL")),
532	)
533	.unwrap_or(Value::Null);
534
535	if Description.Identifier == Value::Null || Description.Identifier == Value::Object(Default::default()) {
536		let Identifier = if Description.Publisher.is_empty() {
537			Description.Name.clone()
538		} else {
539			format!("{}.{}", Description.Publisher, Description.Name)
540		};
541
542		Description.Identifier = serde_json::json!({ "value": Identifier });
543	}
544
545	Description.IsBuiltin = false;
546
547	// Touch the mutable manifest so later tooling that re-serialises it sees
548	// the same canonical form we parsed from.
549	let _ = &mut ManifestValue;
550
551	Ok(Description)
552}