use cordance_core::paths::{
segment_matches_any_ascii_case_insensitive, segments_match_ascii_case_insensitive,
};
const BLOCKED_SEGMENT_NAMES: &[&str] = &[
".cordance",
".git",
".codex-logs",
"node_modules",
"target",
"dist",
"build",
"coverage",
".pytest_cache",
"__pycache__",
".idea",
".vscode",
];
const BLOCKED_SUBPATHS: &[&[&str]] = &[
&[".claude", "cache"],
&[".claude", "sessions"],
&[".claude", "worktrees"],
&[".claude", "projects"],
&[".codex", "cache"],
&[".codex", "sessions"],
];
const SECRET_FILENAMES: &[&str] = &[
"id_rsa",
"id_ed25519",
"id_ecdsa",
"id_dsa",
"secrets.json",
"secrets.yaml",
"secrets.yml",
"credentials.json",
"credentials.yaml",
".npmrc",
".pypirc",
".netrc",
"gcp-key.json",
];
const OS_JUNK_FILENAMES: &[&str] = &[".ds_store", "thumbs.db"];
const BLOCKED_EXTENSIONS: &[&str] = &["log", "pem", "key", "sqlite", "db", "profraw"];
fn has_blocked_extension(final_component: &str, exts: &[&str]) -> bool {
std::path::Path::new(final_component)
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|ext| exts.iter().any(|target| ext.eq_ignore_ascii_case(target)))
}
#[must_use]
pub fn is_blocked(rel_path: &str) -> bool {
let p = rel_path.replace('\\', "/");
if path_matches_block_rules(&p) {
return true;
}
let final_component = p.rsplit('/').next().unwrap_or(p.as_str());
let final_component_lower = final_component.to_ascii_lowercase();
if final_component_lower == ".git" {
return true;
}
if final_component_lower == ".env" || final_component_lower.starts_with(".env.") {
return true;
}
if SECRET_FILENAMES.contains(&final_component_lower.as_str())
|| OS_JUNK_FILENAMES.contains(&final_component_lower.as_str())
{
return true;
}
if final_component_lower.starts_with("secret") || final_component_lower.ends_with("_secret") {
return true;
}
if final_component_lower.contains(".secret.") || final_component_lower.ends_with(".secret") {
return true;
}
if has_blocked_extension(&final_component_lower, BLOCKED_EXTENSIONS) {
return true;
}
false
}
fn path_matches_block_rules(forward_slashed: &str) -> bool {
let segments: Vec<&str> = forward_slashed
.split('/')
.filter(|s| !s.is_empty())
.collect();
if segments
.iter()
.any(|seg| segment_matches_any_ascii_case_insensitive(seg, BLOCKED_SEGMENT_NAMES))
{
return true;
}
for subpath in BLOCKED_SUBPATHS {
if segments.len() >= subpath.len()
&& segments
.windows(subpath.len())
.any(|w| segments_match_ascii_case_insensitive(w, subpath))
{
return true;
}
}
false
}
fn block_reason_from_segments(forward_slashed: &str) -> Option<&'static str> {
let segments: Vec<&str> = forward_slashed
.split('/')
.filter(|s| !s.is_empty())
.collect();
for seg in &segments {
if seg.eq_ignore_ascii_case(".cordance") {
return Some("cordance internal state");
}
if seg.eq_ignore_ascii_case(".git") {
return Some("git internal state");
}
if seg.eq_ignore_ascii_case(".codex-logs") {
return Some("codex runtime exhaust");
}
if segment_matches_any_ascii_case_insensitive(
seg,
&[
"node_modules",
"target",
"dist",
"build",
"coverage",
".pytest_cache",
"__pycache__",
],
) {
return Some("build / vendor artifact");
}
if segment_matches_any_ascii_case_insensitive(seg, &[".idea", ".vscode"]) {
return Some("ide state");
}
}
for window in segments.windows(2) {
if window[0].eq_ignore_ascii_case(".claude")
&& segment_matches_any_ascii_case_insensitive(
window[1],
&["cache", "sessions", "worktrees", "projects"],
)
{
return Some("claude runtime exhaust");
}
if window[0].eq_ignore_ascii_case(".codex")
&& segment_matches_any_ascii_case_insensitive(window[1], &["cache", "sessions"])
{
return Some("codex runtime exhaust");
}
}
None
}
#[must_use]
pub fn block_reason(rel_path: &str) -> Option<&'static str> {
let p = rel_path.replace('\\', "/");
if let Some(reason) = block_reason_from_segments(&p) {
return Some(reason);
}
let final_component = p.rsplit('/').next().unwrap_or(p.as_str());
let final_component_lower = final_component.to_ascii_lowercase();
if final_component_lower == ".git" {
return Some("git internal state");
}
if final_component_lower == ".env" || final_component_lower.starts_with(".env.") {
return Some("environment / secrets file");
}
if SECRET_FILENAMES.contains(&final_component_lower.as_str()) {
return Some("credential material");
}
if OS_JUNK_FILENAMES.contains(&final_component_lower.as_str()) {
return Some("os junk");
}
if final_component_lower.starts_with("secret") || final_component_lower.ends_with("_secret") {
return Some("credential material");
}
if final_component_lower.contains(".secret.") || final_component_lower.ends_with(".secret") {
return Some("credential material");
}
if has_blocked_extension(&final_component_lower, &["pem", "key"]) {
return Some("credential material");
}
if has_blocked_extension(&final_component_lower, &["sqlite", "db"]) {
return Some("binary state");
}
if has_blocked_extension(&final_component_lower, &["log"]) {
return Some("log file");
}
if has_blocked_extension(&final_component_lower, &["profraw"]) {
return Some("coverage exhaust");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_session_blocked() {
assert!(is_blocked(".claude/sessions/foo.json"));
assert!(is_blocked(".codex-logs/foo.log"));
}
#[test]
fn git_internal_state_blocked() {
assert!(is_blocked(".git/index"));
assert!(is_blocked(".git/config"));
assert!(is_blocked(".git/HEAD"));
assert!(is_blocked(".git/logs/HEAD"));
assert!(is_blocked(".git/packed-refs"));
assert!(is_blocked(".git/objects/pack/pack-abc.pack"));
assert!(is_blocked(".git/gc.log"));
assert_eq!(block_reason(".git/index"), Some("git internal state"));
}
#[test]
fn gitignore_at_root_not_blocked() {
assert!(!is_blocked(".gitignore"));
assert!(!is_blocked(".gitattributes"));
assert!(!is_blocked("subproject/.gitignore"));
}
#[test]
fn bare_git_pointer_file_blocked() {
assert!(is_blocked(".git"));
assert!(is_blocked("subproject/.git"));
assert_eq!(block_reason(".git"), Some("git internal state"));
}
#[test]
fn dot_git_is_exact_not_prefix() {
assert!(!is_blocked(".github"));
assert!(!is_blocked(".github/workflows/ci.yml"));
assert!(!is_blocked("gitlab-ci.yml"));
assert!(!is_blocked("dotgit-helper.sh"));
}
#[test]
fn bare_repo_mirror_directories_not_blocked() {
assert!(!is_blocked("myrepo.git/objects/pack.idx"));
assert!(!is_blocked("bare.git/config"));
assert!(!is_blocked("x.git/README.md"));
assert!(!is_blocked("nested/path/foo.git/HEAD"));
assert!(is_blocked("myrepo.git/.git/index"));
}
#[test]
fn segment_exact_blocking_does_not_over_block() {
assert!(is_blocked("target/release/cordance"));
assert!(!is_blocked("mytarget/foo.rs"));
assert!(!is_blocked("docs/target-tracking.md"));
assert!(is_blocked("node_modules/pkg/index.js"));
assert!(!is_blocked("mynode_modules/foo.js"));
assert!(!is_blocked("docs/node_modules-policy.md"));
}
#[test]
fn blocked_segments_are_ascii_case_insensitive() {
assert!(is_blocked(".GIT/index"));
assert_eq!(block_reason(".GIT/index"), Some("git internal state"));
assert!(is_blocked(".Cordance/pack.json"));
assert_eq!(
block_reason(".Cordance/pack.json"),
Some("cordance internal state")
);
assert!(is_blocked("Target/release/foo"));
assert_eq!(
block_reason("Target/release/foo"),
Some("build / vendor artifact")
);
assert!(!is_blocked("myTarget/foo.rs"));
assert!(!is_blocked("docs/Target-tracking.md"));
assert!(!is_blocked(".Cordance-cache/pack.json"));
}
#[test]
fn cordance_internal_state_blocked() {
assert!(is_blocked(".cordance/pack.json"));
assert!(is_blocked(".cordance/sources.lock"));
assert!(is_blocked(".cordance/evidence-map.json"));
assert!(is_blocked(".cordance/cortex-receipt.json"));
assert!(is_blocked(".cordance/llm-candidate.json"));
assert!(is_blocked(".cordance/scan-report.md"));
assert!(is_blocked(".cordance/cache/doctrine/abc/HEAD"));
assert_eq!(
block_reason(".cordance/pack.json"),
Some("cordance internal state")
);
}
#[test]
fn unrelated_cordance_prefix_not_blocked() {
assert!(!is_blocked("docs/cordance-design.md"));
assert!(!is_blocked("src/cordance_helpers.rs"));
}
#[test]
fn repo_tracked_agent_file_not_blocked() {
assert!(!is_blocked(".claude/settings.json"));
assert!(!is_blocked("AGENTS.md"));
assert!(!is_blocked("agents/codex/AGENTS.md"));
}
#[test]
fn dotenv_subdirectory_not_blocked() {
assert!(!is_blocked("crates/.environment/foo.rs"));
assert!(!is_blocked("tests/dotenv-loader.rs"));
assert!(!is_blocked("docs/dotenv-guide.md"));
}
#[test]
fn dotenv_file_blocked() {
assert!(is_blocked(".env"));
assert!(is_blocked("apps/.env"));
assert!(is_blocked(".env.local"));
assert!(is_blocked(".env.production"));
assert!(is_blocked(".env.staging"));
assert!(is_blocked("apps/web/.env.local"));
}
#[test]
fn id_rsa_blocked() {
assert!(is_blocked("id_rsa"));
assert!(is_blocked("~/.ssh/id_rsa"));
assert!(is_blocked(".ssh/id_ed25519"));
assert!(is_blocked("path/to/id_ecdsa"));
assert!(is_blocked("path/to/id_dsa"));
}
#[test]
fn secrets_and_credentials_blocked() {
assert!(is_blocked("secrets.json"));
assert!(is_blocked("config/secrets.yaml"));
assert!(is_blocked("config/secrets.yml"));
assert!(is_blocked("credentials.json"));
assert!(is_blocked("credentials.yaml"));
assert!(is_blocked(".npmrc"));
assert!(is_blocked(".pypirc"));
assert!(is_blocked(".netrc"));
assert!(is_blocked("gcp-key.json"));
}
#[test]
fn secret_prefix_and_suffix_blocked() {
assert!(is_blocked("secret-token.txt"));
assert!(is_blocked("dir/secret-token.txt"));
assert!(is_blocked("path/to/api_secret"));
assert!(is_blocked("api_secret"));
assert!(!is_blocked("secrets/notes.md"));
}
#[test]
fn db_suffix_only_matches_extension() {
assert!(is_blocked("foo.db"));
assert!(!is_blocked("report.dbghelp.txt"));
assert!(!is_blocked("library.dbg.bin"));
}
#[test]
fn log_suffix_blocked() {
assert!(is_blocked("server.log"));
assert!(is_blocked("logs/app.log"));
assert!(!is_blocked("logs.md"));
}
#[test]
fn block_reason_matches_is_blocked() {
let blocked_samples = [
".env",
".env.staging",
"id_rsa",
"secrets.json",
"foo.db",
"server.log",
".claude/sessions/foo.json",
"node_modules/pkg/index.js",
"target/release/cordance",
];
for s in blocked_samples {
assert!(is_blocked(s), "expected blocked: {s}");
assert!(block_reason(s).is_some(), "expected reason for: {s}");
}
}
#[test]
fn block_reason_none_for_clean_paths() {
assert!(block_reason("src/lib.rs").is_none());
assert!(block_reason("docs/adr/0001.md").is_none());
assert!(block_reason("crates/.environment/foo.rs").is_none());
}
#[test]
fn upper_case_secret_prefix_blocked() {
assert!(is_blocked("SECRET-FOO.txt"));
assert!(is_blocked("SECRET-token.txt"));
assert!(is_blocked("Secret-Token.txt"));
assert!(is_blocked("dir/SECRET-FOO.txt"));
assert!(block_reason("SECRET-FOO.txt").is_some());
}
#[test]
fn capitalised_credentials_blocked() {
assert!(is_blocked("Credentials.json"));
assert!(is_blocked("CREDENTIALS.JSON"));
assert!(is_blocked("Secrets.json"));
assert!(is_blocked("SECRETS.YAML"));
assert!(is_blocked("config/Credentials.json"));
assert!(is_blocked("GCP-KEY.JSON"));
assert!(block_reason("Credentials.json") == Some("credential material"));
assert!(block_reason("CREDENTIALS.JSON") == Some("credential material"));
}
#[test]
fn mid_name_caps_secret_blocked() {
assert!(is_blocked("MY.SECRET.txt"));
assert!(is_blocked("my.secret.txt"));
assert!(is_blocked("MY.SECRET")); assert!(is_blocked("foo.SECRET.json"));
assert!(is_blocked("dir/foo.SECRET.json"));
}
#[test]
fn os_junk_case_insensitive() {
assert!(is_blocked(".DS_Store"));
assert!(is_blocked(".ds_store"));
assert!(is_blocked(".DS_STORE"));
assert!(is_blocked("subdir/.DS_Store"));
assert!(is_blocked("Thumbs.db"));
assert!(is_blocked("thumbs.DB"));
assert!(is_blocked("THUMBS.DB"));
assert!(block_reason(".DS_Store") == Some("os junk"));
}
}