backup_suite/security/
path.rs

1use crate::error::{BackupError, Result};
2use std::fs::{File, OpenOptions};
3use std::path::{Component, Path, PathBuf};
4use unicode_normalization::UnicodeNormalization;
5
6/// 安全なパス結合(ディレクトリトラバーサル対策)
7///
8/// ベースディレクトリとチャイルドパスを結合する際に、ディレクトリトラバーサル攻撃を防ぎます。
9/// パス内の `..` コンポーネントを除去し、結果がベースディレクトリ配下にあることを保証します。
10///
11/// # セキュリティ
12///
13/// この関数は以下の攻撃パターンを防ぎます:
14/// - `../../../etc/passwd` のような相対パス攻撃
15/// - `..\\..\\..\\windows\\system32\\config\\sam` のような Windows パス攻撃
16/// - シンボリックリンクによるパストラバーサル(canonicalize を使用)
17///
18/// # 引数
19///
20/// * `base` - ベースディレクトリ(すべての結果パスはこの配下になる)
21/// * `child` - 結合する相対パス
22///
23/// # 戻り値
24///
25/// 安全に結合されたパス、またはセキュリティ違反時のエラー
26///
27/// # Errors
28///
29/// * `BackupError::PathTraversalDetected` - ディレクトリトラバーサル攻撃を検出した場合
30///   - Null byte(`\0`)を含むパス
31///   - Unicode攻撃パターン(全角スラッシュ、全角ピリオド等)を含むパス
32///   - 正規化後のパスがベースディレクトリ配下にない場合
33/// * `BackupError::IoError` - パスの正規化(canonicalize)に失敗した場合
34///   - ベースパスの親ディレクトリがすべて存在しない場合
35///
36/// # 使用例
37///
38/// ```rust,no_run
39/// use backup_suite::security::safe_join;
40/// use std::path::Path;
41///
42/// let base = Path::new("/home/user/backups");
43/// let child = Path::new("report.txt");
44///
45/// // 安全: /home/user/backups/report.txt
46/// let result = safe_join(base, child).unwrap();
47///
48/// // エラー: ディレクトリトラバーサル検出
49/// let malicious = Path::new("../../../etc/passwd");
50/// let result = safe_join(base, malicious);
51/// assert!(result.is_err());
52/// ```
53pub fn safe_join(base: &Path, child: &Path) -> Result<PathBuf> {
54    // Null byte検出(パストラバーサル攻撃対策)
55    let child_str = child
56        .to_str()
57        .ok_or_else(|| BackupError::PathTraversalDetected {
58            path: child.to_path_buf(),
59        })?;
60
61    // Unicode正規化(NFKC: 互換性分解 + 正規合成)
62    let normalized_str: String = child_str.nfkc().collect();
63
64    // Null byte検出
65    if normalized_str.contains('\0') {
66        return Err(BackupError::PathTraversalDetected {
67            path: child.to_path_buf(),
68        });
69    }
70
71    // Unicode攻撃パターン検出
72    if normalized_str.contains('\u{2044}')  // Unicode Fraction Slash
73        || normalized_str.contains('\u{FF0E}')  // 全角ピリオド
74        || normalized_str.contains('\u{FF0F}')
75    // 全角スラッシュ
76    {
77        return Err(BackupError::PathTraversalDetected {
78            path: child.to_path_buf(),
79        });
80    }
81
82    let child = Path::new(&normalized_str);
83
84    // 相対パスから .. を除去して正規化
85    let normalized: PathBuf = child
86        .components()
87        .filter(|c| !matches!(c, Component::ParentDir))
88        .collect();
89
90    // ベースパスと結合
91    let result = base.join(&normalized);
92
93    // ベースパスを正規化
94    // ベースパスが存在しない場合は、親ディレクトリまで遡って正規化
95    let canonical_base = if base.exists() {
96        base.canonicalize().map_err(BackupError::IoError)?
97    } else {
98        // ベースパスが存在しない場合、親ディレクトリを使用
99        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            // 元のベースパスの残りの部分を追加
106            let remaining = base.strip_prefix(&check_base).unwrap_or(base);
107            canonical.join(remaining)
108        } else {
109            // どの親ディレクトリも存在しない場合はエラー
110            return Err(BackupError::IoError(std::io::Error::new(
111                std::io::ErrorKind::NotFound,
112                format!("ベースパス {} が存在しません", base.display()),
113            )));
114        }
115    };
116
117    // 結果パスの親ディレクトリが存在しない場合は作成する必要があるため、
118    // 親ディレクトリまでの部分を検証
119    let result_parent = if result.exists() {
120        result.canonicalize().map_err(BackupError::IoError)?
121    } else {
122        // 存在しないパスの場合、親ディレクトリを基準に検証
123        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    // 結果がベースディレクトリ配下にあることを確認
136    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/// パス文字列のサニタイズ
146///
147/// ファイル名やディレクトリ名から危険な文字を除去し、安全な文字列に変換します。
148///
149/// # セキュリティ
150///
151/// 以下の文字のみを許可します:
152/// - 英数字(a-z, A-Z, 0-9)
153/// - ハイフン(-)
154/// - アンダースコア(_)
155///
156/// # 引数
157///
158/// * `name` - サニタイズ対象の文字列
159///
160/// # 戻り値
161///
162/// 安全な文字のみを含む文字列
163///
164/// # 使用例
165///
166/// ```rust
167/// use backup_suite::security::sanitize_path_component;
168///
169/// let safe = sanitize_path_component("my-file_v10");
170/// assert_eq!(safe, "my-file_v10");
171///
172/// let sanitized = sanitize_path_component("dangerous/../../../file.txt");
173/// assert_eq!(sanitized, "dangerousfiletxt");
174/// ```
175#[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
182/// パスが安全かどうかを検証
183///
184/// # 引数
185///
186/// * `path` - 検証するパス
187///
188/// # 戻り値
189///
190/// パスが安全な場合は `Ok(())`、危険な場合はエラー
191///
192/// # Errors
193///
194/// * `BackupError::PathTraversalDetected` - 危険なパスパターンを検出した場合
195///   - 親ディレクトリ参照(`..`)を含むパス
196///   - 危険な絶対パス(ルート `/` や `/etc`, `/sys`, `/proc`, `/dev` などのシステムディレクトリ)
197pub fn validate_path_safety(path: &Path) -> Result<()> {
198    // 定数時間で全ての検証を実行(タイミング攻撃対策)
199    let mut has_parent_dir = false;
200    let mut is_shallow_absolute = false;
201
202    // 全コンポーネントをチェック(早期リターンなし)
203    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        // ルート直下の危険なディレクトリをブロック
210        // /etc, /sys, /proc, /dev, /boot, /root など
211        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            // ルート直下(/)は常に拒否
219            is_shallow_absolute = true;
220        }
221    }
222
223    // 最後に一度だけ判定(定数時間性を保証)
224    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
233/// TOCTOU対策付き安全なファイルオープン
234///
235/// シンボリックリンクを追跡しないでファイルを開くことで、Time-Of-Check-Time-Of-Use (TOCTOU) 攻撃を防ぎます。
236///
237/// # セキュリティ
238///
239/// Unix系システムでは `O_NOFOLLOW` フラグを使用してシンボリックリンク攻撃を防ぎます。
240/// これにより、チェック時と使用時の間にファイルがシンボリックリンクに置き換えられる攻撃を防止します。
241///
242/// # プラットフォーム
243///
244/// - **Unix系**: `O_NOFOLLOW` フラグで完全な保護
245/// - **その他**: 標準的な `File::open` を使用(基本的な保護のみ)
246///
247/// # 引数
248///
249/// * `path` - オープンするファイルパス
250///
251/// # 戻り値
252///
253/// 開かれたファイルハンドル、またはエラー
254///
255/// # Errors
256///
257/// * `BackupError::IoError` - 以下の場合にエラーを返す
258///   - ファイルのオープンに失敗した場合
259///   - Unix系: シンボリックリンクを検出した場合(`O_NOFOLLOW`による拒否)
260///   - Windows: リパースポイント(シンボリックリンク等)を検出した場合
261///   - メタデータの取得に失敗した場合(Windows)
262///
263/// # 使用例
264///
265/// ```rust,no_run
266/// use backup_suite::security::safe_open;
267/// use std::path::Path;
268///
269/// let path = Path::new("/home/user/backups/data.txt");
270/// let file = safe_open(path).unwrap();
271/// ```
272pub 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        // FILE_FLAG_OPEN_REPARSE_POINTでシンボリックリンク検出
290        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        // ファイルがリパースポイント(シンボリックリンク等)か確認
297        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        // 相対パス内の..は除去されるため、実際には安全なパスになる
339        // しかし、結果がベースパス配下にあることは保証される
340        let relative = Path::new("../../../etc/passwd");
341        let result = safe_join(base, relative);
342
343        // 結果は成功するが、ベースディレクトリ配下にある
344        assert!(result.is_ok());
345        let joined = result.unwrap();
346        assert!(joined.starts_with(base));
347
348        // ..が除去されているため、etc/passwdというパスになる
349        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        // 絶対パスは join によりベースパスが無視されるため、
360        // 正規化後の検証で弾かれる
361        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        // 安全なパス
384        let safe_path = Path::new("documents/report.txt");
385        assert!(validate_path_safety(safe_path).is_ok());
386
387        // 危険なパス(..を含む)
388        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        // 深い階層の絶対パス(安全)
396        let safe_absolute = Path::new("/home/user/documents/file.txt");
397        assert!(validate_path_safety(safe_absolute).is_ok());
398
399        // /tmp/test のようなパスも安全(/tmpは危険なディレクトリリストにない)
400        let temp_path = Path::new("/tmp/test_directory");
401        assert!(validate_path_safety(temp_path).is_ok());
402
403        // /var/log のようなパスも安全(/varは危険なディレクトリリストにない)
404        let var_path = Path::new("/var/log/test.log");
405        assert!(validate_path_safety(var_path).is_ok());
406
407        // システムディレクトリ(危険)
408        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        // ルート(危険)
418        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        // テストファイルを作成
431        let mut file = File::create(&file_path).unwrap();
432        file.write_all(b"test content").unwrap();
433
434        // 通常のファイルをsafe_openで開く
435        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        // ターゲットファイルを作成
451        let mut file = File::create(&target_path).unwrap();
452        file.write_all(b"target content").unwrap();
453
454        // シンボリックリンクを作成
455        symlink(&target_path, &link_path).unwrap();
456
457        // O_NOFOLLOWでシンボリックリンクは拒否される
458        let result = safe_open(&link_path);
459        assert!(result.is_err());
460    }
461}