Skip to main content

oxi_agent/tools/
path_security.rs

1//! 파일 접근 경로 보안 검증.
2
3use std::path::{Path, PathBuf};
4
5/// 경로 보안 오류
6#[derive(Debug)]
7pub enum PathSecurityError {
8    /// 경로를 찾을 수 없음
9    NotFound(PathBuf),
10    /// 경로 순회 감지
11    Traversal(PathBuf),
12    /// 작업 공간 밖 경로
13    OutsideWorkspace(PathBuf),
14}
15
16impl std::fmt::Display for PathSecurityError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Self::NotFound(p) => write!(f, "Path not found: {}", p.display()),
20            Self::Traversal(p) => write!(f, "Path traversal detected: {}", p.display()),
21            Self::OutsideWorkspace(p) => write!(f, "Path outside workspace: {}", p.display()),
22        }
23    }
24}
25
26impl std::error::Error for PathSecurityError {}
27
28/// 파일 접근 시 보안 검증 유틸.
29pub struct PathGuard {
30    root: PathBuf,
31}
32
33impl PathGuard {
34    /// 작업 디렉토리 기반으로 생성.
35    pub fn new(cwd: &Path) -> Self {
36        let root = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
37        Self { root }
38    }
39
40    /// 경로가 작업 공간 내에 있는지 확인.
41    pub fn validate(&self, path: &Path) -> Result<PathBuf, PathSecurityError> {
42        // 1. 순회 방지
43        if path.components().any(|c| c.as_os_str() == "..") {
44            return Err(PathSecurityError::Traversal(path.to_path_buf()));
45        }
46
47        // 2. 존재하는 경로면 canonicalize로 실제 경로 확인
48        if path.exists() {
49            let canonical = path
50                .canonicalize()
51                .map_err(|_| PathSecurityError::NotFound(path.to_path_buf()))?;
52
53            // 3. 루트 내부인지 확인
54            if !canonical.starts_with(&self.root) {
55                return Err(PathSecurityError::OutsideWorkspace(canonical));
56            }
57
58            Ok(canonical)
59        } else {
60            // 존재하지 않는 경로는 순회만 확인
61            Ok(path.to_path_buf())
62        }
63    }
64
65    /// Check traversal only (no workspace boundary enforcement).
66    ///
67    /// Use this for tools that may legitimately access paths outside the
68    /// workspace root (e.g. reading system config, writing to temp dirs).
69    /// Blocks `..` components and canonicalizes existing paths but does
70    /// NOT reject absolute paths outside the workspace.
71    pub fn validate_traversal(&self, path: &Path) -> Result<PathBuf, PathSecurityError> {
72        // 1. 순회 방지
73        if path.components().any(|c| c.as_os_str() == "..") {
74            return Err(PathSecurityError::Traversal(path.to_path_buf()));
75        }
76
77        // 2. 존재하는 경로면 canonicalize로 실제 경로 얻기
78        if path.exists() {
79            let canonical = path
80                .canonicalize()
81                .map_err(|_| PathSecurityError::NotFound(path.to_path_buf()))?;
82            Ok(canonical)
83        } else {
84            Ok(path.to_path_buf())
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::fs;
93
94    #[test]
95    fn reject_traversal() {
96        let tmp = tempfile::tempdir().unwrap();
97        let guard = PathGuard::new(tmp.path());
98        let result = guard.validate(Path::new("../../../etc/passwd"));
99        assert!(result.is_err());
100    }
101
102    #[test]
103    fn accept_valid_path() {
104        let tmp = tempfile::tempdir().unwrap();
105        let test_file = tmp.path().join("test.txt");
106        fs::write(&test_file, "hello").unwrap();
107        let guard = PathGuard::new(tmp.path());
108        let result = guard.validate(&test_file);
109        assert!(result.is_ok());
110    }
111
112    #[test]
113    fn reject_absolute_outside() {
114        let tmp = tempfile::tempdir().unwrap();
115        let guard = PathGuard::new(tmp.path());
116        let result = guard.validate(Path::new("/etc/passwd"));
117        assert!(result.is_err());
118    }
119}