1use crate::errors::{CascadeError, Result};
2use serde::Serialize;
3use std::fs;
4use std::path::Path;
5
6pub mod platform;
8
9pub mod spinner;
11
12pub mod atomic_file {
14 use super::*;
15
16 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 pub fn write_string(path: &Path, content: &str) -> Result<()> {
28 with_concurrent_file_lock(path, || write_string_unlocked(path, content))
29 }
30
31 fn with_concurrent_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
33 where
34 F: FnOnce() -> Result<R>,
35 {
36 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 fn write_string_unlocked(path: &Path, content: &str) -> Result<()> {
51 let temp_path = path.with_extension("tmp");
53
54 {
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 file.sync_all().map_err(|e| {
69 CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
70 })?;
71 }
72
73 atomic_rename(&temp_path, path)
75 }
76
77 #[cfg(windows)]
79 fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
80 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 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 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 fs::rename(temp_path, final_path)
109 .map_err(|e| CascadeError::config(format!("Failed to finalize file write: {e}")))?;
110 Ok(())
111 }
112
113 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 {
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 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
142pub mod path_validation {
144 use super::*;
145 use std::path::PathBuf;
146
147 pub fn validate_config_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
150 if !path.exists() {
152 let canonical_base = base_dir.canonicalize().map_err(|e| {
154 CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
155 })?;
156
157 let mut check_path = path.to_path_buf();
159
160 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 Ok(path.to_path_buf())
179 } else {
180 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 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
210pub mod async_ops {
212 use super::*;
213 use tokio::task;
214
215 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 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
238pub 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 pub struct FileLock {
247 _file: File,
248 lock_path: std::path::PathBuf,
249 }
250
251 impl FileLock {
252 #[cfg(windows)]
254 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(windows)]
256 const RETRY_INTERVAL: Duration = Duration::from_millis(100); #[cfg(not(windows))]
259 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(windows))]
261 const RETRY_INTERVAL: Duration = Duration::from_millis(50); 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 pub fn try_acquire(lock_path: &Path) -> Result<Self> {
287 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 #[cfg(windows)]
298 fn create_lock_file(lock_path: &Path) -> Result<File> {
299 OpenOptions::new()
301 .write(true)
302 .create_new(true)
303 .open(lock_path)
304 .map_err(|e| {
305 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 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 pub fn acquire(file_path: &Path) -> Result<Self> {
338 Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
339 }
340
341 pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
343 let timeout = if cfg!(windows) {
344 Duration::from_secs(15) } else {
346 Duration::from_secs(8) };
348 Self::acquire_with_timeout(file_path, timeout)
349 }
350 }
351
352 impl Drop for FileLock {
353 fn drop(&mut self) {
354 let _ = std::fs::remove_file(&self.lock_path);
356 }
357 }
358
359 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 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}