claude_agent/security/path/
resolver.rs

1//! TOCTOU-safe path resolution using openat() with O_NOFOLLOW.
2
3use std::ffi::{CString, OsStr, OsString};
4use std::os::unix::ffi::OsStrExt;
5use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8
9use rustix::fs::{Mode, OFlags, openat};
10use rustix::io::Errno;
11
12use crate::security::SecurityError;
13
14#[derive(Debug)]
15pub struct SafePath {
16    root_fd: Arc<OwnedFd>,
17    root_path: PathBuf,
18    components: Vec<OsString>,
19    resolved_path: PathBuf,
20    permissive: bool,
21}
22
23impl SafePath {
24    pub fn resolve(
25        root_fd: Arc<OwnedFd>,
26        root_path: PathBuf,
27        relative_path: &Path,
28        max_symlink_depth: u8,
29    ) -> Result<Self, SecurityError> {
30        let mut components = Vec::new();
31        let mut symlink_depth = 0u8;
32
33        for component in relative_path.components() {
34            match component {
35                Component::ParentDir => {
36                    if components.is_empty() {
37                        return Err(SecurityError::PathEscape(relative_path.to_path_buf()));
38                    }
39                    components.pop();
40                }
41                Component::CurDir | Component::RootDir => {}
42                Component::Normal(name) => {
43                    components.push(name.to_os_string());
44                }
45                Component::Prefix(_) => {}
46            }
47        }
48
49        let mut validated_components = Vec::new();
50        let mut current_fd: BorrowedFd<'_> = root_fd.as_fd();
51        let mut owned_fds: Vec<OwnedFd> = Vec::new();
52
53        for (i, component) in components.iter().enumerate() {
54            let is_last = i == components.len() - 1;
55
56            let c_name = CString::new(component.as_bytes())
57                .map_err(|_| SecurityError::InvalidPath("null byte in path".into()))?;
58
59            let flags = if is_last {
60                OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC
61            } else {
62                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC
63            };
64
65            match openat(current_fd, &c_name, flags, Mode::empty()) {
66                Ok(fd) => {
67                    validated_components.push(component.clone());
68                    if !is_last {
69                        // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
70                        let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
71                        std::mem::forget(fd);
72                        owned_fds.push(std_fd);
73                        // SAFETY: We just pushed to owned_fds, so last() is guaranteed to be Some
74                        current_fd = owned_fds
75                            .last()
76                            .expect("owned_fds is non-empty after push")
77                            .as_fd();
78                    } else {
79                        std::mem::forget(fd);
80                    }
81                }
82                Err(Errno::LOOP) | Err(Errno::MLINK) => {
83                    symlink_depth += 1;
84                    if symlink_depth > max_symlink_depth {
85                        return Err(SecurityError::SymlinkDepthExceeded {
86                            path: relative_path.to_path_buf(),
87                            max: max_symlink_depth,
88                        });
89                    }
90
91                    let target = rustix::fs::readlinkat(current_fd, &c_name, vec![0u8; 4096])
92                        .map_err(|e| {
93                            SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error()))
94                        })?;
95
96                    let target_path = PathBuf::from(OsStr::from_bytes(target.to_bytes()));
97                    if target_path.is_absolute() {
98                        if !target_path.starts_with(&root_path) {
99                            return Err(SecurityError::AbsoluteSymlink(target_path));
100                        }
101                        let relative = target_path
102                            .strip_prefix(&root_path)
103                            .expect("path verified with starts_with");
104                        return Self::resolve(
105                            Arc::clone(&root_fd),
106                            root_path,
107                            relative,
108                            max_symlink_depth - symlink_depth,
109                        );
110                    }
111
112                    let mut remaining: Vec<OsString> = target_path
113                        .components()
114                        .filter_map(|c| match c {
115                            Component::Normal(s) => Some(s.to_os_string()),
116                            _ => None,
117                        })
118                        .collect();
119
120                    remaining.extend(components.iter().skip(i + 1).cloned());
121
122                    let new_path: PathBuf = remaining.iter().collect();
123                    let current_path: PathBuf = validated_components.iter().collect();
124                    let full_path = current_path.join(&new_path);
125
126                    return Self::resolve(
127                        Arc::clone(&root_fd),
128                        root_path,
129                        &full_path,
130                        max_symlink_depth - symlink_depth,
131                    );
132                }
133                Err(Errno::NOENT) => {
134                    validated_components.push(component.clone());
135                    validated_components.extend(components.iter().skip(i + 1).cloned());
136                    break;
137                }
138                Err(e) => {
139                    return Err(SecurityError::Io(std::io::Error::from_raw_os_error(
140                        e.raw_os_error(),
141                    )));
142                }
143            }
144        }
145
146        let resolved_path = root_path.join(validated_components.iter().collect::<PathBuf>());
147
148        Ok(Self {
149            root_fd,
150            root_path,
151            components: validated_components,
152            resolved_path,
153            permissive: false,
154        })
155    }
156
157    /// Create a SafePath without validation (for permissive mode).
158    /// This bypasses TOCTOU protection but allows symlinks.
159    pub fn unchecked(root_fd: Arc<OwnedFd>, resolved_path: PathBuf) -> Self {
160        let root_path = PathBuf::from("/");
161        let components = resolved_path
162            .strip_prefix("/")
163            .unwrap_or(&resolved_path)
164            .components()
165            .filter_map(|c| match c {
166                Component::Normal(s) => Some(s.to_os_string()),
167                _ => None,
168            })
169            .collect();
170
171        Self {
172            root_fd,
173            root_path,
174            components,
175            resolved_path,
176            permissive: true,
177        }
178    }
179
180    pub fn is_permissive(&self) -> bool {
181        self.permissive
182    }
183
184    pub fn root_fd(&self) -> BorrowedFd<'_> {
185        self.root_fd.as_fd()
186    }
187
188    pub fn root_path(&self) -> &Path {
189        &self.root_path
190    }
191
192    pub fn components(&self) -> &[OsString] {
193        &self.components
194    }
195
196    pub fn as_path(&self) -> &Path {
197        &self.resolved_path
198    }
199
200    pub fn filename(&self) -> Option<&OsStr> {
201        self.components.last().map(|s| s.as_os_str())
202    }
203
204    pub fn parent_components(&self) -> &[OsString] {
205        if self.components.is_empty() {
206            &[]
207        } else {
208            &self.components[..self.components.len() - 1]
209        }
210    }
211
212    pub fn open(&self, flags: OFlags) -> Result<OwnedFd, SecurityError> {
213        // In permissive mode, use standard library to handle symlinks
214        if self.permissive {
215            use std::fs::OpenOptions;
216            use std::os::unix::fs::OpenOptionsExt;
217
218            let mut opts = OpenOptions::new();
219
220            if flags.contains(OFlags::RDONLY) && !flags.contains(OFlags::WRONLY) {
221                opts.read(true);
222            }
223            if flags.contains(OFlags::WRONLY) || flags.contains(OFlags::RDWR) {
224                opts.write(true);
225            }
226            if flags.contains(OFlags::RDWR) {
227                opts.read(true);
228            }
229            if flags.contains(OFlags::CREATE) {
230                opts.create(true);
231            }
232            if flags.contains(OFlags::TRUNC) {
233                opts.truncate(true);
234            }
235            if flags.contains(OFlags::APPEND) {
236                opts.append(true);
237            }
238
239            opts.mode(0o644);
240
241            let file = opts.open(&self.resolved_path).map_err(SecurityError::Io)?;
242            return Ok(file.into());
243        }
244
245        if self.components.is_empty() {
246            let fd = rustix::fs::openat(
247                self.root_fd.as_fd(),
248                c".",
249                flags | OFlags::CLOEXEC,
250                Mode::empty(),
251            )
252            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
253            // SAFETY: fd is valid from openat. Transfer ownership, original fd leaked intentionally.
254            return Ok(unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) });
255        }
256
257        let mut current_fd: BorrowedFd<'_> = self.root_fd.as_fd();
258        let mut owned_fds: Vec<OwnedFd> = Vec::new();
259
260        for (i, component) in self.components.iter().enumerate() {
261            let is_last = i == self.components.len() - 1;
262            let c_name = CString::new(component.as_bytes())
263                .map_err(|_| SecurityError::InvalidPath("null byte".into()))?;
264
265            let open_flags = if is_last {
266                flags | OFlags::NOFOLLOW | OFlags::CLOEXEC
267            } else {
268                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC
269            };
270
271            let fd = openat(current_fd, &c_name, open_flags, Mode::from_raw_mode(0o644)).map_err(
272                |e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())),
273            )?;
274
275            if is_last {
276                // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
277                let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
278                std::mem::forget(fd);
279                return Ok(std_fd);
280            }
281
282            // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
283            let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
284            std::mem::forget(fd);
285            owned_fds.push(std_fd);
286            // SAFETY: We just pushed to owned_fds, so last() is guaranteed to be Some
287            current_fd = owned_fds
288                .last()
289                .expect("owned_fds is non-empty after push")
290                .as_fd();
291        }
292
293        unreachable!("loop always returns on is_last")
294    }
295
296    pub fn create_parent_dirs(&self) -> Result<(), SecurityError> {
297        if self.components.len() <= 1 {
298            return Ok(());
299        }
300
301        // In permissive mode, use standard library to handle symlinks
302        if self.permissive {
303            if let Some(parent) = self.resolved_path.parent() {
304                std::fs::create_dir_all(parent)?;
305            }
306            return Ok(());
307        }
308
309        let mut current_fd: BorrowedFd<'_> = self.root_fd.as_fd();
310        let mut owned_fds: Vec<OwnedFd> = Vec::new();
311
312        for component in self.parent_components() {
313            let c_name = CString::new(component.as_bytes())
314                .map_err(|_| SecurityError::InvalidPath("null byte".into()))?;
315
316            match openat(
317                current_fd,
318                &c_name,
319                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
320                Mode::empty(),
321            ) {
322                Ok(fd) => {
323                    // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
324                    let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
325                    std::mem::forget(fd);
326                    owned_fds.push(std_fd);
327                    // SAFETY: We just pushed to owned_fds, so last() is guaranteed to be Some
328                    current_fd = owned_fds
329                        .last()
330                        .expect("owned_fds is non-empty after push")
331                        .as_fd();
332                }
333                Err(Errno::NOENT) => {
334                    rustix::fs::mkdirat(current_fd, &c_name, Mode::from_raw_mode(0o755)).map_err(
335                        |e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())),
336                    )?;
337
338                    let fd = openat(
339                        current_fd,
340                        &c_name,
341                        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
342                        Mode::empty(),
343                    )
344                    .map_err(|e| {
345                        SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error()))
346                    })?;
347
348                    // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
349                    let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
350                    std::mem::forget(fd);
351                    owned_fds.push(std_fd);
352                    // SAFETY: We just pushed to owned_fds, so last() is guaranteed to be Some
353                    current_fd = owned_fds
354                        .last()
355                        .expect("owned_fds is non-empty after push")
356                        .as_fd();
357                }
358                Err(e) => {
359                    return Err(SecurityError::Io(std::io::Error::from_raw_os_error(
360                        e.raw_os_error(),
361                    )));
362                }
363            }
364        }
365
366        Ok(())
367    }
368}
369
370impl Clone for SafePath {
371    fn clone(&self) -> Self {
372        Self {
373            root_fd: Arc::clone(&self.root_fd),
374            root_path: self.root_path.clone(),
375            components: self.components.clone(),
376            resolved_path: self.resolved_path.clone(),
377            permissive: self.permissive,
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use std::fs;
386    use tempfile::tempdir;
387
388    fn open_dir(path: &Path) -> Arc<OwnedFd> {
389        let fd = std::fs::File::open(path).unwrap();
390        Arc::new(fd.into())
391    }
392
393    #[test]
394    fn test_resolve_simple() {
395        let dir = tempdir().unwrap();
396        let root = std::fs::canonicalize(dir.path()).unwrap();
397        fs::write(root.join("test.txt"), "content").unwrap();
398
399        let root_fd = open_dir(&root);
400        let path = SafePath::resolve(root_fd, root.clone(), Path::new("test.txt"), 10).unwrap();
401
402        assert_eq!(path.as_path(), root.join("test.txt"));
403    }
404
405    #[test]
406    fn test_resolve_nonexistent() {
407        let dir = tempdir().unwrap();
408        let root = std::fs::canonicalize(dir.path()).unwrap();
409
410        let root_fd = open_dir(&root);
411        let path = SafePath::resolve(root_fd, root.clone(), Path::new("newfile.txt"), 10).unwrap();
412
413        assert_eq!(path.as_path(), root.join("newfile.txt"));
414    }
415
416    #[test]
417    fn test_path_traversal_blocked() {
418        let dir = tempdir().unwrap();
419        let root = std::fs::canonicalize(dir.path()).unwrap();
420
421        let root_fd = open_dir(&root);
422        let result = SafePath::resolve(root_fd, root, Path::new("../../../etc/passwd"), 10);
423
424        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
425    }
426
427    #[test]
428    fn test_symlink_within_sandbox() {
429        let dir = tempdir().unwrap();
430        let root = std::fs::canonicalize(dir.path()).unwrap();
431
432        fs::write(root.join("target.txt"), "content").unwrap();
433        std::os::unix::fs::symlink("target.txt", root.join("link.txt")).unwrap();
434
435        let root_fd = open_dir(&root);
436        let path = SafePath::resolve(root_fd, root.clone(), Path::new("link.txt"), 10).unwrap();
437
438        assert_eq!(path.as_path(), root.join("target.txt"));
439    }
440
441    #[test]
442    fn test_symlink_depth_limit() {
443        let dir = tempdir().unwrap();
444        let root = std::fs::canonicalize(dir.path()).unwrap();
445
446        for i in 0..15 {
447            let target = if i == 14 {
448                "final.txt".to_string()
449            } else {
450                format!("link{}.txt", i + 1)
451            };
452            std::os::unix::fs::symlink(&target, root.join(format!("link{}.txt", i))).unwrap();
453        }
454        fs::write(root.join("final.txt"), "content").unwrap();
455
456        let root_fd = open_dir(&root);
457        let result = SafePath::resolve(root_fd, root, Path::new("link0.txt"), 10);
458
459        assert!(matches!(
460            result,
461            Err(SecurityError::SymlinkDepthExceeded { .. })
462        ));
463    }
464}