voa-core 0.4.1

File Hierarchy for the Verification of OS Artifacts (VOA)
Documentation
//! Helper functionality for handling symlinks in VOA.
//!
//! In particular, resolving symlinks while checking that their structure conforms to the [VOA
//! linking rules].
//!
//! [VOA linking rules]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#symlinking

use std::{
    collections::HashSet,
    ffi::OsStr,
    fs::{read_link, symlink_metadata},
    path::{Path, PathBuf},
};

use log::{debug, warn};

use crate::{error::Error, load_path::LoadPath};

/// The result of resolving a symlink in a VOA structure.
///
/// The resolved target of a link is either a [`PathBuf`] to a directory or a file, or it shows
/// that the symlink points to `/dev/null` to signal [masking].
///
/// [masking]:
/// https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#masking
pub(crate) enum ResolvedSymlink {
    Dir(PathBuf),
    File(PathBuf),
    Masked,
}

/// Resolves an arbitrarily long chain of symlinks and checks its validity.
///
/// Ensures that all intermediate and final paths are located within the set of paths in
/// `legal_symlink_paths` and that the symlink chain does not contain a cycle.
///
/// For results with the variants [`ResolvedSymlink::File`] or [`ResolvedSymlink::Dir`], the
/// returned [`PathBuf`] contains a fully canonicalized path.
///
/// Symlinks that point to `/dev/null` (including in multiple hops) signal [masking] in VOA.
/// The variant [`ResolvedSymlink::Masked`] is returned for such symlinks.
///
/// # Errors
///
/// Returns an error if
///
/// - a cycle is detected in a symlink chain ([`Error::CyclicSymlinks`]),
/// - or a symlink points to a path outside `legal_symlink_paths` ([`Error::IllegalSymlinkTarget`]).
///
/// [masking]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#masking
pub(crate) fn resolve_symlink(
    start: &Path,
    legal_symlink_paths: &[&LoadPath],
    path_type: PathType,
) -> Result<ResolvedSymlink, Error> {
    if !start.is_symlink() {
        warn!("⤷ Not a symlink {start:?} (can't resolve)");

        // This is an inconsistent call for this function
        return Err(Error::InternalError {
            context: format!("'resolve_symlink' was called for the non-symlink {start:?}"),
        });
    }

    let mut path = start.to_path_buf();

    // Remember all paths we've traversed to do symlink cycle detection
    let mut paths_seen = HashSet::new();
    paths_seen.insert(path.clone());

    // Loop through chains of symlinks.
    // Check legality of each intermediate hop!
    loop {
        let mut link_target = read_link(&path).map_err(|source| Error::IoPath {
            path: path.clone(),
            context: "reading link",
            source,
        })?;

        // Are we in a symlink cycle?
        if paths_seen.contains(&link_target) {
            return Err(Error::CyclicSymlinks { path: link_target });
        }

        // If this is a masking symlink, we're done and return
        if link_target.as_path().to_str() == Some("/dev/null") {
            return Ok(ResolvedSymlink::Masked);
        }

        // Start constructing an absolute path for link_target, in case it is currently relative
        if link_target.is_relative() {
            let mut appended = path.clone();
            appended.push(link_target);
            link_target = appended;
        }

        // Normalize link target in case it contains any `../` path segments
        // (note that this normalization step doesn't look at the filesystem or resolve symlinks!)
        let Some(normalized) = normalize_path(&link_target, path_type) else {
            // This should never happen
            return Err(Error::InternalError {
                context: format!("normalize_path called for relative path {link_target:?}"),
            });
        };
        if normalized != link_target {
            debug!("⤷ Normalized link target {link_target:?} path into {normalized:?}");
            link_target = normalized;
        }

        // Check that (normalized) target file path is legal:
        // Symlinks may only point into locations under `legal_symlink_paths`
        if !legal_symlink_paths
            .iter()
            .any(|p| link_target.starts_with(&p.path))
        {
            warn!(
                "⤷ Symlink target is outside the set of legal load paths: {link_target:?} (can't resolve)"
            );
            return Err(Error::IllegalSymlinkTarget { path: link_target });
        }

        let meta = match symlink_metadata(&link_target) {
            Ok(meta) => meta,
            Err(source) => {
                warn!("⤷ Cannot get metadata of symlink target {link_target:?} (can't resolve)");
                return Err(Error::IoPath {
                    source,
                    context: "obtaining symlink metadata",
                    path: link_target,
                });
            }
        };

        // Return if we found any valid (or invalid) non-symlink destination.
        let file_type = meta.file_type();
        if file_type.is_file() {
            return Ok(ResolvedSymlink::File(link_target));
        } else if file_type.is_dir() {
            return Ok(ResolvedSymlink::Dir(link_target));
        } else if !file_type.is_symlink() {
            warn!("Unexpected file type {file_type:?} for {link_target:?} (can't resolve)");
            return Err(Error::IllegalSymlink {
                path: link_target,
                context: "Unexpected file type",
            });
        }

        // We found another symlink, continue with the loop.
        path = link_target;
        paths_seen.insert(path.clone());
    }
}

/// An indicator for what type of path normalization is done for.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PathType {
    /// A directory.
    Dir,
    /// A regular file.
    File,
}

/// Normalizes a path without performing any filesystem operations.
///
/// Returns [`Some`] absolute path for absolute input paths.
/// Returns [`None`] for relative input paths.
///
/// # Notes
///
/// - All redundant separator (e.g. `/`) and parent directory components (i.e. `..`) are normalized.
/// - Any current directory components (i.e. `.`) are ignored.
/// - This function does not resolve links.
pub(crate) fn normalize_path(path: &Path, path_type: PathType) -> Option<PathBuf> {
    if path.is_relative() {
        return None;
    }

    let parent_dir = OsStr::new("..");

    let mut normalized = PathBuf::new();
    let mut dir_pop = false;

    for component in path.iter() {
        // Truncate the already collected path components to the parent of the path.
        if component == parent_dir {
            normalized.pop();
            // If we are normalizing for a directory, we need to truncate the path to its parent for
            // a second time (only once).
            if path_type == PathType::Dir && !dir_pop {
                normalized.pop();
                dir_pop = true;
            }
            continue;
        }

        normalized.push(component);
    }

    Some(normalized)
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use rstest::rstest;
    use tempfile::tempdir;

    use super::*;

    #[rstest]
    #[case::file_no_changes(
        PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ))
    )]
    #[case::dir_no_changes(
        PathBuf::from("/usr/share/voa/example/package/default/openpgp/"),
        PathType::Dir,
        Some(PathBuf::from("/usr/share/voa/example/package/default/openpgp/"))
    )]
    #[case::file_with_current_dir(
        PathBuf::from(
            "/usr/share/voa/example/package/./default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ))
    )]
    #[case::dir_with_current_dir(
        PathBuf::from("/usr/share/voa/example/package/./default/openpgp/"),
        PathType::Dir,
        Some(PathBuf::from("/usr/share/voa/example/package/default/openpgp/"))
    )]
    #[case::file_with_indirection(
        PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp/../../../../image/installation-medium/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from(
            "/usr/share/voa/example/image/installation-medium/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ))
    )]
    #[case::dir_with_indirection(
        PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/../../image/installation-medium/openpgp/"
        ),
        PathType::Dir,
        Some(PathBuf::from("/usr/share/voa/example/image/installation-medium/openpgp/"))
    )]
    #[case::file_with_multi_indirection(
        PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp/../../../../image/installation-medium/../update/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from(
            "/usr/share/voa/example/image/update/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ))
    )]
    #[case::dir_with_multi_indirection(
        PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/../../image/installation-medium/../update/openpgp/"
        ),
        PathType::Dir,
        Some(PathBuf::from("/usr/share/voa/example/image/update/openpgp/"))
    )]
    #[case::file_indirection_past_root(
        PathBuf::from(
            "/usr/share/voa/example/image/installation-medium/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp/../../../../../../../../../f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from("/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"))
    )]
    #[case::dir_indirection_past_root(
        PathBuf::from(
            "/usr/share/voa/example/image/installation-medium/../../../../../../../openpgp"
        ),
        PathType::Dir,
        Some(PathBuf::from("/openpgp"))
    )]
    #[case::file_eliminate_extra_slash_at_root(
        PathBuf::from(
            "//usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ))
    )]
    #[case::file_eliminate_extra_slash_in_path(
        PathBuf::from(
            "/usr/share/voa//example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        Some(PathBuf::from(
            "/usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ))
    )]
    #[case::file_relative_path_with_indirection(
        PathBuf::from("usr/share/voa/example/image/default/openpgp/../baz.xyz"),
        PathType::File,
        None
    )]
    #[case::dir_relative_path_with_indirection(
        PathBuf::from("usr/share/voa/example/image/../package/"),
        PathType::Dir,
        None
    )]
    #[case::file_starts_with_current_dir(
        PathBuf::from(
            "./usr/share/voa/example/package/default/openpgp/f1d2d2f924e986ac86fdf7b36c94bcdf32beec15.openpgp"
        ),
        PathType::File,
        None
    )]
    #[case::dir_starts_with_current_dir(
        PathBuf::from("./usr/share/voa/example/package/default/openpgp/"),
        PathType::Dir,
        None
    )]
    fn test_normalize_path(
        #[case] path: PathBuf,
        #[case] path_type: PathType,
        #[case] expected: Option<PathBuf>,
    ) {
        assert_eq!(normalize_path(&path, path_type), expected);
    }

    #[test]
    fn resolve_symlink_errors_for_directory() -> testresult::TestResult {
        let tmp = tempdir()?;
        let pathbuf: PathBuf = tmp.path().into();

        // create a temporary directory to pass to `resolve_symlink`
        let loadpath_tmp = LoadPath::new("/tmp", true, true);

        let res = resolve_symlink(pathbuf.as_path(), &[&loadpath_tmp], PathType::Dir)
            .err()
            .unwrap();

        // we expect resolve_symplink to reject this path with `InternalError`
        assert!(matches!(res, Error::InternalError { .. }));

        Ok(())
    }
}