Skip to main content

git_vet/
path.rs

1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4use gix::bstr::ByteSlice;
5use serde::{Serialize, Serializer};
6use thiserror::Error;
7
8#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
9pub struct RepoPath {
10    components: Vec<RepoPathComponent>,
11}
12
13impl RepoPath {
14    pub(crate) fn from_git_path(path: &str) -> Result<Self, PathError> {
15        let components = parse_git_path_components(path)?;
16        Ok(Self { components })
17    }
18
19    pub(crate) fn to_path_buf(&self) -> PathBuf {
20        self.components
21            .iter()
22            .map(RepoPathComponent::as_str)
23            .collect()
24    }
25
26    fn to_git_path(&self) -> String {
27        self.components
28            .iter()
29            .map(RepoPathComponent::as_str)
30            .collect::<Vec<_>>()
31            .join("/")
32    }
33}
34
35impl fmt::Display for RepoPath {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(&self.to_git_path())
38    }
39}
40
41impl Serialize for RepoPath {
42    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
43    where
44        S: Serializer,
45    {
46        serializer.serialize_str(&self.to_git_path())
47    }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
51struct RepoPathComponent(String);
52
53impl RepoPathComponent {
54    fn parse(path: &str, component: &str) -> Result<Self, PathError> {
55        match component {
56            "" => Err(PathError::InvalidRepoPath {
57                path: path.to_owned(),
58                details: "path contains an empty component",
59            }),
60            "." => Err(PathError::InvalidRepoPath {
61                path: path.to_owned(),
62                details: "path contains a current-directory component",
63            }),
64            ".." => Err(PathError::PathOutsideRepo(path.to_owned())),
65            value if value.contains('\0') => Err(PathError::InvalidRepoPath {
66                path: path.to_owned(),
67                details: "path contains a NUL byte",
68            }),
69            value => Ok(Self(value.to_owned())),
70        }
71    }
72
73    fn from_os_component(path: &Path, component: &std::ffi::OsStr) -> Result<Self, PathError> {
74        let component = component
75            .to_str()
76            .ok_or_else(|| PathError::NonUtf8Path(path.display().to_string()))?;
77        Self::parse(&path.display().to_string(), component)
78    }
79
80    fn as_str(&self) -> &str {
81        &self.0
82    }
83}
84
85#[derive(Debug, Error)]
86pub enum PathError {
87    #[error("path is not valid UTF-8: {0}")]
88    NonUtf8Path(String),
89    #[error("path escapes the repository root: {0}")]
90    PathOutsideRepo(String),
91    #[error("empty paths are not valid tracked files")]
92    EmptyPath,
93    #[error("repo path is invalid: {path}: {details}")]
94    InvalidRepoPath { path: String, details: &'static str },
95}
96
97pub fn prefix_from_cwd(root: &Path, cwd: &Path) -> Result<PathBuf, PathError> {
98    let root = normalize_lexically(root);
99    let cwd = normalize_lexically(cwd);
100    cwd.strip_prefix(&root)
101        .map(Path::to_path_buf)
102        .map_err(|_| PathError::PathOutsideRepo(cwd.display().to_string()))
103}
104
105pub fn repo_path_from_bstr(path: &gix::bstr::BStr) -> Result<RepoPath, PathError> {
106    let path = path
107        .to_str()
108        .map_err(|err| PathError::NonUtf8Path(err.to_string()))?;
109    RepoPath::from_git_path(path)
110}
111
112pub fn normalize_lexically(path: &Path) -> PathBuf {
113    path.components()
114        .fold(PathBuf::new(), |mut normalized, component| {
115            match component {
116                Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
117                Component::RootDir => normalized.push(component.as_os_str()),
118                Component::CurDir => {}
119                Component::ParentDir => {
120                    normalized.pop();
121                }
122                Component::Normal(part) => normalized.push(part),
123            }
124            normalized
125        })
126}
127
128pub fn repo_path_from_relative(path: &Path) -> Result<RepoPath, PathError> {
129    let components = path
130        .components()
131        .filter_map(|component| match component {
132            Component::Normal(part) => Some(RepoPathComponent::from_os_component(path, part)),
133            Component::CurDir => None,
134            Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
135                Some(Err(PathError::PathOutsideRepo(path.display().to_string())))
136            }
137        })
138        .collect::<Result<Vec<_>, _>>()?;
139
140    if components.is_empty() {
141        Err(PathError::EmptyPath)
142    } else {
143        Ok(RepoPath { components })
144    }
145}
146
147fn parse_git_path_components(path: &str) -> Result<Vec<RepoPathComponent>, PathError> {
148    if path.is_empty() {
149        return Err(PathError::EmptyPath);
150    }
151
152    path.split('/')
153        .map(|component| RepoPathComponent::parse(path, component))
154        .collect()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn repo_path_from_relative_rejects_empty_paths() {
163        assert!(matches!(
164            repo_path_from_relative(Path::new("")),
165            Err(PathError::EmptyPath)
166        ));
167    }
168
169    #[test]
170    fn repo_path_from_git_path_rejects_structurally_invalid_paths() {
171        for path in [
172            "/src/lib.rs",
173            "src//lib.rs",
174            "src/./lib.rs",
175            "src/../lib.rs",
176            "src/lib.rs/",
177        ] {
178            assert!(
179                RepoPath::from_git_path(path).is_err(),
180                "expected {path:?} to be rejected"
181            );
182        }
183    }
184
185    #[test]
186    fn repo_path_round_trips_git_path_and_os_path() -> Result<(), PathError> {
187        let path = RepoPath::from_git_path("src/lib.rs")?;
188
189        assert_eq!(path.to_string(), "src/lib.rs");
190        assert_eq!(path.to_path_buf(), PathBuf::from("src").join("lib.rs"));
191        Ok(())
192    }
193}