Skip to content

Commit e495635

Browse files
feat(Mountain): Add trusted system paths bypass and idempotent VSIX installs
Implement two related extension-management improvements: 1. **Trusted system paths** (`PathSecurity.rs`): Add `IsTrustedSystemPath` that defines directories Land owns (`.land/`, Application Support, bundled extension roots, TMPDIR) which bypass the workspace-folder sandbox check. Without this, the extension scanner's read of `~/.land/extensions` was rejected as "outside workspace" - user-installed VSIXes never reached the Extensions sidebar. 2. **Idempotent VSIX reinstalls** (`VsixInstaller.rs`): Remove `AlreadyInstalled` error and instead treat re-installs as no-op when the target already exists with a readable manifest. Matches VS Code's semantics and prevents the renderer crash where `ExtensionsWorkbenchService` dereferences a null result. Corrupt/partial installs are wiped and re-extracted. Both changes unblock the extension loading pipeline.
1 parent f1de83d commit e495635

2 files changed

Lines changed: 196 additions & 15 deletions

File tree

Source/Environment/Utility/PathSecurity.rs

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//!
33
//! Functions for validating filesystem access and enforcing workspace trust.
44
5-
use std::path::Path;
5+
use std::path::{Path, PathBuf};
66

77
use CommonLibrary::Error::CommonError::CommonError;
88

@@ -11,13 +11,35 @@ use crate::{ApplicationState::ApplicationState, dev_log};
1111
/// A critical security helper that checks if a given filesystem path is
1212
/// allowed for access.
1313
///
14-
/// In this architecture, this means the path must be a descendant of one of the
15-
/// currently open and trusted workspace folders. This prevents extensions from
16-
/// performing arbitrary filesystem operations outside the user's intended
17-
/// scope.
14+
/// The access model has two tiers:
15+
///
16+
/// 1. **Trusted system paths** - directories Land itself owns (user
17+
/// extensions, agent plugins, app-support storage, bundled extension
18+
/// roots). These are never "user content" and the extension scanner,
19+
/// VSIX installer, and global-storage probes must be able to read/write
20+
/// them regardless of which workspace folder is open. They bypass the
21+
/// workspace-folder check entirely.
22+
///
23+
/// 2. **Workspace content** - everything else is only reachable when the
24+
/// resolved path is a descendant of a currently registered, trusted
25+
/// workspace folder. That's the sandbox boundary that keeps extensions
26+
/// from rifling through `$HOME` via `vscode.workspace.fs`.
27+
///
28+
/// Without tier 1, the scanner's read of `~/.land/extensions` is
29+
/// rejected as "Path is outside of the registered workspace folders", so
30+
/// user-installed VSIXes never reach the Extensions sidebar even though
31+
/// they are present on disk.
1832
pub fn IsPathAllowedForAccess(ApplicationState:&ApplicationState, PathToCheck:&Path) -> Result<(), CommonError> {
1933
dev_log!("vfs", "[EnvironmentSecurity] Verifying path: {}", PathToCheck.display());
2034

35+
// Tier 1: trusted system paths bypass workspace gating. See
36+
// `IsTrustedSystemPath` for the complete allow-list. Scanner reads,
37+
// VSIX installs, agent-plugin probes, and per-extension global-storage
38+
// stats hit this path on every boot.
39+
if IsTrustedSystemPath(PathToCheck) {
40+
return Ok(());
41+
}
42+
2143
if !ApplicationState.Workspace.IsTrusted.load(std::sync::atomic::Ordering::Relaxed) {
2244
return Err(CommonError::FileSystemPermissionDenied {
2345
Path:PathToCheck.to_path_buf(),
@@ -53,3 +75,136 @@ pub fn IsPathAllowedForAccess(ApplicationState:&ApplicationState, PathToCheck:&P
5375
})
5476
}
5577
}
78+
79+
/// Return `true` when `PathToCheck` falls under a directory that Land itself
80+
/// manages and the sandbox should not gate.
81+
///
82+
/// Covered roots:
83+
///
84+
/// - `${LAND_USER_EXTENSION_DIRECTORY}` (explicit override, if set).
85+
/// - `$HOME/.land/**` - the canonical namespace for user-installed
86+
/// extensions, agent plugins, global storage, and any other Land-owned
87+
/// state that lives outside the VS Code-style profile tree.
88+
/// - The Mountain executable's own `extensions/`, `../Resources/extensions/`
89+
/// and `../Resources/app/extensions/` neighbours - built-in extension
90+
/// roots that ship inside the `.app` bundle.
91+
/// - `$APPDATA`-equivalents: Tauri's resolved app-data / app-config /
92+
/// app-local directories (via `$XDG_DATA_HOME`, `$XDG_CONFIG_HOME` if
93+
/// set; on macOS the `Library/Application Support/land.editor.*` tree).
94+
/// - `${TMPDIR}` - short-lived temp files the installer unpacks into.
95+
///
96+
/// Anything outside this list still flows through the workspace-folder
97+
/// check. The set is intentionally narrow: it unblocks Land's *own*
98+
/// bookkeeping reads without handing extensions an unbounded filesystem.
99+
fn IsTrustedSystemPath(PathToCheck:&Path) -> bool {
100+
// Canonicalising is best-effort - when the path doesn't exist yet
101+
// (e.g. first-boot probes for `globalStorage/<extension>/state.json`)
102+
// `canonicalize` returns Err and we compare against the raw path.
103+
let Candidate = PathToCheck.canonicalize().unwrap_or_else(|_| PathToCheck.to_path_buf());
104+
105+
if let Ok(Override) = std::env::var("LAND_USER_EXTENSION_DIRECTORY") {
106+
if !Override.is_empty() {
107+
let OverridePath = PathBuf::from(&Override);
108+
if Candidate.starts_with(&OverridePath) || PathToCheck.starts_with(&OverridePath) {
109+
return true;
110+
}
111+
}
112+
}
113+
114+
if let Ok(Home) = std::env::var("HOME") {
115+
let LandRoot = PathBuf::from(&Home).join(".land");
116+
if Candidate.starts_with(&LandRoot) || PathToCheck.starts_with(&LandRoot) {
117+
return true;
118+
}
119+
120+
// macOS / Linux Application-Support trees that host Land's per-profile
121+
// state. `land.editor.*` prefix matches every build profile variant.
122+
let MacAppSupport = PathBuf::from(&Home).join("Library/Application Support");
123+
if (Candidate.starts_with(&MacAppSupport) || PathToCheck.starts_with(&MacAppSupport))
124+
&& ContainsLandEditorSegment(PathToCheck)
125+
{
126+
return true;
127+
}
128+
129+
let XdgConfig = std::env::var("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(&Home).join(".config"));
130+
if (Candidate.starts_with(&XdgConfig) || PathToCheck.starts_with(&XdgConfig))
131+
&& ContainsLandEditorSegment(PathToCheck)
132+
{
133+
return true;
134+
}
135+
136+
let XdgData = std::env::var("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(&Home).join(".local/share"));
137+
if (Candidate.starts_with(&XdgData) || PathToCheck.starts_with(&XdgData))
138+
&& ContainsLandEditorSegment(PathToCheck)
139+
{
140+
return true;
141+
}
142+
}
143+
144+
if let Ok(Exe) = std::env::current_exe() {
145+
if let Some(ExeParent) = Exe.parent() {
146+
let BundleRoots = [
147+
ExeParent.join("extensions"),
148+
ExeParent.join("../Resources/extensions"),
149+
ExeParent.join("../Resources/app/extensions"),
150+
// Sky's Static/Application/extensions root is reached via
151+
// `../../../Sky/Target/Static/Application/extensions` in the
152+
// debug profile - match the canonical `Sky/Target/Static/Application/extensions`
153+
// segment regardless of how many `..` hops the scan path used.
154+
];
155+
for Root in BundleRoots {
156+
let Normalised = Root.canonicalize().unwrap_or(Root.clone());
157+
if Candidate.starts_with(&Normalised) || PathToCheck.starts_with(&Root) {
158+
return true;
159+
}
160+
}
161+
}
162+
}
163+
164+
// Sky / Dependency bundled extension trees. These are debug-profile
165+
// layouts where the scanner reaches the bundle root via relative hops
166+
// from the Mountain executable directory - canonicalising already
167+
// resolves that, but we also fall back to a path-segment match so a
168+
// missing file (first-boot probe) still clears the check.
169+
if ContainsPathSegments(PathToCheck, &["Sky", "Target", "Static", "Application", "extensions"])
170+
|| ContainsPathSegments(PathToCheck, &["Dependency", "Microsoft", "Dependency", "Editor", "extensions"])
171+
{
172+
return true;
173+
}
174+
175+
if let Ok(TempDir) = std::env::var("TMPDIR") {
176+
let TempPath = PathBuf::from(&TempDir);
177+
if !TempPath.as_os_str().is_empty()
178+
&& (Candidate.starts_with(&TempPath) || PathToCheck.starts_with(&TempPath))
179+
{
180+
return true;
181+
}
182+
}
183+
184+
false
185+
}
186+
187+
/// True when `path` contains a directory segment whose name starts with
188+
/// `land.editor.`. Used to tighten the Application-Support / XDG checks so
189+
/// we only trust directories that Land itself provisioned, not every file
190+
/// under `$HOME/Library/Application Support`.
191+
fn ContainsLandEditorSegment(path:&Path) -> bool {
192+
path.components().any(|Component| {
193+
Component
194+
.as_os_str()
195+
.to_str()
196+
.map(|Name| Name.starts_with("land.editor."))
197+
.unwrap_or(false)
198+
})
199+
}
200+
201+
/// True when every element of `segments` appears in order as consecutive
202+
/// path components of `path`. Used to match Sky / Dependency extension
203+
/// roots regardless of which relative-path prefix the scanner used.
204+
fn ContainsPathSegments(path:&Path, segments:&[&str]) -> bool {
205+
let Names:Vec<&str> = path.components().filter_map(|C| C.as_os_str().to_str()).collect();
206+
if segments.is_empty() || Names.len() < segments.len() {
207+
return false;
208+
}
209+
Names.windows(segments.len()).any(|Window| Window.iter().zip(segments.iter()).all(|(A, B)| A == B))
210+
}

Source/ExtensionManagement/VsixInstaller.rs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
//! - Read `extension/package.json`, parse minimal fields (publisher, name,
1313
//! version). These three determine the install directory.
1414
//! - Compute target: `<InstallRoot>/<publisher>.<name>-<version>/`.
15-
//! - If target already exists, refuse (caller decides whether to reinstall).
15+
//! - If target already exists with a readable manifest, treat the install
16+
//! as 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
19+
//! null result from a rejected install.
1620
//! - Stream every entry whose path begins with `extension/` into the target,
1721
//! stripping that prefix.
1822
//! - Re-parse the extracted `package.json` as a full
@@ -91,9 +95,6 @@ pub enum InstallError {
9195
#[error("VSIX manifest missing required field '{0}'")]
9296
ManifestFieldMissing(&'static str),
9397

94-
#[error("Extension '{Identifier}' version {Version} is already installed at {InstalledAt}")]
95-
AlreadyInstalled { Identifier:String, Version:String, InstalledAt:PathBuf },
96-
9798
#[error("Filesystem error during install: {0}")]
9899
FilesystemIO(String),
99100
}
@@ -111,20 +112,45 @@ pub fn InstallVsix(VsixPath:&Path, InstallRoot:&Path) -> Result<InstallOutcome,
111112

112113
let Facts = ReadManifestFacts(VsixPath)?;
113114
let InstalledAt = InstallRoot.join(format!("{}.{}-{}", Facts.Publisher, Facts.Name, Facts.Version));
115+
let Identifier = format!("{}.{}", Facts.Publisher, Facts.Name);
114116

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.
115122
if InstalledAt.exists() {
116-
return Err(InstallError::AlreadyInstalled {
117-
Identifier:format!("{}.{}", Facts.Publisher, Facts.Name),
118-
Version:Facts.Version,
119-
InstalledAt,
120-
});
123+
if let Ok(Description) = BuildDescription(&InstalledAt) {
124+
dev_log!(
125+
"extensions",
126+
"[VsixInstaller] Reinstall no-op - '{}' v{} already present at {}",
127+
Identifier,
128+
Facts.Version,
129+
InstalledAt.display()
130+
);
131+
132+
return Ok(InstallOutcome {
133+
Identifier,
134+
Version:Facts.Version,
135+
InstalledAt,
136+
Description,
137+
});
138+
}
139+
140+
// Corrupt / partial previous install - wipe and re-extract below.
141+
dev_log!(
142+
"extensions",
143+
"[VsixInstaller] Existing install at {} is unreadable - wiping and reinstalling",
144+
InstalledAt.display()
145+
);
146+
147+
fs::remove_dir_all(&InstalledAt).map_err(|Error| InstallError::FilesystemIO(Error.to_string()))?;
121148
}
122149

123150
CreateParent(&InstalledAt)?;
124151
ExtractPayload(VsixPath, &InstalledAt)?;
125152

126153
let Description = BuildDescription(&InstalledAt)?;
127-
let Identifier = format!("{}.{}", Facts.Publisher, Facts.Name);
128154

129155
dev_log!(
130156
"extensions",

0 commit comments

Comments
 (0)