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