Skip to main content

aria_primitives/
file_inspection.rs

1//! Inspection of filesystem targets: canonical paths, metadata snapshots,
2//! executability, and whether the daemon may rely on them.
3
4use 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}