Skip to main content

rskit_git/
paths.rs

1//! Git repository path helpers.
2
3use std::path::{Component, Path, PathBuf};
4
5use rskit_errors::{AppError, AppResult};
6
7use crate::error::GitError;
8
9/// Return `path` relative to `repo_root` after canonicalizing both paths.
10pub fn repo_relative_path(repo_root: &Path, path: &Path) -> AppResult<PathBuf> {
11    let repo_root = repo_root.canonicalize().map_err(|error| {
12        AppError::invalid_input(
13            "repo_root",
14            format!("failed to resolve git root '{}'", repo_root.display()),
15        )
16        .with_cause(error)
17    })?;
18    let path = path.canonicalize().map_err(|error| {
19        AppError::invalid_input(
20            "path",
21            format!("failed to resolve path '{}'", path.display()),
22        )
23        .with_cause(error)
24    })?;
25    path.strip_prefix(&repo_root)
26        .map(normalize_path)
27        .map_err(|error| {
28            AppError::invalid_input(
29                "path",
30                format!(
31                    "path '{}' is not inside git root '{}'",
32                    path.display(),
33                    repo_root.display()
34                ),
35            )
36            .with_cause(error)
37        })
38}
39
40/// Join a repository-relative prefix and path, preserving empty prefixes.
41pub fn join_repo_path(prefix: &Path, relative: &Path) -> AppResult<PathBuf> {
42    validate_repo_relative("prefix", prefix)?;
43    validate_repo_relative("path", relative)?;
44    if prefix.as_os_str().is_empty() {
45        Ok(normalize_path(relative))
46    } else {
47        Ok(normalize_path(&prefix.join(relative)))
48    }
49}
50
51pub(crate) fn validate_repo_relative_path(path: &str) -> AppResult<()> {
52    rskit_fs::validate_relative_path(Path::new(path)).map_err(|error| {
53        AppError::from(GitError::InvalidPath {
54            path: path.to_string(),
55        })
56        .with_cause(error)
57    })
58}
59
60fn validate_repo_relative(field: &'static str, path: &Path) -> AppResult<()> {
61    rskit_fs::validate_relative_path(path).map_err(|error| {
62        AppError::invalid_input(
63            field,
64            format!("repository path {}: {error}", path.display()),
65        )
66        .with_cause(error)
67    })
68}
69
70fn normalize_path(path: &Path) -> PathBuf {
71    path.components()
72        .filter_map(|component| match component {
73            Component::Normal(value) => Some(PathBuf::from(value)),
74            Component::CurDir => None,
75            _ => Some(PathBuf::from(component.as_os_str())),
76        })
77        .fold(PathBuf::new(), |mut normalized, component| {
78            normalized.push(component);
79            normalized
80        })
81}
82
83#[cfg(test)]
84mod tests {
85    use std::fs;
86
87    use super::{join_repo_path, repo_relative_path};
88
89    #[test]
90    fn returns_path_relative_to_repo_root() {
91        let root = rskit_testutil::test_workspace!("git-repo-relative");
92        let repo = root.path().join("repo");
93        let workspace = repo.join("apps/web");
94        fs::create_dir_all(&workspace).expect("create workspace");
95
96        let relative = repo_relative_path(&repo, &workspace).expect("path is inside repo");
97
98        assert_eq!(relative, std::path::Path::new("apps/web"));
99    }
100
101    #[test]
102    fn joins_empty_prefix_as_relative_path() {
103        assert_eq!(
104            join_repo_path(std::path::Path::new(""), std::path::Path::new("src/lib.rs"))
105                .expect("path is safe"),
106            std::path::Path::new("src/lib.rs")
107        );
108    }
109
110    #[test]
111    fn rejects_absolute_relative_path() {
112        let error = join_repo_path(std::path::Path::new("repo"), std::path::Path::new("/tmp/x"))
113            .expect_err("absolute paths escape the repository prefix");
114
115        assert!(error.message().contains("path must be relative"));
116    }
117
118    #[test]
119    fn rejects_parent_traversal_path() {
120        let error = join_repo_path(std::path::Path::new("repo"), std::path::Path::new("../x"))
121            .expect_err("parent traversal escapes the repository prefix");
122
123        assert!(error.message().contains("must not contain '..'"));
124    }
125
126    #[test]
127    fn rejects_escaping_prefix() {
128        let error = join_repo_path(std::path::Path::new("../repo"), std::path::Path::new("x"))
129            .expect_err("prefix must also be repository relative");
130
131        assert!(error.message().contains("must not contain '..'"));
132    }
133
134    #[test]
135    fn returns_empty_relative_path_for_repo_root() {
136        let root = rskit_testutil::test_workspace!("git-repo-root-relative");
137        let repo = root.path().join("repo");
138        fs::create_dir_all(&repo).expect("create repo root");
139
140        let relative = repo_relative_path(&repo, &repo).expect("repo root is inside itself");
141
142        assert_eq!(relative, std::path::Path::new(""));
143    }
144
145    #[test]
146    fn rejects_missing_or_external_paths_with_actionable_errors() {
147        let root = rskit_testutil::test_workspace!("git-repo-relative-errors");
148        let repo = root.path().join("repo");
149        let outside = root.path().join("outside");
150        fs::create_dir_all(&repo).expect("create repo root");
151        fs::create_dir_all(&outside).expect("create outside dir");
152
153        let missing =
154            repo_relative_path(&repo, &repo.join("missing")).expect_err("missing path fails");
155        assert!(missing.message().contains("failed to resolve path"));
156
157        let external = repo_relative_path(&repo, &outside).expect_err("external path fails");
158        assert!(external.message().contains("is not inside git root"));
159    }
160}