1use crate::error::{Error, Result};
2use std::fs::{self, File, OpenOptions};
3use std::path::{Path, PathBuf};
4
5pub fn safe_file_path(path: &Path, allow_symlinks: bool) -> Result<PathBuf> {
25 if path.exists() {
27 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 let target = fs::read_link(path)?;
38
39 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 #[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 Ok(path.to_path_buf())
68}
69
70fn is_safe_symlink_target(target: &Path) -> bool {
72 if let Ok(canonical) = target.canonicalize() {
73 canonical.starts_with("/tmp") || canonical.starts_with("/var/app/data")
75 } else {
76 false
77 }
78}
79
80pub 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
105pub 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
129pub 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 let dir = tempdir()?;
148 let normal_path = dir.path().join("test_file.txt");
149
150 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 let dir = tempdir()?;
161 let nonexistent_path = dir.path().join("nonexistent_file.txt");
162
163 let result = safe_file_path(&nonexistent_path, false)?;
165 assert_eq!(result, nonexistent_path);
166
167 Ok(())
168 }
169
170 #[test]
171 #[cfg(unix)] fn test_safe_file_path_symlink() -> Result<()> {
173 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 let mut file = File::create(&target_path)?;
180 file.write_all(b"target file content")?;
181
182 std::os::unix::fs::symlink(&target_path, &symlink_path)?;
184
185 let result = safe_file_path(&symlink_path, false);
187 assert!(result.is_err(), "Should reject symlinks when not allowed");
188
189 let result = safe_file_path(&symlink_path, true)?;
191
192 assert_eq!(result, target_path);
194
195 Ok(())
196 }
197
198 #[test]
199 #[cfg(unix)] fn test_safe_file_path_unsafe_symlink() {
201 let dir = tempdir().unwrap();
203 let unsafe_target = PathBuf::from("/etc/passwd");
204 let unsafe_symlink = dir.path().join("unsafe_symlink.txt");
205
206 std::os::unix::fs::symlink(&unsafe_target, &unsafe_symlink).unwrap();
208
209 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)] fn test_safe_file_path_hardlink() -> Result<()> {
220 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 let mut file = File::create(&target_path)?;
227 file.write_all(b"target file content")?;
228
229 std::fs::hard_link(&target_path, &hardlink_path)?;
231
232 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 let dir = tempdir()?;
246 let file_path = dir.path().join("test_open.txt");
247
248 {
250 let mut file = File::create(&file_path)?;
251 file.write_all(b"test content")?;
252 }
253
254 let mut file = safe_open_file(&file_path, false)?;
256 let mut content = String::new();
257 file.read_to_string(&mut content)?;
258
259 assert_eq!(content, "test content");
261
262 Ok(())
263 }
264
265 #[test]
266 fn test_safe_create_file() -> Result<()> {
267 let dir = tempdir()?;
269 let file_path = dir.path().join("test_create.txt");
270
271 {
273 let mut file = safe_create_file(&file_path, false)?;
274 file.write_all(b"created content")?;
275 }
276
277 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 let dir = tempdir()?;
291 let file_path = dir.path().join("test_options.txt");
292
293 {
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 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 let nonexistent_path = PathBuf::from("/tmp/this_file_should_not_exist.txt");
314
315 if nonexistent_path.exists() {
317 fs::remove_file(&nonexistent_path).unwrap();
318 }
319
320 let result = safe_open_file(&nonexistent_path, false);
321
322 assert!(result.is_err());
324
325 if let Err(e) = result {
327 match e {
328 crate::error::Error::Io(_) => {} _ => 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 false
343 }
344 };
345
346 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 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 let dir = tempdir()?;
368 let file_path = dir.path().join("comprehensive_test.txt");
369
370 let result = safe_open_file(&file_path, false);
372 assert!(result.is_err(), "Opening non-existent file should fail");
373
374 {
376 let mut file = File::create(&file_path)?;
377 file.write_all(b"comprehensive test")?;
378 }
379
380 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 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 let dir = tempdir()?;
401 let file_path = dir.path().join("existing.txt");
402
403 {
405 let mut file = File::create(&file_path)?;
406 file.write_all(b"initial content")?;
407 }
408
409 {
411 let mut file = safe_create_file(&file_path, false)?;
412 file.write_all(b"overwritten content")?;
413 }
414
415 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}