|
| 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, refuse (caller decides whether to reinstall). |
| 16 | +//! - Stream every entry whose path begins with `extension/` into the |
| 17 | +//! target, stripping that prefix. |
| 18 | +//! - Re-parse the extracted `package.json` as a full |
| 19 | +//! `ExtensionDescriptionStateDTO`, stamp `ExtensionLocation`, |
| 20 | +//! `Identifier`, and `IsBuiltin=false`. |
| 21 | +//! 2. `UninstallExtension(InstallDir)`: |
| 22 | +//! - Recursively delete the install directory. |
| 23 | +//! |
| 24 | +//! The caller (`WindServiceHandlers::extensions:install`) is responsible for |
| 25 | +//! `ScannedExtensionCollection::AddOrUpdate` and for broadcasting the |
| 26 | +//! `extensions:installed` Tauri event so Wind re-fetches the extension list. |
| 27 | +//! |
| 28 | +//! ## Why the minimal two-pass read? |
| 29 | +//! |
| 30 | +//! The first pass reads only `extension/package.json` to compute the install |
| 31 | +//! path (we need publisher+name+version *before* writing any files, so we can |
| 32 | +//! reject collisions without partial writes). The second pass streams |
| 33 | +//! everything to disk. This keeps memory low — we never hold the full archive |
| 34 | +//! in RAM, and we don't unpack to a temp dir just to move it. |
| 35 | +//! |
| 36 | +//! ## Why no gallery API? |
| 37 | +//! |
| 38 | +//! `extensions:install` in `WindServiceHandlers.rs` previously responded to |
| 39 | +//! both `install` (gallery) and `install-vsix` (local file). This installer |
| 40 | +//! handles the local-file case — VS Code's gallery contract requires an |
| 41 | +//! online marketplace which Land does not currently host. Gallery support |
| 42 | +//! can layer on later by resolving a publisher identifier + version to a |
| 43 | +//! VSIX URL, downloading to a temp file, and calling `InstallVsix`. |
| 44 | +
|
| 45 | +#![allow(non_snake_case)] |
| 46 | + |
| 47 | +use std::{ |
| 48 | + fs::{self, File}, |
| 49 | + io::{self, Read}, |
| 50 | + path::{Path, PathBuf}, |
| 51 | +}; |
| 52 | + |
| 53 | +use serde_json::Value; |
| 54 | +use zip::ZipArchive; |
| 55 | + |
| 56 | +use crate::{ |
| 57 | + ApplicationState::DTO::ExtensionDescriptionStateDTO::ExtensionDescriptionStateDTO, |
| 58 | + dev_log, |
| 59 | +}; |
| 60 | + |
| 61 | +/// Everything an IPC handler needs after a successful install. |
| 62 | +#[derive(Debug)] |
| 63 | +pub struct InstallOutcome { |
| 64 | + /// `<publisher>.<name>` — the canonical identifier string. |
| 65 | + pub Identifier:String, |
| 66 | + /// Semver string from the manifest. |
| 67 | + pub Version:String, |
| 68 | + /// Extracted target directory on disk. |
| 69 | + pub InstalledAt:PathBuf, |
| 70 | + /// Fully-populated DTO, ready to `AddOrUpdate` in ScannedExtensions. |
| 71 | + pub Description:ExtensionDescriptionStateDTO, |
| 72 | +} |
| 73 | + |
| 74 | +/// Manifest facts we need before we start writing files. |
| 75 | +struct ManifestFacts { |
| 76 | + Publisher:String, |
| 77 | + Name:String, |
| 78 | + Version:String, |
| 79 | +} |
| 80 | + |
| 81 | +/// Errors distinct enough that the IPC handler can produce useful messages |
| 82 | +/// without a `CommonError` cast. Flattened to String at the handler boundary. |
| 83 | +#[derive(Debug, thiserror::Error)] |
| 84 | +pub enum InstallError { |
| 85 | + #[error("VSIX path '{0}' does not exist")] |
| 86 | + SourceMissing(PathBuf), |
| 87 | + |
| 88 | + #[error("VSIX archive read failure: {0}")] |
| 89 | + ArchiveRead(String), |
| 90 | + |
| 91 | + #[error("VSIX manifest missing or unreadable: {0}")] |
| 92 | + ManifestMissing(String), |
| 93 | + |
| 94 | + #[error("VSIX manifest missing required field '{0}'")] |
| 95 | + ManifestFieldMissing(&'static str), |
| 96 | + |
| 97 | + #[error("Extension '{Identifier}' version {Version} is already installed at {InstalledAt}")] |
| 98 | + AlreadyInstalled { Identifier:String, Version:String, InstalledAt:PathBuf }, |
| 99 | + |
| 100 | + #[error("Filesystem error during install: {0}")] |
| 101 | + FilesystemIO(String), |
| 102 | +} |
| 103 | + |
| 104 | +const MANIFEST_ENTRY:&str = "extension/package.json"; |
| 105 | +const PAYLOAD_PREFIX:&str = "extension/"; |
| 106 | + |
| 107 | +/// Open `VsixPath` and install its payload under `InstallRoot`. On success the |
| 108 | +/// caller receives the new identifier, install directory, and a DTO ready |
| 109 | +/// for `ScannedExtensionCollection::AddOrUpdate`. |
| 110 | +pub fn InstallVsix(VsixPath:&Path, InstallRoot:&Path) -> Result<InstallOutcome, InstallError> { |
| 111 | + if !VsixPath.exists() { |
| 112 | + return Err(InstallError::SourceMissing(VsixPath.to_path_buf())); |
| 113 | + } |
| 114 | + |
| 115 | + let Facts = ReadManifestFacts(VsixPath)?; |
| 116 | + let InstalledAt = InstallRoot.join(format!("{}.{}-{}", Facts.Publisher, Facts.Name, Facts.Version)); |
| 117 | + |
| 118 | + if InstalledAt.exists() { |
| 119 | + return Err(InstallError::AlreadyInstalled { |
| 120 | + Identifier:format!("{}.{}", Facts.Publisher, Facts.Name), |
| 121 | + Version:Facts.Version, |
| 122 | + InstalledAt, |
| 123 | + }); |
| 124 | + } |
| 125 | + |
| 126 | + CreateParent(&InstalledAt)?; |
| 127 | + ExtractPayload(VsixPath, &InstalledAt)?; |
| 128 | + |
| 129 | + let Description = BuildDescription(&InstalledAt)?; |
| 130 | + let Identifier = format!("{}.{}", Facts.Publisher, Facts.Name); |
| 131 | + |
| 132 | + dev_log!( |
| 133 | + "extensions", |
| 134 | + "[VsixInstaller] Installed '{}' v{} at {}", |
| 135 | + Identifier, |
| 136 | + Facts.Version, |
| 137 | + InstalledAt.display() |
| 138 | + ); |
| 139 | + |
| 140 | + Ok(InstallOutcome { Identifier, Version:Facts.Version, InstalledAt, Description }) |
| 141 | +} |
| 142 | + |
| 143 | +/// Delete the install directory. Returns `Ok` if the path was already absent. |
| 144 | +pub fn UninstallExtension(InstallDir:&Path) -> Result<(), InstallError> { |
| 145 | + if !InstallDir.exists() { |
| 146 | + dev_log!("extensions", "[VsixInstaller] Uninstall skipped — {} already absent", InstallDir.display()); |
| 147 | + |
| 148 | + return Ok(()); |
| 149 | + } |
| 150 | + |
| 151 | + fs::remove_dir_all(InstallDir).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 152 | + |
| 153 | + dev_log!("extensions", "[VsixInstaller] Uninstalled {}", InstallDir.display()); |
| 154 | + |
| 155 | + Ok(()) |
| 156 | +} |
| 157 | + |
| 158 | +// --- Internals ---------------------------------------------------------- |
| 159 | + |
| 160 | +fn ReadManifestFacts(VsixPath:&Path) -> Result<ManifestFacts, InstallError> { |
| 161 | + let Archive = File::open(VsixPath).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?; |
| 162 | + let mut Archive = ZipArchive::new(Archive).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?; |
| 163 | + |
| 164 | + let mut Entry = Archive |
| 165 | + .by_name(MANIFEST_ENTRY) |
| 166 | + .map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?; |
| 167 | + |
| 168 | + let mut Raw = String::new(); |
| 169 | + |
| 170 | + Entry |
| 171 | + .read_to_string(&mut Raw) |
| 172 | + .map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?; |
| 173 | + |
| 174 | + let Manifest:Value = serde_json::from_str(&Raw).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?; |
| 175 | + |
| 176 | + let Publisher = ReadStringField(&Manifest, "publisher")?; |
| 177 | + let Name = ReadStringField(&Manifest, "name")?; |
| 178 | + let Version = ReadStringField(&Manifest, "version")?; |
| 179 | + |
| 180 | + Ok(ManifestFacts { Publisher, Name, Version }) |
| 181 | +} |
| 182 | + |
| 183 | +fn ReadStringField(Manifest:&Value, Field:&'static str) -> Result<String, InstallError> { |
| 184 | + Manifest |
| 185 | + .get(Field) |
| 186 | + .and_then(|Value| Value.as_str()) |
| 187 | + .filter(|Value| !Value.is_empty()) |
| 188 | + .map(str::to_owned) |
| 189 | + .ok_or(InstallError::ManifestFieldMissing(Field)) |
| 190 | +} |
| 191 | + |
| 192 | +fn CreateParent(InstalledAt:&Path) -> Result<(), InstallError> { |
| 193 | + if let Some(Parent) = InstalledAt.parent() { |
| 194 | + fs::create_dir_all(Parent).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 195 | + } |
| 196 | + |
| 197 | + Ok(()) |
| 198 | +} |
| 199 | + |
| 200 | +fn ExtractPayload(VsixPath:&Path, InstalledAt:&Path) -> Result<(), InstallError> { |
| 201 | + let Archive = File::open(VsixPath).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?; |
| 202 | + let mut Archive = ZipArchive::new(Archive).map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?; |
| 203 | + |
| 204 | + fs::create_dir_all(InstalledAt).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 205 | + |
| 206 | + for Index in 0..Archive.len() { |
| 207 | + let mut Entry = Archive |
| 208 | + .by_index(Index) |
| 209 | + .map_err(|Error| InstallError::ArchiveRead(Error.to_string()))?; |
| 210 | + |
| 211 | + let EntryName = Entry.name().to_string(); |
| 212 | + |
| 213 | + // Only the `extension/...` subtree is the addon payload. Manifest-level |
| 214 | + // files (`[Content_Types].xml`, `extension.vsixmanifest`, `assets/`, |
| 215 | + // etc.) are VSIX packaging metadata and are not needed at runtime. |
| 216 | + let Stripped = match EntryName.strip_prefix(PAYLOAD_PREFIX) { |
| 217 | + Some(Path) if !Path.is_empty() => Path, |
| 218 | + _ => continue, |
| 219 | + }; |
| 220 | + |
| 221 | + // Guard against zip-slip: the archive must not reference `..` segments |
| 222 | + // that escape the install dir. Reject any entry whose resolved path is |
| 223 | + // outside `InstalledAt`. |
| 224 | + let Target = InstalledAt.join(Stripped); |
| 225 | + |
| 226 | + let CanonicalInstall = InstalledAt.to_path_buf(); |
| 227 | + |
| 228 | + let RejectTraversal = !Target.starts_with(&CanonicalInstall); |
| 229 | + |
| 230 | + if RejectTraversal { |
| 231 | + return Err(InstallError::ArchiveRead(format!("zip-slip entry rejected: {}", EntryName))); |
| 232 | + } |
| 233 | + |
| 234 | + if Entry.is_dir() { |
| 235 | + fs::create_dir_all(&Target).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 236 | + |
| 237 | + continue; |
| 238 | + } |
| 239 | + |
| 240 | + if let Some(Parent) = Target.parent() { |
| 241 | + fs::create_dir_all(Parent).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 242 | + } |
| 243 | + |
| 244 | + let mut Output = |
| 245 | + File::create(&Target).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 246 | + |
| 247 | + io::copy(&mut Entry, &mut Output).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?; |
| 248 | + } |
| 249 | + |
| 250 | + Ok(()) |
| 251 | +} |
| 252 | + |
| 253 | +fn BuildDescription(InstalledAt:&Path) -> Result<ExtensionDescriptionStateDTO, InstallError> { |
| 254 | + let ManifestPath = InstalledAt.join("package.json"); |
| 255 | + |
| 256 | + let Raw = fs::read_to_string(&ManifestPath).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?; |
| 257 | + |
| 258 | + let mut ManifestValue:Value = serde_json::from_str(&Raw).map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?; |
| 259 | + |
| 260 | + let mut Description:ExtensionDescriptionStateDTO = serde_json::from_value(ManifestValue.clone()) |
| 261 | + .map_err(|Error| InstallError::ManifestMissing(Error.to_string()))?; |
| 262 | + |
| 263 | + Description.ExtensionLocation = |
| 264 | + serde_json::to_value(url::Url::from_directory_path(InstalledAt).unwrap_or_else(|_| { |
| 265 | + url::Url::parse("file:///").expect("file:/// is a valid URL") |
| 266 | + })) |
| 267 | + .unwrap_or(Value::Null); |
| 268 | + |
| 269 | + if Description.Identifier == Value::Null || Description.Identifier == Value::Object(Default::default()) { |
| 270 | + let Identifier = if Description.Publisher.is_empty() { |
| 271 | + Description.Name.clone() |
| 272 | + } else { |
| 273 | + format!("{}.{}", Description.Publisher, Description.Name) |
| 274 | + }; |
| 275 | + |
| 276 | + Description.Identifier = serde_json::json!({ "value": Identifier }); |
| 277 | + } |
| 278 | + |
| 279 | + Description.IsBuiltin = false; |
| 280 | + |
| 281 | + // Touch the mutable manifest so later tooling that re-serialises it sees |
| 282 | + // the same canonical form we parsed from. |
| 283 | + let _ = &mut ManifestValue; |
| 284 | + |
| 285 | + Ok(Description) |
| 286 | +} |
0 commit comments