Skip to main content

cljrs_vcs/
lib.rs

1//! Pure-Rust git helpers for versioned symbol resolution.
2//!
3//! All git operations are performed in-process with [`gix`] (gitoxide); no
4//! `git` binary is required. Commit-signature verification is likewise native
5//! (see [`signature`]): PGP signatures are checked with rPGP and SSH signatures
6//! with `ssh-key`, against a caller-supplied set of [`TrustedKeys`].
7//!
8//! Remote fetch/clone over the network is HTTPS-only and fully pure-Rust
9//! (rustls). Local filesystem paths and `file://` URLs are also supported
10//! (handled in-process). `ssh://`/scp-like remotes are fetched natively (no
11//! `ssh` binary) when the optional `ssh` feature is enabled (see the `ssh`
12//! module); without it they are rejected with a clear error. Other schemes
13//! (`git://`, `http://`) are unsupported.
14
15use 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
50/// Returns `true` if `s` looks like a valid (abbreviated or full) commit hash:
51/// 7–40 lowercase or uppercase hex characters.
52pub fn is_valid_commit_hash(s: &str) -> bool {
53    (7..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
54}
55
56/// Walk upward from `start` (a file or directory) to find the root of the
57/// enclosing git repository, i.e. the working-tree root.
58pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
59    // Normalise: if `start` is a file, begin from its parent directory.
60    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
70/// Return the contents of `rel_path` (relative to the repo root) at `commit`.
71///
72/// Errors if the commit hash is malformed, the commit is not present locally,
73/// or the path does not exist at that commit.
74pub 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
100/// Returns the path to the local git-dep cache root: `~/.cljrs/cache/git/`.
101pub fn cache_root() -> PathBuf {
102    // Prefer $HOME; fall back to the current directory if HOME is unset.
103    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
109/// Return the local cache path for a given remote URL, without fetching.
110///
111/// This mirrors the slug derivation inside [`fetch_remote`] so callers can
112/// check cache existence without triggering network access.
113pub fn cache_path_for_url(url: &str) -> PathBuf {
114    cache_root().join(url_slug(url))
115}
116
117/// Stable cache directory slug derived from a URL (non-alphanumerics → `_`).
118fn 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
130/// Clone or fetch a git repository into the local cache.
131///
132/// `url`  — an `https://` URL, a local filesystem path, a `file://` URL, or
133///          (with the `ssh` feature) an `ssh://`/scp-like remote
134/// `sha`  — the commit SHA that must be reachable after the operation
135///
136/// Returns the path to the bare repository in the cache.
137pub 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    // ssh requires the optional `ssh` feature; without it, reject clearly.
146    #[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        // https / local / file (and, without the `ssh` feature, nothing else
157        // reaches here).
158        _ => {
159            if repo_dir.exists() {
160                // Already cloned — fetch to make sure we have the requested commit.
161                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    // Verify that the requested commit is now present locally.
170    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
178/// How a remote URL is transported.
179enum RemoteKind {
180    /// `https://` (pure-Rust network) or a local filesystem path / `file://`.
181    Supported,
182    /// `ssh://` or scp-like `git@host:path`. Fetched natively only with the
183    /// `ssh` feature; otherwise rejected.
184    Ssh,
185    /// `git://`, `http://`, or any other unsupported scheme.
186    Unsupported,
187}
188
189/// Classify a remote URL. `https://` (network) plus local paths and `file://`
190/// are always supported (gitoxide handles them in-process). `ssh://` and
191/// scp-like `git@host:path` map to [`RemoteKind::Ssh`]. Other network schemes
192/// (`http://`, `git://`, …) are unsupported.
193fn 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        // An explicit non-https/file/ssh scheme (git://, http://, …).
202        return RemoteKind::Unsupported;
203    }
204    // No scheme: an scp-like `user@host:path` is SSH; otherwise a local path.
205    match url.split_once(':') {
206        Some((host, _)) if !host.is_empty() && !host.contains('/') => RemoteKind::Ssh,
207        _ => RemoteKind::Supported,
208    }
209}
210
211/// Clone `url` as a bare repository into `repo_dir`.
212fn 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
221/// Fetch updates for an already-cloned bare repository at `repo_dir`.
222fn 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
238/// Verify the PGP or SSH signature on `commit` inside `repo_root` against the
239/// caller-supplied set of `trusted` keys.
240///
241/// Returns `Ok(())` when the commit carries a cryptographically valid signature
242/// whose signing key is present in `trusted`. Returns
243/// `Err(SignatureVerificationFailed)` for an unsigned commit, an invalid
244/// signature, or a signature made by a key that is not trusted.
245pub 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")); // too short
280        assert!(!is_valid_commit_hash("xyz1234")); // non-hex
281        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)); // absolute local path
293        assert!(matches!(classify_remote("../relative/repo"), Supported)); // relative local path
294        assert!(matches!(classify_remote("ssh://git@github.com/u/r"), Ssh));
295        assert!(matches!(classify_remote("git@github.com:u/r.git"), Ssh)); // scp-like
296        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        )); // insecure
304    }
305}