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/// A non-empty UTF-8 path to a tracked file relative to the repository root.
9///
10/// `RepoPath` is normalized into validated components: it never contains an
11/// absolute prefix, empty component, `.`, `..`, or NUL byte. It renders and
12/// serializes as a Git path, using `/` as the component separator.
13#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
14pub struct RepoPath {
15    components: Vec<RepoPathComponent>,
16}
17
18impl RepoPath {
19    /// Parses a Git path into a repository-relative path.
20    ///
21    /// Git paths always use `/` as the separator and must already be relative
22    /// to the repository root.
23    pub(crate) fn from_git_path(path: &str) -> Result<Self, PathError> {
24        let components = parse_git_path_components(path)?;
25        Ok(Self { components })
26    }
27
28    /// Converts this repository path into an OS-native relative path.
29    pub(crate) fn to_os_path_buf(&self) -> PathBuf {
30        self.components
31            .iter()
32            .map(RepoPathComponent::as_str)
33            .collect()
34    }
35
36    /// Renders this repository path using Git's `/` component separator.
37    fn to_git_path(&self) -> String {
38        self.components
39            .iter()
40            .map(RepoPathComponent::as_str)
41            .collect::<Vec<_>>()
42            .join("/")
43    }
44}
45
46impl fmt::Display for RepoPath {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(&self.to_git_path())
49    }
50}
51
52impl Serialize for RepoPath {
53    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
54    where
55        S: Serializer,
56    {
57        serializer.serialize_str(&self.to_git_path())
58    }
59}
60
61/// A single validated UTF-8 repository path component.
62#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
63struct RepoPathComponent(String);
64
65impl RepoPathComponent {
66    /// Parses one textual path component and rejects structural components.
67    fn parse(component: &str) -> Result<Self, RepoPathComponentError> {
68        match component {
69            "" => Err(RepoPathComponentError::Empty),
70            "." => Err(RepoPathComponentError::CurrentDir),
71            ".." => Err(RepoPathComponentError::ParentDir),
72            value if value.contains('\0') => Err(RepoPathComponentError::NulByte),
73            value => Ok(Self(value.to_owned())),
74        }
75    }
76
77    /// Converts one OS path component into a validated repository component.
78    fn from_os_component(component: &std::ffi::OsStr) -> Result<Self, RepoPathComponentError> {
79        let component = component.to_str().ok_or(RepoPathComponentError::NonUtf8)?;
80        Self::parse(component)
81    }
82
83    /// Returns the component as a UTF-8 string slice.
84    fn as_str(&self) -> &str {
85        &self.0
86    }
87}
88
89/// Validation errors for a single repository path component.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91enum RepoPathComponentError {
92    /// The OS component is not valid UTF-8.
93    NonUtf8,
94    /// The component is empty.
95    Empty,
96    /// The component is `.`.
97    CurrentDir,
98    /// The component is `..`.
99    ParentDir,
100    /// The component contains a NUL byte.
101    NulByte,
102}
103
104impl RepoPathComponentError {
105    /// Promotes a component-level validation error to a path-level error.
106    const fn into_path_error(self) -> PathError {
107        match self {
108            Self::NonUtf8 => PathError::NonUtf8,
109            Self::Empty => PathError::EmptyComponent,
110            Self::CurrentDir => PathError::CurrentDirComponent,
111            Self::ParentDir => PathError::OutsideRepo,
112            Self::NulByte => PathError::NulByte,
113        }
114    }
115}
116
117/// Errors produced while converting external path representations into typed paths.
118#[derive(Debug, Error)]
119pub(crate) enum PathError {
120    /// The path or component is not valid UTF-8.
121    #[error("path is not valid UTF-8")]
122    NonUtf8,
123    /// The path escapes, or is not relative to, the repository root.
124    #[error("path escapes the repository root")]
125    OutsideRepo,
126    /// An absolute path was required but a relative path was provided.
127    #[error("path must be absolute")]
128    NonAbsolute,
129    /// The path has no file components.
130    #[error("empty paths are not valid tracked files")]
131    EmptyPath,
132    /// A Git path contains an empty component.
133    #[error("repo path contains an empty component")]
134    EmptyComponent,
135    /// A Git path contains a `.` component.
136    #[error("repo path contains a current-directory component")]
137    CurrentDirComponent,
138    /// The path contains a NUL byte.
139    #[error("repo path contains a NUL byte")]
140    NulByte,
141}
142
143/// Computes the current working directory prefix relative to the repository root.
144///
145/// Both inputs must be absolute paths. `.` components are removed and `..`
146/// components are applied lexically before checking containment.
147pub(crate) fn prefix_from_cwd(root: &Path, cwd: &Path) -> Result<PathBuf, PathError> {
148    let root = normalize_absolute_lexically(root)?;
149    let cwd = normalize_absolute_lexically(cwd)?;
150    cwd.strip_prefix(&root)
151        .map(Path::to_path_buf)
152        .map_err(|_| PathError::OutsideRepo)
153}
154
155/// Converts a Git byte-string path into a typed repository path.
156pub(crate) fn repo_path_from_bstr(path: &gix::bstr::BStr) -> Result<RepoPath, PathError> {
157    let path = path.to_str().map_err(|_| PathError::NonUtf8)?;
158    RepoPath::from_git_path(path)
159}
160
161/// Lexically normalizes an absolute path without touching the filesystem.
162///
163/// This removes `.` components and applies `..` components with `PathBuf::pop`.
164/// It does not resolve symlinks or require the path to exist.
165///
166/// TODO: Replace this implementation with `Path::normalize_lexically()` once
167/// that standard-library API is stabilized.
168pub(crate) fn normalize_absolute_lexically(path: &Path) -> Result<PathBuf, PathError> {
169    if !path.is_absolute() {
170        return Err(PathError::NonAbsolute);
171    }
172
173    Ok(path
174        .components()
175        .fold(PathBuf::new(), |mut normalized, component| {
176            match component {
177                Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
178                Component::RootDir => normalized.push(component.as_os_str()),
179                Component::CurDir => {}
180                Component::ParentDir => {
181                    normalized.pop();
182                }
183                Component::Normal(part) => normalized.push(part),
184            }
185            normalized
186        }))
187}
188
189/// Converts an OS-native relative path into a typed repository path.
190///
191/// The input must be relative to the repository root and resolve to a non-empty
192/// tracked-file path. Parent-directory, root, prefix, non-UTF-8, and NUL-byte
193/// components are rejected.
194pub(crate) fn repo_path_from_relative(path: &Path) -> Result<RepoPath, PathError> {
195    if !path.is_relative() {
196        return Err(PathError::OutsideRepo);
197    }
198
199    let components = path
200        .components()
201        .filter_map(|component| match component {
202            Component::Normal(part) => Some(
203                RepoPathComponent::from_os_component(part)
204                    .map_err(RepoPathComponentError::into_path_error),
205            ),
206            Component::CurDir => None,
207            Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
208                Some(Err(PathError::OutsideRepo))
209            }
210        })
211        .collect::<Result<Vec<_>, _>>()?;
212
213    if components.is_empty() {
214        Err(PathError::EmptyPath)
215    } else {
216        Ok(RepoPath { components })
217    }
218}
219
220/// Splits and validates a Git path into repository path components.
221fn parse_git_path_components(path: &str) -> Result<Vec<RepoPathComponent>, PathError> {
222    if path.is_empty() {
223        return Err(PathError::EmptyPath);
224    }
225
226    path.split('/')
227        .map(|component| {
228            RepoPathComponent::parse(component).map_err(RepoPathComponentError::into_path_error)
229        })
230        .collect()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn repo_path_from_relative_rejects_empty_paths() {
239        assert!(matches!(
240            repo_path_from_relative(Path::new("")),
241            Err(PathError::EmptyPath)
242        ));
243    }
244
245    #[test]
246    fn repo_path_from_relative_rejects_absolute_paths() {
247        assert!(matches!(
248            repo_path_from_relative(Path::new("/src/lib.rs")),
249            Err(PathError::OutsideRepo)
250        ));
251    }
252
253    #[test]
254    fn repo_path_from_git_path_classifies_structural_errors() {
255        assert!(matches!(
256            RepoPath::from_git_path(""),
257            Err(PathError::EmptyPath)
258        ));
259        for path in ["/src/lib.rs", "src//lib.rs", "src/lib.rs/"] {
260            assert!(matches!(
261                RepoPath::from_git_path(path),
262                Err(PathError::EmptyComponent),
263            ));
264        }
265        assert!(matches!(
266            RepoPath::from_git_path("src/./lib.rs"),
267            Err(PathError::CurrentDirComponent)
268        ));
269        assert!(matches!(
270            RepoPath::from_git_path("src/../lib.rs"),
271            Err(PathError::OutsideRepo)
272        ));
273        assert!(matches!(
274            RepoPath::from_git_path("src/\0/lib.rs"),
275            Err(PathError::NulByte)
276        ));
277    }
278
279    #[test]
280    fn repo_path_round_trips_git_path_and_os_path() -> Result<(), PathError> {
281        let path = RepoPath::from_git_path("src/lib.rs")?;
282
283        assert_eq!(path.to_string(), "src/lib.rs");
284        assert_eq!(path.to_os_path_buf(), PathBuf::from("src").join("lib.rs"));
285        Ok(())
286    }
287
288    #[test]
289    fn normalize_absolute_lexically_rejects_relative_paths() {
290        assert!(matches!(
291            normalize_absolute_lexically(Path::new("src/../lib.rs")),
292            Err(PathError::NonAbsolute)
293        ));
294    }
295}