context_harness/agent_runtime/
files.rs1use anyhow::{ensure, Context, Result};
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8
9const MAX_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
10
11#[derive(Debug)]
12pub(crate) struct RunOwnershipUnavailable;
13
14impl std::fmt::Display for RunOwnershipUnavailable {
15 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 formatter.write_str("run already has an active owner")
17 }
18}
19
20impl std::error::Error for RunOwnershipUnavailable {}
21
22#[derive(Debug, Clone)]
23pub struct ArtifactFile {
24 pub relative_path: String,
25 pub sha256: String,
26 pub size: u64,
27}
28
29#[derive(Debug)]
31pub struct RunFiles {
32 root: PathBuf,
33 id: String,
34 directory: std::fs::File,
35 artifacts: std::fs::File,
36 _lock: std::fs::File,
37}
38
39impl Drop for RunFiles {
40 fn drop(&mut self) {
41 let _ = std::fs::File::unlock(&self._lock);
45 }
46}
47
48pub fn acquire(root: &Path, id: &str) -> Result<RunFiles> {
49 ensure!(
51 uuid::Uuid::parse_str(id)
52 .map(|value| value.to_string() == id)
53 .unwrap_or(false),
54 "run ID must be a canonical UUID"
55 );
56 acquire_impl(root, id)
57}
58
59impl RunFiles {
60 pub fn write_artifact(&self, name: &str, content: &[u8]) -> Result<ArtifactFile> {
63 ensure!(
64 !name.is_empty()
65 && name.len() <= 80
66 && name.as_bytes()[0].is_ascii_alphanumeric()
67 && name
68 .bytes()
69 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')),
70 "artifact label must be 1 to 80 safe ASCII characters"
71 );
72 ensure!(
73 content.len() <= MAX_ARTIFACT_BYTES,
74 "artifact exceeds 16 MiB"
75 );
76 self.write_impl(name, content)
77 }
78}
79
80#[cfg(unix)]
81mod unix {
82 use super::*;
83 use std::{
84 ffi::CString,
85 fs::{File, OpenOptions},
86 io::Write,
87 os::{
88 fd::{AsRawFd, FromRawFd},
89 unix::fs::{MetadataExt, OpenOptionsExt},
90 },
91 };
92
93 fn child_directory(parent: &File, name: &str, create: bool) -> Result<File> {
96 let name = CString::new(name)?;
97 if create {
98 let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) };
100 if result < 0 {
101 let error = std::io::Error::last_os_error();
102 if error.kind() != std::io::ErrorKind::AlreadyExists {
103 return Err(error).context("create run directory");
104 }
105 } else {
106 parent.sync_all().context("sync run directory parent")?;
107 }
108 }
109 let fd = unsafe {
111 libc::openat(
112 parent.as_raw_fd(),
113 name.as_ptr(),
114 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
115 )
116 };
117 ensure!(
118 fd >= 0,
119 "run path must contain only real directories: {}",
120 std::io::Error::last_os_error()
121 );
122 Ok(unsafe { File::from_raw_fd(fd) })
124 }
125
126 fn directory(root: &Path, id: &str, create: bool) -> Result<File> {
127 let root = OpenOptions::new()
128 .read(true)
129 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
130 .open(root)
131 .context("open canonical workspace root")?;
132 let ctx = child_directory(&root, ".ctx", create)?;
133 let runs = child_directory(&ctx, "runs", create)?;
134 child_directory(&runs, id, create)
135 }
136
137 fn child_file(directory: &File, name: &str, exclusive: bool) -> Result<File> {
138 let name = CString::new(name)?;
139 let flags = libc::O_RDWR
140 | libc::O_CREAT
141 | libc::O_NONBLOCK
142 | libc::O_NOFOLLOW
143 | libc::O_CLOEXEC
144 | if exclusive { libc::O_EXCL } else { 0 };
145 let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags, 0o600) };
147 if fd < 0 {
148 return Err(std::io::Error::last_os_error()).context("open runtime file");
149 }
150 let file = unsafe { File::from_raw_fd(fd) };
152 let metadata = file.metadata()?;
153 ensure!(
154 metadata.is_file() && metadata.nlink() == 1,
155 "runtime file must be a regular file without hard links"
156 );
157 Ok(file)
158 }
159
160 pub(super) fn acquire(root: &Path, id: &str) -> Result<RunFiles> {
161 let root = root.canonicalize().context("resolve workspace root")?;
162 let directory = directory(&root, id, true)?;
163 let lock = child_file(&directory, ".lock", false)?;
164 if let Err(error) = lock.try_lock() {
165 match error {
166 std::fs::TryLockError::WouldBlock => {
167 return Err(RunOwnershipUnavailable.into());
168 }
169 std::fs::TryLockError::Error(error) => {
170 return Err(error).context("acquire run ownership lock");
171 }
172 }
173 }
174 directory.sync_all().context("sync run lock directory")?;
175 let artifacts = child_directory(&directory, "artifacts", true)?;
176 let files = RunFiles {
177 root,
178 id: id.to_owned(),
179 directory,
180 artifacts,
181 _lock: lock,
182 };
183 files.verify_directory()?;
184 Ok(files)
185 }
186
187 impl RunFiles {
188 fn verify_directory(&self) -> Result<()> {
189 let current = directory(&self.root, &self.id, false)?.metadata()?;
190 let held = self.directory.metadata()?;
191 ensure!(
192 current.dev() == held.dev() && current.ino() == held.ino(),
193 "run directory changed while owned"
194 );
195 let current = child_directory(&self.directory, "artifacts", false)?.metadata()?;
196 let held = self.artifacts.metadata()?;
197 ensure!(
198 current.dev() == held.dev() && current.ino() == held.ino(),
199 "artifact directory changed while owned"
200 );
201 Ok(())
202 }
203
204 pub(super) fn write_impl(&self, name: &str, content: &[u8]) -> Result<ArtifactFile> {
205 self.verify_directory()?;
206 let filename = format!("{}-{name}", uuid::Uuid::new_v4());
207 let mut file = child_file(&self.artifacts, &filename, true)?;
208 file.write_all(content).context("write run artifact")?;
209 file.sync_all().context("sync run artifact")?;
210 self.artifacts
211 .sync_all()
212 .context("sync artifact directory")?;
213 self.verify_directory()?;
214 Ok(ArtifactFile {
215 relative_path: format!(".ctx/runs/{}/artifacts/{filename}", self.id),
216 sha256: format!("{:x}", Sha256::digest(content)),
217 size: content.len() as u64,
218 })
219 }
220 }
221}
222
223#[cfg(unix)]
224fn acquire_impl(root: &Path, id: &str) -> Result<RunFiles> {
225 unix::acquire(root, id)
226}
227
228#[cfg(not(unix))]
229fn acquire_impl(_root: &Path, _id: &str) -> Result<RunFiles> {
230 anyhow::bail!("secure run files are not supported on this platform")
231}
232
233#[cfg(not(unix))]
234impl RunFiles {
235 fn write_impl(&self, _name: &str, _content: &[u8]) -> Result<ArtifactFile> {
236 anyhow::bail!("secure run files are not supported on this platform")
237 }
238}
239
240#[cfg(all(test, unix))]
241mod tests {
242 use super::*;
243 use std::{
244 fs,
245 os::unix::fs::{symlink, PermissionsExt},
246 };
247
248 #[test]
249 fn locks_conflict_and_release_without_removing_inode() {
250 let root = tempfile::tempdir().unwrap();
251 let id = uuid::Uuid::new_v4().to_string();
252 let first = acquire(root.path(), &id).unwrap();
253 let error = acquire(root.path(), &id).unwrap_err();
254 assert!(error.downcast_ref::<RunOwnershipUnavailable>().is_some());
255 let lock = root.path().join(format!(".ctx/runs/{id}/.lock"));
256 assert!(lock.is_file());
257 drop(first);
258 assert!(lock.is_file());
259 let _second = acquire(root.path(), &id).unwrap();
260 }
261
262 #[test]
263 fn immutable_artifact_bytes_digest_and_private_permissions() {
264 let root = tempfile::tempdir().unwrap();
265 let id = uuid::Uuid::new_v4().to_string();
266 let files = acquire(root.path(), &id).unwrap();
267 let first = files.write_artifact("answer.txt", b"hello").unwrap();
268 let second = files.write_artifact("answer.txt", b"world").unwrap();
269 assert_ne!(first.relative_path, second.relative_path);
270 assert_eq!(
271 fs::read(root.path().join(&first.relative_path)).unwrap(),
272 b"hello"
273 );
274 assert_eq!(first.size, 5);
275 assert_eq!(
276 first.sha256,
277 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
278 );
279 assert_eq!(
280 fs::metadata(root.path().join(&first.relative_path))
281 .unwrap()
282 .permissions()
283 .mode()
284 & 0o777,
285 0o600
286 );
287 assert_eq!(
288 fs::metadata(root.path().join(format!(".ctx/runs/{id}")))
289 .unwrap()
290 .permissions()
291 .mode()
292 & 0o777,
293 0o700
294 );
295 for label in [
296 "",
297 "../escape",
298 "/tmp/escape",
299 "nested/name",
300 ".lock",
301 "a\\b",
302 ] {
303 assert!(files.write_artifact(label, b"no").is_err());
304 }
305 assert!(files
306 .write_artifact("large", &vec![0; MAX_ARTIFACT_BYTES + 1])
307 .is_err());
308 assert!(acquire(root.path(), "../../escape").is_err());
309 }
310
311 #[test]
312 fn rejects_symlinks_at_each_runtime_component() {
313 for component in [".ctx", ".ctx/runs", ".ctx/runs/ID", ".ctx/runs/ID/.lock"] {
314 let root = tempfile::tempdir().unwrap();
315 let outside = tempfile::tempdir().unwrap();
316 let id = uuid::Uuid::new_v4().to_string();
317 let path = root.path().join(component.replace("ID", &id));
318 fs::create_dir_all(path.parent().unwrap()).unwrap();
319 symlink(outside.path(), &path).unwrap();
320 assert!(acquire(root.path(), &id).is_err(), "{component}");
321 assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
322 }
323 }
324
325 #[test]
326 fn rejects_artifact_directory_replacement_after_acquiring() {
327 let root = tempfile::tempdir().unwrap();
328 let outside = tempfile::tempdir().unwrap();
329 let id = uuid::Uuid::new_v4().to_string();
330 let files = acquire(root.path(), &id).unwrap();
331 let run = root.path().join(format!(".ctx/runs/{id}"));
332 fs::rename(run.join("artifacts"), run.join("old-artifacts")).unwrap();
333 symlink(outside.path(), run.join("artifacts")).unwrap();
334 assert!(files.write_artifact("answer", b"no").is_err());
335 assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
336 }
337
338 #[test]
339 fn rejects_ancestor_replacement_after_acquiring() {
340 let root = tempfile::tempdir().unwrap();
341 let outside = tempfile::tempdir().unwrap();
342 let id = uuid::Uuid::new_v4().to_string();
343 let files = acquire(root.path(), &id).unwrap();
344 fs::rename(root.path().join(".ctx"), root.path().join("old-ctx")).unwrap();
345 symlink(outside.path(), root.path().join(".ctx")).unwrap();
346 assert!(files.write_artifact("answer", b"no").is_err());
347 assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
348 }
349}