diff --git a/crates/nexus-core/src/paths.rs b/crates/nexus-core/src/paths.rs index be6050c..40a8e8c 100644 --- a/crates/nexus-core/src/paths.rs +++ b/crates/nexus-core/src/paths.rs @@ -2,9 +2,9 @@ use directories::ProjectDirs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; -/// Resolved filesystem locations, honoring the `NEXUS_CACHE_DIR` env override -/// documented in the proposal (config lives at ~/.config, data at ~/.local/share -/// unless overridden). +/// Resolved filesystem locations, honoring the `NEXUS_CACHE_DIR` and +/// `NEXUS_CONFIG_DIR` env overrides documented in the proposal (config lives +/// at ~/.config, data at ~/.local/share unless overridden). pub struct Paths { pub config_dir: PathBuf, pub data_dir: PathBuf, @@ -20,7 +20,9 @@ impl Paths { let dirs = ProjectDirs::from("", "", "nexuscontext") .expect("could not determine a home directory for the current user"); - let config_dir = dirs.config_dir().to_path_buf(); + let config_dir = std::env::var_os("NEXUS_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| dirs.config_dir().to_path_buf()); let data_dir = std::env::var_os("NEXUS_CACHE_DIR") .map(PathBuf::from) .unwrap_or_else(|| dirs.data_dir().to_path_buf()); diff --git a/crates/nexus-index/tests/path_security.rs b/crates/nexus-index/tests/path_security.rs index 9789676..9c4b8ed 100644 --- a/crates/nexus-index/tests/path_security.rs +++ b/crates/nexus-index/tests/path_security.rs @@ -7,44 +7,41 @@ //! `call_graph_dot`, `run_query`/`run_cypher_query`). //! //! `nexus_core::Paths::resolve()` (what every one of these functions calls -//! internally) resolves its config directory from `$HOME` on Unix via the -//! `directories` crate - there's no injectable override, so these tests -//! redirect `HOME` to a scratch directory for the duration of each test and -//! write a `config.toml` there with a controlled `allowed_roots`. That env -//! var is process-global, so `ENV_LOCK` below serializes every test in this -//! binary against it (integration test binaries otherwise run their `#[test]` -//! functions on multiple threads). +//! internally) honors a `NEXUS_CONFIG_DIR` env override (issue #85) ahead of +//! its normal `directories`-crate-based resolution, so these tests point +//! that var directly at a scratch directory for the duration of each test +//! and write a `config.toml` there with a controlled `allowed_roots`. That +//! env var is process-global, so `ENV_LOCK` below serializes every test in +//! this binary against it (integration test binaries otherwise run their +//! `#[test]` functions on multiple threads). //! -//! ## Why this whole file, not just the symlink cases, is `#[cfg(unix)]` +//! ## Platform coverage (issues #83, #85) //! -//! Investigated for issue #83: it's tempting to assume only the -//! `std::os::unix::fs::symlink` cases below need Unix and the rest (outside- -//! root rejection, `..`-traversal, the confused-deputy case) could run -//! unconditionally. They can't, today - every single test in this file, -//! symlink or not, goes through `setup_fake_home`/`FakeHome`, which only -//! works because `directories::ProjectDirs`'s *Unix* backends resolve -//! through `$HOME`/`$XDG_CONFIG_HOME`. Its Windows backend +//! Before `NEXUS_CONFIG_DIR` existed, this whole file was `#[cfg(unix)]`: +//! every test went through `setup_fake_home`/`FakeHome`, which redirected +//! `$HOME`/`$XDG_CONFIG_HOME` and relied on `directories::ProjectDirs`'s +//! *Unix* backends resolving through them. Its Windows backend //! (`directories-5.0.1/src/win.rs`) calls //! `dirs_sys::known_folder_roaming_app_data()`, which goes through the Win32 -//! known-folder API (`SHGetKnownFolderPath`) - not an environment variable a -//! test process can override. Setting `HOME` before calling -//! `nexus_core::Paths::resolve()` on Windows has no effect on where it looks -//! for `config.toml`. There's also no test-only override hook anywhere in -//! this codebase for `config_dir` specifically (`NEXUS_CACHE_DIR` only -//! overrides `data_dir`), so there is currently no way to inject a -//! controlled `allowed_roots` into these functions on Windows at all - a -//! more fundamental blocker than "symlinks need elevation." +//! known-folder API (`SHGetKnownFolderPath`) and ignores those env vars +//! entirely - so no test in this file could run on Windows at all, not just +//! the symlink-specific ones. //! -//! Symlink creation itself, separately, turns out **not** to be the -//! blocker it looked like: probed directly against this repo's own -//! `test-windows` CI job (`windows-latest` GitHub Actions runner) with a -//! throwaway `std::os::windows::fs::symlink_file` call, which succeeded -//! without any elevation or Developer Mode step. So if/when a -//! cross-platform config-injection mechanism exists (tracked as issue #85, -//! not attempted here - it's a real harness change, not a test tweak), the -//! symlink-specific cases below should extend to Windows too, not stay -//! Unix-only. -#![cfg(unix)] +//! `NEXUS_CONFIG_DIR` (a plain env var read in `Paths::resolve()`, not an OS +//! API) sidesteps that entirely: it works identically on every platform, so +//! `setup_fake_home` now sets it directly instead of redirecting +//! `$HOME`/`$XDG_CONFIG_HOME`, and every test below except the +//! symlink-creating ones runs unconditionally, including on Windows. +//! +//! Symlink creation is the one remaining Unix-only piece, and only because +//! these specific tests use `std::os::unix::fs::symlink`, not for any +//! config-injection reason - probed directly against this repo's own +//! `test-windows` CI job (`windows-latest` GitHub Actions runner), Windows +//! itself can create symlinks without elevation or a Developer Mode step, +//! so a `std::os::windows::fs::symlink_file`/`symlink_dir`-based Windows +//! equivalent of those cases is possible; it's just not implemented here. +//! Those tests stay behind `#[cfg(unix)]` individually rather than gating +//! the whole file. use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -66,50 +63,35 @@ fn scratch_dir(label: &str) -> PathBuf { )) } -/// Sets up a fake `$HOME` with `config.toml` configured with -/// `allowed_roots = [allowed_root]` at whatever path -/// `nexus_core::Paths::resolve()` actually resolves for that `$HOME`, and -/// returns the fake home dir plus a guard that restores the previous -/// `$HOME`/`$XDG_CONFIG_HOME` on drop. +/// Sets up a fake config dir (via the `NEXUS_CONFIG_DIR` override, issue +/// #85) with `config.toml` configured with `allowed_roots = [allowed_root]`, +/// and returns a guard that restores the previous `NEXUS_CONFIG_DIR` on +/// drop. /// -/// This must go through `Paths::resolve()` itself rather than hardcoding -/// `$HOME/.config/nexuscontext` - `directories::ProjectDirs` (what -/// `Paths::resolve()` uses) honors `$XDG_CONFIG_HOME` over `$HOME` on -/// Linux when set (as some CI runners do), and uses an entirely different -/// layout on macOS (`~/Library/Application Support`, not `~/.config`). -/// Hardcoding the Linux path silently no-ops the whole test on both: the -/// fake config is never found, `allowed_roots` falls back to "unrestricted" -/// (its documented behavior for an absent/empty config), and every -/// "must be rejected" assertion below fails as "the call succeeded". +/// Works identically on every platform: `NEXUS_CONFIG_DIR` is a plain env +/// var read in `Paths::resolve()`, not routed through the OS-specific +/// `directories` crate the way `$HOME`/`$XDG_CONFIG_HOME` redirection used +/// to be. struct FakeHome { - prev_home: Option, - prev_xdg_config_home: Option, + prev_config_dir: Option, _dir: PathBuf, } impl Drop for FakeHome { fn drop(&mut self) { - match &self.prev_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - match &self.prev_xdg_config_home { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), + match &self.prev_config_dir { + Some(v) => std::env::set_var("NEXUS_CONFIG_DIR", v), + None => std::env::remove_var("NEXUS_CONFIG_DIR"), } } } fn setup_fake_home(label: &str, allowed_root: &Path) -> FakeHome { - let home = scratch_dir(&format!("home-{label}")); - std::fs::create_dir_all(&home).unwrap(); + let config_dir = scratch_dir(&format!("home-{label}")); + std::fs::create_dir_all(&config_dir).unwrap(); - let prev_home = std::env::var_os("HOME"); - let prev_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); - std::env::set_var("HOME", &home); - // A stray XDG_CONFIG_HOME from the outer environment would otherwise - // keep pointing at the real config dir even after $HOME is redirected. - std::env::remove_var("XDG_CONFIG_HOME"); + let prev_config_dir = std::env::var_os("NEXUS_CONFIG_DIR"); + std::env::set_var("NEXUS_CONFIG_DIR", &config_dir); let config_file = nexus_core::Paths::resolve().config_file(); std::fs::create_dir_all(config_file.parent().unwrap()).unwrap(); @@ -117,9 +99,8 @@ fn setup_fake_home(label: &str, allowed_root: &Path) -> FakeHome { std::fs::write(&config_file, config_toml).unwrap(); FakeHome { - prev_home, - prev_xdg_config_home, - _dir: home, + prev_config_dir, + _dir: config_dir, } } diff --git a/docs/NexusContext-Wiki/ADRs/0012-allowed-roots-enforced-uniformly-across-repo-path-tools.md b/docs/NexusContext-Wiki/ADRs/0012-allowed-roots-enforced-uniformly-across-repo-path-tools.md index 154babc..0cfb1ed 100644 --- a/docs/NexusContext-Wiki/ADRs/0012-allowed-roots-enforced-uniformly-across-repo-path-tools.md +++ b/docs/NexusContext-Wiki/ADRs/0012-allowed-roots-enforced-uniformly-across-repo-path-tools.md @@ -185,6 +185,18 @@ attempted inline here. Once that exists, the symlink-creation finding above means the symlink-specific cases can extend to Windows immediately, with no further platform investigation needed. +## Update (2026-08-27): `NEXUS_CONFIG_DIR` harness built, Windows CI now runs the suite (#85, #83) + +`nexus_core::Paths::resolve()` gained a `NEXUS_CONFIG_DIR` env override for +`config_dir`, mirroring the existing `NEXUS_CACHE_DIR` override for +`data_dir` - a plain env var read, no `directories`-crate/OS-API +involvement, so it behaves identically on every platform. `path_security. +rs`'s `setup_fake_home` now sets this directly instead of redirecting +`$HOME`/`$XDG_CONFIG_HOME`, and the file's blanket `#![cfg(unix)]` gate is +removed - only the three symlink-creating tests keep an individual +`#[cfg(unix)]`. Verified via real `test-windows` CI run, not just local +compilation (see PR closing this issue and #83 for the job log evidence). + ## Related [[Security-Model]] · [issue #61](https://github.com/devNalyx/NexusContext/issues/61) · diff --git a/docs/NexusContext-Wiki/Configuration.md b/docs/NexusContext-Wiki/Configuration.md index c509d63..1f7a6ad 100644 --- a/docs/NexusContext-Wiki/Configuration.md +++ b/docs/NexusContext-Wiki/Configuration.md @@ -46,6 +46,13 @@ request_timeout_secs = 10 - `NEXUS_CACHE_DIR` — overrides the data dir (`~/.local/share/nexuscontext` by default). +- `NEXUS_CONFIG_DIR` — overrides the config dir (`~/.config/nexuscontext` on + Linux, `~/Library/Application Support/nexuscontext` on macOS, the roaming + `AppData` known folder on Windows, by default) — where `config.toml` is + read from. A plain env var read, not routed through the OS-specific + `directories` crate, so it works identically cross-platform. Primarily for + tests that need a controlled `allowed_roots` without depending on + home-directory resolution (see `crates/nexus-index/tests/path_security.rs`). - `NEXUS_LOG_LEVEL` — `trace`/`debug`/`info`/`warn`/`error`. - `NEXUS_LOG_FORMAT=json` — structured, machine-parseable logs; plain text is the default. Works for both `serve` and `mcp` modes. diff --git a/docs/NexusContext-Wiki/Security-Model.md b/docs/NexusContext-Wiki/Security-Model.md index e8dbaa0..54016ca 100644 --- a/docs/NexusContext-Wiki/Security-Model.md +++ b/docs/NexusContext-Wiki/Security-Model.md @@ -313,6 +313,30 @@ The general CI matrix (`test-windows`) does exercise the rest of the workspace's test suite on Windows already - this gap is specific to this one security-focused adversarial file, not a general Windows-support gap. +## Update (2026-08-27): `NEXUS_CONFIG_DIR` closes the config-injection gap (#85, #83) + +Issue #85 built the harness the previous update called for: `nexus_core:: +Paths::resolve()` now honors a `NEXUS_CONFIG_DIR` env override for +`config_dir`, the same way it already honored `NEXUS_CACHE_DIR` for +`data_dir`. It's a plain env var read, not routed through the OS-specific +`directories` crate, so it works identically on every platform. + +`path_security.rs`'s `setup_fake_home`/`FakeHome` now sets +`NEXUS_CONFIG_DIR` directly instead of redirecting `$HOME`/ +`$XDG_CONFIG_HOME`, and the blanket `#![cfg(unix)]` gate at the top of the +file is gone. Every non-symlink test (outside-root rejection, +`..`-traversal, the confused-deputy `$HOME/.ssh/id_rsa` case, and their +per-function variants across all seven `repo_path`-accepting tools) now +runs unconditionally, including on Windows. Only the three tests that +create real symlinks (`get_file_context_rejects_symlink_escaping_ +allowed_root`, `get_file_context_rejects_repo_path_itself_a_symlink_ +escaping_allowed_root`, `get_file_context_accepts_symlink_pointing_within_ +the_same_allowed_root`) keep an individual `#[cfg(unix)]`, since Windows +symlink creation (confirmed CI-safe in the earlier update) has no +`std::os::windows::fs::symlink_file`/`symlink_dir` equivalent implemented +for them yet - a smaller, well-scoped remainder rather than the +whole-file blocker this section originally described. + ## Trust boundary, stated plainly The MCP tools trust the calling agent, not arbitrary network input — there