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