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