Skip to main content

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::mem::ManuallyDrop;
6use std::os::unix::ffi::OsStrExt;
7use std::os::unix::io::{AsFd, AsRawFd, FromRawFd, OwnedFd};
8
9use rustix::fs::{AtFlags, Mode, OFlags, openat, renameat, unlinkat};
10use uuid::Uuid;
11
12use super::super::SecurityError;
13use super::super::path::SafePath;
14
15const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
16
17fn with_borrowed_file<T>(fd: &OwnedFd, f: impl FnOnce(&mut std::fs::File) -> T) -> T {
18    let mut file = ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd.as_raw_fd()) });
19    f(&mut file)
20}
21
22pub struct SecureFileHandle {
23    fd: OwnedFd,
24    path: SafePath,
25}
26
27impl SecureFileHandle {
28    pub fn open_read(path: SafePath) -> Result<Self, SecurityError> {
29        let fd = path.open(OFlags::RDONLY)?;
30        Ok(Self { fd, path })
31    }
32
33    pub fn open_write(path: SafePath) -> Result<Self, SecurityError> {
34        path.create_parent_dirs()?;
35        let fd = path.open(OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC)?;
36        Ok(Self { fd, path })
37    }
38
39    pub fn open_append(path: SafePath) -> Result<Self, SecurityError> {
40        path.create_parent_dirs()?;
41        let fd = path.open(OFlags::WRONLY | OFlags::CREATE | OFlags::APPEND)?;
42        Ok(Self { fd, path })
43    }
44
45    pub fn for_atomic_write(path: SafePath) -> Result<Self, SecurityError> {
46        path.create_parent_dirs()?;
47        let fd = path
48            .open(OFlags::RDONLY)
49            .or_else(|_| path.open(OFlags::WRONLY | OFlags::CREATE))?;
50        Ok(Self { fd, path })
51    }
52
53    pub fn path(&self) -> &SafePath {
54        &self.path
55    }
56
57    pub fn display_path(&self) -> String {
58        self.path.as_path().display().to_string()
59    }
60
61    pub fn read_to_string(&self) -> Result<String, SecurityError> {
62        self.check_file_size()?;
63        let mut content = String::new();
64        with_borrowed_file(&self.fd, |file| file.read_to_string(&mut content))?;
65        Ok(content)
66    }
67
68    pub fn read_bytes(&self) -> Result<Vec<u8>, SecurityError> {
69        self.check_file_size()?;
70        let mut content = Vec::new();
71        with_borrowed_file(&self.fd, |file| file.read_to_end(&mut content))?;
72        Ok(content)
73    }
74
75    fn check_file_size(&self) -> Result<(), SecurityError> {
76        let stat = rustix::fs::fstat(&self.fd)
77            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
78
79        let size = stat.st_size as u64;
80        if size > MAX_FILE_SIZE {
81            return Err(SecurityError::InvalidPath(format!(
82                "File too large: {} bytes (max {} bytes)",
83                size, MAX_FILE_SIZE
84            )));
85        }
86        Ok(())
87    }
88
89    pub fn write_all(&self, content: &[u8]) -> Result<(), SecurityError> {
90        with_borrowed_file(&self.fd, |file| -> std::io::Result<()> {
91            file.write_all(content)?;
92            file.sync_all()
93        })?;
94        Ok(())
95    }
96
97    pub fn atomic_write(&self, content: &[u8]) -> Result<(), SecurityError> {
98        let filename = self
99            .path
100            .filename()
101            .ok_or_else(|| SecurityError::InvalidPath("no filename".into()))?;
102
103        let temp_name = format!(".{}.{}.tmp", filename.to_string_lossy(), Uuid::new_v4());
104        let temp_cname = CString::new(temp_name.as_bytes())
105            .map_err(|_| SecurityError::InvalidPath("invalid temp name".into()))?;
106
107        let parent_fd = self.get_parent_fd()?;
108
109        let temp_fd = openat(
110            parent_fd.as_fd(),
111            &temp_cname,
112            OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
113            Mode::from_raw_mode(0o644),
114        )
115        .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
116
117        let write_result = with_borrowed_file(&temp_fd, |file| file.write_all(content));
118
119        if let Err(e) = write_result {
120            let _ = unlinkat(parent_fd.as_fd(), &temp_cname, AtFlags::empty());
121            return Err(SecurityError::Io(e));
122        }
123
124        rustix::fs::fsync(&temp_fd)
125            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
126
127        let filename_cstr = CString::new(filename.as_bytes())
128            .map_err(|_| SecurityError::InvalidPath("invalid filename".into()))?;
129
130        renameat(
131            parent_fd.as_fd(),
132            &temp_cname,
133            parent_fd.as_fd(),
134            &filename_cstr,
135        )
136        .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
137
138        rustix::fs::fsync(&parent_fd)
139            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
140
141        Ok(())
142    }
143
144    fn get_parent_fd(&self) -> Result<OwnedFd, SecurityError> {
145        let parent_components = self.path.parent_components();
146
147        if parent_components.is_empty() {
148            return rustix::fs::openat(
149                self.path.root_fd(),
150                c".",
151                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
152                Mode::empty(),
153            )
154            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())));
155        }
156
157        let mut current_fd = self.path.root_fd();
158        let mut owned_fds: Vec<OwnedFd> = Vec::new();
159
160        for component in parent_components {
161            let c_name = CString::new(component.as_bytes())
162                .map_err(|_| SecurityError::InvalidPath("null byte".into()))?;
163
164            let fd = openat(
165                current_fd,
166                &c_name,
167                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
168                Mode::empty(),
169            )
170            .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
171
172            owned_fds.push(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}