Skip to main content

context_harness/agent_runtime/
files.rs

1//! Durable, immutable artifact files and process-lifetime ownership of local runs.
2//! Files are synced before their metadata is recorded in SQLite. A crash between
3//! those steps can leave an unreferenced file; files are never overwritten or
4//! implicitly removed. The persistent lock inode must never be unlinked.
5use 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/// Owns an exclusive OS lock until dropped, including during unwinding.
30#[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        // Release ownership before any of the held directory descriptors are
42        // dropped. Relying on descriptor close alone proved racy when the same
43        // run was reacquired immediately under Rust 1.99 on Linux.
44        let _ = std::fs::File::unlock(&self._lock);
45    }
46}
47
48pub fn acquire(root: &Path, id: &str) -> Result<RunFiles> {
49    // Require the canonical UUID spelling: no filesystem syntax or aliases.
50    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    /// Create a unique immutable artifact; callers may record this metadata only
61    /// after success. An error or crash can leave an unreferenced partial file.
62    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    /// Every traversal is descriptor-relative and rejects symlinks atomically.
94    /// Holding descriptors also prevents a renamed ancestor redirecting writes.
95    fn child_directory(parent: &File, name: &str, create: bool) -> Result<File> {
96        let name = CString::new(name)?;
97        if create {
98            // SAFETY: parent is live, name is NUL terminated, mode is valid.
99            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        // SAFETY: parent is live, name is NUL terminated, flags require a real dir.
110        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        // SAFETY: openat returned a new owned descriptor.
123        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        // SAFETY: directory is live and name is NUL terminated; mode is valid.
146        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        // SAFETY: openat returned a new owned descriptor.
151        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}