voa_core/util/
symlinks.rs1use 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
18pub(crate) enum ResolvedSymlink {
26 Dir(PathBuf),
27 File(PathBuf),
28 Masked,
29}
30
31pub(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 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 let mut paths_seen = HashSet::new();
67 paths_seen.insert(path.clone());
68
69 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 if paths_seen.contains(&link_target) {
80 return Err(Error::CyclicSymlinks { path: link_target });
81 }
82
83 if link_target.as_path().to_str() == Some("/dev/null") {
85 return Ok(ResolvedSymlink::Masked);
86 }
87
88 if link_target.is_relative() {
90 let mut appended = path.clone();
91 appended.push(link_target);
92 link_target = appended;
93 }
94
95 let Some(normalized) = normalize_path(&link_target) else {
98 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 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 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 path = link_target;
148 paths_seen.insert(path.clone());
149 }
150}
151
152pub(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!(), }
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 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 assert!(matches!(res, Error::InternalError { .. }));
225
226 Ok(())
227 }
228}