diff --git a/Cargo.lock b/Cargo.lock index 6668b4d6..0ee0fc98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,10 +1553,13 @@ dependencies = [ "anyhow", "cc", "etcetera", + "globset", "helix-stdx", "log", "once_cell", + "parking_lot", "serde", + "sha2", "tempfile", "threadpool", "toml", @@ -2526,6 +2529,17 @@ dependencies = [ "sha1", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.0" diff --git a/book/src/editor.md b/book/src/editor.md index 158a646e..af44d059 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -22,6 +22,7 @@ - [`[editor.smart-tab]` Section](#editorsmart-tab-section) - [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section) - [`[editor.word-completion]` Section](#editorword-completion-section) +- [`[editor.workspace-trust]` Section](#editorworkspace-trust-section) ### `[editor]` Section @@ -528,3 +529,32 @@ enable = true # Set the trigger length lower so that words are completed more often trigger-length = 4 ``` + +### `[editor.workspace-trust]` Section + +Controls implicit workspace trust. See the [workspace +trust](./workspace-trust.md) chapter for the full feature. + +| Key | Description | Default | +| --- | --- | --- | +| `level` | The default level of trust for every workspace. | `"servers"` | +| `prompt` | Whether to show a modal when opening a file in an untrusted workspace. | `true` | +| `trusted` | Glob patterns whose matching workspaces are trusted without a grant. | `[]` | + +Example: + +```toml +[editor.workspace-trust] +# Even if `false`, the statusline `[⚠]` indicator is still shown. +prompt = false + +# "none": prompt for every workspace. +# "servers": trust LSP and DAP launches but still gate local config and git; +# .helix/config.toml, .helix/languages.toml, etc. need :workspace-trust. +# "insecure": trust everything (discouraged). +level = "servers" + +# Discouraged: skips .helix/ change detection and trusts anything that lands +# under a matching path. `~` and environment variables are expanded. +trusted = ["~/src/github.com/me/*"] +``` diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index c2c7e2de..51b59437 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -97,5 +97,6 @@ | `:read`, `:r` | Load a file into buffer | | `:echo` | Prints the given arguments to the statusline. | | `:noop` | Does nothing. | -| `:workspace-trust` | Add current workspace to the list of trusted workspaces. | -| `:workspace-untrust` | Remove current workspace from the list of trusted workspaces. | +| `:workspace-trust` | Allow language servers and local config for the current workspace. | +| `:workspace-untrust` | Revoke the current workspace's trust grant or exclusion. | +| `:workspace-exclude` | Mark the current workspace as never-prompt. Never prompts for trust again. | diff --git a/book/src/workspace-trust.md b/book/src/workspace-trust.md index 1975b6e9..d501110c 100644 --- a/book/src/workspace-trust.md +++ b/book/src/workspace-trust.md @@ -1,23 +1,175 @@ # Workspace trust -Helix has a number of potentially dangerous features, namely LSP and ability to use local to workspace configurations. Those features can lead to unexpected code execution. To protect against code execution in dangerous contexts, Helix has a workspace trust protection, which will prevent these potentially dangerous features from running automatically. +Helix has several features that can execute arbitrary code: -Helix will not trust any workspace by default. +- Language servers (LSP) +- Debug adapters (DAP) +- Local workspace configuration (`.helix/config.toml`, `.helix/languages.toml`) +- Git integration (filters and other commands in a repository's `.git/config`) -By default, it will prompt about trust when you open new file in a workspace where you didn't make a decision about trust yet. +To protect against malicious projects (a checked-out PR, a freshly cloned +repository, etc.) Helix gates these behind explicit per-workspace trust. +By default language servers start automatically (their binaries come from +`$PATH`, not from the workspace) and debug adapters may be launched, but +loading `.helix/config.toml` or `.helix/languages.toml` and trusting a +repository's `.git/config` requires opting in. Note that debug adapters +are never started automatically — you launch them yourself — but the same +trust level still gates whether they may run. The model is intentionally +similar to [direnv](https://direnv.net/): you run `:workspace-trust` once +per workspace and Helix remembers across sessions. -If you decide not to trust a workspace and don't want to be prompted about trust every time you start a new session in it, you can exclude the workspace by choosing `Never` option in trust selection window. +## Granting trust -You can always make current workspace trusted by running `:workspace-trust` command, and untrust it with `:workspace-untrust`. +When Helix opens a file inside a workspace it has never seen before, a +modal trust prompt asks: -Lists of trusted and excluded workspaces, delimited by newline characters, are stored in `~/.local/share/helix/trusted_workspaces` and `~/.local/share/helix/excluded_workspaces` correspondingly. - +- **Trust** — allow the workspace permanently. +- **Never** — exclude the workspace; never prompt again. -# Configuration +`` (or any other dismissal) caches "untrusted for this session" so +the prompt doesn't re-fire for every file you open in the workspace. The +next time you start Helix in that workspace, it'll prompt again. -You can return to the old behaviour of loading every local `.helix/config.toml` and `.helix/languages.toml` and starting LSP's without an explicit permission by setting following option: +A small `[⚠]` indicator appears in the bottom-right of the editor (next +to the macro-recording `[@]`) whenever the workspace is in restricted mode +*and* running `:workspace-trust` would change observable behavior — i.e. +when there's a local config to load or an LSP that would start. + +You can also run `:workspace-trust` / `:workspace-untrust` / +`:workspace-exclude` directly from the typed command prompt. + +## Revoking trust + +Run `:workspace-untrust` to revoke a workspace's trust grant. The next time +you open a file in that workspace, you're back to the untrusted hint. + +## Detecting changes after trust was granted + +When you trust a workspace, Helix records a hash of every file under +`.helix/`. If those files change afterwards (a malicious checkout, an +inadvertent rebase, etc.) Helix detects the mismatch on the next open and +reports the workspace as *stale*: + +``` +Workspace `.helix/` config changed since `:workspace-trust`. Local config +not loaded. Run `:workspace-trust` to re-allow. +``` + +In the stale state, language servers continue to run (they use the +globally-configured binaries on `$PATH`, which are unchanged), but +`.helix/config.toml` and `.helix/languages.toml` are not loaded. Run +`:workspace-trust` again to re-pin the new hash. + +## Storage + +Trust grants live in `data_dir()/workspace_trust/`, one small file per +workspace. The filename is the SHA-256 of the workspace's absolute path; +the contents look like: + +``` +path = /home/user/proj1 +hash = sha256:abc123... +excluded = false +``` + +- Linux, macOS: `~/.local/share/helix/workspace_trust/` +- Windows: `%AppData%\Roaming\helix\workspace_trust\` + +The one-file-per-workspace shape is safe under multiple concurrent Helix +instances — different workspaces never write the same file. + +## Configuration + +Settings live under `[editor.workspace-trust]`: + +| Key | Values | Default | Effect | +| --- | --- | --- | --- | +| `level` | `"none"`, `"servers"`, `"insecure"` | `"servers"` | What is auto-trusted in every workspace. See below. | +| `prompt` | `true`, `false` | `true` | Whether to surface the modal popup. The `[⚠]` indicator is shown regardless. | +| `trusted` | list of glob patterns | `[]` | Workspaces matching a pattern are trusted without a grant. Discouraged; see below. | + +### Recommended setups + +**Default: trust servers, prompt before loading workspace config.** + +```toml +[editor.workspace-trust] +level = "servers" +prompt = true +``` + +Language servers start automatically in every workspace — their binaries +come from `$PATH` and are not workspace-controlled — and debug adapters +you launch are allowed to run. The modal only appears when opening a file +in a workspace whose `.helix/config.toml` or `.helix/languages.toml` would +unlock something. Trust everything else with one keystroke per workspace, +deny with another. + +**Maximum security: never prompt, trust each workspace by hand.** ```toml -[editor] -insecure = true +[editor.workspace-trust] +level = "none" +prompt = false ``` + +Nothing trusts implicitly: language servers, debug adapters, local +config, and git `Trust::Full` are all off until you run +`:workspace-trust`. The popup never appears; the `[⚠]` indicator in the +bottom-right is your only signal that the current workspace is +restricted. Suited to users who would rather grant trust as a +deliberate action than dismiss a dialog. + +> [!WARNING] +> `level = "insecure"` is highly discouraged. It implicitly trusts every +> workspace you open, which defeats the protection entirely: a +> checked-out PR with a malicious `.helix/config.toml` would get its +> configuration loaded and any language server it defines launched, with +> no prompt and no indicator. Only set this if you accept full +> responsibility for what's in every project directory you `cd` into. + +### Trusting workspaces by path (discouraged) + +If you keep all your repositories under a predictable layout, you can +trust them in bulk with glob patterns instead of granting each workspace +individually: + +```toml +[editor.workspace-trust] +trusted = [ + "~/src/github.com/me/*", + "~/work/repos/*", +] +``` + +A workspace whose path matches a pattern is trusted for everything, just +as if you had run `:workspace-trust` in it. `~` and environment variables +are expanded. + +> [!WARNING] +> This is weaker than an explicit grant and is discouraged. It skips the +> `.helix/` change detection entirely (a malicious checkout under a +> matched directory is never flagged as stale), and it trusts *any* +> repository that later lands under a matching path — including one you +> clone into `~/src/github.com/me/` from an untrusted source. Prefer +> granting trust per workspace; reach for this only if the prompts are +> genuinely disruptive to your workflow. An explicit `:workspace-exclude` +> still overrides a matching pattern. + +## Git trust + +Workspace trust also gates how Helix opens git repositories. Untrusted +workspaces are opened in [gix](https://github.com/Byron/gitoxide)'s +`Trust::Reduced` mode; trusted workspaces use `Trust::Full`. + +Under `Trust::Reduced`, gix still runs the full filter pipeline (so +built-in conversions like `core.autocrlf` keep working) but ignores +configuration coming from the untrusted, repository-local `.git/config`. +That means `filter.*.clean` / `filter.*.smudge` drivers and similar +keys that would otherwise execute external programs are dropped until you +trust the workspace. + +Helix forces this trust level explicitly rather than letting gix infer it +from `.git` directory ownership — a malicious `.git/config` in a directory +you happen to own is still treated as untrusted until you run +`:workspace-trust`. diff --git a/helix-core/src/config.rs b/helix-core/src/config.rs index 79bdcad1..66c3133c 100644 --- a/helix-core/src/config.rs +++ b/helix-core/src/config.rs @@ -1,3 +1,5 @@ +use helix_loader::workspace_trust::WorkspaceTrust; + use crate::syntax::{ config::{Configuration, LanguageConfiguration}, Loader, LoaderError, @@ -37,13 +39,13 @@ impl std::fmt::Display for LanguageLoaderError { impl std::error::Error for LanguageLoaderError {} /// Language configuration based on user configured languages.toml. -pub fn user_lang_config(insecure: bool) -> Result { - helix_loader::config::user_lang_config(insecure)?.try_into() +pub fn user_lang_config(trust: &WorkspaceTrust) -> Result { + helix_loader::config::user_lang_config(trust)?.try_into() } /// Language configuration loader based on user configured languages.toml. -pub fn user_lang_loader(insecure: bool) -> Result { - let config_val = helix_loader::config::user_lang_config(insecure) +pub fn user_lang_loader(trust: &WorkspaceTrust) -> Result { + let config_val = helix_loader::config::user_lang_config(trust) .map_err(LanguageLoaderError::DeserializeError)?; let config = config_val.clone().try_into().map_err(|e| { if let Some(languages) = config_val.get("language").and_then(|v| v.as_array()) { diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index 88267868..bc7ec0d6 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -22,6 +22,9 @@ serde = { version = "1.0", features = ["derive"] } toml.workspace = true etcetera.workspace = true once_cell = "1.21" +parking_lot.workspace = true +sha2 = "0.10" +globset.workspace = true log = "0.4" # TODO: these two should be on !wasm32 only diff --git a/helix-loader/src/config.rs b/helix-loader/src/config.rs index 04bb55bf..c3c83385 100644 --- a/helix-loader/src/config.rs +++ b/helix-loader/src/config.rs @@ -1,6 +1,6 @@ use std::str::from_utf8; -use crate::workspace_trust::{quick_query_workspace, TrustStatus}; +use crate::workspace_trust::{TrustQuery, WorkspaceTrust}; /// Default built-in languages.toml. pub fn default_lang_config() -> toml::Value { @@ -10,11 +10,14 @@ pub fn default_lang_config() -> toml::Value { } /// User configured languages.toml file, merged with the default config. -pub fn user_lang_config(insecure: bool) -> Result { +/// +/// Workspace-local `.helix/languages.toml` is merged in only when the current +/// workspace is trusted for [`TrustQuery::LocalConfig`]. +pub fn user_lang_config(trust: &WorkspaceTrust) -> Result { let global_config = crate::lang_config_file(); let workspace_config = crate::workspace_lang_config_file(); - let files = if let TrustStatus::Trusted = quick_query_workspace(insecure) { + let files = if trust.query_current(TrustQuery::LocalConfig).is_trusted() { vec![global_config, workspace_config] } else { vec![global_config] diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index 501e9b47..f1ee137f 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -88,6 +88,26 @@ fn ensure_git_is_available() -> Result<()> { Ok(()) } +/// Print a notice if the current workspace has a `.helix/languages.toml` that we *would* have +/// merged but the workspace-trust gate is keeping us from. +fn warn_if_workspace_languages_skipped(trust: &crate::workspace_trust::WorkspaceTrust) { + let workspace_languages = crate::workspace_lang_config_file(); + if !workspace_languages.exists() { + return; + } + if trust + .query_current(crate::workspace_trust::TrustQuery::LocalConfig) + .is_trusted() + { + return; + } + println!( + "Note: workspace `{}` was skipped because the workspace is not trusted. Run \ + `:workspace-trust` from an interactive helix session in this workspace to opt in.", + workspace_languages.display(), + ); +} + pub fn fetch_grammars(strict: bool) -> Result<()> { ensure_git_is_available()?; @@ -226,7 +246,14 @@ pub fn build_grammars(target: Option, strict: bool) -> Result<()> { // merged. The `grammar_selection` key of the config is then used to filter // down all grammars into a subset of the user's choosing. fn get_grammar_configs() -> Result> { - let config: Configuration = crate::config::user_lang_config(false) + // `--grammar fetch/build` clones grammar sources from URLs in `languages.toml` and compiles + // them into `.so` files helix later loads at runtime. If we let workspace + // `.helix/languages.toml` in through `fully_trusted`, a malicious workspace could inject a + // grammar with an attacker-controlled git source — running grammar build in that + // directory would clone and compile attacker code + let trust = crate::workspace_trust::WorkspaceTrust::new(Default::default()); + warn_if_workspace_languages_skipped(&trust); + let config: Configuration = crate::config::user_lang_config(&trust) .context("Could not parse languages.toml")? .try_into()?; @@ -248,7 +275,12 @@ fn get_grammar_configs() -> Result> { } pub fn get_grammar_names() -> Result>> { - let config: Configuration = crate::config::user_lang_config(false) + // See `get_grammar_configs`, same threat: workspace-local + // `languages.toml` must not influence the grammar set without + // explicit on-disk trust. + let trust = crate::workspace_trust::WorkspaceTrust::new(Default::default()); + warn_if_workspace_languages_skipped(&trust); + let config: Configuration = crate::config::user_lang_config(&trust) .context("Could not parse languages.toml")? .try_into()?; diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 2e3b760d..47699196 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -164,14 +164,6 @@ pub fn default_log_file() -> PathBuf { cache_dir().join("helix.log") } -pub fn workspace_trust_file() -> PathBuf { - data_dir().join("trusted_workspaces") -} - -pub fn workspace_exclude_file() -> PathBuf { - data_dir().join("excluded_workspaces") -} - /// Merge two TOML documents, merging values from `right` onto `left` /// /// `merge_depth` sets the nesting depth up to which values are merged instead diff --git a/helix-loader/src/workspace_trust.rs b/helix-loader/src/workspace_trust.rs index c3b023cb..677840b2 100644 --- a/helix-loader/src/workspace_trust.rs +++ b/helix-loader/src/workspace_trust.rs @@ -1,192 +1,833 @@ +//! Workspace trust. +//! +//! Helix can load workspace-local configuration (`.helix/`) and launch language servers, both of +//! which can execute arbitrary code. By default these are gated behind explicit user trust granted +//! per-workspace. +//! +//! Trust is granted with `:workspace-trust` (or the popup) and revoked with `:workspace-untrust`. +//! A grant snapshots a hash of every file under `.helix/`. If those files change later the +//! workspace becomes [`TrustStatus::Stale`] and local config is no longer loaded until the user +//! re-runs `:workspace-trust`. Language servers continue to launch under stale trust because the +//! binaries are configured globally and were not part of the changed surface. +//! +//! ## Storage +//! +//! Each trust entry is a small file at `data_dir()/workspace_trust/`. The +//! filename is the SHA-256 of the workspace's absolute path; the contents are a small +//! `key = value` block: +//! +//! ```text +//! path = /home/user/proj1 +//! hash = sha256:abc123... +//! excluded = false +//! ``` +//! +//! `hash` is omitted for excluded entries. The "one file per workspace" shape is safe under +//! multiple concurrent helix instances writing trust for *different* workspaces (different +//! filenames). Two instances racing to trust the same workspace converge to identical content. +//! +//! ## Trusted globs (discouraged) +//! +//! `[editor.workspace-trust] trusted = [...]` is an escape hatch for users who keep many repos in a +//! predictable layout (`~/src/github.com/me/*`) and don't want to grant trust one workspace at a +//! time. A workspace whose path matches one of these globs is implicitly trusted for everything. +//! +//! This is deliberately weaker than an explicit `:workspace-trust` grant and is discouraged: it +//! bypasses the `.helix/` hash pin (changes to local config are never re-checked) and it trusts any +//! repository that happens to land under a matching directory, including ones cloned there later. An +//! explicit exclude still wins over a matching glob. + use std::{ - collections::HashSet, + collections::HashMap, + fmt::Write, fs, path::{Path, PathBuf}, + sync::Arc, }; -use crate::{data_dir, workspace_exclude_file, workspace_trust_file}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use parking_lot::Mutex; +use sha2::{Digest, Sha256}; -pub struct WorkspaceTrust { - trusted: HashSet, - excluded: Option>, +use crate::{data_dir, find_workspace, find_workspace_in}; + +/// Checks whether a specific capability is trusted +#[derive(Debug, Clone, Copy)] +pub enum TrustQuery { + /// Query language server permissions + Lsp, + /// Query debug adapter permissions + Dap, + /// Query whether `.helix/` config can be loaded + LocalConfig, + /// Query whether git integration can trust the .git/config + Git, } -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrustStatus { - Untrusted, + /// Workspace is trusted for the queried capability. Trusted, + /// No trust decision has been made (and no implicit trust applies). + Untrusted, + /// Workspace was previously trusted, but the `.helix/` tree has changed since the grant — the + /// user should re-trust before local config is re-loaded. LSP launches may still proceed under + /// stale trust because they use the (unchanged) globally-configured binaries. + Stale, + /// Workspace is on the exclude list. Never prompts again. + Excluded, } -impl WorkspaceTrust { - /// Loads `WorkspaceTrust`. - /// - /// Should be used only when there is a need to change trust status - /// of a particular workspace. +impl TrustStatus { + pub fn is_trusted(&self) -> bool { + matches!(self, Self::Trusted) + } + + pub fn is_stale(&self) -> bool { + matches!(self, Self::Stale) + } + + pub fn is_excluded(&self) -> bool { + matches!(self, Self::Excluded) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ImplicitTrustLevel { + /// Prompt for every workspace. + None, + /// Helix-launched server processes (LSP and DAP) start implicitly. Workspace-local config and + /// git `Trust::Full` still require explicit trust. /// - /// For querying trust status of a workspace use `quick_query_workspace()` or - /// `quick_query_workspace_with_explicit_untrust()` - pub fn load(with_exclusion: bool) -> Self { - let mut trusted = HashSet::new(); - - match fs::read_to_string(workspace_trust_file()) { - Ok(workspace_trust_file) => { - for line in workspace_trust_file.split('\n') { - if !line.is_empty() { - let path = PathBuf::from(line); - trusted.insert(path); - } - } - } - Err(e) => log::error!("workspace file couldn't be read: {:?}", e), - }; + /// Default: language servers are how most people use the editor and the binaries are global + /// (PATH-resolved, user-installed), so auto-launching them in a fresh workspace matches + /// expectations. + #[default] + Servers, + /// Everything is trusted unless the workspace is explicitly excluded. + Insecure, +} - let excluded = if with_exclusion { - let mut untrusted = HashSet::new(); - - match fs::read_to_string(workspace_exclude_file()) { - Ok(workspace_untrust_file) => { - for line in workspace_untrust_file.split('\n') { - if !line.is_empty() { - let path = PathBuf::from(line); - untrusted.insert(path); - } - } - } - Err(e) => log::error!("workspace file couldn't be read: {:?}", e), - }; +#[derive(Debug, Clone)] +pub struct Config { + pub level: ImplicitTrustLevel, + /// Whether to surface the trust modal on `DocumentDidOpen` for untrusted workspaces. + pub prompt: bool, + /// Workspaces whose path matches one of these globs are implicitly trusted. + pub trusted_globs: GlobSet, +} - Some(untrusted) - } else { - None - }; - WorkspaceTrust { trusted, excluded } +impl Default for Config { + fn default() -> Self { + Self { + level: ImplicitTrustLevel::default(), + prompt: true, + trusted_globs: GlobSet::empty(), + } } +} - fn write_trust_to_file(&self) { - let mut trust_text = String::new(); - for workspace in self.trusted.iter() { - if let Some(path_str) = workspace.to_str() { - trust_text += &format!("{path_str}\n"); +/// Compile workspace-trust glob patterns into a matcher. `~` and environment variables are expanded +/// in each pattern; invalid patterns are logged and skipped rather than failing the whole config +/// load. Returns an empty set (matches nothing) when `patterns` is empty or all entries are invalid. +pub fn build_trusted_globs(patterns: &[String]) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + let expanded = helix_stdx::path::expand(pattern); + match Glob::new(&expanded.to_string_lossy()) { + Ok(glob) => { + builder.add(glob); } + Err(err) => log::error!("ignoring invalid workspace-trust glob {pattern:?}: {err}"), } - // let chains aren't supported in current MSRV - if let Ok(false) = fs::exists(data_dir()) { - if let Err(e) = fs::create_dir_all(data_dir()) { - log::error!("Couldn't create helix's data directory: {:?}", e); - }; + } + builder.build().unwrap_or_else(|err| { + log::error!("failed to compile workspace-trust globs: {err}"); + GlobSet::empty() + }) +} + +/// Runtime workspace-trust state. Cheap to clone (shared `Arc`). +#[derive(Clone)] +pub struct WorkspaceTrust { + inner: Arc>>, + config: Config, +} + +#[derive(Clone, Copy)] +struct CacheEntry { + status: TrustStatus, + /// Whether `.helix/config.toml` or `.helix/languages.toml` exists in the workspace at the time + /// of the first uncached query. Snapshotted to keep [`WorkspaceTrust::workspace_restricted`] + /// off the syscall path on repeat calls (statusline indicator runs per render). + has_local_config: bool, +} + +impl WorkspaceTrust { + pub fn new(config: Config) -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + config, } - if let Err(e) = fs::write(workspace_trust_file(), trust_text) { - log::error!("Error during write of workspace_trust file: {:?}", e); + } + + /// A trust state that grants every capability. Use for non-interactive contexts (CLI grammar + /// build, `hx --health`) where prompting isn't meaningful. + pub fn fully_trusted() -> Self { + Self::new(Config { + level: ImplicitTrustLevel::Insecure, + ..Config::default() + }) + } + + pub fn implicit_level(&self) -> ImplicitTrustLevel { + self.config.level + } + + /// Whether the trust modal should be surfaced on `DocumentDidOpen`. When false the user is + /// only informed via the statusline indicator and acts explicitly via `:workspace-trust`. + pub fn prompts_enabled(&self) -> bool { + self.config.prompt + } + + /// Replace the configuration in-place. Clears the trust cache so the next query re-reads from + /// disk. This catches external mutations to `.helix/` that happened while helix was running + /// (the editor's in-memory cache would otherwise keep returning a stale `Trusted` even though + /// `compute_workspace_hash` would now produce a different digest). Used by `:config-reload`. + /// + /// Session-only decisions made via [`Self::deny_once`] are discarded as part of the cache + /// clear; the trust popup's `prompted` set (scoped to the hook closure) is what suppresses + /// re-prompting across the reload, not this cache. + pub fn set_config(&mut self, config: Config) { + self.config = config; + self.inner.lock().clear(); + } + + /// Raw on-disk trust status for `workspace`, ignoring implicit-trust-level shortcuts and the + /// `demote_for_query` mapping. Use this when you need to distinguish *Stale* (was trusted, + /// `.helix/` changed) from *Untrusted* (never trusted) + pub fn status(&self, workspace: &Path) -> TrustStatus { + self.entry(workspace).status + } + + /// Cache entry for `workspace`, loaded from disk on first call. + fn entry(&self, workspace: &Path) -> CacheEntry { + if let Some(entry) = self.inner.lock().get(workspace).copied() { + return entry; } + let status = load_status(workspace); + let has_local_config = has_local_config(workspace); + let entry = CacheEntry { + status, + has_local_config, + }; + self.inner.lock().insert(workspace.to_path_buf(), entry); + entry } - fn write_exclusion_to_file(&self) { - if let Some(untrusted) = &self.excluded { - let mut trust_text = String::new(); - for workspace in untrusted.iter() { - if let Some(path_str) = workspace.to_str() { - trust_text += &format!("{path_str}\n"); - } - } - // let chains aren't supported in current MSRV - if let Ok(false) = fs::exists(data_dir()) { - if let Err(e) = fs::create_dir_all(data_dir()) { - log::error!("Couldn't create helix's data directory: {:?}", e); - }; - } - if let Err(e) = fs::write(workspace_exclude_file(), trust_text) { - log::error!("Error during write of workspace_trust file: {:?}", e); - } - } else { - log::error!("Called write_untrust_to_file() when self.untrusted is None"); + /// Query trust for `workspace` from the perspective of a specific subsystem. + pub fn query(&self, workspace: &Path, query: TrustQuery) -> TrustStatus { + let raw = self.status(workspace); + + // Explicit excludes always win. + if raw == TrustStatus::Excluded { + return TrustStatus::Excluded; + } + + // Implicit-trust shortcuts only apply once we've ruled out excludes. + if self.config.level == ImplicitTrustLevel::Insecure { + return TrustStatus::Trusted; } + // A config-listed glob grants full implicit trust to the matching workspace. + if self.is_glob_trusted(workspace) { + return TrustStatus::Trusted; + } + if self.config.level == ImplicitTrustLevel::Servers + && matches!(query, TrustQuery::Lsp | TrustQuery::Dap) + { + return TrustStatus::Trusted; + } + + demote_for_query(raw, query) } - /// Mark current workspace trusted - pub fn trust_workspace(&mut self) { - let workspace = crate::find_workspace().0; - self.trusted.insert(workspace); - self.write_trust_to_file(); + /// Query trust for the workspace containing `file`. + pub fn query_for_file(&self, file: &Path, query: TrustQuery) -> TrustStatus { + let workspace = file + .parent() + .map(|dir| find_workspace_in(dir).0) + .unwrap_or_else(|| find_workspace().0); + self.query(&workspace, query) } - /// Remove trusted mark from current workspace - pub fn untrust_workspace(&mut self) { - let workspace = crate::find_workspace().0; - self.trusted.remove(&workspace); - self.write_trust_to_file(); + /// Query trust for the current working directory's workspace. + pub fn query_current(&self, query: TrustQuery) -> TrustStatus { + let workspace = find_workspace().0; + self.query(&workspace, query) } - /// Mark current workspace excluded. + /// Whether `workspace` matches one of the configured trust globs. Empty glob set never matches. + fn is_glob_trusted(&self, workspace: &Path) -> bool { + !self.config.trusted_globs.is_empty() && self.config.trusted_globs.is_match(workspace) + } + + /// Workspace-wide "is this workspace in restricted mode and would running `trust` + /// change anything visible at the workspace level?" check. /// - /// Should be called only if `WorkspaceTrust` was created with `WorkspaceTrust::load(true)` - pub fn exclude_workspace(&mut self) { - let workspace = crate::find_workspace().0; - self.trusted.remove(&workspace); - if let Some(excluded) = &mut self.excluded { - excluded.insert(workspace); - self.write_exclusion_to_file(); - } else { - log::error!("Called untrust_workspace_permanent() when self.untrusted is None"); + /// Reads the entire cache entry through `Self::entry` in a single lock acquisition (so the + /// `Untrusted` branch's `has_local_config` snapshot is consistent with the status read). Cheap + /// on the hot render path after the first query. + pub fn workspace_restricted(&self, workspace: &Path) -> bool { + // If the workspace is fully trusted there's no restrictions. + if self.config.level == ImplicitTrustLevel::Insecure || self.is_glob_trusted(workspace) { + return false; + } + let entry = self.entry(workspace); + match entry.status { + TrustStatus::Stale => true, + TrustStatus::Trusted | TrustStatus::Excluded => false, + TrustStatus::Untrusted => entry.has_local_config, + } + } + + /// Per-document version of [`Self::workspace_restricted`]. + pub fn restricted_for_doc(&self, workspace: &Path, servers_to_load: bool) -> bool { + if self.workspace_restricted(workspace) { + return true; + } + if !servers_to_load { + return false; + } + if self.status(workspace) != TrustStatus::Untrusted { + return false; } + !self.query(workspace, TrustQuery::Lsp).is_trusted() + || !self.query(workspace, TrustQuery::Dap).is_trusted() + } + + /// Mark `workspace` trusted. Snapshots the current `.helix/` hash. + pub fn trust(&self, workspace: &Path) { + let hash = compute_workspace_hash(workspace); + let has_local_config = has_local_config(workspace); + write_entry( + workspace, + &DiskEntry { + hash, + excluded: false, + }, + ); + self.inner.lock().insert( + workspace.to_path_buf(), + CacheEntry { + status: TrustStatus::Trusted, + has_local_config, + }, + ); + } + + /// Revoke any persisted trust grant or exclusion for `workspace`. + pub fn untrust(&self, workspace: &Path) { + remove_entry(workspace); + self.inner.lock().remove(workspace); + } + + /// Mark `workspace` excluded — never prompts again. + pub fn exclude(&self, workspace: &Path) { + let has_local_config = has_local_config(workspace); + write_entry( + workspace, + &DiskEntry { + hash: None, + excluded: true, + }, + ); + self.inner.lock().insert( + workspace.to_path_buf(), + CacheEntry { + status: TrustStatus::Excluded, + has_local_config, + }, + ); + } + + /// Cache an untrusted decision for the current session. + pub fn deny_once(&self, workspace: &Path) { + let has_local_config = has_local_config(workspace); + self.inner.lock().insert( + workspace.to_path_buf(), + CacheEntry { + status: TrustStatus::Untrusted, + has_local_config, + }, + ); } } -#[derive(Default, Clone, Copy, Debug)] -pub enum TrustUntrustStatus { - DenyAlways, - #[default] - DenyOnce, - AllowAlways, +fn has_local_config(workspace: &Path) -> bool { + workspace.join(".helix").join("config.toml").exists() + || workspace.join(".helix").join("languages.toml").exists() } -pub fn quick_query_workspace(insecure: bool) -> TrustStatus { - if insecure { - return TrustStatus::Trusted; +fn demote_for_query(status: TrustStatus, query: TrustQuery) -> TrustStatus { + // Stale workspaces have their `.helix/` config changed since trust was granted. LSP launches + // still rely on globally-configured binaries that weren't part of the changed surface, so they + // remain Trusted; other queries demote to Untrusted until the user re-trusts. + match (status, query) { + (TrustStatus::Stale, TrustQuery::Lsp) => TrustStatus::Trusted, + (TrustStatus::Stale, _) => TrustStatus::Untrusted, + _ => status, } +} - let workspace = crate::find_workspace().0; - match fs::read_to_string(workspace_trust_file()) { - Ok(workspace_trust_file) => { - for line in workspace_trust_file.split('\n') { - if Path::new(line) == workspace { - return TrustStatus::Trusted; - } +fn load_status(workspace: &Path) -> TrustStatus { + match read_entry(workspace) { + Some(entry) if entry.excluded => TrustStatus::Excluded, + Some(entry) => { + let current = compute_workspace_hash(workspace); + if entry.hash == current { + TrustStatus::Trusted + } else { + TrustStatus::Stale } } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => (), - Err(err) => log::error!("workspace file couldn't be read: {err:?}"), + None => TrustStatus::Untrusted, + } +} + +// ---------- on-disk format ---------- +// +// `data_dir()/workspace_trust/` is a small key-value file. The +// filename derives from the path, so concurrent helix instances writing trust for *different* +// workspaces never touch the same file. The original path is stored inside as a sanity check and +// for `cat workspace_trust/*` debugging. + +struct DiskEntry { + hash: Option, + excluded: bool, +} + +fn workspace_trust_dir() -> PathBuf { + data_dir().join("workspace_trust") +} + +fn entry_path(workspace: &Path) -> PathBuf { + workspace_trust_dir().join(path_filename(workspace)) +} + +fn path_filename(workspace: &Path) -> String { + let mut hasher = Sha256::new(); + // `Path` is OsStr; encode lossy-but-deterministically. Two workspaces whose normalized paths + // differ only in non-UTF-8 bytes will collide here, but find_workspace() returns paths derived + // from CWD walking and that's already lossy on the FS side — accepting parity. + hasher.update(workspace.as_os_str().to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + format!("{digest:x}") +} + +fn read_entry(workspace: &Path) -> Option { + let path = entry_path(workspace); + let contents = match fs::read_to_string(&path) { + Ok(s) => s, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => { + log::error!("workspace trust file {path:?} unreadable: {err:?}"); + return None; + } }; - TrustStatus::Untrusted + + let mut stored_path: Option = None; + let mut hash: Option = None; + let mut excluded = false; + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + let k = k.trim(); + let v = v.trim(); + match k { + "path" => stored_path = Some(v.to_string()), + "hash" => hash = Some(v.to_string()), + "excluded" => excluded = v == "true", + _ => {} + } + } + + // Sanity check that we didn't hit a path collision: the path inside the file should match the + // workspace we looked up. + if let Some(stored) = stored_path { + if Path::new(&stored) != workspace { + log::error!( + "workspace trust file {path:?} contains path {stored:?}, expected {workspace:?}" + ); + return None; + } + } + + Some(DiskEntry { hash, excluded }) } -pub fn quick_query_workspace_with_explicit_untrust(insecure: bool) -> TrustUntrustStatus { - if insecure { - return TrustUntrustStatus::AllowAlways; +fn write_entry(workspace: &Path, entry: &DiskEntry) { + let dir = workspace_trust_dir(); + if let Err(err) = fs::create_dir_all(&dir) { + log::error!("Couldn't create workspace trust dir {dir:?}: {err:?}"); + return; } + let path = entry_path(workspace); - let workspace = crate::find_workspace().0; - match fs::read_to_string(workspace_trust_file()) { - Ok(workspace_trust_file) => { - for line in workspace_trust_file.split('\n') { - if Path::new(line) == workspace { - return TrustUntrustStatus::AllowAlways; - } + let mut contents = String::new(); + let _ = writeln!(contents, "path = {}", workspace.display()); + if let Some(hash) = &entry.hash { + let _ = writeln!(contents, "hash = {hash}"); + } + let _ = writeln!(contents, "excluded = {}", entry.excluded); + + if let Err(err) = fs::write(&path, contents) { + log::error!("Error writing workspace trust file {path:?}: {err:?}"); + } +} + +fn remove_entry(workspace: &Path) { + let path = entry_path(workspace); + match fs::remove_file(&path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => log::error!("Error removing workspace trust file {path:?}: {err:?}"), + } +} + +// ---------- hashing ---------- + +/// SHA-256 of all files under `.helix/`, used to detect changes to local config after trust was +/// granted. Returns `None` if `.helix/` is absent or has no files, so a workspace with no local +/// config can still be trusted. +pub fn compute_workspace_hash(workspace: &Path) -> Option { + let helix_dir = workspace.join(".helix"); + if !helix_dir.is_dir() { + return None; + } + + let mut files: Vec = Vec::new(); + walk(&helix_dir, &mut files); + if files.is_empty() { + return None; + } + files.sort(); + + let mut hasher = Sha256::new(); + for file in &files { + let rel = file.strip_prefix(&helix_dir).unwrap_or(file); + hash_field(&mut hasher, rel.to_string_lossy().as_bytes()); + match fs::read(file) { + Ok(bytes) => hash_field(&mut hasher, &bytes), + Err(err) => { + log::warn!("workspace hash: treating unreadable file {file:?} as empty: {err:?}"); + hash_field(&mut hasher, &[]); } } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => (), - Err(err) => log::error!("workspace_trust file couldn't be read: {err:?}"), - }; + } + let digest = hasher.finalize(); + Some(format!("sha256:{digest:x}")) +} - match fs::read_to_string(workspace_exclude_file()) { - Ok(workspace_untrust_file) => { - for line in workspace_untrust_file.split('\n') { - if Path::new(line) == workspace { - return TrustUntrustStatus::DenyAlways; +/// Length-prefix a field before feeding it to the hasher. +fn hash_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); +} + +fn walk(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(direct) = entry.file_type() else { + continue; + }; + if direct.is_dir() { + // Real directories only — don't follow symlinked directories to avoid filesystem + // cycles via path-loop symlinks. + walk(&path, out); + } else if direct.is_file() { + out.push(path); + } else if direct.is_symlink() { + // Symlinks to files (extremely common: dotfiles managers symlink `.helix/config.toml` + // to an external location). Follow once via `fs::metadata` which traverses links, then + // include the file in the hash so mutations to the *target* are still detected. + if let Ok(target) = fs::metadata(&path) { + if target.is_file() { + out.push(path); } } } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => (), - Err(err) => log::error!("workspace_untrust file couldn't be read: {err:?}"), - }; - TrustUntrustStatus::DenyOnce + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::io::Write; + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut f = fs::File::create(path).unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + } + + #[test] + fn hash_changes_when_helix_dir_changes() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + + let empty = compute_workspace_hash(workspace); + assert_eq!(empty, None, ".helix/ absent should hash to None"); + + write_file(&workspace.join(".helix").join("config.toml"), "a = 1"); + let h1 = compute_workspace_hash(workspace).expect("has files"); + assert!(h1.starts_with("sha256:")); + + // Same content → same hash + let h1b = compute_workspace_hash(workspace).expect("has files"); + assert_eq!(h1, h1b); + + // Different content → different hash + write_file(&workspace.join(".helix").join("config.toml"), "a = 2"); + let h2 = compute_workspace_hash(workspace).expect("has files"); + assert_ne!(h1, h2); + + // Added file → different hash + write_file(&workspace.join(".helix").join("languages.toml"), ""); + let h3 = compute_workspace_hash(workspace).expect("has files"); + assert_ne!(h2, h3); + } + + #[test] + fn sha256_known_vector() { + // Cross-check sha2 crate against a well-known SHA-256 of "abc". + let mut h = Sha256::new(); + h.update(b"abc"); + assert_eq!( + format!("{:x}", h.finalize()), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn workspace_restricted_hides_when_no_local_config() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + + let trust = WorkspaceTrust::new(Config::default()); + // Untrusted + no .helix/ → indicator should hide. + assert!(!trust.workspace_restricted(workspace)); + + // Untrusted + .helix/config.toml exists → indicator should show. + write_file(&workspace.join(".helix").join("config.toml"), "a = 1"); + // Bust the cache so the new file is picked up. + trust.untrust(workspace); + assert!(trust.workspace_restricted(workspace)); + + // After explicit trust, indicator hides. + trust.trust(workspace); + assert!(!trust.workspace_restricted(workspace)); + + // After mutation (and revoking the cached entry to simulate fresh load), workspace becomes + // Stale → indicator should show again. + write_file(&workspace.join(".helix").join("config.toml"), "a = 2"); + trust.inner.lock().remove(workspace); + assert!(trust.workspace_restricted(workspace)); + } + + #[test] + fn set_config_invalidates_cache_so_stale_is_detected() { + // Regression: prior to this fix the editor's WorkspaceTrust cache held a `Trusted` entry + // after `:workspace-trust` was run. If the user (or another process) modified `.helix/` + // while helix was running, subsequent queries kept returning Trusted even though + // `Config::load_default`'s transient WorkspaceTrust would correctly see Stale. Reloading + // config now clears the cache so the editor sees the new state on the next query. + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + write_file(&workspace.join(".helix").join("config.toml"), "a = 1"); + + let mut trust = WorkspaceTrust::new(Config::default()); + trust.trust(workspace); + assert_eq!(trust.status(workspace), TrustStatus::Trusted); + + // External mutation while helix is running. + write_file(&workspace.join(".helix").join("config.toml"), "a = 2"); + + // Without a refresh, the cache still says Trusted. + assert_eq!(trust.status(workspace), TrustStatus::Trusted); + + // Simulate `:config-reload`. + trust.set_config(Config::default()); + + // Fresh disk read now detects the hash mismatch. + assert_eq!(trust.status(workspace), TrustStatus::Stale); + } + + #[cfg(unix)] + #[test] + fn hash_includes_symlinked_config_file() { + // Regression: `.helix/config.toml` is commonly a symlink to an external location (dotfiles + // managers). The old `walk` only matched `is_dir() | is_file()`, so symlinks fell through + // and were excluded from the hash entirely — trust was granted with an effectively empty + // hash and config mutations went undetected. + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + let external = dir.path().join("external_config.toml"); + write_file(&external, "a = 1"); + + fs::create_dir_all(workspace.join(".helix")).unwrap(); + symlink(&external, workspace.join(".helix").join("config.toml")).unwrap(); + + let h1 = compute_workspace_hash(workspace).expect("symlink should be hashed"); + + // Mutating the symlink *target* must change the hash. + write_file(&external, "a = 2"); + let h2 = compute_workspace_hash(workspace).expect("symlink should be hashed"); + assert_ne!( + h1, h2, + "symlinked config file mutations must affect the hash" + ); + } + + #[test] + fn hash_distinguishes_file_split() { + // Regression: without length-prefixing, two files + // foo.toml: "a" + // bar.toml: "b" + // would feed the same byte stream to the hasher as a single file + // foo.toml: "a\0bar.toml\0b" + // because the \0 separators were indistinguishable from content. + let dir1 = tempfile::tempdir().unwrap(); + let split = dir1.path(); + write_file(&split.join(".helix").join("foo.toml"), "a"); + write_file(&split.join(".helix").join("bar.toml"), "b"); + + let dir2 = tempfile::tempdir().unwrap(); + let merged = dir2.path(); + write_file(&merged.join(".helix").join("foo.toml"), "a\0bar.toml\0b"); + + let h1 = compute_workspace_hash(split).expect("split has files"); + let h2 = compute_workspace_hash(merged).expect("merged has files"); + assert_ne!( + h1, h2, + "length-prefixing must prevent file-vs-content ambiguity" + ); + } + + #[test] + fn level_insecure_does_not_bypass_excluded() { + // Regression: under `level = "insecure"` an exclude entry on disk must still produce + // `TrustStatus::Excluded` from `query()`. The old shortcut read only the in-memory cache, + // so a cold cache silently returned `Trusted`. + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + + // Seed an excluded entry via a level=None trust state. + let bootstrap = WorkspaceTrust::new(Config::default()); + bootstrap.exclude(workspace); + + // Fresh state (cold cache) under level=Insecure. + let trust = WorkspaceTrust::new(Config { + level: ImplicitTrustLevel::Insecure, + ..Config::default() + }); + assert_eq!( + trust.query(workspace, TrustQuery::Lsp), + TrustStatus::Excluded, + "level=insecure must honor disk-persisted excludes even on a cold cache" + ); + assert_eq!( + trust.query(workspace, TrustQuery::LocalConfig), + TrustStatus::Excluded + ); + } + + #[test] + fn trusted_glob_grants_full_trust_but_exclude_wins() { + let dir = tempfile::tempdir().unwrap(); + let trusted = dir.path().join("trusted_proj"); + fs::create_dir_all(&trusted).unwrap(); + + let pattern = format!("{}/*", dir.path().display()); + let trust = WorkspaceTrust::new(Config { + level: ImplicitTrustLevel::None, + trusted_globs: build_trusted_globs(&[pattern]), + ..Config::default() + }); + + // Matching workspace is fully trusted for every capability, even local config (which + // `level = "servers"` would still gate). + assert_eq!( + trust.query(&trusted, TrustQuery::LocalConfig), + TrustStatus::Trusted + ); + assert_eq!(trust.query(&trusted, TrustQuery::Git), TrustStatus::Trusted); + assert!(!trust.workspace_restricted(&trusted)); + + // A non-matching path (no `dir/*` segment match: it has a deeper component) still trusts + // since `other` is directly under `dir`. Use a sibling outside `dir` to confirm no match. + let outside = tempfile::tempdir().unwrap(); + assert_eq!( + trust.query(outside.path(), TrustQuery::LocalConfig), + TrustStatus::Untrusted + ); + + // Explicit excludes beat a matching glob. + trust.exclude(&trusted); + assert_eq!( + trust.query(&trusted, TrustQuery::LocalConfig), + TrustStatus::Excluded + ); + assert_eq!( + trust.query(&trusted, TrustQuery::Lsp), + TrustStatus::Excluded + ); + } + + #[test] + fn workspace_restricted_detects_stale() { + // Regression: a previously-trusted workspace whose .helix/ has since been modified must be + // reported as restricted (so the indicator and stale-hint can fire). The old code piped + // Stale through `demote_for_query` and matched Untrusted, which made the `Stale` arm of + // `workspace_restricted` unreachable. + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path(); + write_file(&workspace.join(".helix").join("config.toml"), "a = 1"); + + let trust = WorkspaceTrust::new(Config::default()); + trust.trust(workspace); + assert!(!trust.workspace_restricted(workspace)); + assert_eq!(trust.status(workspace), TrustStatus::Trusted); + + // Mutate `.helix/` and bust the in-memory cache to force a re-read. + write_file(&workspace.join(".helix").join("config.toml"), "a = 2"); + trust.inner.lock().remove(workspace); + + assert_eq!(trust.status(workspace), TrustStatus::Stale); + assert!( + trust.workspace_restricted(workspace), + "Stale workspace must light up the restricted indicator" + ); + } + + #[test] + fn path_filename_is_stable_and_path_specific() { + let a = path_filename(Path::new("/home/u/proj1")); + let b = path_filename(Path::new("/home/u/proj1")); + let c = path_filename(Path::new("/home/u/proj2")); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(a.len(), 64); // hex sha256 + } } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 3542ad47..c1795a5a 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -91,7 +91,12 @@ fn setup_integration_logging() { } impl Application { - pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result { + pub fn new( + args: Args, + config: Config, + lang_loader: syntax::Loader, + workspace_trust: helix_loader::workspace_trust::WorkspaceTrust, + ) -> Result { #[cfg(feature = "integration")] setup_integration_logging(); @@ -124,6 +129,7 @@ impl Application { &config.editor })), handlers, + workspace_trust, ); Self::load_configured_theme(&mut editor, &config.load(), &mut terminal, theme_mode); @@ -409,10 +415,15 @@ impl Application { let default_config = Config::load_default() .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; + // Apply any change to editor.workspace_trust before reading local language config. + self.editor + .workspace_trust + .set_config((&default_config.editor.workspace_trust).into()); + // Update the syntax language loader before setting the theme. Setting the theme will // call `Loader::set_scopes` which must be done before the documents are re-parsed for // the sake of locals highlighting. - let lang_loader = helix_core::config::user_lang_loader(default_config.editor.insecure)?; + let lang_loader = helix_core::config::user_lang_loader(&self.editor.workspace_trust)?; self.editor.syn_loader.store(Arc::new(lang_loader)); Self::load_configured_theme( &mut self.editor, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 93f57a83..5d63cdd3 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3560,10 +3560,18 @@ fn changed_file_picker(cx: &mut Context) { .with_preview(|_editor, meta| Some((meta.path().into(), None))); let injector = picker.injector(); + let trust_full = cx + .editor + .workspace_trust + .query( + &helix_loader::find_workspace_in(&cwd).0, + helix_loader::workspace_trust::TrustQuery::Git, + ) + .is_trusted(); cx.editor .diff_providers .clone() - .for_each_changed_file(cwd, move |change| match change { + .for_each_changed_file(cwd, trust_full, move |change| match change { Ok(change) => injector.push(change).is_ok(), Err(err) => { status::report_blocking(err); diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 08cec7fc..f5b3c7fd 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -119,6 +119,17 @@ pub fn dap_start_impl( socket: Option, params: Option>>, ) -> Result<(), anyhow::Error> { + // Refuse to spawn a debug adapter in workspace trust restricted mode. + let workspace = doc!(cx.editor).workspace_root().to_path_buf(); + if !cx + .editor + .workspace_trust + .query(&workspace, helix_loader::workspace_trust::TrustQuery::Dap) + .is_trusted() + { + bail!("Workspace is not trusted. Run `:workspace-trust` to enable the debug adapter."); + } + let doc = doc!(cx.editor); let config = doc .language_config() diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 18777c38..e393bc08 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1507,10 +1507,12 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh } let scrolloff = cx.editor.config().scrolloff; + let trust_full = doc_trust_full(cx.editor); let (view, doc) = current!(cx.editor); - doc.reload(view, &cx.editor.diff_providers).map(|_| { - view.ensure_cursor_in_view(doc, scrolloff); - })?; + doc.reload(view, &cx.editor.diff_providers, trust_full) + .map(|_| { + view.ensure_cursor_in_view(doc, scrolloff); + })?; if let Some(path) = doc.path().map(ToOwned::to_owned) { cx.editor .language_servers @@ -1552,7 +1554,16 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> // Ensure that the view is synced with the document's history. view.sync_changes(doc); - if let Err(error) = doc.reload(view, &cx.editor.diff_providers) { + // Per-document trust: each doc's workspace may differ. + let trust_full = cx + .editor + .workspace_trust + .query( + doc.workspace_root(), + helix_loader::workspace_trust::TrustQuery::Git, + ) + .is_trusted(); + if let Err(error) = doc.reload(view, &cx.editor.diff_providers, trust_full) { cx.editor.set_error(format!("{}", error)); continue; } @@ -3986,7 +3997,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "workspace-trust", aliases: &[], - doc: "Add current workspace to the list of trusted workspaces.", + doc: "Allow language servers and local config for the current workspace.", fun: trust_workspace, completer: CommandCompleter::none(), signature: Signature { positionals: (0, None), ..Signature::DEFAULT }, @@ -3994,10 +4005,18 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "workspace-untrust", aliases: &[], - doc: "Remove current workspace from the list of trusted workspaces.", + doc: "Revoke the current workspace's trust grant or exclusion.", fun: untrust_workspace, completer: CommandCompleter::none(), signature: Signature { positionals: (0, None), ..Signature::DEFAULT }, + }, + TypableCommand { + name: "workspace-exclude", + aliases: &[], + doc: "Mark the current workspace as never-prompt. Never prompts for trust again.", + fun: exclude_workspace, + completer: CommandCompleter::none(), + signature: Signature { positionals: (0, None), ..Signature::DEFAULT }, } ]; @@ -4429,6 +4448,24 @@ fn complete_expansion_kind(content: &str, offset: usize) -> Vec std::path::PathBuf { + let (_, doc) = current_ref!(cx.editor); + doc.workspace_root().to_path_buf() +} + +/// Whether the currently focused document's workspace is trusted for git operations (gix +/// `Trust::Full`). +fn doc_trust_full(editor: &helix_view::Editor) -> bool { + let (_, doc) = current_ref!(editor); + editor + .workspace_trust + .query( + doc.workspace_root(), + helix_loader::workspace_trust::TrustQuery::Git, + ) + .is_trusted() +} + fn trust_workspace( cx: &mut compositor::Context, args: Args<'_>, @@ -4438,15 +4475,34 @@ fn trust_workspace( return Ok(()); } - helix_loader::workspace_trust::WorkspaceTrust::load(false).trust_workspace(); + let workspace = current_workspace(cx); + cx.editor.workspace_trust.trust(&workspace); cx.editor.config_events.0.send(ConfigEvent::Refresh)?; - // HACK + // Restart any LSPs that didn't start because trust was missing. lsp_restart(cx, args, event) } fn untrust_workspace( - _cx: &mut compositor::Context, + cx: &mut compositor::Context, + _args: Args<'_>, + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let workspace = current_workspace(cx); + cx.editor.workspace_trust.untrust(&workspace); + // Drop any workspace overrides that were merged into the live editor config while trust was + // granted. Running LSPs are not stopped here (use `:lsp-stop` for that); this only handles + // in-memory config. + cx.editor.config_events.0.send(ConfigEvent::Refresh)?; + Ok(()) +} + +fn exclude_workspace( + cx: &mut compositor::Context, _args: Args<'_>, event: PromptEvent, ) -> anyhow::Result<()> { @@ -4454,6 +4510,8 @@ fn untrust_workspace( return Ok(()); } - helix_loader::workspace_trust::WorkspaceTrust::load(false).untrust_workspace(); + let workspace = current_workspace(cx); + cx.editor.workspace_trust.exclude(&workspace); + cx.editor.config_events.0.send(ConfigEvent::Refresh)?; Ok(()) } diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 09edbfaf..3e61b801 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -125,10 +125,26 @@ impl Config { let phony_config = ConfigLoadError::Error(IOError::other("hacky placeholder")); let global_parsed = Config::load(Ok(&global_config), Err(phony_config))?; - if let helix_loader::workspace_trust::TrustStatus::Trusted = - helix_loader::workspace_trust::quick_query_workspace(global_parsed.editor.insecure) + + // We need to build a transient `WorkspaceTrust` just to ask whether the workspace is + // trusted enough to load its `.helix/config.toml`. The persisted-trust file on disk is the + // source of truth either way; this transient instance has an empty cache and is dropped + // after the check. + let trust = helix_loader::workspace_trust::WorkspaceTrust::new( + (&global_parsed.editor.workspace_trust).into(), + ); + if trust + .query_current(helix_loader::workspace_trust::TrustQuery::LocalConfig) + .is_trusted() { - Config::load(Ok(&global_config), local_config) + let mut merged = Config::load(Ok(&global_config), local_config)?; + // editor.workspace-trust is global/user-scope only. Without this override, a + // workspace's `.helix/config.toml` could set `level = "insecure"`; once the user trusted + // *that* workspace, refresh_config would re-load with the override merged in and from + // then on every subsequent workspace in the session would be implicitly trusted. Pin + // the gate's own configuration to the global file. + merged.editor.workspace_trust = global_parsed.editor.workspace_trust; + Ok(merged) } else { Ok(global_parsed) } diff --git a/helix-term/src/handlers/workspace_trust.rs b/helix-term/src/handlers/workspace_trust.rs index 68e9d8bd..b0bcdc59 100644 --- a/helix-term/src/handlers/workspace_trust.rs +++ b/helix-term/src/handlers/workspace_trust.rs @@ -1,99 +1,134 @@ -use std::{collections::HashSet, path::PathBuf}; +use std::{ + collections::HashSet, + path::PathBuf, + sync::{Arc, Mutex}, +}; use helix_event::register_hook; -use helix_loader::workspace_trust::{ - quick_query_workspace_with_explicit_untrust, TrustUntrustStatus, WorkspaceTrust, -}; +use helix_loader::workspace_trust::TrustStatus; use helix_view::{events::DocumentDidOpen, handlers::Handlers, DocumentId}; -use once_cell::sync::Lazy; -use parking_lot::Mutex; use crate::{compositor::Compositor, job, ui}; const ID: &str = "workspace-trust-select"; -/// A set of canonicalized workspace paths which have been prompted for trust at runtime. -static PROMPTED_WORKSPACES: Lazy>> = - Lazy::new(|| Mutex::new(HashSet::new())); - pub(super) fn register_hooks(_handlers: &Handlers) { + // Tracks which workspaces have already been prompted (or auto-dismissed) during this session. + // Without this, every document opened in an untrusted workspace would re-dispatch the modal — + // `deny_once` writes `Untrusted` to the trust cache but `restricted_for_doc` returns `true` + // based purely on workspace state, so the cache alone is not enough to suppress re-prompts. + // Inserts return false when the path was already present, which we use to short-circuit. + let prompted: Arc>> = Arc::default(); register_hook!(move |event: &mut DocumentDidOpen<'_>| { - let doc = doc!(event.editor, &event.doc); - - // If there is no servers to be loaded, then the workspace might not be trusted yet - if doc.language_servers().next().is_none() { - if let TrustUntrustStatus::DenyOnce = - quick_query_workspace_with_explicit_untrust(event.editor.config().insecure) - { - let (workspace, _) = helix_loader::find_workspace(); - job::dispatch_blocking(|_editor, compositor| prompt(workspace, compositor)); - } + let doc_id = event.doc; + + let (workspace, servers_to_load) = { + let doc = doc!(event.editor, &doc_id); + let servers_to_load = doc + .language_config() + .map(|lang| !lang.language_servers.is_empty() || lang.debugger.is_some()) + .unwrap_or(false); + (doc.workspace_root().to_path_buf(), servers_to_load) + }; + + // Stale: `.helix/` was edited since the user last ran `trust`. LSPs keep + // running (binaries are unchanged), but local config is dropped. + // Note: must use `status` (raw) not `query`: `query` collapses Stale to Untrusted via `demote_for_query`. + if event.editor.workspace_trust.status(&workspace) == TrustStatus::Stale { + event.editor.set_status( + "Workspace `.helix/` config changed since `:workspace-trust`. \ + Local config not loaded. Run `:workspace-trust` to re-allow.", + ); + return Ok(()); } + + if !event + .editor + .workspace_trust + .restricted_for_doc(&workspace, servers_to_load) + { + return Ok(()); + } + + // Users who opt out of the modal still get the statusline `[⚠]` indicator and can act + // explicitly via `:workspace-trust`. + if !event.editor.workspace_trust.prompts_enabled() { + return Ok(()); + } + + // First time we've seen this workspace this session — prompt once. + if !prompted.lock().unwrap().insert(workspace.clone()) { + return Ok(()); + } + + // Cache "denied for this session" so future trust queries treat the workspace as untrusted. + event.editor.workspace_trust.deny_once(&workspace); + let workspace = workspace.clone(); + job::dispatch_blocking(move |_editor, compositor| prompt(workspace, compositor)); + Ok(()) }); } -pub fn prompt(path: PathBuf, compositor: &mut Compositor) { - let mut workspaces = PROMPTED_WORKSPACES.lock(); - if workspaces.contains(&path) { - return; - } else { - workspaces.insert(path.clone()); - } - let select = select(); +fn prompt(workspace: PathBuf, compositor: &mut Compositor) { + let select = select(workspace); compositor.replace_or_push(ID, select); } const TRUST_MESSAGE: &str = "Trust this workspace? -Trusted workspaces may load local config files and auto-start language servers. Config and language servers can execute arbitrary code. Only trust workspaces which you know contain harmless config and code."; +Trusted workspaces may load local Helix config files (`.helix/*`) and auto-start language servers. \ +Both can execute arbitrary code. Only trust workspaces whose contents you have inspected."; + +#[derive(Default, Clone, Copy, Debug)] +pub enum TrustChoice { + #[default] + Trust, + Never, +} -fn select() -> ui::Select { +fn select(workspace: PathBuf) -> ui::Select { ui::Select::new( TRUST_MESSAGE, - [ - TrustUntrustStatus::DenyOnce, - TrustUntrustStatus::DenyAlways, - TrustUntrustStatus::AllowAlways, - ], + [TrustChoice::Trust, TrustChoice::Never], (), move |editor, option, event| { - if event == ui::PromptEvent::Validate { - let mut trust = WorkspaceTrust::load(true); - match option { - TrustUntrustStatus::DenyAlways => { - trust.exclude_workspace(); - } - TrustUntrustStatus::DenyOnce => { - // Do nothing - } - TrustUntrustStatus::AllowAlways => { - trust.trust_workspace(); - - let documents: Vec = editor.documents.keys().cloned().collect(); - for document_id in documents.iter() { - editor.launch_language_servers(*document_id); - } - - let _ = editor - .config_events - .0 - .send(helix_view::editor::ConfigEvent::Refresh); + if event != ui::PromptEvent::Validate { + return; + } + match option { + TrustChoice::Trust => { + editor.workspace_trust.trust(&workspace); + let documents: Vec = editor.documents.keys().cloned().collect(); + for document_id in documents.iter() { + editor.launch_language_servers(*document_id); } + let _ = editor + .config_events + .0 + .send(helix_view::editor::ConfigEvent::Refresh); + } + TrustChoice::Never => { + editor.workspace_trust.exclude(&workspace); + // Drop any workspace overrides that snuck into the live editor config before + // the user excluded the workspace. + let _ = editor + .config_events + .0 + .send(helix_view::editor::ConfigEvent::Refresh); } } }, ) } -impl crate::ui::menu::Item for TrustUntrustStatus { +impl crate::ui::menu::Item for TrustChoice { type Data = (); fn format(&self, _data: &Self::Data) -> tui::widgets::Row<'_> { match self { - TrustUntrustStatus::DenyAlways => "Never", - TrustUntrustStatus::DenyOnce => "Not now", - TrustUntrustStatus::AllowAlways => "Always", + TrustChoice::Trust => "Trust", + TrustChoice::Never => "Never", } .into() } diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index c73df787..73b3cd12 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -163,7 +163,8 @@ fn languages(selection: Option>) -> std::io::Result<()> { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); - let mut syn_loader_conf = match user_lang_config(false) { + let trust = helix_loader::workspace_trust::WorkspaceTrust::fully_trusted(); + let mut syn_loader_conf = match user_lang_config(&trust) { Ok(conf) => conf, Err(err) => { let stderr = std::io::stderr(); @@ -283,7 +284,8 @@ pub fn language(lang_str: String) -> std::io::Result<()> { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); - let syn_loader_conf = match user_lang_config(false) { + let trust = helix_loader::workspace_trust::WorkspaceTrust::fully_trusted(); + let syn_loader_conf = match user_lang_config(&trust) { Ok(conf) => conf, Err(err) => { let stderr = std::io::stderr(); diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 87768da1..bd3782ca 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -130,8 +130,11 @@ FLAGS: } }; + let workspace_trust = + helix_loader::workspace_trust::WorkspaceTrust::new((&config.editor.workspace_trust).into()); + let lang_loader = - helix_core::config::user_lang_loader(config.editor.insecure).unwrap_or_else(|err| { + helix_core::config::user_lang_loader(&workspace_trust).unwrap_or_else(|err| { eprintln!("{}", err); eprintln!("Press to continue with default language config"); use std::io::Read; @@ -141,7 +144,8 @@ FLAGS: }); // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args, config, lang_loader).context("unable to start Helix")?; + let mut app = Application::new(args, config, lang_loader, workspace_trust) + .context("unable to start Helix")?; let mut events = app.event_stream(); let exit_code = app.run(&mut events).await?; diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 0d7eba18..37869218 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1164,6 +1164,20 @@ impl EditorView { } } +/// Whether the focused doc's workspace is in restricted mode and running `trust` would +/// change something visible at the workspace level. +fn workspace_trust_indicator_visible(editor: &Editor) -> bool { + if editor.workspace_trust.implicit_level() + == helix_loader::workspace_trust::ImplicitTrustLevel::Insecure + { + return false; + } + let (_, doc) = helix_view::current_ref!(editor); + editor + .workspace_trust + .workspace_restricted(doc.workspace_root()) +} + impl EditorView { /// must be called whenever the editor processed input that /// is not a `KeyEvent`. In these cases any pending keys/on next @@ -1676,13 +1690,30 @@ impl Component for EditorView { } else { 0 }; + let restricted = workspace_trust_indicator_visible(cx.editor); + let trust_width = if restricted { 3 } else { 0 }; surface.set_string( - area.x + area.width.saturating_sub(key_width + macro_width), + area.x + + area + .width + .saturating_sub(key_width + macro_width + trust_width), area.y + area.height.saturating_sub(1), disp.get(disp.len().saturating_sub(key_width as usize)..) .unwrap_or(&disp), style, ); + if restricted { + let style = style + .fg(helix_view::graphics::Color::Yellow) + .add_modifier(Modifier::BOLD); + surface.set_string( + area.x + .saturating_add(area.width.saturating_sub(3 + macro_width)), + area.y + area.height.saturating_sub(1), + "[⚠]", + style, + ); + } if let Some((reg, _)) = cx.editor.macro_recording { let disp = format!("[{}]", reg); let style = style diff --git a/helix-term/tests/test/helpers.rs b/helix-term/tests/test/helpers.rs index b0e61a93..7a3251cb 100644 --- a/helix-term/tests/test/helpers.rs +++ b/helix-term/tests/test/helpers.rs @@ -7,10 +7,11 @@ use std::{ use anyhow::bail; use helix_core::{diagnostic::Severity, test, Selection, Transaction}; +use helix_loader::workspace_trust::WorkspaceTrust; use helix_term::{application::Application, args::Args, config::Config, keymap::merge_keys}; use helix_view::{ current_ref, doc, - editor::{LspConfig, WordCompletion}, + editor::{ImplicitTrustLevelConfig, LspConfig, WordCompletion, WorkspaceTrustConfig}, input::parse_macro, Editor, }; @@ -202,7 +203,12 @@ pub async fn test_key_sequence_with_input_text>( let mut app = match app { Some(app) => app, - None => Application::new(Args::default(), test_config(), test_syntax_loader(None))?, + None => Application::new( + Args::default(), + test_config(), + test_syntax_loader(None), + WorkspaceTrust::fully_trusted(), + )?, }; let (view, doc) = helix_view::current!(app.editor); @@ -311,7 +317,11 @@ pub fn test_editor_config() -> helix_view::editor::Config { enable: false, ..Default::default() }, - insecure: true, + // Trust everything implicitly so tests don't hit popups. + workspace_trust: WorkspaceTrustConfig { + level: ImplicitTrustLevelConfig::Insecure, + ..Default::default() + }, ..Default::default() } } @@ -402,7 +412,12 @@ impl AppBuilder { bail!("Having the directory {path:?} in args.files[0] is not yet supported for integration tests"); } - let mut app = Application::new(self.args, self.config, self.syn_loader)?; + let mut app = Application::new( + self.args, + self.config, + self.syn_loader, + WorkspaceTrust::fully_trusted(), + )?; if let Some((text, selection)) = self.input { let (view, doc) = helix_view::current!(app.editor); diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs index 9cb010e6..133b77da 100644 --- a/helix-vcs/src/git.rs +++ b/helix-vcs/src/git.rs @@ -27,7 +27,7 @@ fn get_repo_dir(file: &Path) -> Result<&Path> { file.parent().context("file has no parent directory") } -pub fn get_diff_base(file: &Path) -> Result> { +pub fn get_diff_base(file: &Path, trust_full: bool) -> Result> { debug_assert!(!file.exists() || file.is_file()); debug_assert!(file.is_absolute()); let file = gix::path::realpath(file).context("resolve symlinks")?; @@ -35,7 +35,7 @@ pub fn get_diff_base(file: &Path) -> Result> { // TODO cache repository lookup let repo_dir = get_repo_dir(&file)?; - let repo = open_repo(repo_dir) + let repo = open_repo(repo_dir, trust_full) .context("failed to open git repo")? .to_thread_local(); let head = repo.head_commit()?; @@ -45,6 +45,12 @@ pub fn get_diff_base(file: &Path) -> Result> { let data = file_object.detach().data; // Get the actual data that git would make out of the git object. // This will apply the user's git config or attributes like crlf conversions. + // + // The whole filter pipeline still runs in untrusted (`Trust::Reduced`) mode so built-in + // conversions like autocrlf keep working, but gix drops `filter.*.clean` / `filter.*.smudge` + // drivers defined in untrusted (repository-local) config, so those external programs are not + // executed unless the workspace was explicitly trusted. This relies on `open_repo` forcing the + // trust level instead of letting gix re-derive it from `.git` ownership; see the note there. if let Some(work_dir) = repo.workdir() { let rela_path = file.strip_prefix(work_dir)?; let rela_path = gix::path::try_into_bstr(rela_path)?; @@ -59,13 +65,13 @@ pub fn get_diff_base(file: &Path) -> Result> { } } -pub fn get_current_head_name(file: &Path) -> Result>>> { +pub fn get_current_head_name(file: &Path, trust_full: bool) -> Result>>> { debug_assert!(!file.exists() || file.is_file()); debug_assert!(file.is_absolute()); let file = gix::path::realpath(file).context("resolve symlinks")?; let repo_dir = get_repo_dir(&file)?; - let repo = open_repo(repo_dir) + let repo = open_repo(repo_dir, trust_full) .context("failed to open git repo")? .to_thread_local(); let head_ref = repo.head_ref()?; @@ -79,18 +85,33 @@ pub fn get_current_head_name(file: &Path) -> Result>>> { Ok(Arc::new(ArcSwap::from_pointee(name.into_boxed_str()))) } -pub fn for_each_changed_file(cwd: &Path, f: impl Fn(Result) -> bool) -> Result<()> { - status(&open_repo(cwd)?.to_thread_local(), f) +pub fn for_each_changed_file( + cwd: &Path, + trust_full: bool, + f: impl Fn(Result) -> bool, +) -> Result<()> { + status(&open_repo(cwd, trust_full)?.to_thread_local(), f) } -fn open_repo(path: &Path) -> Result { - // custom open options - let mut git_open_opts_map = gix::sec::trust::Mapping::::default(); +fn open_repo(path: &Path, trust_full: bool) -> Result { + // `trust_full` is the workspace-trust decision made by the caller, and it must be the + // authority on the gix trust level. gix's own discovery (`discover_*`) ignores a + // caller-supplied trust level: it always re-derives trust from `.git` ownership, so a malicious + // `.git/config` in a user-owned directory would be opened as `Trust::Full` regardless of our + // gate. Worse, the GIT_DIR-environment branch of that discovery panics because it never sets a + // trust level at all. So we split discovery from opening: find the repository path ourselves, + // then `open_opts(..).with(trust)`, which forces the trust level and skips gix's ownership + // check. Under `Trust::Reduced`, gix then refuses to honor untrusted repository-local config + // such as `filter.*` smudge/clean drivers. + + let trust = if trust_full { + gix::sec::Trust::Full + } else { + gix::sec::Trust::Reduced + }; - // On windows various configuration options are bundled as part of the installations - // This path depends on the install location of git and therefore requires some overhead to lookup - // This is basically only used on windows and has some overhead hence it's disabled on other platforms. - // `gitoxide` doesn't use this as default + // On Windows various configuration options are bundled as part of the git installation. The + // lookup is expensive; only do it there. let config = gix::open::permissions::Config { system: true, git: true, @@ -99,30 +120,28 @@ fn open_repo(path: &Path) -> Result { includes: true, git_binary: cfg!(windows), }; - // change options for config permissions without touching anything else - git_open_opts_map.reduced = git_open_opts_map - .reduced - .permissions(gix::open::Permissions { - config, - ..gix::open::Permissions::default_for_level(gix::sec::Trust::Reduced) - }); - git_open_opts_map.full = git_open_opts_map.full.permissions(gix::open::Permissions { + + let permissions = gix::open::Permissions { config, - ..gix::open::Permissions::default_for_level(gix::sec::Trust::Full) - }); + ..gix::open::Permissions::default_for_level(trust) + }; - let open_options = gix::discover::upwards::Options { + let discover_options = gix::discover::upwards::Options { dot_git_only: true, ..Default::default() }; - - let res = ThreadSafeRepository::discover_with_environment_overrides_opts( - path, - open_options, - git_open_opts_map, - )?; - - Ok(res) + let (repo_path, _trust_from_ownership) = gix::discover::upwards_opts(path, discover_options) + .context("failed to discover git repo")?; + let (git_dir, _work_dir) = repo_path.into_repository_and_work_tree_directories(); + + let options = gix::open::Options::default() + .permissions(permissions) + // `git_dir` is the discovered `.git` directory (or a linked-worktree git dir), so open it + // as-is rather than letting gix append `.git` again. + .open_path_as_is(true) + .with(trust); + + Ok(ThreadSafeRepository::open_opts(git_dir, options)?) } /// Emulates the result of running `git status` from the command line. diff --git a/helix-vcs/src/git/test.rs b/helix-vcs/src/git/test.rs index 164040f5..fe941d2e 100644 --- a/helix-vcs/src/git/test.rs +++ b/helix-vcs/src/git/test.rs @@ -54,7 +54,7 @@ fn missing_file() { let file = temp_git.path().join("file.txt"); File::create(&file).unwrap().write_all(b"foo").unwrap(); - assert!(git::get_diff_base(&file).is_err()); + assert!(git::get_diff_base(&file, true).is_err()); } #[test] @@ -64,7 +64,10 @@ fn unmodified_file() { let contents = b"foo".as_slice(); File::create(&file).unwrap().write_all(contents).unwrap(); create_commit(temp_git.path(), true); - assert_eq!(git::get_diff_base(&file).unwrap(), Vec::from(contents)); + assert_eq!( + git::get_diff_base(&file, true).unwrap(), + Vec::from(contents) + ); } #[test] @@ -76,7 +79,10 @@ fn modified_file() { create_commit(temp_git.path(), true); File::create(&file).unwrap().write_all(b"bar").unwrap(); - assert_eq!(git::get_diff_base(&file).unwrap(), Vec::from(contents)); + assert_eq!( + git::get_diff_base(&file, true).unwrap(), + Vec::from(contents) + ); } /// Test that `get_file_head` does not return content for a directory. @@ -95,7 +101,7 @@ fn directory() { std::fs::remove_dir_all(&dir).unwrap(); File::create(&dir).unwrap().write_all(b"bar").unwrap(); - assert!(git::get_diff_base(&dir).is_err()); + assert!(git::get_diff_base(&dir, true).is_err()); } /// Test that `get_diff_base` resolves symlinks so that the same diff base is @@ -122,8 +128,8 @@ fn symlink() { symlink("file.txt", &file_link).unwrap(); create_commit(temp_git.path(), true); - assert_eq!(git::get_diff_base(&file_link).unwrap(), contents); - assert_eq!(git::get_diff_base(&file).unwrap(), contents); + assert_eq!(git::get_diff_base(&file_link, true).unwrap(), contents); + assert_eq!(git::get_diff_base(&file, true).unwrap(), contents); } /// Test that `get_diff_base` returns content when the file is a symlink to @@ -147,6 +153,6 @@ fn symlink_to_git_repo() { let file_link = temp_dir.path().join("file_link.txt"); symlink(&file, &file_link).unwrap(); - assert_eq!(git::get_diff_base(&file_link).unwrap(), contents); - assert_eq!(git::get_diff_base(&file).unwrap(), contents); + assert_eq!(git::get_diff_base(&file_link, true).unwrap(), contents); + assert_eq!(git::get_diff_base(&file, true).unwrap(), contents); } diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs index 4c5f2036..e337a6a8 100644 --- a/helix-vcs/src/lib.rs +++ b/helix-vcs/src/lib.rs @@ -30,10 +30,10 @@ pub struct DiffProviderRegistry { impl DiffProviderRegistry { /// Get the given file from the VCS. This provides the unedited document as a "base" /// for a diff to be created. - pub fn get_diff_base(&self, file: &Path) -> Option> { + pub fn get_diff_base(&self, file: &Path, trust_full: bool) -> Option> { self.providers .iter() - .find_map(|provider| match provider.get_diff_base(file) { + .find_map(|provider| match provider.get_diff_base(file, trust_full) { Ok(res) => Some(res), Err(err) => { log::debug!("{err:#?}"); @@ -44,17 +44,21 @@ impl DiffProviderRegistry { } /// Get the current name of the current [HEAD](https://stackoverflow.com/questions/2304087/what-is-head-in-git). - pub fn get_current_head_name(&self, file: &Path) -> Option>>> { - self.providers - .iter() - .find_map(|provider| match provider.get_current_head_name(file) { + pub fn get_current_head_name( + &self, + file: &Path, + trust_full: bool, + ) -> Option>>> { + self.providers.iter().find_map(|provider| { + match provider.get_current_head_name(file, trust_full) { Ok(res) => Some(res), Err(err) => { log::debug!("{err:#?}"); log::debug!("failed to obtain current head name for {}", file.display()); None } - }) + } + }) } /// Fire-and-forget changed file iteration. Runs everything in a background task. Keeps @@ -62,13 +66,14 @@ impl DiffProviderRegistry { pub fn for_each_changed_file( self, cwd: PathBuf, + trust_full: bool, f: impl Fn(Result) -> bool + Send + 'static, ) { tokio::task::spawn_blocking(move || { if self .providers .iter() - .find_map(|provider| provider.for_each_changed_file(&cwd, &f).ok()) + .find_map(|provider| provider.for_each_changed_file(&cwd, trust_full, &f).ok()) .is_none() { f(Err(anyhow!("no diff provider returns success"))); @@ -102,18 +107,22 @@ enum DiffProvider { } impl DiffProvider { - fn get_diff_base(&self, file: &Path) -> Result> { + fn get_diff_base(&self, file: &Path, trust_full: bool) -> Result> { match self { #[cfg(feature = "git")] - Self::Git => git::get_diff_base(file), + Self::Git => git::get_diff_base(file, trust_full), Self::None => bail!("No diff support compiled in"), } } - fn get_current_head_name(&self, file: &Path) -> Result>>> { + fn get_current_head_name( + &self, + file: &Path, + trust_full: bool, + ) -> Result>>> { match self { #[cfg(feature = "git")] - Self::Git => git::get_current_head_name(file), + Self::Git => git::get_current_head_name(file, trust_full), Self::None => bail!("No diff support compiled in"), } } @@ -121,11 +130,12 @@ impl DiffProvider { fn for_each_changed_file( &self, cwd: &Path, + trust_full: bool, f: impl Fn(Result) -> bool, ) -> Result<()> { match self { #[cfg(feature = "git")] - Self::Git => git::for_each_changed_file(cwd, f), + Self::Git => git::for_each_changed_file(cwd, trust_full, f), Self::None => bail!("No diff support compiled in"), } } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index e1813cd0..e5011d9f 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -159,6 +159,11 @@ pub struct Document { path: Option, relative_path: OnceCell>, + /// Lazily-computed workspace root for this document (the ancestor that contains a `.git` / + /// `.svn` / `.jj` / `.helix`). Avoids per-call `find_workspace_in` ancestor walks for hot + /// consumers like the statusline trust indicator, LSP launch, and DAP launch. Taken in + /// `set_path` so save-as recomputes. + workspace_root: OnceCell, encoding: &'static encoding::Encoding, has_bom: bool, @@ -726,6 +731,7 @@ impl Document { active_snippet: None, path: None, relative_path: OnceCell::new(), + workspace_root: OnceCell::new(), encoding, has_bom, text, @@ -1271,6 +1277,7 @@ impl Document { &mut self, view: &mut View, provider_registry: &DiffProviderRegistry, + trust_full: bool, ) -> Result<(), Error> { let encoding = self.encoding; let path = match self.path() { @@ -1297,12 +1304,12 @@ impl Document { self.pickup_last_saved_time(); self.detect_indent_and_line_ending(); - match provider_registry.get_diff_base(&path) { + match provider_registry.get_diff_base(&path, trust_full) { Some(diff_base) => self.set_diff_base(diff_base), None => self.diff_handle = None, } - self.version_control_head = provider_registry.get_current_head_name(&path); + self.version_control_head = provider_registry.get_current_head_name(&path, trust_full); Ok(()) } @@ -1331,6 +1338,8 @@ impl Document { // `take` to remove any prior relative path that may have existed. // This will get set in `relative_path()`. self.relative_path.take(); + // Same story: invalidate so the next workspace_root() recomputes against the new path. + self.workspace_root.take(); // if parent doesn't exist we still want to open the document // and error out when document is saved @@ -2093,6 +2102,20 @@ impl Document { .as_deref() } + /// The workspace root for this document — the nearest ancestor that contains a `.git`, `.svn`, + /// `.jj`, or `.helix`. Falls back to the current working directory's workspace when the + /// document has no path (scratch buffers). Lazily memoised on first call. + pub fn workspace_root(&self) -> &Path { + self.workspace_root + .get_or_init(|| match self.path.as_deref() { + Some(p) => p + .parent() + .map(|dir| helix_loader::find_workspace_in(dir).0) + .unwrap_or_else(|| helix_loader::find_workspace().0), + None => helix_loader::find_workspace().0, + }) + } + pub fn display_name(&self) -> Cow<'_, str> { self.relative_path() .map_or_else(|| SCRATCH_BUFFER_NAME.into(), |path| path.to_string_lossy()) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 6171cf4e..46fe099a 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -15,7 +15,7 @@ use crate::{ Document, DocumentId, View, ViewId, }; use helix_event::dispatch; -use helix_loader::workspace_trust::TrustStatus; +use helix_loader::workspace_trust::{ImplicitTrustLevel, TrustQuery, WorkspaceTrust}; use helix_vcs::DiffProviderRegistry; use futures_util::stream::select_all::SelectAll; @@ -432,8 +432,67 @@ pub struct Config { /// Whether to enable Kitty Keyboard Protocol pub kitty_keyboard_protocol: KittyKeyboardProtocolConfig, pub buffer_picker: BufferPickerConfig, - /// Whether to implicitly trust every workspace or not - pub insecure: bool, + /// Workspace-trust configuration. + pub workspace_trust: WorkspaceTrustConfig, +} + +/// User-facing configuration for `[editor.workspace-trust]`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct WorkspaceTrustConfig { + /// What to trust implicitly without an explicit grant. See [`ImplicitTrustLevelConfig`]. + pub level: ImplicitTrustLevelConfig, + /// Whether opening a file in an untrusted workspace surfaces the trust modal. The statusline + /// `[⚠]` indicator is always shown either way; disabling the prompt is for users who would + /// rather act explicitly via `:workspace-trust` than be interrupted. Defaults to `true`. + pub prompt: bool, + /// Glob patterns whose matching workspaces are implicitly trusted. + pub trusted: Vec, +} + +impl Default for WorkspaceTrustConfig { + fn default() -> Self { + Self { + level: ImplicitTrustLevelConfig::default(), + prompt: true, + trusted: Vec::new(), + } + } +} + +#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum ImplicitTrustLevelConfig { + /// Don't trust anything implicitly — prompt for every new workspace. + None, + /// Trust Helix-launched server processes (LSP and DAP) implicitly. Workspace-local config and + /// git full-trust still require explicit `:workspace-trust`. This is the default — language + /// servers are configured globally, so auto-starting them in fresh workspaces matches user + /// expectations while the workspace-controlled `.helix/` config still requires opt-in. + #[default] + Servers, + /// Trust everything implicitly. Explicit excludes still win. + Insecure, +} + +impl From for ImplicitTrustLevel { + fn from(v: ImplicitTrustLevelConfig) -> Self { + match v { + ImplicitTrustLevelConfig::None => ImplicitTrustLevel::None, + ImplicitTrustLevelConfig::Servers => ImplicitTrustLevel::Servers, + ImplicitTrustLevelConfig::Insecure => ImplicitTrustLevel::Insecure, + } + } +} + +impl From<&WorkspaceTrustConfig> for helix_loader::workspace_trust::Config { + fn from(v: &WorkspaceTrustConfig) -> Self { + Self { + level: v.level.into(), + prompt: v.prompt, + trusted_globs: helix_loader::workspace_trust::build_trusted_globs(&v.trusted), + } + } } #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)] @@ -1156,7 +1215,7 @@ impl Default for Config { rainbow_brackets: false, kitty_keyboard_protocol: Default::default(), buffer_picker: BufferPickerConfig::default(), - insecure: false, + workspace_trust: WorkspaceTrustConfig::default(), } } } @@ -1259,6 +1318,7 @@ pub struct Editor { pub mouse_down_range: Option, pub cursor_cache: CursorCache, + pub workspace_trust: WorkspaceTrust, } pub type Motion = Box; @@ -1332,6 +1392,7 @@ impl Editor { syn_loader: Arc>, config: Arc>, handlers: Handlers, + workspace_trust: WorkspaceTrust, ) -> Self { let language_servers = helix_lsp::Registry::new(syn_loader.clone()); let conf = config.load(); @@ -1382,6 +1443,7 @@ impl Editor { mouse_down_range: None, cursor_cache: CursorCache::default(), dir_stack: VecDeque::with_capacity(DIR_STACK_CAP), + workspace_trust, } } @@ -1742,14 +1804,11 @@ impl Editor { let config = doc.config.load(); let root_dirs = &config.workspace_lsp_roots; - if let TrustStatus::Untrusted = - helix_loader::workspace_trust::quick_query_workspace(self.config.load().insecure) - { - self.set_status( - "Current workspace is not trusted. Run `:workspace-trust` to enable all features.", - ); + let workspace = doc.workspace_root(); + let trust = self.workspace_trust.query(workspace, TrustQuery::Lsp); + if !trust.is_trusted() { return; - }; + } // store only successfully started language servers let language_servers = lang.as_ref().map_or_else(HashMap::default, |language| { @@ -2035,10 +2094,16 @@ impl Editor { Editor::doc_diagnostics(&self.language_servers, &self.diagnostics, &doc); doc.replace_diagnostics(diagnostics, &[], None); - if let Some(diff_base) = self.diff_providers.get_diff_base(&path) { + let trust_full = self + .workspace_trust + .query(doc.workspace_root(), TrustQuery::Git) + .is_trusted(); + if let Some(diff_base) = self.diff_providers.get_diff_base(&path, trust_full) { doc.set_diff_base(diff_base); } - doc.set_version_control_head(self.diff_providers.get_current_head_name(&path)); + doc.set_version_control_head( + self.diff_providers.get_current_head_name(&path, trust_full), + ); let id = self.new_document(doc); self.launch_language_servers(id);