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)]
14pub struct RepoPath {
15 components: Vec<RepoPathComponent>,
16}
17
18impl RepoPath {
19 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 pub(crate) fn to_os_path_buf(&self) -> PathBuf {
30 self.components
31 .iter()
32 .map(RepoPathComponent::as_str)
33 .collect()
34 }
35
36 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#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
63struct RepoPathComponent(String);
64
65impl RepoPathComponent {
66 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 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 fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91enum RepoPathComponentError {
92 NonUtf8,
94 Empty,
96 CurrentDir,
98 ParentDir,
100 NulByte,
102}
103
104impl RepoPathComponentError {
105 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#[derive(Debug, Error)]
119pub(crate) enum PathError {
120 #[error("path is not valid UTF-8")]
122 NonUtf8,
123 #[error("path escapes the repository root")]
125 OutsideRepo,
126 #[error("path must be absolute")]
128 NonAbsolute,
129 #[error("empty paths are not valid tracked files")]
131 EmptyPath,
132 #[error("repo path contains an empty component")]
134 EmptyComponent,
135 #[error("repo path contains a current-directory component")]
137 CurrentDirComponent,
138 #[error("repo path contains a NUL byte")]
140 NulByte,
141}
142
143pub(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
155pub(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
161pub(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
189pub(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
220fn 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}