atlas_cli/
utils.rs

1use crate::error::{Error, Result};
2use std::fs::{self, File, OpenOptions};
3use std::path::{Path, PathBuf};
4
5/// Ensures a file path is safe to use (not a symlink or hard link unless allowed)
6///
7/// # Examples
8///
9/// ```no_run
10/// use atlas_cli::utils::safe_file_path;
11/// use std::path::Path;
12///
13/// let path = Path::new("/tmp/safe_file.txt");
14///
15/// // Check path without allowing symlinks
16/// match safe_file_path(&path, false) {
17///     Ok(safe_path) => println!("Path is safe: {:?}", safe_path),
18///     Err(e) => println!("Path is not safe: {}", e),
19/// }
20///
21/// // Allow symlinks
22/// let _ = safe_file_path(&path, true);
23/// ```
24pub fn safe_file_path(path: &Path, allow_symlinks: bool) -> Result<PathBuf> {
25    // Check if the file exists
26    if path.exists() {
27        // Check if it's a symlink
28        if path.is_symlink() {
29            if !allow_symlinks {
30                return Err(Error::Validation(format!(
31                    "Security error: Path {} is a symlink, which is not allowed",
32                    path.display()
33                )));
34            }
35
36            // If symlinks are allowed, check the target is valid
37            let target = fs::read_link(path)?;
38
39            // Validate the target path (customize this logic based on your requirements)
40            // For example, ensure it's within a specific directory
41            if !is_safe_symlink_target(&target) {
42                return Err(Error::Validation(format!(
43                    "Security error: Symlink target {} is not in an allowed location",
44                    target.display()
45                )));
46            }
47
48            return Ok(target);
49        }
50
51        // Check for hard links (files with multiple links)
52        #[cfg(unix)]
53        {
54            use std::os::unix::fs::MetadataExt;
55            let metadata = fs::metadata(path)?;
56            if metadata.nlink() > 1 {
57                return Err(Error::Validation(format!(
58                    "Security error: Path {} has multiple hard links ({})",
59                    path.display(),
60                    metadata.nlink()
61                )));
62            }
63        }
64    }
65
66    // Path is safe or doesn't exist yet !
67    Ok(path.to_path_buf())
68}
69
70/// Checks if a symlink target is in an allowed location
71fn is_safe_symlink_target(target: &Path) -> bool {
72    if let Ok(canonical) = target.canonicalize() {
73        // Only allow /tmp or /var/app/data for now
74        canonical.starts_with("/tmp") || canonical.starts_with("/var/app/data")
75    } else {
76        false
77    }
78}
79
80/// Safely opens a file for reading
81///
82/// # Examples
83///
84/// ```no_run
85/// use atlas_cli::utils::safe_open_file;
86/// use std::path::Path;
87/// use std::io::Read;
88///
89/// let path = Path::new("example.txt");
90///
91/// match safe_open_file(&path, false) {
92///     Ok(mut file) => {
93///         let mut contents = String::new();
94///         file.read_to_string(&mut contents).unwrap();
95///         println!("File contents: {}", contents);
96///     }
97///     Err(e) => eprintln!("Error opening file: {}", e),
98/// }
99/// ```
100pub fn safe_open_file(path: &Path, allow_symlinks: bool) -> Result<File> {
101    let safe_path = safe_file_path(path, allow_symlinks)?;
102    File::open(&safe_path).map_err(Error::from)
103}
104
105/// Safely creates a file for writing
106///
107/// # Examples
108///
109/// ```no_run
110/// use atlas_cli::utils::safe_create_file;
111/// use std::path::Path;
112/// use std::io::Write;
113///
114/// let path = Path::new("/tmp/new_file.txt");
115///
116/// match safe_create_file(&path, false) {
117///     Ok(mut file) => {
118///         file.write_all(b"Hello, World!").unwrap();
119///         println!("File created successfully");
120///     }
121///     Err(e) => eprintln!("Error creating file: {}", e),
122/// }
123/// ```
124pub fn safe_create_file(path: &Path, allow_symlinks: bool) -> Result<File> {
125    let safe_path = safe_file_path(path, allow_symlinks)?;
126    File::create(&safe_path).map_err(Error::from)
127}
128
129/// Safely opens a file with custom options
130pub fn safe_open_options(path: &Path, allow_symlinks: bool) -> Result<OpenOptions> {
131    let _safe_path = safe_file_path(path, allow_symlinks)?;
132    Ok(OpenOptions::new())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::error::Result;
139    use std::fs::{self, File};
140    use std::io::{Read, Write};
141    use std::path::PathBuf;
142    use tempfile::tempdir;
143
144    #[test]
145    fn test_safe_file_path_normal() -> Result<()> {
146        // Test with a normal path
147        let dir = tempdir()?;
148        let normal_path = dir.path().join("test_file.txt");
149
150        // Should return the same path
151        let result = safe_file_path(&normal_path, false)?;
152        assert_eq!(result, normal_path);
153
154        Ok(())
155    }
156
157    #[test]
158    fn test_safe_file_path_nonexistent() -> Result<()> {
159        // Test with a nonexistent path
160        let dir = tempdir()?;
161        let nonexistent_path = dir.path().join("nonexistent_file.txt");
162
163        // Should still return the path if it doesn't exist
164        let result = safe_file_path(&nonexistent_path, false)?;
165        assert_eq!(result, nonexistent_path);
166
167        Ok(())
168    }
169
170    #[test]
171    #[cfg(unix)] // This test is Unix-specific
172    fn test_safe_file_path_symlink() -> Result<()> {
173        // Create a temporary directory and files
174        let dir = tempdir()?;
175        let target_path = dir.path().join("target_file.txt");
176        let symlink_path = dir.path().join("symlink_file.txt");
177
178        // Create the target file
179        let mut file = File::create(&target_path)?;
180        file.write_all(b"target file content")?;
181
182        // Create a symlink to the target
183        std::os::unix::fs::symlink(&target_path, &symlink_path)?;
184
185        // Test with symlinks not allowed (default)
186        let result = safe_file_path(&symlink_path, false);
187        assert!(result.is_err(), "Should reject symlinks when not allowed");
188
189        // Test with symlinks allowed
190        let result = safe_file_path(&symlink_path, true)?;
191
192        // Should return the target path when symlinks are allowed
193        assert_eq!(result, target_path);
194
195        Ok(())
196    }
197
198    #[test]
199    #[cfg(unix)] // This wnt work on Windows
200    fn test_safe_file_path_unsafe_symlink() {
201        // Test with a symlink to a potentially unsafe location
202        let dir = tempdir().unwrap();
203        let unsafe_target = PathBuf::from("/etc/passwd");
204        let unsafe_symlink = dir.path().join("unsafe_symlink.txt");
205
206        // Create a symlink to the unsafe target
207        std::os::unix::fs::symlink(&unsafe_target, &unsafe_symlink).unwrap();
208
209        // Even with symlinks allowed, should reject unsafe targets
210        let result = safe_file_path(&unsafe_symlink, true);
211        assert!(
212            result.is_err(),
213            "Should reject symlinks to unsafe locations"
214        );
215    }
216
217    #[test]
218    #[cfg(unix)] // This test is Unix-specific
219    fn test_safe_file_path_hardlink() -> Result<()> {
220        // Create a temporary directory and files
221        let dir = tempdir()?;
222        let target_path = dir.path().join("target_file.txt");
223        let hardlink_path = dir.path().join("hardlink_file.txt");
224
225        // Create the target file
226        let mut file = File::create(&target_path)?;
227        file.write_all(b"target file content")?;
228
229        // Create a hard link to the target
230        std::fs::hard_link(&target_path, &hardlink_path)?;
231
232        // Hard links should be detected and rejected
233        let result = safe_file_path(&hardlink_path, false);
234        assert!(
235            result.is_err(),
236            "Should reject files with multiple hard links"
237        );
238
239        Ok(())
240    }
241
242    #[test]
243    fn test_safe_open_file() -> Result<()> {
244        // Create a temporary directory and file
245        let dir = tempdir()?;
246        let file_path = dir.path().join("test_open.txt");
247
248        // Create and write to the file
249        {
250            let mut file = File::create(&file_path)?;
251            file.write_all(b"test content")?;
252        }
253
254        // Test opening the file
255        let mut file = safe_open_file(&file_path, false)?;
256        let mut content = String::new();
257        file.read_to_string(&mut content)?;
258
259        // Should be able to read the content
260        assert_eq!(content, "test content");
261
262        Ok(())
263    }
264
265    #[test]
266    fn test_safe_create_file() -> Result<()> {
267        // Create a temporary directory
268        let dir = tempdir()?;
269        let file_path = dir.path().join("test_create.txt");
270
271        // Test creating a file
272        {
273            let mut file = safe_create_file(&file_path, false)?;
274            file.write_all(b"created content")?;
275        }
276
277        // Verify the file was created with the content
278        let mut content = String::new();
279        let mut file = File::open(&file_path)?;
280        file.read_to_string(&mut content)?;
281
282        assert_eq!(content, "created content");
283
284        Ok(())
285    }
286
287    #[test]
288    fn test_safe_open_options() -> Result<()> {
289        // Create a temporary directory and file
290        let dir = tempdir()?;
291        let file_path = dir.path().join("test_options.txt");
292
293        // Test creating with OpenOptions
294        {
295            let mut options = safe_open_options(&file_path, false)?;
296            let mut file = options.write(true).create(true).open(&file_path)?;
297            file.write_all(b"options content")?;
298        }
299
300        // Verify the file was created with the content
301        let mut content = String::new();
302        let mut file = File::open(&file_path)?;
303        file.read_to_string(&mut content)?;
304
305        assert_eq!(content, "options content");
306
307        Ok(())
308    }
309
310    #[test]
311    fn test_safe_open_file_nonexistent() {
312        // Test opening a nonexistent file
313        let nonexistent_path = PathBuf::from("/tmp/this_file_should_not_exist.txt");
314
315        // Make sure the file doesn't exist
316        if nonexistent_path.exists() {
317            fs::remove_file(&nonexistent_path).unwrap();
318        }
319
320        let result = safe_open_file(&nonexistent_path, false);
321
322        // Should return an error
323        assert!(result.is_err());
324
325        // The error should be an IO error
326        if let Err(e) = result {
327            match e {
328                crate::error::Error::Io(_) => {} // Expected error type
329                _ => panic!("Unexpected error type: {e:?}"),
330            }
331        }
332    }
333
334    #[test]
335    fn test_is_safe_symlink_target() {
336        let check_path = |path: &str| -> bool {
337            let path = Path::new(path);
338            if let Ok(canonical) = path.canonicalize() {
339                canonical.starts_with("/tmp") || canonical.starts_with("/var/app/data")
340            } else {
341                // Simulate behavior for paths that can't be canonicalized
342                false
343            }
344        };
345
346        // Test a path that exists and should be allowed (tmpdir)
347        let tmp_dir = tempdir().unwrap();
348        assert!(
349            check_path(tmp_dir.path().to_str().unwrap()),
350            "Temporary directory should be considered safe"
351        );
352
353        // Test paths that should not be allowed
354        assert!(
355            !check_path("/etc/passwd"),
356            "/etc/passwd should not be considered safe"
357        );
358        assert!(
359            !check_path("/home/user/file.txt"),
360            "/home/user/file.txt should not be considered safe"
361        );
362    }
363
364    #[test]
365    fn test_safe_open_file_comprehensive() -> Result<()> {
366        // Create a temporary directory and file
367        let dir = tempdir()?;
368        let file_path = dir.path().join("comprehensive_test.txt");
369
370        // Test with non-existent file (should fail)
371        let result = safe_open_file(&file_path, false);
372        assert!(result.is_err(), "Opening non-existent file should fail");
373
374        // Create the file
375        {
376            let mut file = File::create(&file_path)?;
377            file.write_all(b"comprehensive test")?;
378        }
379
380        // Test opening existing file (should succeed)
381        let mut file = safe_open_file(&file_path, false)?;
382        let mut content = String::new();
383        file.read_to_string(&mut content)?;
384        assert_eq!(content, "comprehensive test");
385
386        // Test with invalid path
387        let invalid_path = PathBuf::from("\0invalid");
388        let result = safe_open_file(&invalid_path, false);
389        assert!(
390            result.is_err(),
391            "Opening file with invalid path should fail"
392        );
393
394        Ok(())
395    }
396
397    #[test]
398    fn test_safe_create_file_existing() -> Result<()> {
399        // Create a temporary directory and file
400        let dir = tempdir()?;
401        let file_path = dir.path().join("existing.txt");
402
403        // Create the file with initial content
404        {
405            let mut file = File::create(&file_path)?;
406            file.write_all(b"initial content")?;
407        }
408
409        // Use safe_create_file to overwrite the file
410        {
411            let mut file = safe_create_file(&file_path, false)?;
412            file.write_all(b"overwritten content")?;
413        }
414
415        // Verify the content was overwritten
416        let mut content = String::new();
417        let mut file = File::open(&file_path)?;
418        file.read_to_string(&mut content)?;
419
420        assert_eq!(content, "overwritten content");
421
422        Ok(())
423    }
424}