claude_agent/security/fs/
handle.rs

1//! Secure file handle with TOCTOU protection.
2
3use std::ffi::CString;
4use std::io::{Read, Write};
5use std::os::unix::ffi::OsStrExt;
6use std::os::unix::io::{AsFd, AsRawFd, FromRawFd, OwnedFd};
7
8use rustix::fs::{AtFlags, Mode, OFlags, openat, renameat, unlinkat};
9use uuid::Uuid;
10
11use super::super::SecurityError;
12use super::super::path::SafePath;
13
14pub struct SecureFileHandle {
15    fd: OwnedFd,
16    path: SafePath,
17}
18
19impl SecureFileHandle {
20    pub fn open_read(path: SafePath) -> Result<Self, SecurityError> {
21        let fd = path.open(OFlags::RDONLY)?;
22        Ok(Self { fd, path })
23    }
24
25    pub fn open_write(path: SafePath) -> Result<Self, SecurityError> {
26        path.create_parent_dirs()?;
27        let fd = path.open(OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC)?;
28        Ok(Self { fd, path })
29    }
30
31    pub fn open_append(path: SafePath) -> Result<Self, SecurityError> {
32        path.create_parent_dirs()?;
33        let fd = path.open(OFlags::WRONLY | OFlags::CREATE | OFlags::APPEND)?;
34        Ok(Self { fd, path })
35    }
36
37    pub fn for_atomic_write(path: SafePath) -> Result<Self, SecurityError> {
38        path.create_parent_dirs()?;
39        let fd = path
40            .open(OFlags::RDONLY)
41            .or_else(|_| path.open(OFlags::WRONLY | OFlags::CREATE))?;
42        Ok(Self { fd, path })
43    }
44
45    pub fn path(&self) -> &SafePath {
46        &self.path
47    }
48
49    pub fn display_path(&self) -> String {
50        self.path.as_path().display().to_string()
51    }
52
53    pub fn read_to_string(&self) -> Result<String, SecurityError> {
54        // SAFETY: self.fd is a valid OwnedFd. We create a temporary File from the raw fd,
55        // use it for reading, then forget it to prevent double-close since OwnedFd owns the fd.
56        let mut file = unsafe { std::fs::File::from_raw_fd(self.fd.as_raw_fd()) };
57        let mut content = String::new();
58        file.read_to_string(&mut content)?;
59        std::mem::forget(file);
60        Ok(content)
61    }
62
63    pub fn read_bytes(&self) -> Result<Vec<u8>, SecurityError> {
64        // SAFETY: Same as read_to_string - temporary File wrapper, forgotten to prevent double-close.
65        let mut file = unsafe { std::fs::File::from_raw_fd(self.fd.as_raw_fd()) };
66        let mut content = Vec::new();
67        file.read_to_end(&mut content)?;
68        std::mem::forget(file);
69        Ok(content)
70    }
71
72    pub fn write_all(&self, content: &[u8]) -> Result<(), SecurityError> {
73        // SAFETY: Same as read_to_string - temporary File wrapper, forgotten to prevent double-close.
74        let mut file = unsafe { std::fs::File::from_raw_fd(self.fd.as_raw_fd()) };
75        file.write_all(content)?;
76        file.sync_all()?;
77        std::mem::forget(file);
78        Ok(())
79    }
80
81    pub fn atomic_write(&self, content: &[u8]) -> Result<(), SecurityError> {
82        let filename = self
83            .path
84            .filename()
85            .ok_or_else(|| SecurityError::InvalidPath("no filename".into()))?;
86
87        let temp_name = format!(".{}.{}.tmp", filename.to_string_lossy(), Uuid::new_v4());
88        let temp_cname = CString::new(temp_name.as_bytes())
89            .map_err(|_| SecurityError::InvalidPath("invalid temp name".into()))?;
90
91        let parent_fd = self.get_parent_fd()?;
92
93        let temp_fd = openat(
94            parent_fd.as_fd(),
95            &temp_cname,
96            OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
97            Mode::from_raw_mode(0o644),
98        )
99        .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
100
101        // SAFETY: temp_fd is a valid fd from openat. We transfer ownership to temp_std_fd
102        // and forget temp_fd to prevent double-close.
103        let temp_std_fd = unsafe { OwnedFd::from_raw_fd(temp_fd.as_raw_fd()) };
104        std::mem::forget(temp_fd);
105        // SAFETY: temp_std_fd is valid. We create a temporary File for writing,
106        // then forget it since temp_std_fd manages the fd lifetime.
107        let mut temp_file = unsafe { std::fs::File::from_raw_fd(temp_std_fd.as_raw_fd()) };
108
109        let write_result = temp_file.write_all(content);
110        std::mem::forget(temp_file);
111
112        if let Err(e) = write_result {
113            let _ = unlinkat(parent_fd.as_fd(), &temp_cname, AtFlags::empty());
114            return Err(SecurityError::Io(e));
115        }
116
117        rustix::fs::fsync(&temp_std_fd)
118            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
119
120        let filename_cstr = CString::new(filename.as_bytes())
121            .map_err(|_| SecurityError::InvalidPath("invalid filename".into()))?;
122
123        renameat(
124            parent_fd.as_fd(),
125            &temp_cname,
126            parent_fd.as_fd(),
127            &filename_cstr,
128        )
129        .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
130
131        rustix::fs::fsync(&parent_fd)
132            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
133
134        Ok(())
135    }
136
137    fn get_parent_fd(&self) -> Result<OwnedFd, SecurityError> {
138        let parent_components = self.path.parent_components();
139
140        if parent_components.is_empty() {
141            let fd = rustix::fs::openat(
142                self.path.root_fd(),
143                c".",
144                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
145                Mode::empty(),
146            )
147            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
148            // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
149            let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
150            std::mem::forget(fd);
151            return Ok(std_fd);
152        }
153
154        let mut current_fd = self.path.root_fd();
155        let mut owned_fds: Vec<OwnedFd> = Vec::new();
156
157        for component in parent_components {
158            let c_name = CString::new(component.as_bytes())
159                .map_err(|_| SecurityError::InvalidPath("null byte".into()))?;
160
161            let fd = openat(
162                current_fd,
163                &c_name,
164                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
165                Mode::empty(),
166            )
167            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
168
169            // SAFETY: fd is valid from openat. Transfer ownership to std_fd, forget fd.
170            let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
171            std::mem::forget(fd);
172            owned_fds.push(std_fd);
173            current_fd = owned_fds.last().expect("just pushed").as_fd();
174        }
175
176        owned_fds
177            .pop()
178            .ok_or_else(|| SecurityError::InvalidPath("no parent".into()))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::fs;
186    use std::path::Path;
187    use std::sync::Arc;
188    use tempfile::tempdir;
189
190    fn create_safe_path(dir: &Path, filename: &str) -> SafePath {
191        let root = std::fs::canonicalize(dir).unwrap();
192        let root_fd = Arc::new(std::fs::File::open(&root).unwrap().into());
193        SafePath::resolve(root_fd, root, Path::new(filename), 10).unwrap()
194    }
195
196    #[test]
197    fn test_read_file() {
198        let dir = tempdir().unwrap();
199        let root = std::fs::canonicalize(dir.path()).unwrap();
200        fs::write(root.join("test.txt"), "hello world").unwrap();
201
202        let path = create_safe_path(&root, "test.txt");
203        let handle = SecureFileHandle::open_read(path).unwrap();
204        let content = handle.read_to_string().unwrap();
205        assert_eq!(content, "hello world");
206    }
207
208    #[test]
209    fn test_write_file() {
210        let dir = tempdir().unwrap();
211        let root = std::fs::canonicalize(dir.path()).unwrap();
212
213        let path = create_safe_path(&root, "output.txt");
214        let handle = SecureFileHandle::open_write(path).unwrap();
215        handle.write_all(b"test content").unwrap();
216
217        let content = fs::read_to_string(root.join("output.txt")).unwrap();
218        assert_eq!(content, "test content");
219    }
220
221    #[test]
222    fn test_atomic_write() {
223        let dir = tempdir().unwrap();
224        let root = std::fs::canonicalize(dir.path()).unwrap();
225
226        let path = create_safe_path(&root, "atomic.txt");
227        let handle = SecureFileHandle::for_atomic_write(path.clone()).unwrap();
228        handle.atomic_write(b"atomic content").unwrap();
229
230        let content = fs::read_to_string(root.join("atomic.txt")).unwrap();
231        assert_eq!(content, "atomic content");
232
233        let entries: Vec<_> = fs::read_dir(&root).unwrap().collect();
234        assert!(!entries.iter().any(|e| {
235            e.as_ref()
236                .unwrap()
237                .file_name()
238                .to_string_lossy()
239                .contains(".tmp")
240        }));
241    }
242
243    #[test]
244    fn test_atomic_write_preserves_original_on_new_file() {
245        let dir = tempdir().unwrap();
246        let root = std::fs::canonicalize(dir.path()).unwrap();
247
248        let path = create_safe_path(&root, "new_atomic.txt");
249        let handle = SecureFileHandle::for_atomic_write(path).unwrap();
250        handle.atomic_write(b"new content").unwrap();
251
252        let content = fs::read_to_string(root.join("new_atomic.txt")).unwrap();
253        assert_eq!(content, "new content");
254    }
255
256    #[test]
257    fn test_atomic_write_overwrites_existing() {
258        let dir = tempdir().unwrap();
259        let root = std::fs::canonicalize(dir.path()).unwrap();
260        fs::write(root.join("existing.txt"), "original").unwrap();
261
262        let path = create_safe_path(&root, "existing.txt");
263        let handle = SecureFileHandle::for_atomic_write(path).unwrap();
264        handle.atomic_write(b"updated").unwrap();
265
266        let content = fs::read_to_string(root.join("existing.txt")).unwrap();
267        assert_eq!(content, "updated");
268    }
269
270    #[test]
271    fn test_for_atomic_write_does_not_truncate() {
272        let dir = tempdir().unwrap();
273        let root = std::fs::canonicalize(dir.path()).unwrap();
274        fs::write(root.join("preserve.txt"), "original content").unwrap();
275
276        let path = create_safe_path(&root, "preserve.txt");
277        let _handle = SecureFileHandle::for_atomic_write(path).unwrap();
278
279        let content = fs::read_to_string(root.join("preserve.txt")).unwrap();
280        assert_eq!(content, "original content");
281    }
282
283    #[test]
284    fn test_create_nested_dirs() {
285        let dir = tempdir().unwrap();
286        let root = std::fs::canonicalize(dir.path()).unwrap();
287
288        let path = create_safe_path(&root, "a/b/c/file.txt");
289        let handle = SecureFileHandle::open_write(path).unwrap();
290        handle.write_all(b"nested").unwrap();
291
292        let content = fs::read_to_string(root.join("a/b/c/file.txt")).unwrap();
293        assert_eq!(content, "nested");
294    }
295}