voa_core/util/
symlinks.rs

1//! Helper functionality for handling symlinks in VOA.
2//!
3//! In particular, resolving symlinks while checking that their structure conforms to the [VOA
4//! linking rules].
5//!
6//! [VOA linking rules]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#symlinking
7
8use std::{
9    collections::HashSet,
10    fs::{read_link, symlink_metadata},
11    path::{Path, PathBuf},
12};
13
14use log::{debug, warn};
15
16use crate::{error::Error, load_path::LoadPath};
17
18/// The result of resolving a symlink in a VOA structure.
19///
20/// The resolved target of a link is either a [`PathBuf`] to a directory or a file, or it shows
21/// that the symlink points to `/dev/null` to signal [masking].
22///
23/// [masking]:
24/// https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#masking
25pub(crate) enum ResolvedSymlink {
26    Dir(PathBuf),
27    File(PathBuf),
28    Masked,
29}
30
31/// Resolves an arbitrarily long chain of symlinks and checks its validity.
32///
33/// Ensures that all intermediate and final paths are located within the set of paths in
34/// `legal_symlink_paths` and that the symlink chain does not contain a cycle.
35///
36/// For results with the variants [`ResolvedSymlink::File`] or [`ResolvedSymlink::Dir`], the
37/// returned [`PathBuf`] contains a fully canonicalized path.
38///
39/// Symlinks that point to `/dev/null` (including in multiple hops) signal [masking] in VOA.
40/// The variant [`ResolvedSymlink::Masked`] is returned for such symlinks.
41///
42/// # Errors
43///
44/// Returns an error if
45///
46/// - a cycle is detected in a symlink chain ([`Error::CyclicSymlinks`]),
47/// - or a symlink points to a path outside `legal_symlink_paths` ([`Error::IllegalSymlinkTarget`]).
48///
49/// [masking]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#masking
50pub(crate) fn resolve_symlink(
51    start: &Path,
52    legal_symlink_paths: &[&LoadPath],
53) -> Result<ResolvedSymlink, Error> {
54    if !start.is_symlink() {
55        warn!("⤷ Not a symlink {start:?} (can't resolve)");
56
57        // This is an inconsistent call for this function
58        return Err(Error::InternalError {
59            context: format!("'resolve_symlink' was called for the non-symlink {start:?}"),
60        });
61    }
62
63    let mut path = start.to_path_buf();
64
65    // Remember all paths we've traversed to do symlink cycle detection
66    let mut paths_seen = HashSet::new();
67    paths_seen.insert(path.clone());
68
69    // Loop through chains of symlinks.
70    // Check legality of each intermediate hop!
71    loop {
72        let mut link_target = read_link(&path).map_err(|source| Error::IoPath {
73            path: path.clone(),
74            context: "reading link",
75            source,
76        })?;
77
78        // Are we in a symlink cycle?
79        if paths_seen.contains(&link_target) {
80            return Err(Error::CyclicSymlinks { path: link_target });
81        }
82
83        // If this is a masking symlink, we're done and return
84        if link_target.as_path().to_str() == Some("/dev/null") {
85            return Ok(ResolvedSymlink::Masked);
86        }
87
88        // Start constructing an absolute path for link_target, in case it is currently relative
89        if link_target.is_relative() {
90            let mut appended = path.clone();
91            appended.push(link_target);
92            link_target = appended;
93        }
94
95        // Normalize link target in case it contains any `../` path segments
96        // (note that this normalization step doesn't look at the filesystem or resolve symlinks!)
97        let Some(normalized) = normalize_path(&link_target) else {
98            // This should never happen
99            return Err(Error::InternalError {
100                context: format!("normalize_path called for relative path {link_target:?}"),
101            });
102        };
103        if normalized != link_target {
104            debug!("⤷ Normalized link target {link_target:?} path into {normalized:?}");
105            link_target = normalized;
106        }
107
108        // Check that (normalized) target file path is legal:
109        // Symlinks may only point into locations under `legal_symlink_paths`
110        if !legal_symlink_paths
111            .iter()
112            .any(|p| link_target.starts_with(&p.path))
113        {
114            warn!(
115                "⤷ Symlink target is outside the set of legal load paths: {link_target:?} (can't resolve)"
116            );
117            return Err(Error::IllegalSymlinkTarget { path: link_target });
118        }
119
120        let meta = match symlink_metadata(&link_target) {
121            Ok(meta) => meta,
122            Err(source) => {
123                warn!("⤷ Cannot get metadata of symlink target {link_target:?} (can't resolve)");
124                return Err(Error::IoPath {
125                    source,
126                    context: "obtaining symlink metadata",
127                    path: link_target,
128                });
129            }
130        };
131
132        // Return if we found any valid (or invalid) non-symlink destination.
133        let file_type = meta.file_type();
134        if file_type.is_file() {
135            return Ok(ResolvedSymlink::File(link_target));
136        } else if file_type.is_dir() {
137            return Ok(ResolvedSymlink::Dir(link_target));
138        } else if !file_type.is_symlink() {
139            warn!("Unexpected file type {file_type:?} for {link_target:?} (can't resolve)");
140            return Err(Error::IllegalSymlink {
141                path: link_target,
142                context: "Unexpected file type",
143            });
144        }
145
146        // We found another symlink, continue with the loop.
147        path = link_target;
148        paths_seen.insert(path.clone());
149    }
150}
151
152/// Normalize a path without performing any filesystem operations.
153///
154/// # Notes
155///
156/// - All redundant separator and ParentDir components are collapsed.
157/// - This function does not resolve links.
158/// - For all absolute input paths, it returns `Some`.
159/// - For relative input paths, it returns `None`.
160pub(crate) fn normalize_path(path: &Path) -> Option<PathBuf> {
161    use std::path::Component;
162
163    if path.is_relative() {
164        return None;
165    }
166
167    let mut normalized = PathBuf::new();
168    for component in path.components() {
169        match component {
170            Component::CurDir => {}
171            Component::ParentDir => {
172                normalized.pop();
173            }
174            Component::Normal(c) => normalized.push(c),
175            Component::RootDir => normalized.push(component),
176            Component::Prefix(_) => unreachable!(), // Does not occur on Unix
177        }
178    }
179
180    Some(normalized)
181}
182
183#[cfg(test)]
184mod tests {
185    use std::path::PathBuf;
186
187    use rstest::rstest;
188    use tempfile::tempdir;
189
190    use super::*;
191
192    #[rstest]
193    #[case("/foo/bar/baz.xyz", Some("/foo/bar/baz.xyz"))]
194    #[case("/foo/bar/../baz.xyz", Some("/foo/baz.xyz"))]
195    #[case("/foo/bar/../../baz.xyz", Some("/baz.xyz"))]
196    #[case("/foo/bar/../../../baz.xyz", Some("/baz.xyz"))]
197    #[case("//foo/bar/baz.xyz", Some("/foo/bar/baz.xyz"))]
198    #[case("/foo/bar//baz.xyz", Some("/foo/bar/baz.xyz"))]
199    #[case("bar/../baz.xyz", None)]
200    #[case("bar/../../baz.xyz", None)]
201    #[case("./baz.xyz", None)]
202    fn test_normalize_path(#[case] raw: &str, #[case] expected: Option<&str>) {
203        let expected = expected.map(PathBuf::from);
204
205        let raw = PathBuf::from(raw);
206        let normalized = normalize_path(&raw);
207
208        assert_eq!(normalized, expected);
209    }
210
211    #[test]
212    fn resolve_symlink_errors_for_directory() -> testresult::TestResult {
213        let tmp = tempdir()?;
214        let pathbuf: PathBuf = tmp.path().into();
215
216        // create a temporary directory to pass to `resolve_symlink`
217        let loadpath_tmp = LoadPath::new("/tmp", true, true);
218
219        let res = resolve_symlink(pathbuf.as_path(), &[&loadpath_tmp])
220            .err()
221            .unwrap();
222
223        // we expect resolve_symplink to reject this path with `InternalError`
224        assert!(matches!(res, Error::InternalError { .. }));
225
226        Ok(())
227    }
228}