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}