use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::CONFIG_FILE_NAME;
use super::has_config_marker;
pub const ALLOW_ANY_ROOT_ENV: &str = "BASEMIND_ALLOW_ANY_ROOT";
pub const ROOT_ADMISSION_FILE: &str = "root-admission.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootRefusal {
NotAProject,
FilesystemRoot,
Unresolvable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdmissionReason {
PreExistingIndex,
EnvHatch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RootAdmission {
reason: AdmissionReason,
admitted_unix: i64,
}
pub fn workspace_root_verdict(root: &Path) -> Result<PathBuf, RootRefusal> {
if is_filesystem_root(root) {
return Err(RootRefusal::FilesystemRoot);
}
let resolved = root.canonicalize().map_err(|_| RootRefusal::Unresolvable)?;
if is_filesystem_root(&resolved) {
return Err(RootRefusal::FilesystemRoot);
}
if has_config_marker(&resolved) || is_git_workdir(&resolved) || is_grandfathered(&resolved) {
return Ok(resolved);
}
if allow_any_root() {
record_admission(&resolved, AdmissionReason::EnvHatch);
return Ok(resolved);
}
Err(RootRefusal::NotAProject)
}
pub fn refusal_message(root: &Path, refusal: RootRefusal) -> String {
let root = root.display();
match refusal {
RootRefusal::FilesystemRoot => format!(
"refusing to use {root} as a basemind workspace root: that is a filesystem/volume root.\n\
\n\
basemind opens a workspace root read-write and indexes every file beneath it. Rooted at \
a whole volume that means walking the entire machine — it will exhaust memory and disk \
long before it finishes.\n\
\n\
There is no override for this case. Point basemind at a project directory instead: pass \
`--root <path>`, or launch the MCP host with its working directory inside the project."
),
RootRefusal::Unresolvable => format!(
"refusing to use {root} as a basemind workspace root: it does not resolve to an existing \
path.\n\
\n\
basemind resolves a root — collapsing `..` and following symlinks — before deciding \
anything about it, so the path it checks is exactly the path it opens. A relative path \
would resolve against whatever working directory this process happens to have, and a \
missing one cannot be checked at all.\n\
\n\
Pass an absolute path to an existing project directory: `--root <path>`."
),
RootRefusal::NotAProject => format!(
"refusing to use {root} as a basemind workspace root: it is neither a git repository nor \
a directory containing a basemind config ({CONFIG_FILE_NAME}, or under `.config/`).\n\
\n\
basemind opens a workspace root read-write and indexes every file beneath it, so an \
accidentally-inherited root — `/`, your home directory, or wherever an MCP host happened \
to start — becomes a whole-filesystem scan that exhausts memory.\n\
\n\
If this really is a project you want indexed, either:\n\
\x20 - run `basemind init` in {root} (writes {CONFIG_FILE_NAME}, the durable marker), or\n\
\x20 - set {ALLOW_ANY_ROOT_ENV}=1 in the environment. That hatch applies to the \
invocation that sets it and nothing else: the index it produces does not make the root \
permanently acceptable, so unset it and this root is refused again."
),
}
}
fn is_filesystem_root(root: &Path) -> bool {
root.parent().is_none()
|| root
.components()
.all(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
}
fn is_git_workdir(root: &Path) -> bool {
let Ok(repo) = crate::git::Repo::discover(root) else {
return false;
};
let workdir = repo.workdir();
let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
workdir == root || canonical(workdir) == canonical(root)
}
fn allow_any_root() -> bool {
std::env::var(ALLOW_ANY_ROOT_ENV).is_ok_and(|value| {
let value = value.trim();
value.eq_ignore_ascii_case("1") || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
})
}
fn is_grandfathered(root: &Path) -> bool {
let dir = crate::store::workspace_cache_dir(root);
match read_admission(&dir).map(|record| record.reason) {
Some(AdmissionReason::PreExistingIndex) => true,
Some(AdmissionReason::EnvHatch) => false,
None => {
let has_index = dir
.join(crate::store::VIEWS_DIR)
.join(crate::store::VIEW_WORKING)
.join(crate::store::INDEX_FILE)
.exists();
if has_index {
write_admission(&dir, AdmissionReason::PreExistingIndex);
}
has_index
}
}
}
fn record_admission(root: &Path, reason: AdmissionReason) {
let dir = crate::store::workspace_cache_dir(root);
if read_admission(&dir).is_some() {
return;
}
write_admission(&dir, reason);
}
fn admission_path(workspace_dir: &Path) -> PathBuf {
workspace_dir.join(ROOT_ADMISSION_FILE)
}
fn read_admission(workspace_dir: &Path) -> Option<RootAdmission> {
let bytes = std::fs::read(admission_path(workspace_dir)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn write_admission(workspace_dir: &Path, reason: AdmissionReason) {
let record = RootAdmission {
reason,
admitted_unix: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
};
let Ok(bytes) = serde_json::to_vec(&record) else {
return;
};
if std::fs::create_dir_all(workspace_dir).is_err() {
return;
}
let _ = crate::store_blob::write_bytes_atomic(admission_path(workspace_dir), &bytes);
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct AllowAnyRoot(Option<String>);
impl AllowAnyRoot {
fn set(value: &str) -> Self {
let prior = std::env::var(ALLOW_ANY_ROOT_ENV).ok();
unsafe { std::env::set_var(ALLOW_ANY_ROOT_ENV, value) };
Self(prior)
}
fn cleared() -> Self {
let prior = std::env::var(ALLOW_ANY_ROOT_ENV).ok();
unsafe { std::env::remove_var(ALLOW_ANY_ROOT_ENV) };
Self(prior)
}
}
impl Drop for AllowAnyRoot {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(prior) => std::env::set_var(ALLOW_ANY_ROOT_ENV, prior),
None => std::env::remove_var(ALLOW_ANY_ROOT_ENV),
}
}
}
}
fn plain_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn git_repo() -> (tempfile::TempDir, PathBuf) {
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
let status = std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("run git init");
assert!(status.success(), "git init failed");
(dir, root)
}
fn write_working_index(root: &Path) {
let view_dir = crate::store::workspace_cache_dir(root)
.join(crate::store::VIEWS_DIR)
.join(crate::store::VIEW_WORKING);
std::fs::create_dir_all(&view_dir).expect("mkdir view");
std::fs::write(view_dir.join(crate::store::INDEX_FILE), b"").expect("write index");
}
#[test]
fn filesystem_root_is_refused() {
assert_eq!(workspace_root_verdict(Path::new("/")), Err(RootRefusal::FilesystemRoot));
}
#[test]
fn paths_that_resolve_to_the_filesystem_root_are_refused_even_under_the_env_hatch() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = AllowAnyRoot::set("1");
crate::store::init_isolated_cache();
for spelling in ["/..", "/../..", "/usr/..", "/tmp/../.."] {
assert_eq!(
workspace_root_verdict(Path::new(spelling)),
Err(RootRefusal::FilesystemRoot),
"{spelling} resolves to the filesystem root"
);
}
#[cfg(unix)]
{
let dir = plain_dir();
let link = dir.path().join("slash");
std::os::unix::fs::symlink("/", &link).expect("symlink to /");
assert_eq!(
workspace_root_verdict(&link),
Err(RootRefusal::FilesystemRoot),
"a symlink whose target is / is still the filesystem root"
);
}
}
#[test]
fn the_verdict_returns_the_resolved_root() {
let (_dir, root) = git_repo();
let unresolved = root.join("..").join(root.file_name().expect("leaf"));
assert_eq!(
workspace_root_verdict(&unresolved).expect("the repo is allowed"),
root,
"the `..` segment must be collapsed in the returned root"
);
}
#[test]
fn a_relative_or_missing_root_is_unresolvable() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = AllowAnyRoot::set("1");
let dir = plain_dir();
assert_eq!(
workspace_root_verdict(&dir.path().join("no-such-child")),
Err(RootRefusal::Unresolvable),
"the env hatch cannot conjure a directory into existence"
);
}
#[cfg(windows)]
#[test]
fn windows_style_volume_and_unc_roots_are_refused() {
for shape in [r"C:\", "C:/", r"\\?\C:\", r"\\server\share\", r"\\server\share"] {
assert!(is_filesystem_root(Path::new(shape)), "{shape} is a volume/share root");
assert_eq!(
workspace_root_verdict(Path::new(shape)),
Err(RootRefusal::FilesystemRoot),
"{shape} must be refused"
);
}
assert!(!is_filesystem_root(Path::new(r"C:\projects")));
assert!(!is_filesystem_root(Path::new(r"\\server\share\projects")));
}
#[cfg(unix)]
#[test]
fn unix_root_shapes_are_classified() {
assert!(is_filesystem_root(Path::new("/")));
assert!(is_filesystem_root(Path::new("")));
assert!(!is_filesystem_root(Path::new("/usr")));
assert!(
!is_filesystem_root(Path::new("/..")),
"the syntactic predicate cannot see through `..` — that is why the verdict canonicalizes"
);
}
#[test]
fn a_git_repository_is_allowed() {
let (_dir, root) = git_repo();
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
}
#[test]
fn a_directory_with_basemind_toml_is_allowed() {
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
std::fs::write(root.join(CONFIG_FILE_NAME), "\"$schema\" = \"v1\"\n").expect("write marker");
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
}
#[test]
fn a_directory_with_the_config_convention_is_allowed() {
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
let path = crate::config::convention_nested_config_path(&root);
std::fs::create_dir_all(path.parent().expect("convention dir")).expect("mkdir .config/basemind");
std::fs::write(&path, "\"$schema\" = \"v1\"\n").expect("write marker");
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
}
#[test]
fn a_plain_directory_is_refused() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = AllowAnyRoot::cleared();
crate::store::init_isolated_cache();
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
assert_eq!(workspace_root_verdict(&root), Err(RootRefusal::NotAProject));
}
#[test]
fn allow_any_root_env_permits_a_plain_directory_but_never_the_filesystem_root() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = AllowAnyRoot::set("1");
crate::store::init_isolated_cache();
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
assert_eq!(
workspace_root_verdict(Path::new("/")),
Err(RootRefusal::FilesystemRoot),
"the filesystem-root refusal is not overridable"
);
}
#[test]
fn allow_any_root_env_accepts_true_and_yes_but_not_arbitrary_values() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
for truthy in ["1", "true", "TRUE", "yes"] {
let _env = AllowAnyRoot::set(truthy);
assert!(allow_any_root(), "{truthy:?} is truthy");
}
for falsy in ["0", "false", "no", ""] {
let _env = AllowAnyRoot::set(falsy);
assert!(!allow_any_root(), "{falsy:?} is not truthy");
}
}
#[test]
fn an_existing_working_index_grandfathers_a_plain_directory() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = AllowAnyRoot::cleared();
crate::store::init_isolated_cache();
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
assert_eq!(
workspace_root_verdict(&root),
Err(RootRefusal::NotAProject),
"no index yet"
);
write_working_index(&root);
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
assert_eq!(
read_admission(&crate::store::workspace_cache_dir(&root)).map(|r| r.reason),
Some(AdmissionReason::PreExistingIndex),
"the upgrade path records an explicit standing verdict"
);
}
#[test]
fn the_env_hatch_does_not_grandfather_the_index_it_produces() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
crate::store::init_isolated_cache();
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
{
let _env = AllowAnyRoot::set("1");
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
}
write_working_index(&root);
let _env = AllowAnyRoot::cleared();
assert_eq!(
workspace_root_verdict(&root),
Err(RootRefusal::NotAProject),
"unsetting the hatch must re-refuse a root it only ever admitted"
);
}
#[test]
fn a_grandfathered_root_is_not_downgraded_by_a_later_hatched_run() {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
crate::store::init_isolated_cache();
let dir = plain_dir();
let root = dir.path().canonicalize().expect("canonicalize");
write_working_index(&root);
{
let _env = AllowAnyRoot::set("1");
assert_eq!(workspace_root_verdict(&root), Ok(root.clone()));
}
let _env = AllowAnyRoot::cleared();
assert_eq!(
workspace_root_verdict(&root),
Ok(root.clone()),
"the pre-existing-index admission stands on its own"
);
}
#[test]
fn the_refusal_message_names_both_escape_hatches_and_the_hatchs_scope() {
let message = refusal_message(Path::new("/home/dev/notes"), RootRefusal::NotAProject);
assert!(message.contains("/home/dev/notes"));
assert!(message.contains("basemind init"));
assert!(message.contains(ALLOW_ANY_ROOT_ENV));
assert!(message.contains("indexes every file beneath it"));
assert!(
message.contains("unset it and this root is refused again"),
"the hatch's per-invocation scope must be stated, not implied: {message}"
);
let fs_root = refusal_message(Path::new("/"), RootRefusal::FilesystemRoot);
assert!(fs_root.contains("no override"), "{fs_root}");
let unresolvable = refusal_message(Path::new("relative/path"), RootRefusal::Unresolvable);
assert!(unresolvable.contains("does not resolve"), "{unresolvable}");
}
}