Skip to main content

Mountain/Environment/Utility/
PathSecurity.rs

1//! # Path Security Utilities
2//!
3//! Functions for validating filesystem access and enforcing workspace trust.
4
5use std::path::{Path, PathBuf};
6
7use CommonLibrary::Error::CommonError::CommonError;
8
9use crate::{ApplicationState::State::ApplicationState::ApplicationState, dev_log};
10
11/// A critical security helper that checks if a given filesystem path is
12/// allowed for access.
13///
14/// The access model has two tiers:
15///
16/// 1. **Trusted system paths** - directories Land itself owns (user extensions,
17///    agent plugins, app-support storage, bundled extension roots). These are
18///    never "user content" and the extension scanner, VSIX installer, and
19///    global-storage probes must be able to read/write them regardless of which
20///    workspace folder is open. They bypass the workspace-folder check
21///    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 from
26///    rifling through `$HOME` via `vscode.workspace.fs`.
27///
28/// Without tier 1, the scanner's read of `~/.fiddee/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.
32pub fn Fn(ApplicationState:&ApplicationState, PathToCheck:&Path) -> Result<(), CommonError> {
33	// Per-call verification line is one of the highest-volume tags
34	// (~15k hits per long session). The failure path below logs its own
35	// line; the success path is auditable from IPC-side request logs.
36	// Keep under `vfs-verbose` for deep debugging only.
37	dev_log!("vfs-verbose", "[EnvironmentSecurity] Verifying path: {}", PathToCheck.display());
38
39	// Defensive: empty path would slip through the trusted-system
40	// check (no allow-list segment matches) AND the workspace-
41	// descendant check (`Path::starts_with("")` returns true). Without
42	// this guard, an extension probing `vscode.workspace.fs.stat("")`
43	// would be authorised against ANY registered workspace folder.
44	// Reject up front so the caller falls through to its not-found
45	// handler.
46	if PathToCheck.as_os_str().is_empty() {
47		return Err(CommonError::FileSystemPermissionDenied {
48			Path:PathToCheck.to_path_buf(),
49			Reason:"Empty path: caller must supply an explicit filesystem path.".to_string(),
50		});
51	}
52
53	// Tier 1: trusted system paths bypass workspace gating. See
54	// `IsTrustedSystemPath` for the complete allow-list. Scanner reads,
55	// VSIX installs, agent-plugin probes, and per-extension global-storage
56	// stats hit this path on every boot.
57	if IsTrustedSystemPath(PathToCheck) {
58		return Ok(());
59	}
60
61	if !ApplicationState.Workspace.IsTrusted.load(std::sync::atomic::Ordering::Relaxed) {
62		return Err(CommonError::FileSystemPermissionDenied {
63			Path:PathToCheck.to_path_buf(),
64			Reason:"Workspace is not trusted. File access is denied.".to_string(),
65		});
66	}
67
68	let FoldersGuard = ApplicationState
69		.Workspace
70		.WorkspaceFolders
71		.lock()
72		.map_err(super::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
73
74	if FoldersGuard.is_empty() {
75		// Allow access if no folder is open, as operations are likely on user-chosen
76		// files. A stricter model could deny this.
77		return Ok(());
78	}
79
80	// Use canonical paths on both sides so that prefix-matching survives
81	// macOS's `/Volumes/<vol>/...` vs `/private/var/...` resolution and
82	// any symlinked submodule roots. Cocoon's URI strip yields the user-
83	// visible path (`/Volumes/<vol>/.../Land/Dependency/...`) while the
84	// workspace folder URL stays as built from `from_directory_path` -
85	// these can disagree on platforms where the resolved canonical path
86	// differs from the URI-derived one (encoded mount-point indirection,
87	// case-insensitive HFS+, etc.). Without this, a workspace with deep
88	// submodule trees rejects every read that walks past the first level
89	// even though the path is a literal descendant of the open folder.
90	let CanonicalPathToCheck =
91		::Cache::PathCanon::Canonicalize::Fn(PathToCheck).unwrap_or_else(|_| PathToCheck.to_path_buf());
92
93	let IsAllowed = FoldersGuard.iter().any(|Folder| {
94		let FolderPath = match Folder.URI.to_file_path() {
95			Ok(P) => P,
96			Err(_) => return false,
97		};
98
99		let CanonicalFolderPath =
100			::Cache::PathCanon::Canonicalize::Fn(&FolderPath).unwrap_or_else(|_| FolderPath.clone());
101
102		// Try both canonical-canonical AND raw-raw - either match wins.
103		PathToCheck.starts_with(&FolderPath)
104			|| PathToCheck.starts_with(&CanonicalFolderPath)
105			|| CanonicalPathToCheck.starts_with(&FolderPath)
106			|| CanonicalPathToCheck.starts_with(&CanonicalFolderPath)
107	});
108
109	if IsAllowed {
110		Ok(())
111	} else {
112		// Surface the comparison details so a workspace-mismatch bug
113		// (URL-to-path conversion, canonicalisation drift) is debuggable
114		// without rebuilding. Tag is `vfs` so it appears under the
115		// default `short` trace set.
116		let FolderPaths:Vec<String> = FoldersGuard
117			.iter()
118			.map(|F| {
119				F.URI
120					.to_file_path()
121					.map(|P| P.display().to_string())
122					.unwrap_or_else(|_| format!("<bad-uri:{}>", F.URI))
123			})
124			.collect();
125
126		dev_log!(
127			"vfs",
128			"[PathSecurity] reject path={} canonical={} folders=[{}]",
129			PathToCheck.display(),
130			CanonicalPathToCheck.display(),
131			FolderPaths.join(", ")
132		);
133
134		Err(CommonError::FileSystemPermissionDenied {
135			Path:PathToCheck.to_path_buf(),
136			Reason:"Path is outside of the registered workspace folders.".to_string(),
137		})
138	}
139}
140
141/// Return `true` when `PathToCheck` falls under a directory that Land itself
142/// manages and the sandbox should not gate.
143///
144/// Covered roots:
145///
146/// - `${Lodge}` (explicit override, if set).
147/// - `$HOME/.fiddee/**` - the canonical namespace for user-installed
148///   extensions, agent plugins, global storage, and any other FIDDEE-owned
149///   state that lives outside the VS Code-style profile tree. Resolved through
150///   the `Utilities::FiddeeRoot` atom.
151/// - `$HOME/.land/**` - legacy alias kept for forward-compat reads of
152///   pre-rename install trees so existing user data stays reachable until the
153///   next install migrates it.
154/// - The Mountain executable's own `extensions/`, `../Resources/extensions/`,
155///   `../Resources/app/extensions/`, and
156///   `../Resources/Static/Application/extensions/` neighbours - built-in
157///   extension roots that ship inside the `.app` bundle.
158/// - `$APPDATA`-equivalents: Tauri's resolved app-data / app-config / app-local
159///   directories (via `$XDG_DATA_HOME`, `$XDG_CONFIG_HOME` if set; on macOS the
160///   `Library/Application Support/land.editor.*` tree).
161/// - `${TMPDIR}` + `/tmp`, `/private/tmp`, `/var/tmp` - scratch dirs language
162///   servers write their port-handoff / socket / lock files to. `TMPDIR` on
163///   macOS points at `/var/folders/.../T/` but extensions hardcode
164///   `/tmp/<tool>` directly.
165/// - Third-party tool state under `$HOME/{.gitkraken,.gk,.copilot,
166///   .config/git}` - probed by GitLens, copilot-chat, etc. Application state,
167///   not user content.
168///
169/// Anything outside this list still flows through the workspace-folder
170/// check. The set is intentionally narrow: it unblocks Land's *own*
171/// bookkeeping reads + cooperating neighbour-tool probes without
172/// handing extensions an unbounded filesystem.
173fn IsTrustedSystemPath(PathToCheck:&Path) -> bool {
174	// Canonicalising is best-effort - when the path doesn't exist yet
175	// (e.g. first-boot probes for `globalStorage/<extension>/state.json`)
176	// `canonicalize` returns Err and we compare against the raw path.
177	let Candidate = ::Cache::PathCanon::Canonicalize::Fn(PathToCheck).unwrap_or_else(|_| PathToCheck.to_path_buf());
178
179	if let Ok(Override) = std::env::var("Lodge") {
180		if !Override.is_empty() {
181			let OverridePath = PathBuf::from(&Override);
182
183			if Candidate.starts_with(&OverridePath) || PathToCheck.starts_with(&OverridePath) {
184				return true;
185			}
186		}
187	}
188
189	if let Ok(Home) = std::env::var("HOME") {
190		// Primary user-scope root post-rename. Resolved through the
191		// `FiddeeRoot` atom so any future rename touches a single file.
192		let FiddeeRoot = crate::IPC::WindServiceHandlers::Utilities::FiddeeRoot::Fn();
193
194		if Candidate.starts_with(&FiddeeRoot) || PathToCheck.starts_with(&FiddeeRoot) {
195			return true;
196		}
197
198		// Legacy alias - pre-rename installs still hold extensions and
199		// recently-opened state under `~/.land`. Reads stay allow-listed
200		// so existing data remains visible until the user reinstalls or
201		// migrates to `~/.fiddee`.
202		let LandRoot = PathBuf::from(&Home).join(".land");
203
204		if Candidate.starts_with(&LandRoot) || PathToCheck.starts_with(&LandRoot) {
205			return true;
206		}
207
208		// macOS / Linux Application-Support trees that host Land's per-profile
209		// state. `land.editor.*` prefix matches every build profile variant.
210		let MacAppSupport = PathBuf::from(&Home).join("Library/Application Support");
211
212		if (Candidate.starts_with(&MacAppSupport) || PathToCheck.starts_with(&MacAppSupport))
213			&& ContainsLandEditorSegment(PathToCheck)
214		{
215			return true;
216		}
217
218		let XdgConfig = std::env::var("XDG_CONFIG_HOME")
219			.map(PathBuf::from)
220			.unwrap_or_else(|_| PathBuf::from(&Home).join(".config"));
221
222		if (Candidate.starts_with(&XdgConfig) || PathToCheck.starts_with(&XdgConfig))
223			&& ContainsLandEditorSegment(PathToCheck)
224		{
225			return true;
226		}
227
228		let XdgData = std::env::var("XDG_DATA_HOME")
229			.map(PathBuf::from)
230			.unwrap_or_else(|_| PathBuf::from(&Home).join(".local/share"));
231
232		if (Candidate.starts_with(&XdgData) || PathToCheck.starts_with(&XdgData))
233			&& ContainsLandEditorSegment(PathToCheck)
234		{
235			return true;
236		}
237	}
238
239	if let Ok(Exe) = std::env::current_exe() {
240		if let Some(ExeParent) = Exe.parent() {
241			let BundleRoots = [
242				ExeParent.join("extensions"),
243				ExeParent.join("../Resources/extensions"),
244				ExeParent.join("../Resources/app/extensions"),
245				// Canonical bundle layout: tauri.conf.json maps Sky's
246				// Static/Application/extensions into Contents/Resources/Static/
247				// Application/extensions. This is the path ScanPathConfigure
248				// adds first and it must bypass the workspace-folder gate.
249				ExeParent.join("../Resources/Static/Application/extensions"),
250			];
251
252			for Root in BundleRoots {
253				let Normalised = ::Cache::PathCanon::Canonicalize::Fn(&Root).unwrap_or(Root.clone());
254
255				if Candidate.starts_with(&Normalised) || PathToCheck.starts_with(&Root) {
256					return true;
257				}
258			}
259		}
260	}
261
262	// Sky / Dependency bundled extension trees. These are debug-profile
263	// layouts where the scanner reaches the bundle root via relative hops
264	// from the Mountain executable directory - canonicalising already
265	// resolves that, but we also fall back to a path-segment match so a
266	// missing file (first-boot probe) still clears the check.
267	if ContainsPathSegments(PathToCheck, &["Sky", "Target", "Static", "Application", "extensions"])
268		|| ContainsPathSegments(PathToCheck, &["Dependency", "Microsoft", "Dependency", "Editor", "extensions"])
269	{
270		return true;
271	}
272
273	// Sky's Target tree as a whole is build output Land controls (product.json,
274	// nls bundles, package.json, workbench bundle artifacts). gitlens reads
275	// `Sky/Target/product.json` to detect the host product; the workbench reads
276	// its own bundled metadata. None of these are user content - allowing the
277	// whole `Sky/Target/` subtree mirrors the bundled-extension carve-out
278	// above and keeps third-party probes from getting "outside workspace"
279	// rejections for files Land itself shipped.
280	if ContainsPathSegments(PathToCheck, &["Sky", "Target"])
281		|| ContainsPathSegments(PathToCheck, &["Output", "Target"])
282		|| ContainsPathSegments(PathToCheck, &["Dependency", "Microsoft", "Dependency", "Editor", "out"])
283		|| ContainsPathSegments(
284			PathToCheck,
285			&["Dependency", "Microsoft", "Dependency", "Editor", "product.json"],
286		) {
287		return true;
288	}
289
290	if let Ok(TempDir) = std::env::var("TMPDIR") {
291		let TempPath = PathBuf::from(&TempDir);
292
293		if !TempPath.as_os_str().is_empty() && (Candidate.starts_with(&TempPath) || PathToCheck.starts_with(&TempPath))
294		{
295			return true;
296		}
297	}
298
299	// Platform-conventional scratch roots that don't show up in `TMPDIR`
300	// on macOS/Linux. Language servers (ruby-lsp, solargraph, jdtls,
301	// pyright, …) write port-handoff / reporter / socket files under
302	// `/tmp/<tool>/` as a matter of course. `/var/folders/.../T/` IS
303	// covered by `TMPDIR` on macOS, but `/tmp` and `/private/tmp` are
304	// the ones extensions actually target. Guarding these under the
305	// system-trust tier is safe: extensions run inside Cocoon's Node
306	// host, which already has unconstrained process-level filesystem
307	// access - the sandbox only gates IPC round-trips through Mountain,
308	// not the extension's own `fs.writeFileSync`.
309	for Root in ["/tmp", "/private/tmp", "/var/tmp"] {
310		let RootPath = PathBuf::from(Root);
311
312		if Candidate.starts_with(&RootPath) || PathToCheck.starts_with(&RootPath) {
313			return true;
314		}
315	}
316
317	// Third-party tool state directories extensions commonly probe.
318	// GitLens stats `~/.gitkraken/workspaces/workspaces.json` to offer a
319	// "Open in GitKraken" menu; copilot-chat stats `~/.copilot/` for
320	// cached completions. These live outside Land's namespace but are
321	// not user-content either - they're application state from another
322	// tool, safe to read/stat.
323	if let Ok(Home) = std::env::var("HOME") {
324		for Suffix in [".gitkraken", ".gk", ".copilot", ".config/git"] {
325			let ToolRoot = PathBuf::from(&Home).join(Suffix);
326
327			if Candidate.starts_with(&ToolRoot) || PathToCheck.starts_with(&ToolRoot) {
328				return true;
329			}
330		}
331	}
332
333	// Read-only POSIX OS-info files. Many extensions (csharp, ruby-lsp,
334	// rust-analyzer, debug adapters, telemetry SDKs) probe these to
335	// branch on distro / kernel for spawning the correct binary. They
336	// are world-readable system files - the workspace-folder check
337	// rejects them as "outside workspace" but there's no plausible
338	// abuse vector. Match by full equality to keep the carve-out tight.
339	for SystemFile in [
340		"/etc/os-release",
341		"/etc/lsb-release",
342		"/etc/system-release",
343		"/etc/redhat-release",
344		"/etc/SuSE-release",
345		"/etc/debian_version",
346		"/etc/alpine-release",
347		"/etc/machine-id",
348		"/etc/timezone",
349		"/etc/localtime",
350		"/proc/version",
351		"/proc/cpuinfo",
352		"/proc/meminfo",
353		"/proc/self/status",
354		"/proc/self/cgroup",
355	] {
356		let SysPath = PathBuf::from(SystemFile);
357
358		if Candidate == SysPath || PathToCheck == SysPath {
359			return true;
360		}
361	}
362
363	false
364}
365
366/// True when `path` contains a directory segment whose name starts with
367/// `land.editor.`. Used to tighten the Application-Support / XDG checks so
368/// we only trust directories that Land itself provisioned, not every file
369/// under `$HOME/Library/Application Support`.
370fn ContainsLandEditorSegment(path:&Path) -> bool {
371	path.components().any(|Component| {
372		Component
373			.as_os_str()
374			.to_str()
375			.map(|Name| Name.starts_with("land.editor."))
376			.unwrap_or(false)
377	})
378}
379
380/// True when every element of `segments` appears in order as consecutive
381/// path components of `path`. Used to match Sky / Dependency extension
382/// roots regardless of which relative-path prefix the scanner used.
383fn ContainsPathSegments(path:&Path, segments:&[&str]) -> bool {
384	let Names:Vec<&str> = path.components().filter_map(|C| C.as_os_str().to_str()).collect();
385
386	if segments.is_empty() || Names.len() < segments.len() {
387		return false;
388	}
389
390	Names
391		.windows(segments.len())
392		.any(|Window| Window.iter().zip(segments.iter()).all(|(A, B)| A == B))
393}