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