Skip to main content

ag_harness/
file_system.rs

1use std::ffi::OsStr;
2use std::io;
3use std::path::{Component, Path, PathBuf};
4
5use async_trait::async_trait;
6use rustix::fs::{FileType, Mode, OFlags};
7use tokio::io::AsyncRead;
8
9const DIRECTORY_OPEN_FLAGS: OFlags = OFlags::RDONLY
10    .union(OFlags::CLOEXEC)
11    .union(OFlags::DIRECTORY)
12    .union(OFlags::NOFOLLOW);
13const FILE_OPEN_FLAGS: OFlags = OFlags::RDONLY
14    .union(OFlags::CLOEXEC)
15    .union(OFlags::NOFOLLOW)
16    .union(OFlags::NONBLOCK);
17
18/// Asynchronous filesystem boundary used by harness tools.
19///
20/// The harness uses this boundary for diagnostic path resolution and
21/// descriptor-relative opening, keeping repository containment inside the
22/// filesystem operation.
23#[cfg_attr(test, mockall::automock)]
24#[async_trait]
25pub trait FileSystem: Send + Sync {
26    /// Resolves a path to its canonical absolute representation.
27    ///
28    /// # Errors
29    ///
30    /// Returns an I/O error when the path cannot be resolved.
31    async fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
32
33    /// Opens a repository-relative file without following symlinks beneath
34    /// `root`.
35    ///
36    /// # Errors
37    ///
38    /// Returns an I/O error when `path` is invalid, traverses a symlink, does
39    /// not name a regular file, or cannot be opened for reading.
40    async fn open_beneath(
41        &self,
42        root: &Path,
43        path: &Path,
44    ) -> io::Result<Box<dyn AsyncRead + Send + Unpin>>;
45}
46
47/// Tokio-backed filesystem implementation for local repositories.
48pub struct LocalFileSystem;
49
50impl LocalFileSystem {
51    fn open_beneath(root: &Path, relative_path: &Path) -> io::Result<std::fs::File> {
52        let mut directory =
53            rustix::fs::open(root, DIRECTORY_OPEN_FLAGS, Mode::empty()).map_err(io::Error::from)?;
54        let components = relative_path
55            .components()
56            .map(|component| match component {
57                Component::Normal(component) => Ok(component),
58                _ => Err(io::Error::new(
59                    io::ErrorKind::InvalidInput,
60                    "read path must be repository-relative",
61                )),
62            })
63            .collect::<io::Result<Vec<&OsStr>>>()?;
64        let (file_name, ancestor_components) = components.split_last().ok_or_else(|| {
65            io::Error::new(io::ErrorKind::InvalidInput, "read path must not be empty")
66        })?;
67
68        for component in ancestor_components {
69            directory =
70                rustix::fs::openat(&directory, *component, DIRECTORY_OPEN_FLAGS, Mode::empty())
71                    .map_err(io::Error::from)?;
72        }
73        let descriptor = rustix::fs::openat(&directory, *file_name, FILE_OPEN_FLAGS, Mode::empty())
74            .map_err(io::Error::from)?;
75        let metadata = rustix::fs::fstat(&descriptor).map_err(io::Error::from)?;
76        if !FileType::from_raw_mode(metadata.st_mode).is_file() {
77            return Err(io::Error::new(
78                io::ErrorKind::InvalidInput,
79                "read path must name a regular file",
80            ));
81        }
82
83        Ok(std::fs::File::from(descriptor))
84    }
85}
86
87#[async_trait]
88impl FileSystem for LocalFileSystem {
89    async fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
90        tokio::fs::canonicalize(path).await
91    }
92
93    async fn open_beneath(
94        &self,
95        root: &Path,
96        path: &Path,
97    ) -> io::Result<Box<dyn AsyncRead + Send + Unpin>> {
98        let root = root.to_path_buf();
99        let path = path.to_path_buf();
100        let file = tokio::task::spawn_blocking(move || Self::open_beneath(&root, &path))
101            .await
102            .map_err(io::Error::other)??;
103
104        Ok(Box::new(tokio::fs::File::from_std(file)))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use std::io::Write as _;
111    use std::os::unix::fs::symlink;
112
113    use tokio::io::AsyncReadExt as _;
114
115    use super::*;
116
117    #[tokio::test]
118    async fn local_file_system_canonicalizes_and_opens_file() {
119        // Arrange
120        let directory = tempfile::tempdir().expect("temporary directory should be created");
121        let nested_directory = directory.path().join("nested");
122        std::fs::create_dir(&nested_directory).expect("nested directory should be created");
123        let path = nested_directory.join("input.txt");
124        std::fs::File::create(&path)
125            .and_then(|mut file| file.write_all(b"hello"))
126            .expect("fixture file should be written");
127        let file_system = LocalFileSystem;
128
129        // Act
130        let canonical_path = file_system
131            .canonicalize(&path)
132            .await
133            .expect("fixture path should canonicalize");
134        let mut file = file_system
135            .open_beneath(directory.path(), Path::new("nested/input.txt"))
136            .await
137            .expect("fixture file should open");
138        let mut content = String::new();
139        file.read_to_string(&mut content)
140            .await
141            .expect("fixture file should be readable");
142
143        // Assert
144        assert!(canonical_path.is_absolute());
145        assert_eq!(content, "hello");
146    }
147
148    #[tokio::test]
149    async fn local_file_system_reports_missing_paths() {
150        // Arrange
151        let directory = tempfile::tempdir().expect("temporary directory should be created");
152        let path = directory.path().join("missing.txt");
153        let file_system = LocalFileSystem;
154
155        // Act
156        let canonicalize_error = file_system
157            .canonicalize(&path)
158            .await
159            .expect_err("missing path should not canonicalize");
160        let open_error = file_system
161            .open_beneath(directory.path(), Path::new("missing.txt"))
162            .await
163            .err()
164            .expect("missing path should not open");
165
166        // Assert
167        assert_eq!(canonicalize_error.kind(), io::ErrorKind::NotFound);
168        assert_eq!(open_error.kind(), io::ErrorKind::NotFound);
169    }
170
171    #[tokio::test]
172    async fn local_file_system_rejects_invalid_relative_paths() {
173        // Arrange
174        let directory = tempfile::tempdir().expect("temporary directory should be created");
175        let file_system = LocalFileSystem;
176
177        // Act
178        let empty_error = file_system
179            .open_beneath(directory.path(), Path::new(""))
180            .await
181            .err()
182            .expect("empty path should fail");
183        let parent_error = file_system
184            .open_beneath(directory.path(), Path::new("../input.txt"))
185            .await
186            .err()
187            .expect("parent traversal should fail");
188
189        // Assert
190        assert_eq!(empty_error.kind(), io::ErrorKind::InvalidInput);
191        assert_eq!(parent_error.kind(), io::ErrorKind::InvalidInput);
192    }
193
194    #[tokio::test]
195    async fn local_file_system_rejects_symlink_traversal() {
196        // Arrange
197        let repository = tempfile::tempdir().expect("repository should be created");
198        let outside = tempfile::tempdir().expect("outside directory should be created");
199        let outside_file = outside.path().join("outside.txt");
200        std::fs::File::create(&outside_file)
201            .and_then(|mut file| file.write_all(b"outside"))
202            .expect("outside file should be written");
203        symlink(&outside_file, repository.path().join("file-link"))
204            .expect("file symlink should be created");
205        symlink(outside.path(), repository.path().join("directory-link"))
206            .expect("directory symlink should be created");
207        let file_system = LocalFileSystem;
208
209        // Act
210        let file_error = file_system
211            .open_beneath(repository.path(), Path::new("file-link"))
212            .await
213            .err()
214            .expect("file symlink should not be followed");
215        let directory_error = file_system
216            .open_beneath(repository.path(), Path::new("directory-link/outside.txt"))
217            .await
218            .err()
219            .expect("directory symlink should not be followed");
220
221        // Assert
222        assert_ne!(file_error.kind(), io::ErrorKind::NotFound);
223        assert_ne!(directory_error.kind(), io::ErrorKind::NotFound);
224    }
225
226    #[test]
227    fn local_file_system_opens_nonblocking_and_rejects_non_regular_file() {
228        // Arrange
229        let repository = tempfile::tempdir().expect("repository should be created");
230        std::fs::create_dir(repository.path().join("directory"))
231            .expect("directory fixture should be created");
232
233        // Act
234        let error = LocalFileSystem::open_beneath(repository.path(), Path::new("directory"))
235            .expect_err("directory should not be readable as a regular file");
236
237        // Assert
238        assert!(FILE_OPEN_FLAGS.contains(OFlags::NONBLOCK));
239        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
240    }
241}