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/// Atomic file operations to prevent corruption during writes
10pub mod atomic_file {
11    use super::*;
12
13    /// Write JSON data to a file atomically using a temporary file + rename strategy with file locking
14    pub fn write_json<T: Serialize>(path: &Path, data: &T) -> Result<()> {
15        with_concurrent_file_lock(path, || {
16            let content = serde_json::to_string_pretty(data)
17                .map_err(|e| CascadeError::config(format!("Failed to serialize data: {e}")))?;
18
19            write_string_unlocked(path, &content)
20        })
21    }
22
23    /// Write string content to a file atomically using a temporary file + rename strategy with file locking
24    pub fn write_string(path: &Path, content: &str) -> Result<()> {
25        with_concurrent_file_lock(path, || write_string_unlocked(path, content))
26    }
27
28    /// Execute an operation with file locking optimized for concurrent access
29    fn with_concurrent_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
30    where
31        F: FnOnce() -> Result<R>,
32    {
33        // Use aggressive timeout in environments where concurrent access is expected
34        let use_aggressive =
35            std::env::var("CI").is_ok() || std::env::var("CONCURRENT_ACCESS_EXPECTED").is_ok();
36
37        let _lock = if use_aggressive {
38            crate::utils::file_locking::FileLock::acquire_aggressive(file_path)?
39        } else {
40            crate::utils::file_locking::FileLock::acquire(file_path)?
41        };
42
43        operation()
44    }
45
46    /// Internal unlocked version for use within lock contexts
47    fn write_string_unlocked(path: &Path, content: &str) -> Result<()> {
48        // Create temporary file in the same directory as the target
49        let temp_path = path.with_extension("tmp");
50
51        // Write to temporary file first and ensure it's synced to disk
52        {
53            use std::fs::File;
54            use std::io::Write;
55
56            let mut file = File::create(&temp_path).map_err(|e| {
57                CascadeError::config(format!("Failed to create temporary file: {e}"))
58            })?;
59
60            file.write_all(content.as_bytes()).map_err(|e| {
61                CascadeError::config(format!("Failed to write to temporary file: {e}"))
62            })?;
63
64            // Force data to be written to disk before rename
65            file.sync_all().map_err(|e| {
66                CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
67            })?;
68        }
69
70        // Platform-specific atomic rename
71        atomic_rename(&temp_path, path)
72    }
73
74    /// Platform-specific atomic rename operation
75    #[cfg(windows)]
76    fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
77        // Windows: More robust rename with retry on failure
78        const MAX_RETRIES: u32 = 3;
79        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
80
81        for attempt in 1..=MAX_RETRIES {
82            match fs::rename(temp_path, final_path) {
83                Ok(()) => return Ok(()),
84                Err(e) => {
85                    if attempt == MAX_RETRIES {
86                        // Clean up temp file on final failure
87                        let _ = fs::remove_file(temp_path);
88                        return Err(CascadeError::config(format!(
89                            "Failed to finalize file write after {MAX_RETRIES} attempts on Windows: {e}"
90                        )));
91                    }
92
93                    // Retry after a short delay for transient Windows file locking issues
94                    std::thread::sleep(RETRY_DELAY);
95                }
96            }
97        }
98
99        unreachable!("Loop should have returned or failed by now")
100    }
101
102    #[cfg(not(windows))]
103    fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
104        // Unix: Standard atomic rename works reliably
105        fs::rename(temp_path, final_path)
106            .map_err(|e| CascadeError::config(format!("Failed to finalize file write: {e}")))?;
107        Ok(())
108    }
109
110    /// Write binary data to a file atomically with file locking
111    pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> {
112        with_concurrent_file_lock(path, || {
113            let temp_path = path.with_extension("tmp");
114
115            // Write and sync binary data to disk
116            {
117                use std::fs::File;
118                use std::io::Write;
119
120                let mut file = File::create(&temp_path).map_err(|e| {
121                    CascadeError::config(format!("Failed to create temporary file: {e}"))
122                })?;
123
124                file.write_all(data).map_err(|e| {
125                    CascadeError::config(format!("Failed to write to temporary file: {e}"))
126                })?;
127
128                // Force data to be written to disk before rename
129                file.sync_all().map_err(|e| {
130                    CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
131                })?;
132            }
133
134            atomic_rename(&temp_path, path)
135        })
136    }
137}
138
139/// Path validation utilities to prevent path traversal attacks
140pub mod path_validation {
141    use super::*;
142    use std::path::PathBuf;
143
144    /// Validate and canonicalize a path to ensure it's within allowed boundaries
145    /// Handles both existing and non-existing paths for security validation
146    pub fn validate_config_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
147        // For non-existing paths, we need to validate without canonicalize
148        if !path.exists() {
149            // Validate the base directory exists and can be canonicalized
150            let canonical_base = base_dir.canonicalize().map_err(|e| {
151                CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
152            })?;
153
154            // For non-existing paths, check if the parent directory is within bounds
155            let mut check_path = path.to_path_buf();
156
157            // Find the first existing parent
158            while !check_path.exists() && check_path.parent().is_some() {
159                check_path = check_path.parent().unwrap().to_path_buf();
160            }
161
162            if check_path.exists() {
163                let canonical_check = check_path.canonicalize().map_err(|e| {
164                    CascadeError::config(format!("Cannot validate path security: {e}"))
165                })?;
166
167                if !canonical_check.starts_with(&canonical_base) {
168                    return Err(CascadeError::config(format!(
169                        "Path '{path:?}' would be outside allowed directory '{canonical_base:?}'"
170                    )));
171                }
172            }
173
174            // Return the original path for non-existing files
175            Ok(path.to_path_buf())
176        } else {
177            // For existing paths, use full canonicalization
178            let canonical_path = path
179                .canonicalize()
180                .map_err(|e| CascadeError::config(format!("Invalid path '{path:?}': {e}")))?;
181
182            let canonical_base = base_dir.canonicalize().map_err(|e| {
183                CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
184            })?;
185
186            if !canonical_path.starts_with(&canonical_base) {
187                return Err(CascadeError::config(format!(
188                    "Path '{canonical_path:?}' is outside allowed directory '{canonical_base:?}'"
189                )));
190            }
191
192            Ok(canonical_path)
193        }
194    }
195
196    /// Sanitize a filename to prevent issues with special characters
197    pub fn sanitize_filename(name: &str) -> String {
198        name.chars()
199            .map(|c| match c {
200                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
201                _ => '_',
202            })
203            .collect()
204    }
205}
206
207/// Async utilities to prevent blocking operations
208pub mod async_ops {
209    use super::*;
210    use tokio::task;
211
212    /// Run a potentially blocking Git operation in a background thread
213    pub async fn run_git_operation<F, R>(operation: F) -> Result<R>
214    where
215        F: FnOnce() -> Result<R> + Send + 'static,
216        R: Send + 'static,
217    {
218        task::spawn_blocking(operation)
219            .await
220            .map_err(|e| CascadeError::config(format!("Background task failed: {e}")))?
221    }
222
223    /// Run a potentially blocking file operation in a background thread
224    pub async fn run_file_operation<F, R>(operation: F) -> Result<R>
225    where
226        F: FnOnce() -> Result<R> + Send + 'static,
227        R: Send + 'static,
228    {
229        task::spawn_blocking(operation)
230            .await
231            .map_err(|e| CascadeError::config(format!("File operation failed: {e}")))?
232    }
233}
234
235/// File locking utilities for concurrent access protection
236pub mod file_locking {
237    use super::*;
238    use std::fs::{File, OpenOptions};
239    use std::path::Path;
240    use std::time::{Duration, Instant};
241
242    /// A file lock that prevents concurrent access to critical files
243    pub struct FileLock {
244        _file: File,
245        lock_path: std::path::PathBuf,
246    }
247
248    impl FileLock {
249        /// Platform-specific configuration for file locking
250        #[cfg(windows)]
251        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); // Longer timeout for Windows
252        #[cfg(windows)]
253        const RETRY_INTERVAL: Duration = Duration::from_millis(100); // Less aggressive polling
254
255        #[cfg(not(windows))]
256        const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); // Shorter timeout for Unix
257        #[cfg(not(windows))]
258        const RETRY_INTERVAL: Duration = Duration::from_millis(50); // More frequent polling
259
260        /// Attempt to acquire a lock on a file with timeout
261        pub fn acquire_with_timeout(file_path: &Path, timeout: Duration) -> Result<Self> {
262            let lock_path = file_path.with_extension("lock");
263            let start_time = Instant::now();
264
265            loop {
266                match Self::try_acquire(&lock_path) {
267                    Ok(lock) => return Ok(lock),
268                    Err(e) => {
269                        if start_time.elapsed() >= timeout {
270                            return Err(CascadeError::config(format!(
271                                "Timeout waiting for lock on {file_path:?} after {}ms (platform: {}): {e}",
272                                timeout.as_millis(),
273                                if cfg!(windows) { "windows" } else { "unix" }
274                            )));
275                        }
276                        std::thread::sleep(Self::RETRY_INTERVAL);
277                    }
278                }
279            }
280        }
281
282        /// Try to acquire a lock immediately (non-blocking)
283        pub fn try_acquire(lock_path: &Path) -> Result<Self> {
284            // Platform-specific lock file creation
285            let file = Self::create_lock_file(lock_path)?;
286
287            Ok(Self {
288                _file: file,
289                lock_path: lock_path.to_path_buf(),
290            })
291        }
292
293        /// Platform-specific lock file creation
294        #[cfg(windows)]
295        fn create_lock_file(lock_path: &Path) -> Result<File> {
296            // Windows: More robust file creation with explicit sharing mode
297            OpenOptions::new()
298                .write(true)
299                .create_new(true)
300                .open(lock_path)
301                .map_err(|e| {
302                    // Provide more specific error information for Windows
303                    match e.kind() {
304                        std::io::ErrorKind::AlreadyExists => {
305                            CascadeError::config(format!(
306                                "Lock file {lock_path:?} already exists - another process may be accessing the file"
307                            ))
308                        }
309                        std::io::ErrorKind::PermissionDenied => {
310                            CascadeError::config(format!(
311                                "Permission denied creating lock file {lock_path:?} - check file permissions"
312                            ))
313                        }
314                        _ => CascadeError::config(format!(
315                            "Failed to acquire lock {lock_path:?} on Windows: {e}"
316                        ))
317                    }
318                })
319        }
320
321        #[cfg(not(windows))]
322        fn create_lock_file(lock_path: &Path) -> Result<File> {
323            // Unix: Standard approach works well
324            OpenOptions::new()
325                .write(true)
326                .create_new(true)
327                .open(lock_path)
328                .map_err(|e| {
329                    CascadeError::config(format!("Failed to acquire lock {lock_path:?}: {e}"))
330                })
331        }
332
333        /// Acquire a lock with platform-appropriate default timeout
334        pub fn acquire(file_path: &Path) -> Result<Self> {
335            Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
336        }
337
338        /// Acquire a lock with aggressive timeout for high-concurrency scenarios
339        pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
340            let timeout = if cfg!(windows) {
341                Duration::from_secs(15) // Even longer for Windows under load
342            } else {
343                Duration::from_secs(8) // Slightly longer for Unix under load
344            };
345            Self::acquire_with_timeout(file_path, timeout)
346        }
347    }
348
349    impl Drop for FileLock {
350        fn drop(&mut self) {
351            // Clean up lock file on drop
352            let _ = std::fs::remove_file(&self.lock_path);
353        }
354    }
355
356    /// Execute an operation with file locking protection
357    pub fn with_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
358    where
359        F: FnOnce() -> Result<R>,
360    {
361        let _lock = FileLock::acquire(file_path)?;
362        operation()
363    }
364
365    /// Execute an async operation with file locking protection
366    pub async fn with_file_lock_async<F, Fut, R>(file_path: &Path, operation: F) -> Result<R>
367    where
368        F: FnOnce() -> Fut,
369        Fut: std::future::Future<Output = Result<R>>,
370    {
371        let file_path = file_path.to_path_buf();
372        let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&file_path))
373            .await
374            .map_err(|e| CascadeError::config(format!("Lock task failed: {e}")))?;
375
376        operation().await
377    }
378}