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