Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 46 additions & 19 deletions src/cortex-cli/src/utils/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ pub fn get_cortex_home() -> PathBuf {
/// // Returns: /home/user/documents/file.txt
/// ```
pub fn expand_tilde(path: &str) -> String {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(&path[2..]).to_string_lossy().to_string();
if path == "~" {
// Handle bare "~" - return home directory
if let Some(home) = dirs::home_dir() {
return home.to_string_lossy().to_string();
}
} else if let Some(suffix) = path.strip_prefix("~/") {
// Handle "~/" prefix - expand to home directory + rest of path
if let Some(home) = dirs::home_dir() {
return home.join(suffix).to_string_lossy().to_string();
}
}
path.to_string()
}
Expand All @@ -58,8 +64,12 @@ pub fn expand_tilde(path: &str) -> String {
pub fn validate_path_safety(path: &Path, base_dir: Option<&Path>) -> Result<(), String> {
let path_str = path.to_string_lossy();

// Check for path traversal attempts
if path_str.contains("..") {
// Check for path traversal attempts by examining path components
// This correctly handles filenames containing ".." like "file..txt"
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err("Path contains traversal sequence '..'".to_string());
}

Expand Down Expand Up @@ -257,8 +267,15 @@ mod tests {

#[test]
fn test_expand_tilde_with_tilde_only() {
// Test tilde alone - should remain unchanged (not "~/")
assert_eq!(expand_tilde("~"), "~");
// Test bare "~" - should expand to home directory
let result = expand_tilde("~");
if let Some(home) = dirs::home_dir() {
let expected = home.to_string_lossy().to_string();
assert_eq!(result, expected);
} else {
// If no home dir, original is returned
assert_eq!(result, "~");
}
}

#[test]
Expand Down Expand Up @@ -320,20 +337,30 @@ mod tests {

#[test]
fn test_validate_path_safety_detects_various_traversal_patterns() {
// Different traversal patterns
let patterns = ["foo/../bar", "...", "foo/bar/../baz", "./foo/../../../etc"];
// Patterns that ARE path traversal (contain ".." as a component)
let traversal_patterns = ["foo/../bar", "foo/bar/../baz", "./foo/../../../etc", ".."];

for pattern in patterns {
for pattern in traversal_patterns {
let path = Path::new(pattern);
let result = validate_path_safety(path, None);
// Only patterns containing ".." should fail
if pattern.contains("..") {
assert!(
result.is_err(),
"Expected traversal detection for: {}",
pattern
);
}
assert!(
result.is_err(),
"Expected traversal detection for: {}",
pattern
);
}

// Patterns that are NOT path traversal (contain ".." in filenames only)
let safe_patterns = ["file..txt", "..hidden", "test...file", "foo/bar..baz/file"];

for pattern in safe_patterns {
let path = Path::new(pattern);
let result = validate_path_safety(path, None);
assert!(
result.is_ok(),
"False positive: '{}' should not be detected as traversal",
pattern
);
}
}

Expand Down
35 changes: 35 additions & 0 deletions src/cortex-common/src/file_locking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,9 @@ pub async fn atomic_write_async(
.map_err(|e| FileLockError::AtomicWriteFailed(format!("spawn_blocking failed: {}", e)))?
}

/// Maximum number of lock entries before triggering cleanup.
const MAX_LOCK_ENTRIES: usize = 10_000;

/// A file lock manager for coordinating access across multiple operations.
///
/// This is useful when you need to perform multiple operations on a file
Expand All @@ -577,15 +580,47 @@ impl FileLockManager {
///
/// This is in addition to the filesystem-level advisory lock and helps
/// coordinate access within the same process.
///
/// Automatically cleans up stale lock entries when the map grows too large.
pub fn get_lock(&self, path: impl AsRef<Path>) -> Arc<std::sync::Mutex<()>> {
let path = path.as_ref().to_path_buf();
let mut locks = self.locks.lock().unwrap();

// Clean up stale entries if the map is getting large
if locks.len() >= MAX_LOCK_ENTRIES {
Self::cleanup_stale_entries(&mut locks);
}

locks
.entry(path)
.or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
.clone()
}

/// Remove lock entries that are no longer in use.
///
/// An entry is considered stale when only the HashMap holds a reference
/// to it (strong_count == 1), meaning no caller is currently using the lock.
fn cleanup_stale_entries(
locks: &mut std::collections::HashMap<PathBuf, Arc<std::sync::Mutex<()>>>,
) {
locks.retain(|_, arc| Arc::strong_count(arc) > 1);
}

/// Manually trigger cleanup of stale lock entries.
///
/// This removes entries where no external reference exists (only the
/// manager holds the Arc). Useful for periodic maintenance.
pub fn cleanup(&self) {
let mut locks = self.locks.lock().unwrap();
Self::cleanup_stale_entries(&mut locks);
}

/// Returns the current number of lock entries in the manager.
pub fn lock_count(&self) -> usize {
self.locks.lock().unwrap().len()
}

/// Execute an operation with both process-local and file-system locks.
pub fn with_lock<T, F>(&self, path: impl AsRef<Path>, mode: LockMode, f: F) -> FileLockResult<T>
where
Expand Down
28 changes: 22 additions & 6 deletions src/cortex-engine/src/config/config_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,36 @@
//! with caching support for performance in monorepo environments.

use std::collections::HashMap;
use std::hash::Hash;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, RwLock};

use tracing::{debug, trace};

/// Maximum number of entries in each cache to prevent unbounded memory growth.
const MAX_CACHE_SIZE: usize = 1000;

/// Cache for discovered config paths.
/// Key is the start directory, value is the found config path (or None).
static CONFIG_CACHE: LazyLock<RwLock<HashMap<PathBuf, Option<PathBuf>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
LazyLock::new(|| RwLock::new(HashMap::with_capacity(MAX_CACHE_SIZE)));

/// Cache for project roots.
/// Key is the start directory, value is the project root path.
static PROJECT_ROOT_CACHE: LazyLock<RwLock<HashMap<PathBuf, Option<PathBuf>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
LazyLock::new(|| RwLock::new(HashMap::with_capacity(MAX_CACHE_SIZE)));

/// Insert a key-value pair into the cache with eviction when full.
/// When the cache reaches MAX_CACHE_SIZE, removes an arbitrary entry before inserting.
fn insert_with_eviction<K: Eq + Hash + Clone, V>(cache: &mut HashMap<K, V>, key: K, value: V) {
if cache.len() >= MAX_CACHE_SIZE {
// Remove first entry (simple eviction strategy)
if let Some(k) = cache.keys().next().cloned() {
cache.remove(&k);
}
}
cache.insert(key, value);
}

/// Markers that indicate a project root directory.
const PROJECT_ROOT_MARKERS: &[&str] = &[
Expand Down Expand Up @@ -57,9 +73,9 @@ pub fn find_up(start_dir: &Path, filename: &str) -> Option<PathBuf> {

let result = find_up_uncached(start_dir, filename);

// Store in cache
// Store in cache with eviction when full
if let Ok(mut cache) = CONFIG_CACHE.write() {
cache.insert(cache_key, result.clone());
insert_with_eviction(&mut cache, cache_key, result.clone());
}

result
Expand Down Expand Up @@ -169,9 +185,9 @@ pub fn find_project_root(start_dir: &Path) -> Option<PathBuf> {

let result = find_project_root_uncached(start_dir);

// Store in cache
// Store in cache with eviction when full
if let Ok(mut cache) = PROJECT_ROOT_CACHE.write() {
cache.insert(start_dir.to_path_buf(), result.clone());
insert_with_eviction(&mut cache, start_dir.to_path_buf(), result.clone());
}

result
Expand Down
22 changes: 19 additions & 3 deletions src/cortex-engine/src/tokenizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,25 @@
//! Provides token counting and text tokenization for various models.

use std::collections::HashMap;
use std::hash::Hash;

use serde::{Deserialize, Serialize};

/// Maximum number of entries in the token cache to prevent unbounded memory growth.
const MAX_CACHE_SIZE: usize = 1000;

/// Insert a key-value pair into the cache with eviction when full.
/// When the cache reaches MAX_CACHE_SIZE, removes an arbitrary entry before inserting.
fn insert_with_eviction<K: Eq + Hash + Clone, V>(cache: &mut HashMap<K, V>, key: K, value: V) {
if cache.len() >= MAX_CACHE_SIZE {
// Remove first entry (simple eviction strategy)
if let Some(k) = cache.keys().next().cloned() {
cache.remove(&k);
}
}
cache.insert(key, value);
}

/// Tokenizer type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -58,7 +74,7 @@ impl TokenizerType {
pub struct TokenCounter {
/// Tokenizer type.
tokenizer: TokenizerType,
/// Cache.
/// Cache with bounded size to prevent unbounded memory growth.
cache: HashMap<u64, u32>,
}

Expand All @@ -67,7 +83,7 @@ impl TokenCounter {
pub fn new(tokenizer: TokenizerType) -> Self {
Self {
tokenizer,
cache: HashMap::new(),
cache: HashMap::with_capacity(MAX_CACHE_SIZE),
}
}

Expand All @@ -85,7 +101,7 @@ impl TokenCounter {
}

let count = self.count_uncached(text);
self.cache.insert(hash, count);
insert_with_eviction(&mut self.cache, hash, count);
count
}

Expand Down
6 changes: 6 additions & 0 deletions src/cortex-engine/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod artifacts;
pub mod context;
pub mod handlers;
pub mod registry;
pub mod response_store;
pub mod router;
pub mod spec;
pub mod unified_executor;
Expand All @@ -45,6 +46,11 @@ pub use artifacts::{
pub use context::ToolContext;
pub use handlers::*;
pub use registry::{PluginTool, ToolRegistry};
pub use response_store::{
CLEANUP_INTERVAL, DEFAULT_TTL, MAX_STORE_SIZE, StoreInfo, StoreStats, StoredResponse,
ToolResponseStore, ToolResponseStoreConfig, create_shared_store,
create_shared_store_with_config,
};
pub use router::ToolRouter;
pub use spec::{ToolCall, ToolDefinition, ToolHandler, ToolResult};
pub use unified_executor::{ExecutorConfig, UnifiedToolExecutor};
Loading