diff --git a/README.md b/README.md index aef5a67..c75706e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The authored evaluation benchmark is located in `evaluation/questions.json`, wit - **No In-Binary Hybrid or Vector Search**: Vector embeddings and hybrid search algorithms exist only in the offline Python script `evaluation/evaluate_hybrid.py`. They are **not** integrated into the Rust product binary, library, or HTTP API. - **Heuristic Citation Screening, Not Answer Verification**: The CLI screens some plain `path:line` tokens against retrieved text. This is not a complete citation parser and does not prove that an answer is supported by its sources. The lexical evaluator reports retrieval Recall@5 and MRR only, not answer or citation accuracy. -- **File Access Policy**: Initial scans and incremental updates exclude hidden path components, common build/dependency directories, `.pem`/`.key` files, and named SSH private keys. Incremental updates reject absolute/parent paths and symlink components. This name-based policy is not a secret scanner and does not defend against a hostile concurrent filesystem mutation. +- **File Access Policy**: Initial scans and incremental updates exclude hidden path components, common build/dependency directories, `.pem`/`.key` files, and named SSH private keys. Incremental updates reject absolute/parent paths and symlink components. Sensitive-name matching is ASCII case-insensitive, so an alias like `CREDENTIALS.JSON` cannot bypass the policy on a case-insensitive filesystem. This name-based policy is not a secret scanner and does not defend against a hostile concurrent filesystem mutation. - **Working-Tree Prototype**: Files are read from the working tree, not an immutable Git snapshot. A `-dirty` label is diagnostic, not a reproducible snapshot identifier. Commit-consistent indexing and complete rename handling remain unfinished. - **No Background Watcher**: Background filesystem events are not monitored; synchronization is triggered explicitly via CLI or HTTP `/reload`. - **No Production Hardening**: Authentication, TLS, rate limiting, and multi-tenant isolation remain out of scope for this local development prototype. diff --git a/src/lib.rs b/src/lib.rs index 6d6012d..02b62e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -284,6 +284,7 @@ fn is_indexable(path: &Path) -> bool { /// Single source of truth for file names that must never be indexed. /// Shared by the full walk and incremental updates so a scan and a rebase agree. fn is_sensitive_file_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); (name.starts_with('.') && name != ".github" && name != ".gitignore") || name.ends_with(".pem") || name.ends_with(".key") @@ -463,4 +464,66 @@ mod tests { .unwrap(); assert!(index.search("legacycanary", 5).is_empty()); } + + #[test] + fn sensitive_names_match_case_insensitively() { + for name in [ + "CREDENTIALS.JSON", + "Service-Account.json", + "CLIENT.P12", + "CLIENT.PFX", + "RELEASE.KEYSTORE", + "ID_RSA", + "ID_ED25519", + "SERVER.PEM", + "SERVER.KEY", + ".ENV", + ".Env.local", + ] { + assert!(is_sensitive_file_name(name), "{name} must be sensitive"); + } + for name in ["credentials.rs", "service.rs", "keep.p12.bak"] { + assert!( + !is_sensitive_file_name(name), + "{name} must not be sensitive" + ); + } + } + + #[test] + fn full_scan_and_update_agree_on_case_variant_sensitive_names() { + let root = fixture(); + fs::create_dir_all(root.join("sub")).unwrap(); + let sensitive = [ + "CREDENTIALS.JSON", + "Service-Account.json", + "CLIENT.P12", + "sub/RELEASE.PFX", + "sub/KEYSTORE.KEYSTORE", + "ID_RSA", + ]; + for name in sensitive { + fs::write(root.join(name), "casecanary").unwrap(); + } + + let mut index = Index::build(&root).unwrap(); + assert!(index.search("casecanary", 20).is_empty()); + for name in sensitive { + index.update_file(&root, Path::new(name)).unwrap(); + } + assert!(index.search("casecanary", 20).is_empty()); + } + + #[test] + fn update_file_case_alias_cannot_bypass_sensitive_policy() { + let root = fixture(); + fs::write(root.join("credentials.json"), "aliascanary").unwrap(); + let mut index = Index::build(&root).unwrap(); + assert!(index.search("aliascanary", 5).is_empty()); + + index + .update_file(&root, Path::new("CREDENTIALS.JSON")) + .unwrap(); + assert!(index.search("aliascanary", 5).is_empty()); + } }