Skip to main content

sley_submodule/
path_safety.rs

1//! Submodule worktree path traversal safety.
2
3use sley_core::{GitError, Result};
4use std::io;
5use std::path::{Component, Path};
6use std::{fs, iter::Peekable};
7
8/// Whether any leading component of a repository-relative submodule path is a
9/// symbolic link.
10///
11/// The final component is intentionally excluded: checkout may legitimately
12/// replace a tracked symlink with a gitlink directory. Following a symlink in
13/// any parent component would escape the intended worktree location and is
14/// rejected by callers.
15pub fn submodule_path_has_symlink_parent(
16    worktree_root: &Path,
17    relative_path: &Path,
18) -> Result<bool> {
19    if relative_path.is_absolute()
20        || relative_path
21            .components()
22            .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
23    {
24        return Err(GitError::InvalidPath(format!(
25            "invalid submodule worktree path {}",
26            relative_path.display()
27        )));
28    }
29    let mut components: Peekable<_> = relative_path.components().peekable();
30    let mut current = worktree_root.to_path_buf();
31    while let Some(component) = components.next() {
32        let Component::Normal(name) = component else {
33            continue;
34        };
35        if components.peek().is_none() {
36            break;
37        }
38        current.push(name);
39        match fs::symlink_metadata(&current) {
40            Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
41            Ok(_) => {}
42            Err(err)
43                if matches!(
44                    err.kind(),
45                    io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
46                ) =>
47            {
48                return Ok(false);
49            }
50            Err(err) => return Err(err.into()),
51        }
52    }
53    Ok(false)
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[cfg(unix)]
61    #[test]
62    fn allows_final_symlink_but_rejects_symlinked_parent() {
63        let root = std::env::temp_dir().join(format!(
64            "sley-submodule-path-safety-{}-{}",
65            std::process::id(),
66            line!()
67        ));
68        let _ = fs::remove_dir_all(&root);
69        fs::create_dir_all(root.join("target")).expect("create symlink target");
70        std::os::unix::fs::symlink("target", root.join("final")).expect("create final symlink");
71        std::os::unix::fs::symlink("target", root.join("parent")).expect("create parent symlink");
72
73        assert!(
74            !submodule_path_has_symlink_parent(&root, Path::new("final"))
75                .expect("inspect final symlink")
76        );
77        assert!(
78            submodule_path_has_symlink_parent(&root, Path::new("parent/submodule"))
79                .expect("inspect symlinked parent")
80        );
81
82        fs::remove_dir_all(root).expect("clean fixture");
83    }
84}