1use crate::errors::{CascadeError, Result};
2use serde::Serialize;
3use std::fs;
4use std::path::Path;
5
6pub mod platform;
8
9pub mod atomic_file {
11 use super::*;
12
13 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 pub fn write_string(path: &Path, content: &str) -> Result<()> {
25 with_concurrent_file_lock(path, || write_string_unlocked(path, content))
26 }
27
28 fn with_concurrent_file_lock<F, R>(file_path: &Path, operation: F) -> Result<R>
30 where
31 F: FnOnce() -> Result<R>,
32 {
33 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 fn write_string_unlocked(path: &Path, content: &str) -> Result<()> {
48 let temp_path = path.with_extension("tmp");
50
51 {
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 file.sync_all().map_err(|e| {
66 CascadeError::config(format!("Failed to sync temporary file to disk: {e}"))
67 })?;
68 }
69
70 atomic_rename(&temp_path, path)
72 }
73
74 #[cfg(windows)]
76 fn atomic_rename(temp_path: &Path, final_path: &Path) -> Result<()> {
77 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 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 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 fs::rename(temp_path, final_path)
106 .map_err(|e| CascadeError::config(format!("Failed to finalize file write: {e}")))?;
107 Ok(())
108 }
109
110 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 {
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 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
139pub mod path_validation {
141 use super::*;
142 use std::path::PathBuf;
143
144 pub fn validate_config_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
147 if !path.exists() {
149 let canonical_base = base_dir.canonicalize().map_err(|e| {
151 CascadeError::config(format!("Invalid base directory '{base_dir:?}': {e}"))
152 })?;
153
154 let mut check_path = path.to_path_buf();
156
157 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 Ok(path.to_path_buf())
176 } else {
177 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 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
207pub mod async_ops {
209 use super::*;
210 use tokio::task;
211
212 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 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
235pub 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 pub struct FileLock {
244 _file: File,
245 lock_path: std::path::PathBuf,
246 }
247
248 impl FileLock {
249 #[cfg(windows)]
251 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(windows)]
253 const RETRY_INTERVAL: Duration = Duration::from_millis(100); #[cfg(not(windows))]
256 const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(windows))]
258 const RETRY_INTERVAL: Duration = Duration::from_millis(50); 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 pub fn try_acquire(lock_path: &Path) -> Result<Self> {
284 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 #[cfg(windows)]
295 fn create_lock_file(lock_path: &Path) -> Result<File> {
296 OpenOptions::new()
298 .write(true)
299 .create_new(true)
300 .open(lock_path)
301 .map_err(|e| {
302 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 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 pub fn acquire(file_path: &Path) -> Result<Self> {
335 Self::acquire_with_timeout(file_path, Self::DEFAULT_TIMEOUT)
336 }
337
338 pub fn acquire_aggressive(file_path: &Path) -> Result<Self> {
340 let timeout = if cfg!(windows) {
341 Duration::from_secs(15) } else {
343 Duration::from_secs(8) };
345 Self::acquire_with_timeout(file_path, timeout)
346 }
347 }
348
349 impl Drop for FileLock {
350 fn drop(&mut self) {
351 let _ = std::fs::remove_file(&self.lock_path);
353 }
354 }
355
356 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 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}