1use std::path::{Path, PathBuf};
16
17use thiserror::Error;
18
19mod signature;
20#[cfg(feature = "ssh")]
21mod ssh;
22pub use signature::{TrustedKeyError, TrustedKeys};
23
24#[derive(Debug, Error)]
25pub enum VcsError {
26 #[error("invalid commit hash {0:?} (must be 7-40 hex characters)")]
27 InvalidCommit(String),
28 #[error("commit {0:?} not found in repository (run `cljrs deps fetch`)")]
29 CommitNotFound(String),
30 #[error("path {0:?} not found at commit {1:?}")]
31 PathNotFound(String, String),
32 #[error("git error: {0}")]
33 Io(#[from] std::io::Error),
34 #[error("git output is not valid UTF-8")]
35 Utf8,
36 #[error("no git repository found at or above {0:?}")]
37 NoRepo(PathBuf),
38 #[error(
39 "unsupported remote {0:?}: supported are https:// URLs, local paths, and (with the `ssh` feature) ssh:// remotes"
40 )]
41 UnsupportedRemote(String),
42 #[error("git error: {0}")]
43 Git(String),
44 #[error("commit {commit:?} has no valid signature: {reason}")]
45 SignatureVerificationFailed { commit: String, reason: String },
46}
47
48pub type VcsResult<T> = Result<T, VcsError>;
49
50pub fn is_valid_commit_hash(s: &str) -> bool {
53 (7..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
54}
55
56pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
59 let dir: &Path = if start.is_file() {
61 start.parent()?
62 } else {
63 start
64 };
65
66 let repo = gix::discover(dir).ok()?;
67 repo.workdir().map(|p| p.to_path_buf())
68}
69
70pub fn get_file_at_commit(repo_root: &Path, rel_path: &str, commit: &str) -> VcsResult<String> {
75 if !is_valid_commit_hash(commit) {
76 return Err(VcsError::InvalidCommit(commit.to_string()));
77 }
78
79 let repo = gix::open(repo_root).map_err(|e| VcsError::Git(e.to_string()))?;
80 let object = repo
81 .rev_parse_single(commit)
82 .map_err(|_| VcsError::CommitNotFound(commit.to_string()))?
83 .object()
84 .map_err(|_| VcsError::CommitNotFound(commit.to_string()))?;
85 let tree = object
86 .try_into_commit()
87 .map_err(|_| VcsError::CommitNotFound(commit.to_string()))?
88 .tree()
89 .map_err(|_| VcsError::CommitNotFound(commit.to_string()))?;
90
91 let entry = tree
92 .lookup_entry_by_path(Path::new(rel_path))
93 .map_err(|e| VcsError::Git(e.to_string()))?
94 .ok_or_else(|| VcsError::PathNotFound(rel_path.to_string(), commit.to_string()))?;
95
96 let blob = entry.object().map_err(|e| VcsError::Git(e.to_string()))?;
97 String::from_utf8(blob.data.clone()).map_err(|_| VcsError::Utf8)
98}
99
100pub fn cache_root() -> PathBuf {
102 let home = std::env::var_os("HOME")
104 .map(PathBuf::from)
105 .unwrap_or_else(|| PathBuf::from("."));
106 home.join(".cljrs").join("cache").join("git")
107}
108
109pub fn cache_path_for_url(url: &str) -> PathBuf {
114 cache_root().join(url_slug(url))
115}
116
117fn url_slug(url: &str) -> String {
119 url.chars()
120 .map(|c| {
121 if c.is_alphanumeric() || c == '-' {
122 c
123 } else {
124 '_'
125 }
126 })
127 .collect()
128}
129
130pub fn fetch_remote(url: &str, sha: &str) -> VcsResult<PathBuf> {
138 if !is_valid_commit_hash(sha) {
139 return Err(VcsError::InvalidCommit(sha.to_string()));
140 }
141 let kind = classify_remote(url);
142 if matches!(kind, RemoteKind::Unsupported) {
143 return Err(VcsError::UnsupportedRemote(url.to_string()));
144 }
145 #[cfg(not(feature = "ssh"))]
147 if matches!(kind, RemoteKind::Ssh) {
148 return Err(VcsError::UnsupportedRemote(url.to_string()));
149 }
150
151 let repo_dir = cache_root().join(url_slug(url));
152
153 match kind {
154 #[cfg(feature = "ssh")]
155 RemoteKind::Ssh => ssh::fetch_into_cache(url, &repo_dir)?,
156 _ => {
159 if repo_dir.exists() {
160 fetch_existing(&repo_dir)?;
162 } else {
163 std::fs::create_dir_all(&repo_dir).map_err(VcsError::Io)?;
164 clone_bare(url, &repo_dir)?;
165 }
166 }
167 }
168
169 let repo = gix::open(&repo_dir).map_err(|e| VcsError::Git(e.to_string()))?;
171 if repo.rev_parse_single(sha).is_err() {
172 return Err(VcsError::CommitNotFound(sha.to_string()));
173 }
174
175 Ok(repo_dir)
176}
177
178enum RemoteKind {
180 Supported,
182 Ssh,
185 Unsupported,
187}
188
189fn classify_remote(url: &str) -> RemoteKind {
194 if url.starts_with("https://") || url.starts_with("file://") {
195 return RemoteKind::Supported;
196 }
197 if url.starts_with("ssh://") {
198 return RemoteKind::Ssh;
199 }
200 if url.contains("://") {
201 return RemoteKind::Unsupported;
203 }
204 match url.split_once(':') {
206 Some((host, _)) if !host.is_empty() && !host.contains('/') => RemoteKind::Ssh,
207 _ => RemoteKind::Supported,
208 }
209}
210
211fn clone_bare(url: &str, repo_dir: &Path) -> VcsResult<()> {
213 let mut prepare =
214 gix::prepare_clone_bare(url, repo_dir).map_err(|e| VcsError::Git(e.to_string()))?;
215 let (_repo, _outcome) = prepare
216 .fetch_only(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
217 .map_err(|e| VcsError::Git(e.to_string()))?;
218 Ok(())
219}
220
221fn fetch_existing(repo_dir: &Path) -> VcsResult<()> {
223 let repo = gix::open(repo_dir).map_err(|e| VcsError::Git(e.to_string()))?;
224 let remote = repo
225 .find_default_remote(gix::remote::Direction::Fetch)
226 .ok_or_else(|| VcsError::Git("repository has no default remote".to_string()))?
227 .map_err(|e| VcsError::Git(e.to_string()))?;
228 remote
229 .connect(gix::remote::Direction::Fetch)
230 .map_err(|e| VcsError::Git(e.to_string()))?
231 .prepare_fetch(gix::progress::Discard, Default::default())
232 .map_err(|e| VcsError::Git(e.to_string()))?
233 .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
234 .map_err(|e| VcsError::Git(e.to_string()))?;
235 Ok(())
236}
237
238pub fn verify_commit_signature(
246 repo_root: &Path,
247 commit: &str,
248 trusted: &TrustedKeys,
249) -> VcsResult<()> {
250 if !is_valid_commit_hash(commit) {
251 return Err(VcsError::InvalidCommit(commit.to_string()));
252 }
253 let repo = gix::open(repo_root).map_err(|e| VcsError::Git(e.to_string()))?;
254 let object = repo
255 .rev_parse_single(commit)
256 .map_err(|_| VcsError::CommitNotFound(commit.to_string()))?
257 .object()
258 .map_err(|_| VcsError::CommitNotFound(commit.to_string()))?;
259 let raw = object.data.clone();
260
261 signature::verify_commit_object(&raw, trusted).map_err(|reason| {
262 VcsError::SignatureVerificationFailed {
263 commit: commit.to_string(),
264 reason,
265 }
266 })
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn valid_hashes() {
275 assert!(is_valid_commit_hash("abc1234"));
276 assert!(is_valid_commit_hash(
277 "abc1234ef5678901234567890123456789012345"
278 ));
279 assert!(!is_valid_commit_hash("abc123")); assert!(!is_valid_commit_hash("xyz1234")); assert!(!is_valid_commit_hash(""));
282 }
283
284 #[test]
285 fn remote_classification() {
286 use RemoteKind::*;
287 assert!(matches!(
288 classify_remote("https://github.com/u/r"),
289 Supported
290 ));
291 assert!(matches!(classify_remote("file:///tmp/repo"), Supported));
292 assert!(matches!(classify_remote("/tmp/local/repo"), Supported)); assert!(matches!(classify_remote("../relative/repo"), Supported)); assert!(matches!(classify_remote("ssh://git@github.com/u/r"), Ssh));
295 assert!(matches!(classify_remote("git@github.com:u/r.git"), Ssh)); assert!(matches!(
297 classify_remote("git://github.com/u/r"),
298 Unsupported
299 ));
300 assert!(matches!(
301 classify_remote("http://github.com/u/r"),
302 Unsupported
303 )); }
305}