aria_primitives/
file_inspection.rs1use std::fs::{self, Metadata};
5use std::io;
6use std::os::unix::fs::{MetadataExt, PermissionsExt};
7use std::path::PathBuf;
8
9const GROUP_OR_OTHER_WRITE: u32 = 0o022;
10
11pub struct InspectedFile {
12 path: PathBuf,
13 metadata: Metadata,
14}
15
16impl InspectedFile {
17 pub fn inspect(path: PathBuf) -> Result<Self, InspectionError> {
18 if !path.is_absolute() {
19 return Err(InspectionError::NotAbsolute { path });
20 }
21
22 let path = fs::canonicalize(&path).map_err(|source| InspectionError::Inspect {
23 path: path.clone(),
24 source,
25 })?;
26 let metadata = fs::metadata(&path).map_err(|source| InspectionError::Inspect {
27 path: path.clone(),
28 source,
29 })?;
30
31 Ok(Self { path, metadata })
32 }
33
34 pub fn require_regular_file(self) -> Result<Self, InspectionError> {
35 if self.metadata.is_file() {
36 return Ok(self);
37 }
38 Err(InspectionError::NotRegularFile {
39 path: self.into_path(),
40 })
41 }
42
43 pub fn require_directory(self) -> Result<Self, InspectionError> {
44 if self.metadata.is_dir() {
45 return Ok(self);
46 }
47 Err(InspectionError::NotDirectory {
48 path: self.into_path(),
49 })
50 }
51
52 #[cfg(not(target_os = "ios"))]
53 pub fn require_executable(self) -> Result<Self, InspectionError> {
54 let runnable = self.metadata.is_file()
55 && rustix::fs::access(&self.path, rustix::fs::Access::EXEC_OK).is_ok();
56 if runnable {
57 return Ok(self);
58 }
59 Err(InspectionError::NotExecutable {
60 path: self.into_path(),
61 })
62 }
63
64 pub fn is_regular_file(&self) -> bool {
65 self.metadata.is_file()
66 }
67
68 pub fn require_trusted(self) -> Result<PathBuf, InspectionError> {
69 let expected_uid = rustix::process::getuid().as_raw();
70 let actual_uid = self.metadata.uid();
71 if actual_uid != expected_uid && actual_uid != 0 {
72 return Err(InspectionError::UntrustedOwner {
73 path: self.into_path(),
74 expected_uid,
75 actual_uid,
76 });
77 }
78
79 let actual_mode = self.metadata.permissions().mode() & 0o777;
80 if actual_mode & GROUP_OR_OTHER_WRITE != 0 {
81 return Err(InspectionError::WritableByOthers {
82 path: self.into_path(),
83 actual_mode,
84 });
85 }
86 Ok(self.into_path())
87 }
88
89 pub fn into_path(self) -> PathBuf {
90 self.path
91 }
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum InspectionError {
96 #[error("path must be absolute: {}", path.display())]
97 NotAbsolute { path: PathBuf },
98
99 #[error("failed to inspect {}", path.display())]
100 Inspect {
101 path: PathBuf,
102 #[source]
103 source: io::Error,
104 },
105
106 #[error("path is not a regular file: {}", path.display())]
107 NotRegularFile { path: PathBuf },
108
109 #[error("path is not a directory: {}", path.display())]
110 NotDirectory { path: PathBuf },
111
112 #[error("path is not a runnable regular file: {}", path.display())]
113 NotExecutable { path: PathBuf },
114
115 #[error("{} belongs to uid {actual_uid}; expected uid {expected_uid} or root", path.display())]
116 UntrustedOwner {
117 path: PathBuf,
118 expected_uid: u32,
119 actual_uid: u32,
120 },
121
122 #[error(
123 "{} has mode {actual_mode:o}; group and other users must not have write access",
124 path.display()
125 )]
126 WritableByOthers { path: PathBuf, actual_mode: u32 },
127}
128
129#[cfg(test)]
130mod tests {
131 use std::os::unix::fs::symlink;
132
133 use tempfile::TempDir;
134
135 use super::*;
136
137 #[cfg(not(target_os = "ios"))]
138 const EXECUTABLE_MODE: u32 = 0o750;
139 const REGULAR_FILE_MODE: u32 = 0o640;
140 const GROUP_WRITABLE_MODE: u32 = 0o660;
141
142 struct FileFixture {
143 _directory: TempDir,
144 target: PathBuf,
145 link: PathBuf,
146 }
147
148 impl FileFixture {
149 fn with_mode(mode: u32) -> Self {
150 let directory = TempDir::new().expect("test directory should be created");
151 let target = directory.path().join("target");
152 let link = directory.path().join("link");
153
154 fs::write(&target, []).expect("test target should be written");
155 fs::set_permissions(&target, fs::Permissions::from_mode(mode))
156 .expect("test target permissions should be set");
157 symlink(&target, &link).expect("test symlink should be created");
158
159 Self {
160 _directory: directory,
161 target,
162 link,
163 }
164 }
165
166 fn canonical_target(&self) -> PathBuf {
167 fs::canonicalize(&self.target).expect("test target should have a canonical path")
168 }
169 }
170
171 #[test]
172 fn inspect_rejects_a_relative_path() {
173 let relative = PathBuf::from("relative/file");
174
175 let result = InspectedFile::inspect(relative.clone());
176
177 assert!(matches!(
178 result,
179 Err(InspectionError::NotAbsolute { path }) if path == relative
180 ));
181 }
182
183 #[test]
184 fn inspect_reports_the_canonical_target_and_its_metadata() {
185 let fixture = FileFixture::with_mode(REGULAR_FILE_MODE);
186
187 let file =
188 InspectedFile::inspect(fixture.link.clone()).expect("test symlink should be inspected");
189
190 assert!(file.is_regular_file());
191 assert_eq!(file.into_path(), fixture.canonical_target());
192 }
193
194 #[cfg(not(target_os = "ios"))]
195 #[test]
196 fn inspect_accepts_a_runnable_regular_file() {
197 let fixture = FileFixture::with_mode(EXECUTABLE_MODE);
198 let expected_path = fixture.canonical_target();
199
200 let executable = InspectedFile::inspect(fixture.link)
201 .and_then(InspectedFile::require_executable)
202 .expect("test executable should be inspected");
203
204 assert_eq!(executable.into_path(), expected_path);
205 }
206
207 #[test]
208 fn a_file_only_its_owner_can_write_is_trusted() {
209 let fixture = FileFixture::with_mode(REGULAR_FILE_MODE);
210 let file = InspectedFile::inspect(fixture.link.clone()).expect("the file should inspect");
211
212 let trusted = file
213 .require_trusted()
214 .expect("an owned private file is trusted");
215
216 assert_eq!(trusted, fixture.canonical_target());
217 }
218
219 #[test]
220 fn a_file_other_users_can_write_is_refused() {
221 let fixture = FileFixture::with_mode(GROUP_WRITABLE_MODE);
222 let file = InspectedFile::inspect(fixture.link).expect("the file should inspect");
223
224 let error = file
225 .require_trusted()
226 .expect_err("a group-writable file must be refused");
227
228 assert!(matches!(
229 error,
230 InspectionError::WritableByOthers { actual_mode, .. } if actual_mode == GROUP_WRITABLE_MODE
231 ));
232 }
233
234 #[cfg(not(target_os = "ios"))]
235 #[test]
236 fn inspect_rejects_a_regular_file_that_is_not_runnable() {
237 let fixture = FileFixture::with_mode(REGULAR_FILE_MODE);
238 let expected_path = fixture.canonical_target();
239
240 let result =
241 InspectedFile::inspect(fixture.link).and_then(InspectedFile::require_executable);
242
243 assert!(matches!(
244 result,
245 Err(InspectionError::NotExecutable { path }) if path == expected_path
246 ));
247 }
248}