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