use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use ahash::{AHashMap, AHashSet};
use globset::GlobSet;
use ignore::WalkBuilder;
use crate::config::Config;
use crate::config::rules::{compile_patterns, expand_pattern};
use crate::lang_rules::{Detection, LangRules};
use crate::scanner::{ScanError, ScanSource, submodule_roots_for_source};
const FLOOR_EXCLUDES: &[&str] = &[
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/out/**",
"**/coverage/**",
"**/.next/**",
"**/.nuxt/**",
"**/.svelte-kit/**",
"**/.venv/**",
"**/venv/**",
"**/__pycache__/**",
"**/*.pyc",
"**/.pytest_cache/**",
"**/.mypy_cache/**",
"**/.ruff_cache/**",
"**/.tox/**",
"**/target/**",
"**/.gradle/**",
"**/vendor/**",
"**/.terraform/**",
"**/bazel-*/**",
"**/bazel-out/**",
"**/bazel-bin/**",
"**/bazel-testlogs/**",
"**/.git/**",
"**/.basemind/**",
"**/.idea/**",
"**/.DS_Store",
"**/.env",
"**/.env.*",
"**/.aws/**",
"**/.ssh/**",
"**/.gnupg/**",
"**/.npmrc",
"**/.pypirc",
"**/.netrc",
"**/.git-credentials",
"**/id_rsa",
"**/id_dsa",
"**/id_ecdsa",
"**/id_ed25519",
"**/*.pem",
"**/*.key",
"**/*.p12",
"**/*.pfx",
"**/*.jks",
"**/*.keystore",
];
const FLOOR_CREDENTIALS: &[&str] = &[
"**/.env",
"**/.env.*",
"**/.aws/**",
"**/.ssh/**",
"**/.gnupg/**",
"**/.npmrc",
"**/.pypirc",
"**/.netrc",
"**/.git-credentials",
"**/id_rsa",
"**/id_dsa",
"**/id_ecdsa",
"**/id_ed25519",
"**/*.pem",
"**/*.key",
"**/*.p12",
"**/*.pfx",
"**/*.jks",
"**/*.keystore",
];
pub const ALLOW_REPO_CREDENTIALS_ENV: &str = "BASEMIND_ALLOW_REPO_CREDENTIALS";
fn repo_credentials_granted() -> bool {
std::env::var(ALLOW_REPO_CREDENTIALS_ENV).is_ok_and(|v| {
let v = v.trim();
v.eq_ignore_ascii_case("1") || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes")
})
}
const FLOOR_PROTECTED: &[&str] = &["**/.git/**", "**/.basemind/**"];
fn floor_name(pattern: &str) -> &str {
let p = pattern.strip_prefix("**/").unwrap_or(pattern);
p.strip_suffix("/**").unwrap_or(p)
}
fn floor_allow_names(entry: &str, pattern: &str) -> bool {
let e = entry.trim().trim_matches('/');
e == pattern || e == floor_name(pattern) || format!("**/{e}/**") == pattern
}
fn effective_floor(floor_allow: &[String], credentials_granted: bool) -> Vec<&'static str> {
for entry in floor_allow {
let matched = FLOOR_EXCLUDES.iter().any(|p| floor_allow_names(entry, p));
let protected = FLOOR_PROTECTED.iter().any(|p| floor_allow_names(entry, p));
let credential = FLOOR_CREDENTIALS.iter().any(|p| floor_allow_names(entry, p));
if protected {
tracing::warn!(entry, "[scan] floor_allow cannot remove this entry; it stays excluded");
} else if credential && !credentials_granted {
tracing::warn!(
entry,
env = ALLOW_REPO_CREDENTIALS_ENV,
"refusing [scan] floor_allow on credential material from basemind.toml: it stays \
excluded; set the grant to re-include secrets deliberately"
);
} else if !matched {
tracing::warn!(
entry,
"[scan] floor_allow entry matches no exclude-floor entry; ignored"
);
}
}
FLOOR_EXCLUDES
.iter()
.copied()
.filter(|p| {
FLOOR_PROTECTED.contains(p)
|| !floor_allow
.iter()
.any(|e| floor_allow_names(e, p) && (!FLOOR_CREDENTIALS.contains(p) || credentials_granted))
})
.collect()
}
#[derive(Default)]
struct Gate {
include: Option<GlobSet>,
exclude: Option<GlobSet>,
}
impl Gate {
fn build(include: &[String], exclude: &[String]) -> Result<Self, ScanError> {
let compile = |list: &[String]| -> Result<Option<GlobSet>, ScanError> {
if list.is_empty() {
return Ok(None);
}
compile_patterns(list.iter().map(String::as_str))
.map(Some)
.map_err(ScanError::BadGlob)
};
Ok(Self {
include: compile(include)?,
exclude: compile(exclude)?,
})
}
fn allows(&self, key: &str) -> bool {
!self.exclude.as_ref().is_some_and(|e| e.is_match(key)) && self.include.as_ref().is_none_or(|i| i.is_match(key))
}
}
pub(crate) struct Filters {
include: globset::GlobSet,
exclude: globset::GlobSet,
dir_exclude: Arc<globset::GlobSet>,
pub(crate) max_file_bytes: u64,
#[cfg_attr(not(feature = "documents"), allow(dead_code))]
pub(crate) doc_max_file_bytes: u64,
#[cfg_attr(not(feature = "code-search"), allow(dead_code))]
pub(crate) code_digest: String,
#[cfg_attr(not(feature = "documents"), allow(dead_code))]
pub(crate) doc_digest: String,
submodule_prefixes: Arc<[String]>,
pub(crate) eager_l2: bool,
lang: LangRules,
#[cfg_attr(not(feature = "documents"), allow(dead_code))]
doc_scope: Gate,
#[cfg_attr(not(feature = "documents"), allow(dead_code))]
doc_embed: Gate,
#[cfg_attr(not(feature = "code-search"), allow(dead_code))]
code_embed: Gate,
extra_prefixes: Vec<String>,
#[cfg_attr(not(any(feature = "code-search", feature = "documents")), allow(dead_code))]
pub(crate) reflush_embeds: bool,
}
impl Filters {
pub(crate) fn build(config: &Config, submodule_roots: Vec<String>) -> Result<Self, ScanError> {
let include_patterns: Vec<String> = config.scan.include.iter().flat_map(|p| expand_pattern(p)).collect();
let include = compile_globs(include_patterns.iter().map(String::as_str))?;
let exclude_patterns: Vec<String> = effective_floor(&config.scan.floor_allow, repo_credentials_granted())
.into_iter()
.map(str::to_string)
.chain(config.scan.exclude.iter().flat_map(|p| expand_pattern(p)))
.collect();
let exclude = compile_globs(exclude_patterns.iter().map(String::as_str))?;
let dir_exclude = compile_globs(dir_exclude_patterns(&exclude_patterns).iter().map(String::as_str))?;
let submodule_prefixes: Arc<[String]> = if config.scan.skip_submodules {
submodule_roots
.into_iter()
.map(|s| s.trim_end_matches('/').to_string())
.filter(|s| !s.is_empty())
.map(|r| format!("{r}/"))
.collect()
} else {
Arc::from([] as [String; 0])
};
let extra_prefixes = config
.scan
.extra_roots
.iter()
.filter_map(|r| r.canonicalize().ok())
.filter_map(|r| {
r.to_str()
.map(|s| format!("{}/", s.replace('\\', "/").trim_end_matches('/')))
})
.collect();
Ok(Self {
include,
exclude,
dir_exclude: Arc::new(dir_exclude),
max_file_bytes: config.scan.max_file_bytes,
doc_max_file_bytes: config.documents.max_file_bytes,
code_digest: crate::config::rules::code_digest(&config.code_search),
doc_digest: crate::config::rules::doc_digest(&config.documents, &config.resources, &config.llm),
submodule_prefixes,
eager_l2: config.scan.eager_l2,
lang: LangRules::from_config(&config.languages).map_err(ScanError::BadLanguage)?,
doc_scope: Gate::build(&config.documents.include, &config.documents.exclude)?,
doc_embed: Gate::build(&config.documents.embed_include, &config.documents.embed_exclude)?,
code_embed: Gate::build(&config.code_search.embed_include, &config.code_search.embed_exclude)?,
extra_prefixes,
reflush_embeds: false,
})
}
pub(crate) fn detect_lang(&self, rel: &str) -> Detection {
self.lang.detect(Path::new(rel))
}
fn scoped<'a>(&self, key: &'a str) -> &'a str {
if self.extra_prefixes.is_empty() || !crate::path::is_external_key(key.as_bytes()) {
return key;
}
self.extra_prefixes
.iter()
.filter_map(|p| key.strip_prefix(p.as_str()))
.min_by_key(|rest| rest.len())
.unwrap_or(key)
}
#[cfg_attr(not(feature = "documents"), allow(dead_code))]
pub(crate) fn doc_allowed(&self, rel: &str) -> bool {
self.doc_scope.allows(self.scoped(rel))
}
#[cfg_attr(not(feature = "documents"), allow(dead_code))]
pub(crate) fn doc_embed_allowed(&self, rel: &str) -> bool {
self.doc_embed.allows(self.scoped(rel))
}
#[cfg_attr(not(feature = "code-search"), allow(dead_code))]
pub(crate) fn code_embed_allowed(&self, rel: &str) -> bool {
self.code_embed.allows(self.scoped(rel))
}
fn excluded(&self, rel: &str) -> bool {
self.exclude.is_match(rel) || under_submodule(rel, &self.submodule_prefixes)
}
pub(crate) fn allows(&self, rel: &str) -> bool {
if self.excluded(rel) {
return false;
}
self.include.is_match(rel)
}
pub(crate) fn allows_in_extra_root(&self, rel_to_extra_root: &str) -> bool {
!self.exclude.is_match(rel_to_extra_root) && self.include.is_match(rel_to_extra_root)
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn allows_dir(&self, rel: &str) -> bool {
dir_allowed(rel, &self.dir_exclude, &self.submodule_prefixes)
}
pub(crate) fn dir_pruner(&self, base: Option<&Path>) -> DirPruner {
DirPruner {
dir_exclude: Arc::clone(&self.dir_exclude),
submodule_prefixes: Arc::clone(&self.submodule_prefixes),
base: base.map(Path::to_path_buf),
}
}
pub(crate) fn dir_pruner_for_extra_root(&self, extra_root: &Path) -> DirPruner {
DirPruner {
dir_exclude: Arc::clone(&self.dir_exclude),
submodule_prefixes: Arc::from([] as [String; 0]),
base: Some(extra_root.to_path_buf()),
}
}
}
fn under_submodule(rel: &str, prefixes: &[String]) -> bool {
prefixes
.iter()
.any(|p| rel.starts_with(p.as_str()) || (p.len() == rel.len() + 1 && p.starts_with(rel)))
}
fn dir_allowed(rel: &str, dir_exclude: &globset::GlobSet, submodule_prefixes: &[String]) -> bool {
!dir_exclude.is_match(rel) && !under_submodule(rel, submodule_prefixes)
}
fn dir_exclude_patterns(patterns: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(patterns.len() * 2);
for p in patterns {
let Some(stem) = p.strip_suffix("/**") else {
continue;
};
if stem.is_empty() {
continue;
}
out.push(p.clone());
out.push(stem.to_string());
}
out
}
pub(crate) struct DirPruner {
dir_exclude: Arc<globset::GlobSet>,
submodule_prefixes: Arc<[String]>,
base: Option<PathBuf>,
}
impl DirPruner {
pub(crate) fn keep(&self, dent: &ignore::DirEntry) -> bool {
if dent.depth() == 0 {
return true;
}
if !dent.file_type().map(|t| t.is_dir()).unwrap_or(false) {
return true;
}
let path = dent.path();
let candidate = match &self.base {
Some(base) => match path.strip_prefix(base) {
Ok(rel) => rel,
Err(_) => return true,
},
None => path,
};
let Some(rel) = candidate.to_str() else {
return true;
};
#[cfg(windows)]
let rel_owned = rel.replace('\\', "/");
#[cfg(windows)]
let rel = rel_owned.as_str();
if rel.is_empty() {
return true;
}
dir_allowed(rel, &self.dir_exclude, &self.submodule_prefixes)
}
}
fn compile_globs<'a>(patterns: impl IntoIterator<Item = &'a str>) -> Result<globset::GlobSet, ScanError> {
let mut b = globset::GlobSetBuilder::new();
for p in patterns {
let g = globset::Glob::new(p).map_err(|e| ScanError::BadGlob(format!("{p:?}: {e}")))?;
b.add(g);
}
b.build().map_err(|e| ScanError::BadGlob(format!("{e}")))
}
pub(crate) fn ignore_walk_builder(dir: &Path, respect_gitignore: bool, follow_links: bool) -> WalkBuilder {
let mut b = WalkBuilder::new(dir);
b.standard_filters(respect_gitignore)
.follow_links(follow_links)
.git_ignore(respect_gitignore)
.git_exclude(respect_gitignore)
.hidden(false);
b
}
pub(crate) struct IndexFilter {
filters: Filters,
root: PathBuf,
respect_gitignore: bool,
follow_links: bool,
allowed_children: RefCell<AHashMap<PathBuf, AHashSet<PathBuf>>>,
}
impl IndexFilter {
pub(crate) fn new(root: &Path, config: &Config) -> Result<Self, ScanError> {
let submodule_roots = submodule_roots_for_source(root, &ScanSource::WorkingTree);
let filters = Filters::build(config, submodule_roots)?;
Ok(Self {
filters,
root: root.to_path_buf(),
respect_gitignore: config.scan.respect_gitignore,
follow_links: config.scan.follow_symlinks,
allowed_children: RefCell::new(AHashMap::new()),
})
}
pub(crate) fn clear_cache(&self) {
self.allowed_children.borrow_mut().clear();
}
pub(crate) fn allows_glob(&self, rel: &str) -> bool {
self.filters.allows(rel)
}
pub(crate) fn filters(&self) -> &Filters {
&self.filters
}
fn rel_of(&self, abs: &Path) -> Option<String> {
let rel = abs.strip_prefix(&self.root).ok()?;
let rel = rel.to_string_lossy().replace('\\', "/");
if rel.is_empty() { None } else { Some(rel) }
}
pub(crate) fn is_indexable(&self, abs: &Path) -> bool {
let Some(rel) = self.rel_of(abs) else {
return false;
};
if !self.filters.allows(&rel) {
return false;
}
if !self.is_regular_file(abs) {
return false;
}
if !self.respect_gitignore {
return true;
}
self.gitignore_allows(abs)
}
fn is_regular_file(&self, abs: &Path) -> bool {
if self.follow_links {
return std::fs::metadata(abs).is_ok_and(|m| m.is_file());
}
let Ok(rel) = abs.strip_prefix(&self.root) else {
return false;
};
let mut cur = self.root.clone();
let mut last_is_file = false;
for comp in rel.components() {
cur.push(comp.as_os_str());
let Ok(meta) = std::fs::symlink_metadata(&cur) else {
return false;
};
if meta.file_type().is_symlink() {
return false;
}
last_is_file = meta.is_file();
}
last_is_file
}
fn gitignore_allows(&self, abs: &Path) -> bool {
let Ok(rel) = abs.strip_prefix(&self.root) else {
return false;
};
let mut cur = self.root.clone();
for comp in rel.components() {
let child = cur.join(comp.as_os_str());
{
let mut memo = self.allowed_children.borrow_mut();
let allowed = memo
.entry(cur.clone())
.or_insert_with(|| shallow_allowed_children(&cur, self.respect_gitignore, self.follow_links));
if !allowed.contains(&child) {
return false;
}
}
cur = child;
}
true
}
}
fn shallow_allowed_children(dir: &Path, respect_gitignore: bool, follow_links: bool) -> AHashSet<PathBuf> {
let mut set = AHashSet::new();
let walker = ignore_walk_builder(dir, respect_gitignore, follow_links)
.parents(false)
.max_depth(Some(1))
.build();
for dent in walker.flatten() {
let p = dent.path();
if p == dir {
continue;
}
set.insert(p.to_path_buf());
}
set
}
#[cfg(test)]
#[path = "scanner_filter_tests.rs"]
mod tests;