Skip to content

Commit 43b39ff

Browse files
feat(Mountain): Implement local VSIX install and kernel profiles
Add VSIX installer module (Atom K2/K3) to handle local .vsix file installation: - New VsixInstaller.rs unpacks VSIX archives (ZIP with extension/ prefix) to ~/.land/extensions - Two-pass extraction: read manifest for install path before writing files - Handles zip-slip protection and produces ExtensionDescriptionStateDTO for the registry - Install and uninstall IPC handlers in WindServiceHandler/Extension.rs - Notifies Cocoon via $deltaExtensions for hot-activation without reload - Broadcasts sky://extensions/installed|uninstalled for Wind sidebar refresh Add kernel/minimal profile support (Atom J3): - LAND_SKIP_BUILTIN_EXTENSIONS env var skips built-in extension scan paths - User-extensions path (~/.land/extensions) still scanned for VSIX-installed extensions Add debug-mountain-only/release-mountain-only profiles (Atom N1): - LAND_SPAWN_COCOON=false skips extension host spawn entirely - Useful for Mountain-only integration tests and minimal shippable surface Forward environment to Cocoon (Atom I11): - NODE_ENV, LAND_DEV_LOG, TAURI_ENV_DEBUG now propagate to Cocoon subprocess Improve gRPC connection logging (Atom I12): - Distinguish expected startup race from real failures in Cocoon connection attempts Better unknown command logging (Atom L2): - Distinguish typos from registered-but-unimplemented channels Closes #
1 parent a3a45cc commit 43b39ff

7 files changed

Lines changed: 686 additions & 17 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ brotli = { workspace = true }
9090
hex = { workspace = true }
9191
opentelemetry = { workspace = true, features = ["trace"] }
9292
posthog-rs = { workspace = true }
93+
zip = { workspace = true }
9394

9495
[features]
9596
default = ["ExtensionHostCocoon", "MistNative", "AirIntegration"]

Source/Binary/Extension/ScanPathConfigure.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,25 @@ pub fn ScanPathConfigure(AppState:&std::sync::Arc<ApplicationState>) -> Result<V
4040
.map_err(MapLockError)
4141
.map_err(|e| format!("Failed to lock ExtensionScanPaths: {}", e))?;
4242

43-
dev_log!("extensions", "[Extensions] [ScanPaths] Adding default scan paths...");
43+
// Atom J3: kernel / minimal profiles set LAND_SKIP_BUILTIN_EXTENSIONS=true
44+
// to ship without any bundled extensions. The user-extensions path
45+
// (`~/.land/extensions`) still scans so VSIX-installed extensions work.
46+
let SkipBuiltins = matches!(
47+
std::env::var("LAND_SKIP_BUILTIN_EXTENSIONS").as_deref(),
48+
Ok("1") | Ok("true")
49+
);
50+
51+
if SkipBuiltins {
52+
dev_log!(
53+
"extensions",
54+
"[Extensions] [ScanPaths] LAND_SKIP_BUILTIN_EXTENSIONS=true — skipping all built-in paths, keeping user path"
55+
);
56+
} else {
57+
dev_log!("extensions", "[Extensions] [ScanPaths] Adding default scan paths...");
58+
}
4459

4560
// Resolve paths from executable directory
61+
if !SkipBuiltins {
4662
if let Ok(ExecutableDirectory) = std::env::current_exe() {
4763
if let Some(Parent) = ExecutableDirectory.parent() {
4864
// Standard Tauri bundle path: ../Resources/extensions.
@@ -99,6 +115,7 @@ pub fn ScanPathConfigure(AppState:&std::sync::Arc<ApplicationState>) -> Result<V
99115
}
100116
}
101117
}
118+
} // end !SkipBuiltins
102119

103120
// User-scope paths: always scanned, independent of whether the binary
104121
// was launched from the repo, a `.app`, or a symlink on the Desktop.
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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+
}

Source/ExtensionManagement/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@
3232
#![allow(non_snake_case)]
3333

3434
pub mod Scanner;
35+
36+
pub mod VsixInstaller;

0 commit comments

Comments
 (0)