Skip to main content

super_types/
symlinks.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fmt,
4    rc::Rc,
5};
6
7/// A source link and the reason it cannot be resolved.
8#[derive(Debug)]
9pub struct SourceLinkIssue {
10    pub path: String,
11    pub target: String,
12    pub reason: &'static str,
13}
14
15impl fmt::Display for SourceLinkIssue {
16    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17        write!(formatter, "{:?} -> {:?}: {}", self.path, self.target, self.reason)
18    }
19}
20
21/// Validate portable Git link targets against a complete, filtered tree without accessing the host.
22/// Missing targets and cycles are retained, as they are in Git. A manifest entry may never be
23/// another entry's parent: materialization must not write through a symlink.
24///
25/// # Errors
26/// Returns an error for an escaping, nonportable, or overlong target, or overlapping entries.
27pub fn validate_source_links<'a>(entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>) -> Result<(), String> {
28    let issues = source_link_issues(entries)?;
29    if issues.is_empty() {
30        Ok(())
31    } else {
32        Err(issues.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"))
33    }
34}
35
36/// Report each link causing a validation failure once, without listing dependent aliases.
37///
38/// # Errors
39/// Returns an error when a source entry is also the parent of another entry.
40pub fn source_link_issues<'a>(
41    entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
42) -> Result<Vec<SourceLinkIssue>, String> {
43    let entries = entries.into_iter().collect::<BTreeMap<_, _>>();
44    let mut links = BTreeMap::new();
45    let mut cache = BTreeMap::new();
46    for (&path, &target) in &entries {
47        let mut parent = path;
48        while let Some((ancestor, _)) = parent.rsplit_once('/') {
49            if entries.contains_key(ancestor) {
50                return Err(format!("source entry `{ancestor}` is also a parent of `{path}`"));
51            }
52            parent = ancestor;
53        }
54        if let Some(target) = target {
55            if let Some(reason) = invalid_target(target) {
56                cache.insert(
57                    path.to_owned(),
58                    Err(Rc::new(LinkFailure {
59                        path: path.to_owned(),
60                        reason,
61                    })),
62                );
63            }
64            links.insert(path, target);
65        }
66    }
67    let mut issues = BTreeMap::new();
68    for &path in links.keys() {
69        if let Err(failure) = resolve(path, &links, &mut cache, None) {
70            issues.entry(failure.path.clone()).or_insert_with(|| SourceLinkIssue {
71                path: failure.path.clone(),
72                target: links[failure.path.as_str()].to_owned(),
73                reason: failure.reason,
74            });
75        }
76    }
77    Ok(issues.into_values().collect())
78}
79
80/// Find unresolved links in a validated source manifest, including missing targets and cycles.
81/// Uses only submitted paths; excluded and locally generated files do not count as targets.
82pub fn source_link_warnings<'a>(entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>) -> Vec<SourceLinkIssue> {
83    let entries = entries.into_iter().collect::<Vec<_>>();
84    let links = entries
85        .iter()
86        .filter_map(|&(path, target)| target.map(|target| (path, target)))
87        .collect::<BTreeMap<_, _>>();
88    if links.is_empty() {
89        return Vec::new();
90    }
91    let paths = SourcePaths::new(entries.iter().map(|&(path, _)| path));
92    let mut cache = BTreeMap::new();
93    links
94        .iter()
95        .filter_map(|(&path, &target)| {
96            let reason = match resolve(path, &links, &mut cache, Some(&paths)) {
97                Ok(Some(_)) => return None,
98                Ok(None) => "target contains a symlink cycle",
99                Err(failure) => failure.reason,
100            };
101            Some(SourceLinkIssue {
102                path: path.to_owned(),
103                target: target.to_owned(),
104                reason,
105            })
106        })
107        .collect()
108}
109
110struct SourcePaths<'a> {
111    files: BTreeSet<&'a str>,
112    directories: BTreeSet<&'a str>,
113}
114
115impl<'a> SourcePaths<'a> {
116    fn new(files: impl Iterator<Item = &'a str>) -> Self {
117        let files = files.collect::<BTreeSet<_>>();
118        let mut directories = BTreeSet::from([""]);
119        for &file in &files {
120            let mut path = file;
121            while let Some((parent, _)) = path.rsplit_once('/') {
122                directories.insert(parent);
123                path = parent;
124            }
125        }
126        Self { files, directories }
127    }
128
129    fn issue(&self, path: &[&str], traversing: bool) -> Option<&'static str> {
130        let path = path.join("/");
131        if self.directories.contains(path.as_str()) {
132            None
133        } else if self.files.contains(path.as_str()) {
134            traversing.then_some("target traverses a file as a directory")
135        } else {
136            Some("target is missing from the submitted source")
137        }
138    }
139}
140
141fn invalid_target(target: &str) -> Option<&'static str> {
142    if target.is_empty() {
143        Some("target is empty")
144    } else if target.len() > 4096 {
145        Some("target exceeds 4096 bytes")
146    } else if target.starts_with('/') || target.as_bytes().get(1) == Some(&b':') {
147        Some("absolute or drive-qualified targets are not supported")
148    } else if target.contains(['\\', '\0']) {
149        Some("target contains a backslash or NUL byte")
150    } else if target.split('/').any(|part| part.eq_ignore_ascii_case(".git")) {
151        Some("target enters reserved .git or .hydra metadata")
152    } else {
153        None
154    }
155}
156
157/// Resolve a selector in the immutable source tree. Cycles have no resolved selector.
158///
159/// # Errors
160/// Returns an error for an escaping, overlong, or broken link target.
161pub fn resolve_source_path<'a>(
162    entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
163    path: &'a str,
164) -> Result<Option<String>, String> {
165    let entries = entries.into_iter().collect::<BTreeMap<_, _>>();
166    let links = entries
167        .iter()
168        .filter_map(|(&path, &target)| target.map(|target| (path, target)))
169        .collect::<BTreeMap<_, _>>();
170    // Ordinary missing selectors retain their discovery diagnostics. Once a selector enters
171    // a link, every traversed component must exist in the selected source tree.
172    let follows_link = std::iter::successors(Some(path), |path| path.rsplit_once('/').map(|(parent, _)| parent))
173        .any(|prefix| links.contains_key(prefix));
174    let paths = follows_link.then(|| SourcePaths::new(entries.keys().copied()));
175    resolve(path, &links, &mut BTreeMap::new(), paths.as_ref())
176        .map_err(|error| format!("symlink {:?}: {}", error.path, error.reason))
177        .map(|resolved| {
178            resolved.map(|parts| {
179                if parts.is_empty() {
180                    ".".to_owned()
181                } else {
182                    parts.join("/")
183                }
184            })
185        })
186}
187
188#[derive(Debug)]
189struct LinkFailure {
190    path: String,
191    reason: &'static str,
192}
193
194type ResolvedPath<'a> = Result<Option<Vec<&'a str>>, Rc<LinkFailure>>;
195
196fn cache_failure<'a>(
197    stack: &[Resolution<'a>],
198    cache: &mut BTreeMap<String, ResolvedPath<'a>>,
199    failure: Rc<LinkFailure>,
200) -> Rc<LinkFailure> {
201    for frame in stack {
202        if let Some(link) = &frame.link {
203            cache.insert(link.clone(), Err(Rc::clone(&failure)));
204        }
205    }
206    failure
207}
208
209struct Resolution<'a> {
210    path: Vec<&'a str>,
211    remaining: std::str::Split<'a, char>,
212    link: Option<String>,
213}
214
215impl Resolution<'_> {
216    fn failure(&self, path: &str, reason: &'static str) -> Rc<LinkFailure> {
217        Rc::new(LinkFailure {
218            path: self.link.clone().unwrap_or_else(|| path.to_owned()),
219            reason,
220        })
221    }
222}
223
224fn resolve<'a>(
225    remaining: &'a str,
226    links: &BTreeMap<&str, &'a str>,
227    cache: &mut BTreeMap<String, ResolvedPath<'a>>,
228    paths: Option<&SourcePaths<'_>>,
229) -> ResolvedPath<'a> {
230    // An explicit stack accepts Git-storable long chains without recursive stack growth.
231    let mut stack = vec![Resolution {
232        path: Vec::new(),
233        remaining: remaining.split('/'),
234        link: None,
235    }];
236    let mut active = BTreeSet::new();
237    while let Some(frame) = stack.last_mut() {
238        let Some(component) = frame.remaining.next() else {
239            let resolved = stack.pop().expect("resolution frame exists");
240            if let Some(link) = resolved.link {
241                active.remove(&link);
242                cache.insert(link, Ok(Some(resolved.path.clone())));
243            }
244            if let Some(parent) = stack.last_mut() {
245                parent.path = resolved.path;
246                continue;
247            }
248            return Ok(Some(resolved.path));
249        };
250        if let Some(reason) = paths.and_then(|paths| paths.issue(&frame.path, true)) {
251            let failure = frame.failure(remaining, reason);
252            return Err(cache_failure(&stack, cache, failure));
253        }
254        match component {
255            "" | "." => {}
256            ".." => {
257                if frame.path.pop().is_none() {
258                    let failure = frame.failure(remaining, "target escapes the repository");
259                    return Err(cache_failure(&stack, cache, failure));
260                }
261            }
262            component => {
263                // Like source manifest paths, only the root .hydra namespace is reserved.
264                // Resolve directory aliases and parent components before deciding the depth.
265                if component == ".hydra" && frame.path.is_empty() {
266                    let failure = frame.failure(remaining, "target enters reserved .git or .hydra metadata");
267                    return Err(cache_failure(&stack, cache, failure));
268                }
269                frame.path.push(component);
270                if frame.path.iter().map(|part| part.len() + 1).sum::<usize>() > 4096 {
271                    let failure = frame.failure(remaining, "resolved target exceeds 4096 bytes");
272                    return Err(cache_failure(&stack, cache, failure));
273                }
274                if let Some(reason) = paths.and_then(|paths| paths.issue(&frame.path, false)) {
275                    let failure = frame.failure(remaining, reason);
276                    return Err(cache_failure(&stack, cache, failure));
277                }
278                let name = frame.path.join("/");
279                if let Some(&target) = links.get(name.as_str()) {
280                    if let Some(cached) = cache.get(&name) {
281                        match cached {
282                            Ok(Some(resolved)) => {
283                                frame.path.clone_from(resolved);
284                                continue;
285                            }
286                            Err(failure) => {
287                                return Err(cache_failure(&stack, cache, Rc::clone(failure)));
288                            }
289                            Ok(None) => {}
290                        }
291                    }
292                    if cache.contains_key(&name) || !active.insert(name.clone()) {
293                        // Cycles are preserved as links. They cannot resolve to an external referent.
294                        for link in active {
295                            cache.insert(link, Ok(None));
296                        }
297                        return Ok(None);
298                    }
299                    frame.path.pop();
300                    let path = std::mem::take(&mut frame.path);
301                    stack.push(Resolution {
302                        path,
303                        remaining: target.split('/'),
304                        link: Some(name),
305                    });
306                }
307            }
308        }
309    }
310    Ok(Some(Vec::new()))
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn long_chains_are_preserved_and_checked_without_dereferencing() {
319        let mut entries = (0..1000)
320            .map(|index| (format!("link{index:04}"), format!("link{:04}", index + 1)))
321            .collect::<Vec<_>>();
322        validate_source_links(
323            entries
324                .iter()
325                .map(|(path, target)| (path.as_str(), Some(target.as_str()))),
326        )
327        .unwrap();
328        assert_eq!(
329            source_link_warnings(
330                entries
331                    .iter()
332                    .map(|(path, target)| (path.as_str(), Some(target.as_str())))
333            )
334            .len(),
335            1000
336        );
337        entries.push(("link1000".to_owned(), "../outside".to_owned()));
338        let issues = source_link_issues(
339            entries
340                .iter()
341                .map(|(path, target)| (path.as_str(), Some(target.as_str()))),
342        )
343        .unwrap();
344        assert_eq!(issues.len(), 1);
345        assert_eq!(issues[0].path, "link1000");
346        assert_eq!(issues[0].target, "../outside");
347        assert!(!issues[0].to_string().contains("related link"));
348    }
349
350    #[test]
351    fn git_links_preserve_missing_targets_cycles_and_parent_components() {
352        validate_source_links([
353            ("src/file", None),
354            ("src/link", Some("../src/file")),
355            ("missing", Some("generated/output")),
356            ("cycle-a", Some("cycle-b")),
357            ("cycle-b", Some("cycle-a")),
358            ("dir", Some("src")),
359            ("twice", Some("dir/../dir/file")),
360        ])
361        .unwrap();
362    }
363
364    #[test]
365    fn warnings_distinguish_missing_targets_cycles_and_non_directory_paths() {
366        let entries = [
367            ("src/file", None),
368            ("src/nested/file", None),
369            ("file", Some("src/file")),
370            ("directory", Some("src")),
371            ("directory-file", Some("directory/nested/file")),
372            ("root", Some(".")),
373            ("root-file", Some("root/src/file")),
374            ("missing", Some("generated/output")),
375            ("missing-chain", Some("missing")),
376            ("missing-parent", Some("absent/../src/file")),
377            ("not-directory", Some("file/../file")),
378            ("trailing-slash", Some("file/")),
379            ("cycle-a", Some("cycle-b")),
380            ("cycle-b", Some("cycle-a")),
381        ];
382        validate_source_links(entries).unwrap();
383        let warnings = source_link_warnings(entries);
384        let details = warnings
385            .iter()
386            .map(|warning| (warning.path.as_str(), warning.reason))
387            .collect::<Vec<_>>();
388        assert_eq!(
389            details,
390            [
391                ("cycle-a", "target contains a symlink cycle"),
392                ("cycle-b", "target contains a symlink cycle"),
393                ("missing", "target is missing from the submitted source"),
394                ("missing-chain", "target is missing from the submitted source"),
395                ("missing-parent", "target is missing from the submitted source"),
396                ("not-directory", "target traverses a file as a directory"),
397                ("trailing-slash", "target traverses a file as a directory"),
398            ]
399        );
400        assert_eq!(warnings[3].target, "missing");
401        assert!(source_link_warnings([("src/file", None), ("alias", Some("src"))]).is_empty());
402    }
403
404    #[test]
405    fn rejects_escapes_including_directory_links_and_missing_components() {
406        for target in [
407            "../outside",
408            "/outside",
409            "C:/outside",
410            "C:outside",
411            "\\\\host\\share",
412            "missing/../../outside",
413            ".git/config",
414        ] {
415            assert!(validate_source_links([("link", Some(target))]).is_err(), "{target}");
416        }
417        assert!(validate_source_links([("deep/dir", Some("..")), ("link", Some("deep/dir/../outside")),]).is_err());
418        assert!(validate_source_links([("link", Some("dir")), ("link/file", None)]).is_err());
419    }
420
421    #[test]
422    fn hydra_metadata_is_reserved_only_at_the_resolved_repository_root() {
423        let entries = [
424            ("nested/.hydra/state", None),
425            ("nested/link", Some(".hydra/state")),
426            ("dir", Some("nested")),
427            ("alias", Some("dir/.hydra/state")),
428            ("missing", Some("other/.hydra/state")),
429        ];
430        validate_source_links(entries).unwrap();
431        for path in ["nested/link", "alias"] {
432            assert_eq!(
433                resolve_source_path(entries, path).unwrap().as_deref(),
434                Some("nested/.hydra/state")
435            );
436        }
437
438        for (path, target) in [
439            ("link", ".hydra/state"),
440            ("link", "./.hydra/state"),
441            ("link", ".hydra/../source"),
442            ("nested/link", "../.hydra/state"),
443            ("link", "nested/../.hydra/state"),
444            ("link", "dir/../.hydra/state"),
445            ("link", "nested/up/.hydra/state"),
446            ("link", "root/.hydra/state"),
447            ("link", ".git/config"),
448            ("link", "nested/.git/config"),
449        ] {
450            let entries = [
451                ("dir", Some("nested")),
452                ("nested/up", Some("..")),
453                ("root", Some(".")),
454                (path, Some(target)),
455            ];
456            let issues = source_link_issues(entries).unwrap();
457            assert_eq!(issues.len(), 1, "{path} -> {target}");
458            assert_eq!(issues[0].path, path);
459            assert_eq!(issues[0].target, target);
460            assert_eq!(issues[0].reason, "target enters reserved .git or .hydra metadata");
461        }
462    }
463
464    #[test]
465    fn reports_all_causes_and_retains_aliases_after_the_causes_are_excluded() {
466        let entries = [
467            ("alias", Some("outside")),
468            ("another-alias", Some("alias")),
469            ("absolute", Some("/external")),
470            ("deep/dir", Some("..")),
471            ("indirect", Some("deep/dir/../secret")),
472            ("metadata", Some(".git/config")),
473            ("outside", Some("../external")),
474            ("cycle-a", Some("cycle-b")),
475            ("cycle-b", Some("cycle-a")),
476            ("missing", Some("generated/output")),
477        ];
478        let issues = source_link_issues(entries).unwrap();
479        assert_eq!(
480            issues.iter().map(|issue| issue.path.as_str()).collect::<Vec<_>>(),
481            ["absolute", "indirect", "metadata", "outside"]
482        );
483        assert_eq!(
484            issues[3].to_string(),
485            "\"outside\" -> \"../external\": target escapes the repository"
486        );
487        let remaining = entries
488            .into_iter()
489            .filter(|(path, _)| !issues.iter().any(|issue| issue.path == *path))
490            .collect::<Vec<_>>();
491        validate_source_links(remaining.iter().copied()).unwrap();
492        assert!(
493            resolve_source_path(remaining, "another-alias")
494                .unwrap_err()
495                .contains("missing")
496        );
497    }
498
499    #[test]
500    fn invalid_targets_and_diagnostics_are_safe_and_deterministic() {
501        let entries = [
502            ("tab\tlink", Some("../outside\nname")),
503            ("windows", Some("C:/outside")),
504            ("reserved", Some(".hydra/state")),
505        ];
506        let forward = validate_source_links(entries).unwrap_err();
507        let reversed = validate_source_links(entries.into_iter().rev()).unwrap_err();
508        assert_eq!(forward, reversed);
509        assert!(forward.contains("\"tab\\tlink\" -> \"../outside\\nname\""));
510        assert!(forward.contains("\"windows\" -> \"C:/outside\""));
511        assert!(forward.contains("\"reserved\" -> \".hydra/state\""));
512        assert!(!forward.contains('\t'));
513    }
514}