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    ffi::OsStr,
11    fs::{read_link, symlink_metadata},
12    path::{Path, PathBuf},
13};
14
15use log::{debug, warn};
16
17use crate::{error::Error, load_path::LoadPath};
18
19/// The result of resolving a symlink in a VOA structure.
20///
21/// The resolved target of a link is either a [`PathBuf`] to a directory or a file, or it shows
22/// that the symlink points to `/dev/null` to signal [masking].
23///
24/// [masking]:
25/// https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#masking
26pub(crate) enum ResolvedSymlink {
27    Dir(PathBuf),
28    File(PathBuf),
29    Masked,
30}
31
32/// Resolves an arbitrarily long chain of symlinks and checks its validity.
33///
34/// Ensures that all intermediate and final paths are located within the set of paths in
35/// `legal_symlink_paths` and that the symlink chain does not contain a cycle.
36///
37/// For results with the variants [`ResolvedSymlink::File`] or [`ResolvedSymlink::Dir`], the
38/// returned [`PathBuf`] contains a fully canonicalized path.
39///
40/// Symlinks that point to `/dev/null` (including in multiple hops) signal [masking] in VOA.
41/// The variant [`ResolvedSymlink::Masked`] is returned for such symlinks.
42///
43/// # Errors
44///
45/// Returns an error if
46///
47/// - a cycle is detected in a symlink chain ([`Error::CyclicSymlinks`]),
48/// - or a symlink points to a path outside `legal_symlink_paths` ([`Error::IllegalSymlinkTarget`]).
49///
50/// [masking]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#masking
51pub(crate) fn resolve_symlink(
52    start: &Path,
53    legal_symlink_paths: &[&LoadPath],
54    path_type: PathType,
55) -> Result<ResolvedSymlink, Error> {
56    if !start.is_symlink() {
57        warn!("⤷ Not a symlink {start:?} (can't resolve)");
58
59        // This is an inconsistent call for this function
60        return Err(Error::InternalError {
61            context: format!("'resolve_symlink' was called for the non-symlink {start:?}"),
62        });
63    }
64
65    let mut path = start.to_path_buf();
66
67    // Remember all paths we've traversed to do symlink cycle detection
68    let mut paths_seen = HashSet::new();
69    paths_seen.insert(path.clone());
70
71    // Loop through chains of symlinks.
72    // Check legality of each intermediate hop!
73    loop {
74        let mut link_target = read_link(&path).map_err(|source| Error::IoPath {
75            path: path.clone(),
76            context: "reading link",
77            source,
78        })?;
79
80        // Are we in a symlink cycle?
81        if paths_seen.contains(&link_target) {
82            return Err(Error::CyclicSymlinks { path: link_target });
83        }
84
85        // If this is a masking symlink, we're done and return
86        if link_target.as_path().to_str() == Some("/dev/null") {
87            return Ok(ResolvedSymlink::Masked);
88        }
89
90        // Start constructing an absolute path for link_target, in case it is currently relative
91        if link_target.is_relative() {
92            let mut appended = path.clone();
93            appended.push(link_target);
94            link_target = appended;
95        }
96
97        // Normalize link target in case it contains any `../` path segments
98        // (note that this normalization step doesn't look at the filesystem or resolve symlinks!)
99        let Some(normalized) = normalize_path(&link_target, path_type) else {
100            // This should never happen
101            return Err(Error::InternalError {
102                context: format!("normalize_path called for relative path {link_target:?}"),
103            });
104        };
105        if normalized != link_target {
106            debug!("⤷ Normalized link target {link_target:?} path into {normalized:?}");
107            link_target = normalized;
108        }
109
110        // Check that (normalized) target file path is legal:
111        // Symlinks may only point into locations under `legal_symlink_paths`
112        if !legal_symlink_paths
113            .iter()
114            .any(|p| link_target.starts_with(&p.path))
115        {
116            warn!(
117                "⤷ Symlink target is outside the set of legal load paths: {link_target:?} (can't resolve)"
118            );
119            return Err(Error::IllegalSymlinkTarget { path: link_target });
120        }
121
122        let meta = match symlink_metadata(&link_target) {
123            Ok(meta) => meta,
124            Err(source) => {
125                warn!("⤷ Cannot get metadata of symlink target {link_target:?} (can't resolve)");
126                return Err(Error::IoPath {
127                    source,
128                    context: "obtaining symlink metadata",
129                    path: link_target,
130                });
131            }
132        };
133
134        // Return if we found any valid (or invalid) non-symlink destination.
135        let file_type = meta.file_type();
136        if file_type.is_file() {
137            return Ok(ResolvedSymlink::File(link_target));
138        } else if file_type.is_dir() {
139            return Ok(ResolvedSymlink::Dir(link_target));
140        } else if !file_type.is_symlink() {
141            warn!("Unexpected file type {file_type:?} for {link_target:?} (can't resolve)");
142            return Err(Error::IllegalSymlink {
143                path: link_target,
144                context: "Unexpected file type",
145            });
146        }
147
148        // We found another symlink, continue with the loop.
149        path = link_target;
150        paths_seen.insert(path.clone());
151    }
152}
153
154/// An indicator for what type of path normalization is done for.
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub(crate) enum PathType {
157    /// A directory.
158    Dir,
159    /// A regular file.
160    File,
161}
162
163/// Normalizes a path without performing any filesystem operations.
164///
165/// Returns [`Some`] absolute path for absolute input paths.
166/// Returns [`None`] for relative input paths.
167///
168/// # Notes
169///
170/// - All redundant separator (e.g. `/`) and parent directory components (i.e. `..`) are normalized.
171/// - Any current directory components (i.e. `.`) are ignored.
172/// - This function does not resolve links.
173pub(crate) fn normalize_path(path: &Path, path_type: PathType) -> Option<PathBuf> {
174    if path.is_relative() {
175        return None;
176    }
177
178    let parent_dir = OsStr::new("..");
179
180    let mut normalized = PathBuf::new();
181    let mut dir_pop = false;
182
183    for component in path.iter() {
184        // Truncate the already collected path components to the parent of the path.
185        if component == parent_dir {
186            normalized.pop();
187            // If we are normalizing for a directory, we need to truncate the path to its parent for
188            // a second time (only once).
189            if path_type == PathType::Dir && !dir_pop {
190                normalized.pop();
191                dir_pop = true;
192            }
193            continue;
194        }
195
196        normalized.push(component);
197    }
198
199    Some(normalized)
200}
201
202#[cfg(test)]
203mod tests {
204    use std::path::PathBuf;
205
206    use rstest::rstest;
207    use tempfile::tempdir;
208
209    use super::*;
210
211    #[rstest]
212    #[case::file_no_changes(
213        PathBuf::from(
214            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
215        ),
216        PathType::File,
217        Some(PathBuf::from(
218            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
219        ))
220    )]
221    #[case::dir_no_changes(
222        PathBuf::from("/usr/share/voa/example/package/default/openpgp/"),
223        PathType::Dir,
224        Some(PathBuf::from("/usr/share/voa/example/package/default/openpgp/"))
225    )]
226    #[case::file_with_current_dir(
227        PathBuf::from(
228            "/usr/share/voa/example/package/./default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
229        ),
230        PathType::File,
231        Some(PathBuf::from(
232            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
233        ))
234    )]
235    #[case::dir_with_current_dir(
236        PathBuf::from("/usr/share/voa/example/package/./default/openpgp/"),
237        PathType::Dir,
238        Some(PathBuf::from("/usr/share/voa/example/package/default/openpgp/"))
239    )]
240    #[case::file_with_indirection(
241        PathBuf::from(
242            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp/../../../../image/installation-medium/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
243        ),
244        PathType::File,
245        Some(PathBuf::from(
246            "/usr/share/voa/example/image/installation-medium/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
247        ))
248    )]
249    #[case::dir_with_indirection(
250        PathBuf::from(
251            "/usr/share/voa/example/package/default/openpgp/../../image/installation-medium/openpgp/"
252        ),
253        PathType::Dir,
254        Some(PathBuf::from("/usr/share/voa/example/image/installation-medium/openpgp/"))
255    )]
256    #[case::file_with_multi_indirection(
257        PathBuf::from(
258            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp/../../../../image/installation-medium/../update/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
259        ),
260        PathType::File,
261        Some(PathBuf::from(
262            "/usr/share/voa/example/image/update/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
263        ))
264    )]
265    #[case::dir_with_multi_indirection(
266        PathBuf::from(
267            "/usr/share/voa/example/package/default/openpgp/../../image/installation-medium/../update/openpgp/"
268        ),
269        PathType::Dir,
270        Some(PathBuf::from("/usr/share/voa/example/image/update/openpgp/"))
271    )]
272    #[case::file_indirection_past_root(
273        PathBuf::from(
274            "/usr/share/voa/example/image/installation-medium/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp/../../../../../../../../../f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
275        ),
276        PathType::File,
277        Some(PathBuf::from("/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"))
278    )]
279    #[case::dir_indirection_past_root(
280        PathBuf::from(
281            "/usr/share/voa/example/image/installation-medium/../../../../../../../openpgp"
282        ),
283        PathType::Dir,
284        Some(PathBuf::from("/openpgp"))
285    )]
286    #[case::file_eliminate_extra_slash_at_root(
287        PathBuf::from(
288            "//usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
289        ),
290        PathType::File,
291        Some(PathBuf::from(
292            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
293        ))
294    )]
295    #[case::file_eliminate_extra_slash_in_path(
296        PathBuf::from(
297            "/usr/share/voa//example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
298        ),
299        PathType::File,
300        Some(PathBuf::from(
301            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
302        ))
303    )]
304    #[case::file_relative_path_with_indirection(
305        PathBuf::from("usr/share/voa/example/image/default/openpgp/../baz.xyz"),
306        PathType::File,
307        None
308    )]
309    #[case::dir_relative_path_with_indirection(
310        PathBuf::from("usr/share/voa/example/image/../package/"),
311        PathType::Dir,
312        None
313    )]
314    #[case::file_starts_with_current_dir(
315        PathBuf::from(
316            "./usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
317        ),
318        PathType::File,
319        None
320    )]
321    #[case::dir_starts_with_current_dir(
322        PathBuf::from("./usr/share/voa/example/package/default/openpgp/"),
323        PathType::Dir,
324        None
325    )]
326    fn test_normalize_path(
327        #[case] path: PathBuf,
328        #[case] path_type: PathType,
329        #[case] expected: Option<PathBuf>,
330    ) {
331        assert_eq!(normalize_path(&path, path_type), expected);
332    }
333
334    #[test]
335    fn resolve_symlink_errors_for_directory() -> testresult::TestResult {
336        let tmp = tempdir()?;
337        let pathbuf: PathBuf = tmp.path().into();
338
339        // create a temporary directory to pass to `resolve_symlink`
340        let loadpath_tmp = LoadPath::new("/tmp", true, true);
341
342        let res = resolve_symlink(pathbuf.as_path(), &[&loadpath_tmp], PathType::Dir)
343            .err()
344            .unwrap();
345
346        // we expect resolve_symplink to reject this path with `InternalError`
347        assert!(matches!(res, Error::InternalError { .. }));
348
349        Ok(())
350    }
351}