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
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 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 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 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 let temp_std_fd = unsafe { OwnedFd::from_raw_fd(temp_fd.as_raw_fd()) };
104 std::mem::forget(temp_fd);
105 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 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 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}