claude_agent/security/fs/
handle.rs1use 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}