cascade_cli/
utils.rs

1use crate::errors::{CascadeError, Result};
2use serde::Serialize;
3use std::fs;
4use std::path::Path;
5
6/// Platform-specific utilities for cross-platform compatibility
7pub mod platform;
8
9/// Terminal spinner utilities for progress indication
10pub mod spinner;
11
12/// Atomic file operations to prevent corruption during writes
13pub mod atomic_file {
14    use super::*;
15
16    /// Write JSON data to a file atomically using a temporary file + rename strategy with file locking
17    pub fn write_json<T: Serialize>(path: &Path, data: &T) -> Result<()> {
18        with_concurrent_file_lock(path, || {
19            let content = serde_json::to_string_pretty(data)
20                .map_err(|e| CascadeError::config(format!("Failed to serialize data: {e}")))?;
21
22            write_string_unlocked(path, &content)
23        })
24    }
25
26    /// Write string content to a file atomically using a temporary file + rename strategy with file locking
27    pub fn write_string(path: &Path, content: &str) -> Result<()> {
28        with_concurrent_file_lock(path, || write_string_unlocked(path, content))
29    }
30
31    /// Execute an operation with file locking optimized for concurrent access
32    fn with_concurrent_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
33    where
34        F: FnOnce() -> Result<R>,
35    {
36        // Use aggressive timeout in environments where concurrent access is expected
37        let use_aggressive =
38            std::env::var("CI").is_ok() || std::env::var("CONCURRENT_ACCESS_EXPECTED").is_ok();
39
40        let _lock = if use_aggressive {
41            crate::utils::file_locking::FileLock::acquire_aggressive(file_path)?
42        } else {
43            crate::utils::file_locking::FileLock::acquire(file_path)?
44        };
45
46        operation()
47    }
48
49    /// Internal unlocked version for use within lock contexts
50    fn write_string_unlocked(path: &Path, content: &str) -> Result<()> {
51        // Create temporary file in the same directory as the target
52        let temp_path = path.with_extension("tmp");
53
54        // Write to temporary file first and ensure it's synced to disk
55        {
56            use std::fs::File;
57            use std::io::Write;
58
59            let mut file = File::create(&temp_path).map_err(|e| {
60                CascadeError::config(format!("Failed to create temporary file: {e}"))
61            })?;
62
63            file.write_all(content.as_bytes()).map_err(|e| {
64                CascadeError::config(format!("Failed to write to temporary file: {e}"))
65            })?;
66
67            // Force data to be written to disk before rename
68            file.sync_all().map_err(|e| {
69                CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
70            })?;
71        }
72
73        // Platform-specific atomic rename
74        atomic_rename(&temp_path, path)
75    }
76
77    /// Platform-specific atomic rename operation
78    #[cfg(windows)]
79    fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
80        // Windows: More robust rename with retry on failure
81        const MAX_RETRIES: u32 = 3;
82        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
83
84        for attempt in 1..=MAX_RETRIES {
85            match fs::rename(temp_path, final_path) {
86                Ok(()) => return Ok(()),
87                Err(e) => {
88                    if attempt == MAX_RETRIES {
89                        // Clean up temp file on final failure
90                        let _ = fs::remove_file(temp_path);
91                        return Err(CascadeError::config(format!(
92                            "Failed to finalize file write after {MAX_RETRIES} attempts on Windows: {e}"
93                        )));
94                    }
95
96                    // Retry after a short delay for transient Windows file locking issues
97                    std::thread::sleep(RETRY_DELAY);
98                }
99            }
100        }
101
102        unreachable!("Loop should have returned or failed by now")
103    }
104
105    #[cfg(not(windows))]
106    fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
107        // Unix: Standard atomic rename works reliably
108        fs::rename(temp_path, final_path)
109            .map_err(|e| CascadeError::config(format!("Failed to finalize file write: {e}")))?;
110        Ok(())
111    }
112
113    /// Write binary data to a file atomically with file locking
114    pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> {
115        with_concurrent_file_lock(path, || {
116            let temp_path = path.with_extension("tmp");
117
118            // Write and sync binary data to disk
119            {
120                use std::fs::File;
121                use std::io::Write;
122
123                let mut file = File::create(&temp_path).map_err(|e| {
124                    CascadeError::config(format!("Failed to create temporary file: {e}"))
125                })?;
126
127                file.write_all(data).map_err(|e| {
128                    CascadeError::config(format!("Failed to write to temporary file: {e}"))
129                })?;
130
131                // Force data to be written to disk before rename
132                file.sync_all().map_err(|e| {
133                    CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
134                })?;
135            }
136
137            atomic_rename(&temp_path, path)
138        })
139    }
140}
141
142/// Path validation utilities to prevent path traversal attacks
143pub mod path_validation {
144    use super::*;
145    use std::path::PathBuf;
146
147    /// Validate and canonicalize a path to ensure it's within allowed boundaries
148    /// Handles both existing and non-existing paths for security validation
149    pub fn validate_config_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
150        // For non-existing paths, we need to validate without canonicalize
151        if !path.exists() {
152            // Validate the base directory exists and can be canonicalized
153            let canonical_base = base_dir.canonicalize().map_err(|e| {
154                CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
155            })?;
156
157            // For non-existing paths, check if the parent directory is within bounds
158            let mut check_path = path.to_path_buf();
159
160            // Find the first existing parent
161            while !check_path.exists() && check_path.parent().is_some() {
162                check_path = check_path.parent().unwrap().to_path_buf();
163            }
164
165            if check_path.exists() {
166                let canonical_check = check_path.canonicalize().map_err(|e| {
167                    CascadeError::config(format!("Cannot validate path security: {e}"))
168                })?;
169
170                if !canonical_check.starts_with(&canonical_base) {
171                    return Err(CascadeError::config(format!(
172                        "Path '{path:?}' would be outside allowed directory '{canonical_base:?}'"
173                    )));
174                }
175            }
176
177            // Return the original path for non-existing files
178            Ok(path.to_path_buf())
179        } else {
180            // For existing paths, use full canonicalization
181            let canonical_path = path
182                .canonicalize()
183                .map_err(|e| CascadeError::config(format!("Invalid path '{path:?}': {e}")))?;
184
185            let canonical_base = base_dir.canonicalize().map_err(|e| {
186                CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
187            })?;
188
189            if !canonical_path.starts_with(&canonical_base) {
190                return Err(CascadeError::config(format!(
191                    "Path '{canonical_path:?}' is outside allowed directory '{canonical_base:?}'"
192                )));
193            }
194
195            Ok(canonical_path)
196        }
197    }
198
199    /// Sanitize a filename to prevent issues with special characters
200    pub fn sanitize_filename(name: &str) -> String {
201        name.chars()
202            .map(|c| match c {
203                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
204                _ => '_',
205            })
206            .collect()
207    }
208}
209
210/// Async utilities to prevent blocking operations
211pub mod async_ops {
212    use super::*;
213    use tokio::task;
214
215    /// Run a potentially blocking Git operation in a background thread
216    pub async fn run_git_operation<F, R>(operation: F) -> Result<R>
217    where
218        F: FnOnce() -> Result<R> + Send + 'static,
219        R: Send + 'static,
220    {
221        task::spawn_blocking(operation)
222            .await
223            .map_err(|e| CascadeError::config(format!("Background task failed: {e}")))?
224    }
225
226    /// Run a potentially blocking file operation in a background thread
227    pub async fn run_file_operation<F, R>(operation: F) -> Result<R>
228    where
229        F: FnOnce() -> Result<R> + Send + 'static,
230        R: Send + 'static,
231    {
232        task::spawn_blocking(operation)
233            .await
234            .map_err(|e| CascadeError::config(format!("File operation failed: {e}")))?
235    }
236}
237
238/// Git lock utilities for detecting and cleaning stale locks
239pub mod git_lock {
240    use super::*;
241    use std::path::Path;
242    use std::process::Command;
243
244    /// Check if any Git processes are currently running
245    /// Returns Some(true) if Git processes detected, Some(false) if none detected,
246    /// None if detection failed (unable to check)
247    fn has_running_git_processes() -> Option<bool> {
248        // Try to detect running git processes
249        #[cfg(unix)]
250        {
251            match Command::new("pgrep").arg("-i").arg("git").output() {
252                Ok(output) => {
253                    return Some(!output.stdout.is_empty());
254                }
255                Err(e) => {
256                    tracing::debug!("Failed to run pgrep to detect Git processes: {}", e);
257                    return None;
258                }
259            }
260        }
261
262        #[cfg(windows)]
263        {
264            match Command::new("tasklist")
265                .arg("/FI")
266                .arg("IMAGENAME eq git.exe")
267                .output()
268            {
269                Ok(output) => {
270                    let stdout = String::from_utf8_lossy(&output.stdout);
271                    return Some(stdout.contains("git.exe"));
272                }
273                Err(e) => {
274                    tracing::debug!("Failed to run tasklist to detect Git processes: {}", e);
275                    return None;
276                }
277            }
278        }
279
280        #[allow(unreachable_code)]
281        {
282            // Fallback for non-unix, non-windows platforms
283            None
284        }
285    }
286
287    /// Detect and remove stale Git index lock if safe to do so
288    /// Returns true if a stale lock was removed
289    pub fn clean_stale_index_lock(repo_path: &Path) -> Result<bool> {
290        let lock_path = repo_path.join(".git").join("index.lock");
291
292        if !lock_path.exists() {
293            return Ok(false);
294        }
295
296        // Check if any Git processes are running
297        match has_running_git_processes() {
298            Some(true) => {
299                // Git processes are definitely running, don't remove the lock
300                tracing::debug!(
301                    "Git index lock exists and Git processes are running - not removing"
302                );
303                Ok(false)
304            }
305            Some(false) => {
306                // No Git processes detected - safe to remove stale lock
307                tracing::debug!(
308                    "Detected stale Git index lock (no Git processes running), removing: {:?}",
309                    lock_path
310                );
311
312                fs::remove_file(&lock_path).map_err(|e| {
313                    CascadeError::config(format!(
314                        "Failed to remove stale index lock at {:?}: {}",
315                        lock_path, e
316                    ))
317                })?;
318
319                tracing::info!("Removed stale Git index lock at {:?}", lock_path);
320                Ok(true)
321            }
322            None => {
323                // Can't detect processes - fail safe by not removing the lock
324                tracing::debug!(
325                    "Cannot detect Git processes (pgrep/tasklist unavailable) - not removing lock for safety"
326                );
327                Ok(false)
328            }
329        }
330    }
331
332    /// Check if an error is a Git lock error
333    pub fn is_lock_error(error: &git2::Error) -> bool {
334        error.code() == git2::ErrorCode::Locked
335            || error.message().contains("index is locked")
336            || error.message().contains("index.lock")
337    }
338
339    /// Execute a git2 operation with automatic stale lock cleanup and retry
340    /// If the operation fails with a lock error, checks for stale locks and retries once
341    pub fn with_lock_retry<F, R>(repo_path: &Path, operation: F) -> Result<R>
342    where
343        F: Fn() -> std::result::Result<R, git2::Error>,
344    {
345        match operation() {
346            Ok(result) => Ok(result),
347            Err(e) if is_lock_error(&e) => {
348                // Got a lock error - check if we can clean up a stale lock
349                tracing::debug!("Git operation failed with lock error: {}", e);
350
351                match clean_stale_index_lock(repo_path) {
352                    Ok(true) => {
353                        // Stale lock was removed, retry the operation
354                        tracing::info!("Retrying operation after removing stale lock");
355                        operation().map_err(CascadeError::Git)
356                    }
357                    Ok(false) => {
358                        // Lock exists but is not stale (Git processes are running)
359                        Err(CascadeError::Git(e))
360                    }
361                    Err(cleanup_err) => {
362                        // Failed to clean up lock, return original error
363                        tracing::warn!("Failed to clean up stale lock: {}", cleanup_err);
364                        Err(CascadeError::Git(e))
365                    }
366                }
367            }
368            Err(e) => Err(CascadeError::Git(e)),
369        }
370    }
371}
372
373/// File locking utilities for concurrent access protection
374pub mod file_locking {
375    use super::*;
376    use std::fs::{File, OpenOptions};
377    use std::path::Path;
378    use std::time::{Duration, Instant};
379
380    /// A file lock that prevents concurrent access to critical files
381    pub struct FileLock {
382        _file: File,
383        lock_path: std::path::PathBuf,
384    }
385
386    impl FileLock {
387        /// Platform-specific configuration for file locking
388        #[cfg(windows)]
389        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); // Longer timeout for Windows
390        #[cfg(windows)]
391        const RETRY_INTERVAL: Duration = Duration::from_millis(100); // Less aggressive polling
392
393        #[cfg(not(windows))]
394        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); // Shorter timeout for Unix
395        #[cfg(not(windows))]
396        const RETRY_INTERVAL: Duration = Duration::from_millis(50); // More frequent polling
397
398        /// Attempt to acquire a lock on a file with timeout
399        pub fn acquire_with_timeout(file_path: &Path, timeout: Duration) -> Result<Self> {
400            let lock_path = file_path.with_extension("lock");
401            let start_time = Instant::now();
402
403            loop {
404                match Self::try_acquire(&lock_path) {
405                    Ok(lock) => return Ok(lock),
406                    Err(e) => {
407                        if start_time.elapsed() >= timeout {
408                            return Err(CascadeError::config(format!(
409                                "Timeout waiting for lock on {file_path:?} after {}ms (platform: {}): {e}",
410                                timeout.as_millis(),
411                                if cfg!(windows) { "windows" } else { "unix" }
412                            )));
413                        }
414                        std::thread::sleep(Self::RETRY_INTERVAL);
415                    }
416                }
417            }
418        }
419
420        /// Try to acquire a lock immediately (non-blocking)
421        pub fn try_acquire(lock_path: &Path) -> Result<Self> {
422            // Platform-specific lock file creation
423            let file = Self::create_lock_file(lock_path)?;
424
425            Ok(Self {
426                _file: file,
427                lock_path: lock_path.to_path_buf(),
428            })
429        }
430
431        /// Platform-specific lock file creation
432        #[cfg(windows)]
433        fn create_lock_file(lock_path: &Path) -> Result<File> {
434            // Windows: More robust file creation with explicit sharing mode
435            OpenOptions::new()
436                .write(true)
437                .create_new(true)
438                .open(lock_path)
439                .map_err(|e| {
440                    // Provide more specific error information for Windows
441                    match e.kind() {
442                        std::io::ErrorKind::AlreadyExists => {
443                            CascadeError::config(format!(
444                                "Lock file {lock_path:?} already exists - another process may be accessing the file"
445                            ))
446                        }
447                        std::io::ErrorKind::PermissionDenied => {
448                            CascadeError::config(format!(
449                                "Permission denied creating lock file {lock_path:?} - check file permissions"
450                            ))
451                        }
452                        _ => CascadeError::config(format!(
453                            "Failed to acquire lock {lock_path:?} on Windows: {e}"
454                        ))
455                    }
456                })
457        }
458
459        #[cfg(not(windows))]
460        fn create_lock_file(lock_path: &Path) -> Result<File> {
461            // Unix: Standard approach works well
462            OpenOptions::new()
463                .write(true)
464                .create_new(true)
465                .open(lock_path)
466                .map_err(|e| {
467                    CascadeError::config(format!("Failed to acquire lock {lock_path:?}: {e}"))
468                })
469        }
470
471        /// Acquire a lock with platform-appropriate default timeout
472        pub fn acquire(file_path: &Path) -> Result<Self> {
473            Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
474        }
475
476        /// Acquire a lock with aggressive timeout for high-concurrency scenarios
477        pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
478            let timeout = if cfg!(windows) {
479                Duration::from_secs(15) // Even longer for Windows under load
480            } else {
481                Duration::from_secs(8) // Slightly longer for Unix under load
482            };
483            Self::acquire_with_timeout(file_path, timeout)
484        }
485    }
486
487    impl Drop for FileLock {
488        fn drop(&mut self) {
489            // Clean up lock file on drop
490            let _ = std::fs::remove_file(&self.lock_path);
491        }
492    }
493
494    /// Execute an operation with file locking protection
495    pub fn with_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
496    where
497        F: FnOnce() -> Result<R>,
498    {
499        let _lock = FileLock::acquire(file_path)?;
500        operation()
501    }
502
503    /// Execute an async operation with file locking protection
504    pub async fn with_file_lock_async<F, Fut, R>(file_path: &Path, operation: F) -> Result<R>
505    where
506        F: FnOnce() -> Fut,
507        Fut: std::future::Future<Output = Result<R>>,
508    {
509        let file_path = file_path.to_path_buf();
510        let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&file_path))
511            .await
512            .map_err(|e| CascadeError::config(format!("Lock task failed: {e}")))?;
513
514        operation().await
515    }
516}