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};
pub(crate) enum ResolvedSymlink {
Dir(PathBuf),
File(PathBuf),
Masked,
}
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)");
return Err(Error::InternalError {
context: format!("'resolve_symlink' was called for the non-symlink {start:?}"),
});
}
let mut path = start.to_path_buf();
let mut paths_seen = HashSet::new();
paths_seen.insert(path.clone());
loop {
let mut link_target = read_link(&path).map_err(|source| Error::IoPath {
path: path.clone(),
context: "reading link",
source,
})?;
if paths_seen.contains(&link_target) {
return Err(Error::CyclicSymlinks { path: link_target });
}
if link_target.as_path().to_str() == Some("/dev/null") {
return Ok(ResolvedSymlink::Masked);
}
if link_target.is_relative() {
let mut appended = path.clone();
appended.push(link_target);
link_target = appended;
}
let Some(normalized) = normalize_path(&link_target, path_type) else {
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;
}
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,
});
}
};
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",
});
}
path = link_target;
paths_seen.insert(path.clone());
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PathType {
Dir,
File,
}
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() {
if component == parent_dir {
normalized.pop();
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();
let loadpath_tmp = LoadPath::new("/tmp", true, true);
let res = resolve_symlink(pathbuf.as_path(), &[&loadpath_tmp], PathType::Dir)
.err()
.unwrap();
assert!(matches!(res, Error::InternalError { .. }));
Ok(())
}
}