backup_suite/security/
path.rs1use crate::error::{BackupError, Result};
2use std::fs::{File, OpenOptions};
3use std::path::{Component, Path, PathBuf};
4use unicode_normalization::UnicodeNormalization;
5
6pub fn safe_join(base: &Path, child: &Path) -> Result<PathBuf> {
54 let child_str = child
56 .to_str()
57 .ok_or_else(|| BackupError::PathTraversalDetected {
58 path: child.to_path_buf(),
59 })?;
60
61 let normalized_str: String = child_str.nfkc().collect();
63
64 if normalized_str.contains('\0') {
66 return Err(BackupError::PathTraversalDetected {
67 path: child.to_path_buf(),
68 });
69 }
70
71 if normalized_str.contains('\u{2044}') || normalized_str.contains('\u{FF0E}') || normalized_str.contains('\u{FF0F}')
75 {
77 return Err(BackupError::PathTraversalDetected {
78 path: child.to_path_buf(),
79 });
80 }
81
82 let child = Path::new(&normalized_str);
83
84 let normalized: PathBuf = child
86 .components()
87 .filter(|c| !matches!(c, Component::ParentDir))
88 .collect();
89
90 let result = base.join(&normalized);
92
93 let canonical_base = if base.exists() {
96 base.canonicalize().map_err(BackupError::IoError)?
97 } else {
98 let mut check_base = base.to_path_buf();
100 while !check_base.exists() && check_base.parent().is_some() {
101 check_base = check_base.parent().unwrap().to_path_buf();
102 }
103 if check_base.exists() {
104 let canonical = check_base.canonicalize().map_err(BackupError::IoError)?;
105 let remaining = base.strip_prefix(&check_base).unwrap_or(base);
107 canonical.join(remaining)
108 } else {
109 return Err(BackupError::IoError(std::io::Error::new(
111 std::io::ErrorKind::NotFound,
112 format!("ベースパス {} が存在しません", base.display()),
113 )));
114 }
115 };
116
117 let result_parent = if result.exists() {
120 result.canonicalize().map_err(BackupError::IoError)?
121 } else {
122 let mut check_path = result.clone();
124 while !check_path.exists() && check_path.parent().is_some() {
125 check_path = check_path.parent().unwrap().to_path_buf();
126 }
127
128 if check_path.exists() {
129 check_path.canonicalize().map_err(BackupError::IoError)?
130 } else {
131 canonical_base.clone()
132 }
133 };
134
135 if !result_parent.starts_with(&canonical_base) {
137 return Err(BackupError::PathTraversalDetected {
138 path: child.to_path_buf(),
139 });
140 }
141
142 Ok(result)
143}
144
145#[must_use]
176pub fn sanitize_path_component(name: &str) -> String {
177 name.chars()
178 .filter(|&c| c.is_alphanumeric() || "-_".contains(c))
179 .collect()
180}
181
182pub fn validate_path_safety(path: &Path) -> Result<()> {
198 let mut has_parent_dir = false;
200 let mut is_shallow_absolute = false;
201
202 for component in path.components() {
204 has_parent_dir |= matches!(component, Component::ParentDir);
205 }
206
207 if path.is_absolute() {
208 let components: Vec<_> = path.components().collect();
209 if components.len() >= 2 {
212 if let Component::Normal(first_dir) = components[1] {
213 let first_dir_str = first_dir.to_string_lossy();
214 let dangerous_dirs = ["etc", "sys", "proc", "dev", "boot", "root", "bin", "sbin"];
215 is_shallow_absolute = dangerous_dirs.contains(&first_dir_str.as_ref());
216 }
217 } else {
218 is_shallow_absolute = true;
220 }
221 }
222
223 if has_parent_dir || is_shallow_absolute {
225 return Err(BackupError::PathTraversalDetected {
226 path: path.to_path_buf(),
227 });
228 }
229
230 Ok(())
231}
232
233pub fn safe_open(path: &Path) -> Result<File> {
273 #[cfg(unix)]
274 {
275 use std::os::unix::fs::OpenOptionsExt;
276 OpenOptions::new()
277 .read(true)
278 .custom_flags(libc::O_NOFOLLOW)
279 .open(path)
280 .map_err(BackupError::IoError)
281 }
282
283 #[cfg(windows)]
284 {
285 use std::os::windows::fs::OpenOptionsExt;
286 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x00200000;
287 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
288
289 let file = OpenOptions::new()
291 .read(true)
292 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
293 .open(path)
294 .map_err(BackupError::IoError)?;
295
296 let metadata = file.metadata().map_err(BackupError::IoError)?;
298
299 use std::os::windows::fs::MetadataExt;
300 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
301 return Err(BackupError::IoError(std::io::Error::new(
302 std::io::ErrorKind::Other,
303 "シンボリックリンクは許可されていません",
304 )));
305 }
306
307 Ok(file)
308 }
309
310 #[cfg(not(any(unix, windows)))]
311 {
312 File::open(path).map_err(BackupError::IoError)
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 use tempfile::TempDir;
321
322 #[test]
323 fn test_safe_join_normal_path() {
324 let temp_dir = TempDir::new().unwrap();
325 let base = temp_dir.path();
326 let child = Path::new("subdir/file.txt");
327
328 let result = safe_join(base, child).unwrap();
329 assert!(result.starts_with(base));
330 assert!(result.ends_with("subdir/file.txt"));
331 }
332
333 #[test]
334 fn test_safe_join_rejects_parent_dir() {
335 let temp_dir = TempDir::new().unwrap();
336 let base = temp_dir.path();
337
338 let relative = Path::new("../../../etc/passwd");
341 let result = safe_join(base, relative);
342
343 assert!(result.is_ok());
345 let joined = result.unwrap();
346 assert!(joined.starts_with(base));
347
348 assert!(joined.ends_with("etc/passwd"));
350 }
351
352 #[test]
353 fn test_safe_join_rejects_absolute_path() {
354 let temp_dir = TempDir::new().unwrap();
355 let base = temp_dir.path();
356 let absolute = Path::new("/etc/passwd");
357
358 let result = safe_join(base, absolute);
359 assert!(result.is_err());
362 }
363
364 #[test]
365 fn test_sanitize_path_component() {
366 assert_eq!(
367 sanitize_path_component("normal-file_v10txt"),
368 "normal-file_v10txt"
369 );
370 assert_eq!(
371 sanitize_path_component("file with spaces"),
372 "filewithspaces"
373 );
374 assert_eq!(sanitize_path_component("../../../etc/passwd"), "etcpasswd");
375 assert_eq!(
376 sanitize_path_component("file:with:colons"),
377 "filewithcolons"
378 );
379 }
380
381 #[test]
382 fn test_validate_path_safety() {
383 let safe_path = Path::new("documents/report.txt");
385 assert!(validate_path_safety(safe_path).is_ok());
386
387 let dangerous_path = Path::new("../../../etc/passwd");
389 assert!(validate_path_safety(dangerous_path).is_err());
390 }
391
392 #[test]
393 #[cfg(unix)]
394 fn test_validate_path_safety_absolute() {
395 let safe_absolute = Path::new("/home/user/documents/file.txt");
397 assert!(validate_path_safety(safe_absolute).is_ok());
398
399 let temp_path = Path::new("/tmp/test_directory");
401 assert!(validate_path_safety(temp_path).is_ok());
402
403 let var_path = Path::new("/var/log/test.log");
405 assert!(validate_path_safety(var_path).is_ok());
406
407 let etc_passwd = Path::new("/etc/passwd");
409 assert!(validate_path_safety(etc_passwd).is_err());
410
411 let sys_path = Path::new("/sys/class");
412 assert!(validate_path_safety(sys_path).is_err());
413
414 let proc_path = Path::new("/proc/self");
415 assert!(validate_path_safety(proc_path).is_err());
416
417 let root = Path::new("/");
419 assert!(validate_path_safety(root).is_err());
420 }
421
422 #[test]
423 fn test_safe_open_normal_file() {
424 use std::fs::File;
425 use std::io::Write;
426
427 let temp_dir = TempDir::new().unwrap();
428 let file_path = temp_dir.path().join("test.txt");
429
430 let mut file = File::create(&file_path).unwrap();
432 file.write_all(b"test content").unwrap();
433
434 let result = safe_open(&file_path);
436 assert!(result.is_ok());
437 }
438
439 #[cfg(unix)]
440 #[test]
441 fn test_safe_open_rejects_symlink() {
442 use std::fs::File;
443 use std::io::Write;
444 use std::os::unix::fs::symlink;
445
446 let temp_dir = TempDir::new().unwrap();
447 let target_path = temp_dir.path().join("target.txt");
448 let link_path = temp_dir.path().join("link.txt");
449
450 let mut file = File::create(&target_path).unwrap();
452 file.write_all(b"target content").unwrap();
453
454 symlink(&target_path, &link_path).unwrap();
456
457 let result = safe_open(&link_path);
459 assert!(result.is_err());
460 }
461}