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/// File locking utilities for concurrent access protection
239pub mod file_locking {
240    use super::*;
241    use std::fs::{File, OpenOptions};
242    use std::path::Path;
243    use std::time::{Duration, Instant};
244
245    /// A file lock that prevents concurrent access to critical files
246    pub struct FileLock {
247        _file: File,
248        lock_path: std::path::PathBuf,
249    }
250
251    impl FileLock {
252        /// Platform-specific configuration for file locking
253        #[cfg(windows)]
254        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); // Longer timeout for Windows
255        #[cfg(windows)]
256        const RETRY_INTERVAL: Duration = Duration::from_millis(100); // Less aggressive polling
257
258        #[cfg(not(windows))]
259        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); // Shorter timeout for Unix
260        #[cfg(not(windows))]
261        const RETRY_INTERVAL: Duration = Duration::from_millis(50); // More frequent polling
262
263        /// Attempt to acquire a lock on a file with timeout
264        pub fn acquire_with_timeout(file_path: &Path, timeout: Duration) -> Result<Self> {
265            let lock_path = file_path.with_extension("lock");
266            let start_time = Instant::now();
267
268            loop {
269                match Self::try_acquire(&lock_path) {
270                    Ok(lock) => return Ok(lock),
271                    Err(e) => {
272                        if start_time.elapsed() >= timeout {
273                            return Err(CascadeError::config(format!(
274                                "Timeout waiting for lock on {file_path:?} after {}ms (platform: {}): {e}",
275                                timeout.as_millis(),
276                                if cfg!(windows) { "windows" } else { "unix" }
277                            )));
278                        }
279                        std::thread::sleep(Self::RETRY_INTERVAL);
280                    }
281                }
282            }
283        }
284
285        /// Try to acquire a lock immediately (non-blocking)
286        pub fn try_acquire(lock_path: &Path) -> Result<Self> {
287            // Platform-specific lock file creation
288            let file = Self::create_lock_file(lock_path)?;
289
290            Ok(Self {
291                _file: file,
292                lock_path: lock_path.to_path_buf(),
293            })
294        }
295
296        /// Platform-specific lock file creation
297        #[cfg(windows)]
298        fn create_lock_file(lock_path: &Path) -> Result<File> {
299            // Windows: More robust file creation with explicit sharing mode
300            OpenOptions::new()
301                .write(true)
302                .create_new(true)
303                .open(lock_path)
304                .map_err(|e| {
305                    // Provide more specific error information for Windows
306                    match e.kind() {
307                        std::io::ErrorKind::AlreadyExists => {
308                            CascadeError::config(format!(
309                                "Lock file {lock_path:?} already exists - another process may be accessing the file"
310                            ))
311                        }
312                        std::io::ErrorKind::PermissionDenied => {
313                            CascadeError::config(format!(
314                                "Permission denied creating lock file {lock_path:?} - check file permissions"
315                            ))
316                        }
317                        _ => CascadeError::config(format!(
318                            "Failed to acquire lock {lock_path:?} on Windows: {e}"
319                        ))
320                    }
321                })
322        }
323
324        #[cfg(not(windows))]
325        fn create_lock_file(lock_path: &Path) -> Result<File> {
326            // Unix: Standard approach works well
327            OpenOptions::new()
328                .write(true)
329                .create_new(true)
330                .open(lock_path)
331                .map_err(|e| {
332                    CascadeError::config(format!("Failed to acquire lock {lock_path:?}: {e}"))
333                })
334        }
335
336        /// Acquire a lock with platform-appropriate default timeout
337        pub fn acquire(file_path: &Path) -> Result<Self> {
338            Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
339        }
340
341        /// Acquire a lock with aggressive timeout for high-concurrency scenarios
342        pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
343            let timeout = if cfg!(windows) {
344                Duration::from_secs(15) // Even longer for Windows under load
345            } else {
346                Duration::from_secs(8) // Slightly longer for Unix under load
347            };
348            Self::acquire_with_timeout(file_path, timeout)
349        }
350    }
351
352    impl Drop for FileLock {
353        fn drop(&mut self) {
354            // Clean up lock file on drop
355            let _ = std::fs::remove_file(&self.lock_path);
356        }
357    }
358
359    /// Execute an operation with file locking protection
360    pub fn with_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
361    where
362        F: FnOnce() -> Result<R>,
363    {
364        let _lock = FileLock::acquire(file_path)?;
365        operation()
366    }
367
368    /// Execute an async operation with file locking protection
369    pub async fn with_file_lock_async<F, Fut, R>(file_path: &Path, operation: F) -> Result<R>
370    where
371        F: FnOnce() -> Fut,
372        Fut: std::future::Future<Output = Result<R>>,
373    {
374        let file_path = file_path.to_path_buf();
375        let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&file_path))
376            .await
377            .map_err(|e| CascadeError::config(format!("Lock task failed: {e}")))?;
378
379        operation().await
380    }
381}