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 crate::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 let CanonicalFolderPath =
99 crate::Cache::PathCanon::Canonicalize::Fn(&FolderPath).unwrap_or_else(|_| FolderPath.clone());
100 // Try both canonical-canonical AND raw-raw - either match wins.
101 PathToCheck.starts_with(&FolderPath)
102 || PathToCheck.starts_with(&CanonicalFolderPath)
103 || CanonicalPathToCheck.starts_with(&FolderPath)
104 || CanonicalPathToCheck.starts_with(&CanonicalFolderPath)
105 });
106
107 if IsAllowed {
108 Ok(())
109 } else {
110 // Surface the comparison details so a workspace-mismatch bug
111 // (URL-to-path conversion, canonicalisation drift) is debuggable
112 // without rebuilding. Tag is `vfs` so it appears under the
113 // default `short` trace set.
114 let FolderPaths:Vec<String> = FoldersGuard
115 .iter()
116 .map(|F| {
117 F.URI
118 .to_file_path()
119 .map(|P| P.display().to_string())
120 .unwrap_or_else(|_| format!("<bad-uri:{}>", F.URI))
121 })
122 .collect();
123
124 dev_log!(
125 "vfs",
126 "[PathSecurity] reject path={} canonical={} folders=[{}]",
127 PathToCheck.display(),
128 CanonicalPathToCheck.display(),
129 FolderPaths.join(", ")
130 );
131
132 Err(CommonError::FileSystemPermissionDenied {
133 Path:PathToCheck.to_path_buf(),
134 Reason:"Path is outside of the registered workspace folders.".to_string(),
135 })
136 }
137}
138
139/// Return `true` when `PathToCheck` falls under a directory that Land itself
140/// manages and the sandbox should not gate.
141///
142/// Covered roots:
143///
144/// - `${Lodge}` (explicit override, if set).
145/// - `$HOME/.fiddee/**` - the canonical namespace for user-installed
146/// extensions, agent plugins, global storage, and any other FIDDEE-owned
147/// state that lives outside the VS Code-style profile tree. Resolved through
148/// the `Utilities::FiddeeRoot` atom.
149/// - `$HOME/.land/**` - legacy alias kept for forward-compat reads of
150/// pre-rename install trees so existing user data stays reachable until the
151/// next install migrates it.
152/// - The Mountain executable's own `extensions/`, `../Resources/extensions/`,
153/// `../Resources/app/extensions/`, and
154/// `../Resources/Static/Application/extensions/` neighbours - built-in
155/// extension roots that ship inside the `.app` bundle.
156/// - `$APPDATA`-equivalents: Tauri's resolved app-data / app-config / app-local
157/// directories (via `$XDG_DATA_HOME`, `$XDG_CONFIG_HOME` if set; on macOS the
158/// `Library/Application Support/land.editor.*` tree).
159/// - `${TMPDIR}` + `/tmp`, `/private/tmp`, `/var/tmp` - scratch dirs language
160/// servers write their port-handoff / socket / lock files to. `TMPDIR` on
161/// macOS points at `/var/folders/.../T/` but extensions hardcode
162/// `/tmp/<tool>` directly.
163/// - Third-party tool state under `$HOME/{.gitkraken,.gk,.copilot,
164/// .config/git}` - probed by GitLens, copilot-chat, etc. Application state,
165/// not user content.
166///
167/// Anything outside this list still flows through the workspace-folder
168/// check. The set is intentionally narrow: it unblocks Land's *own*
169/// bookkeeping reads + cooperating neighbour-tool probes without
170/// handing extensions an unbounded filesystem.
171fn IsTrustedSystemPath(PathToCheck:&Path) -> bool {
172 // Canonicalising is best-effort - when the path doesn't exist yet
173 // (e.g. first-boot probes for `globalStorage/<extension>/state.json`)
174 // `canonicalize` returns Err and we compare against the raw path.
175 let Candidate =
176 crate::Cache::PathCanon::Canonicalize::Fn(PathToCheck).unwrap_or_else(|_| PathToCheck.to_path_buf());
177
178 if let Ok(Override) = std::env::var("Lodge") {
179 if !Override.is_empty() {
180 let OverridePath = PathBuf::from(&Override);
181
182 if Candidate.starts_with(&OverridePath) || PathToCheck.starts_with(&OverridePath) {
183 return true;
184 }
185 }
186 }
187
188 if let Ok(Home) = std::env::var("HOME") {
189 // Primary user-scope root post-rename. Resolved through the
190 // `FiddeeRoot` atom so any future rename touches a single file.
191 let FiddeeRoot = crate::IPC::WindServiceHandlers::Utilities::FiddeeRoot::Fn();
192
193 if Candidate.starts_with(&FiddeeRoot) || PathToCheck.starts_with(&FiddeeRoot) {
194 return true;
195 }
196
197 // Legacy alias - pre-rename installs still hold extensions and
198 // recently-opened state under `~/.land`. Reads stay allow-listed
199 // so existing data remains visible until the user reinstalls or
200 // migrates to `~/.fiddee`.
201 let LandRoot = PathBuf::from(&Home).join(".land");
202
203 if Candidate.starts_with(&LandRoot) || PathToCheck.starts_with(&LandRoot) {
204 return true;
205 }
206
207 // macOS / Linux Application-Support trees that host Land's per-profile
208 // state. `land.editor.*` prefix matches every build profile variant.
209 let MacAppSupport = PathBuf::from(&Home).join("Library/Application Support");
210
211 if (Candidate.starts_with(&MacAppSupport) || PathToCheck.starts_with(&MacAppSupport))
212 && ContainsLandEditorSegment(PathToCheck)
213 {
214 return true;
215 }
216
217 let XdgConfig = std::env::var("XDG_CONFIG_HOME")
218 .map(PathBuf::from)
219 .unwrap_or_else(|_| PathBuf::from(&Home).join(".config"));
220
221 if (Candidate.starts_with(&XdgConfig) || PathToCheck.starts_with(&XdgConfig))
222 && ContainsLandEditorSegment(PathToCheck)
223 {
224 return true;
225 }
226
227 let XdgData = std::env::var("XDG_DATA_HOME")
228 .map(PathBuf::from)
229 .unwrap_or_else(|_| PathBuf::from(&Home).join(".local/share"));
230
231 if (Candidate.starts_with(&XdgData) || PathToCheck.starts_with(&XdgData))
232 && ContainsLandEditorSegment(PathToCheck)
233 {
234 return true;
235 }
236 }
237
238 if let Ok(Exe) = std::env::current_exe() {
239 if let Some(ExeParent) = Exe.parent() {
240 let BundleRoots = [
241 ExeParent.join("extensions"),
242 ExeParent.join("../Resources/extensions"),
243 ExeParent.join("../Resources/app/extensions"),
244 // Canonical bundle layout: tauri.conf.json maps Sky's
245 // Static/Application/extensions into Contents/Resources/Static/
246 // Application/extensions. This is the path ScanPathConfigure
247 // adds first and it must bypass the workspace-folder gate.
248 ExeParent.join("../Resources/Static/Application/extensions"),
249 ];
250
251 for Root in BundleRoots {
252 let Normalised = crate::Cache::PathCanon::Canonicalize::Fn(&Root).unwrap_or(Root.clone());
253
254 if Candidate.starts_with(&Normalised) || PathToCheck.starts_with(&Root) {
255 return true;
256 }
257 }
258 }
259 }
260
261 // Sky / Dependency bundled extension trees. These are debug-profile
262 // layouts where the scanner reaches the bundle root via relative hops
263 // from the Mountain executable directory - canonicalising already
264 // resolves that, but we also fall back to a path-segment match so a
265 // missing file (first-boot probe) still clears the check.
266 if ContainsPathSegments(PathToCheck, &["Sky", "Target", "Static", "Application", "extensions"])
267 || ContainsPathSegments(PathToCheck, &["Dependency", "Microsoft", "Dependency", "Editor", "extensions"])
268 {
269 return true;
270 }
271
272 // Sky's Target tree as a whole is build output Land controls (product.json,
273 // nls bundles, package.json, workbench bundle artifacts). gitlens reads
274 // `Sky/Target/product.json` to detect the host product; the workbench reads
275 // its own bundled metadata. None of these are user content - allowing the
276 // whole `Sky/Target/` subtree mirrors the bundled-extension carve-out
277 // above and keeps third-party probes from getting "outside workspace"
278 // rejections for files Land itself shipped.
279 if ContainsPathSegments(PathToCheck, &["Sky", "Target"])
280 || ContainsPathSegments(PathToCheck, &["Output", "Target"])
281 || ContainsPathSegments(PathToCheck, &["Dependency", "Microsoft", "Dependency", "Editor", "out"])
282 || ContainsPathSegments(
283 PathToCheck,
284 &["Dependency", "Microsoft", "Dependency", "Editor", "product.json"],
285 ) {
286 return true;
287 }
288
289 if let Ok(TempDir) = std::env::var("TMPDIR") {
290 let TempPath = PathBuf::from(&TempDir);
291
292 if !TempPath.as_os_str().is_empty() && (Candidate.starts_with(&TempPath) || PathToCheck.starts_with(&TempPath))
293 {
294 return true;
295 }
296 }
297
298 // Platform-conventional scratch roots that don't show up in `TMPDIR`
299 // on macOS/Linux. Language servers (ruby-lsp, solargraph, jdtls,
300 // pyright, …) write port-handoff / reporter / socket files under
301 // `/tmp/<tool>/` as a matter of course. `/var/folders/.../T/` IS
302 // covered by `TMPDIR` on macOS, but `/tmp` and `/private/tmp` are
303 // the ones extensions actually target. Guarding these under the
304 // system-trust tier is safe: extensions run inside Cocoon's Node
305 // host, which already has unconstrained process-level filesystem
306 // access - the sandbox only gates IPC round-trips through Mountain,
307 // not the extension's own `fs.writeFileSync`.
308 for Root in ["/tmp", "/private/tmp", "/var/tmp"] {
309 let RootPath = PathBuf::from(Root);
310
311 if Candidate.starts_with(&RootPath) || PathToCheck.starts_with(&RootPath) {
312 return true;
313 }
314 }
315
316 // Third-party tool state directories extensions commonly probe.
317 // GitLens stats `~/.gitkraken/workspaces/workspaces.json` to offer a
318 // "Open in GitKraken" menu; copilot-chat stats `~/.copilot/` for
319 // cached completions. These live outside Land's namespace but are
320 // not user-content either - they're application state from another
321 // tool, safe to read/stat.
322 if let Ok(Home) = std::env::var("HOME") {
323 for Suffix in [".gitkraken", ".gk", ".copilot", ".config/git"] {
324 let ToolRoot = PathBuf::from(&Home).join(Suffix);
325
326 if Candidate.starts_with(&ToolRoot) || PathToCheck.starts_with(&ToolRoot) {
327 return true;
328 }
329 }
330 }
331
332 // Read-only POSIX OS-info files. Many extensions (csharp, ruby-lsp,
333 // rust-analyzer, debug adapters, telemetry SDKs) probe these to
334 // branch on distro / kernel for spawning the correct binary. They
335 // are world-readable system files - the workspace-folder check
336 // rejects them as "outside workspace" but there's no plausible
337 // abuse vector. Match by full equality to keep the carve-out tight.
338 for SystemFile in [
339 "/etc/os-release",
340 "/etc/lsb-release",
341 "/etc/system-release",
342 "/etc/redhat-release",
343 "/etc/SuSE-release",
344 "/etc/debian_version",
345 "/etc/alpine-release",
346 "/etc/machine-id",
347 "/etc/timezone",
348 "/etc/localtime",
349 "/proc/version",
350 "/proc/cpuinfo",
351 "/proc/meminfo",
352 "/proc/self/status",
353 "/proc/self/cgroup",
354 ] {
355 let SysPath = PathBuf::from(SystemFile);
356
357 if Candidate == SysPath || PathToCheck == SysPath {
358 return true;
359 }
360 }
361
362 false
363}
364
365/// True when `path` contains a directory segment whose name starts with
366/// `land.editor.`. Used to tighten the Application-Support / XDG checks so
367/// we only trust directories that Land itself provisioned, not every file
368/// under `$HOME/Library/Application Support`.
369fn ContainsLandEditorSegment(path:&Path) -> bool {
370 path.components().any(|Component| {
371 Component
372 .as_os_str()
373 .to_str()
374 .map(|Name| Name.starts_with("land.editor."))
375 .unwrap_or(false)
376 })
377}
378
379/// True when every element of `segments` appears in order as consecutive
380/// path components of `path`. Used to match Sky / Dependency extension
381/// roots regardless of which relative-path prefix the scanner used.
382fn ContainsPathSegments(path:&Path, segments:&[&str]) -> bool {
383 let Names:Vec<&str> = path.components().filter_map(|C| C.as_os_str().to_str()).collect();
384
385 if segments.is_empty() || Names.len() < segments.len() {
386 return false;
387 }
388
389 Names
390 .windows(segments.len())
391 .any(|Window| Window.iter().zip(segments.iter()).all(|(A, B)| A == B))
392}