voa_core/util/
symlinks.rs1use 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
19pub(crate) enum ResolvedSymlink {
27 Dir(PathBuf),
28 File(PathBuf),
29 Masked,
30}
31
32pub(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 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 let mut paths_seen = HashSet::new();
69 paths_seen.insert(path.clone());
70
71 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 if paths_seen.contains(&link_target) {
82 return Err(Error::CyclicSymlinks { path: link_target });
83 }
84
85 if link_target.as_path().to_str() == Some("/dev/null") {
87 return Ok(ResolvedSymlink::Masked);
88 }
89
90 if link_target.is_relative() {
92 let mut appended = path.clone();
93 appended.push(link_target);
94 link_target = appended;
95 }
96
97 let Some(normalized) = normalize_path(&link_target, path_type) else {
100 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 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 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 path = link_target;
150 paths_seen.insert(path.clone());
151 }
152}
153
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub(crate) enum PathType {
157 Dir,
159 File,
161}
162
163pub(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 if component == parent_dir {
186 normalized.pop();
187 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 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 assert!(matches!(res, Error::InternalError { .. }));
348
349 Ok(())
350 }
351}