ag_harness/
file_system.rs1use 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#[cfg_attr(test, mockall::automock)]
24#[async_trait]
25pub trait FileSystem: Send + Sync {
26 async fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
32
33 async fn open_beneath(
41 &self,
42 root: &Path,
43 path: &Path,
44 ) -> io::Result<Box<dyn AsyncRead + Send + Unpin>>;
45}
46
47pub 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 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 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!(canonical_path.is_absolute());
145 assert_eq!(content, "hello");
146 }
147
148 #[tokio::test]
149 async fn local_file_system_reports_missing_paths() {
150 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 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_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 let directory = tempfile::tempdir().expect("temporary directory should be created");
175 let file_system = LocalFileSystem;
176
177 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_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 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 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_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 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 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!(FILE_OPEN_FLAGS.contains(OFlags::NONBLOCK));
239 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
240 }
241}