Skip to main content

lean_ctx/core/git/
clone.rs

1//! Bounded, SSRF-guarded local clone cache for remote repositories.
2//!
3//! A repo URL is shallow-fetched (`--depth 1`) into
4//! `<data>/cache/repos/<host>/<owner>/<repo>/<ref>` and reused while fresh, so
5//! the agent can read a remote project like a local one without re-cloning on
6//! every call. The clone URL is validated through [`crate::core::web::url_guard`]
7//! (https-only, blocks private/loopback), and every git call is time-bounded.
8
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12use super::repo_url::RepoRef;
13use super::run_git;
14
15/// Default wall-clock timeout for a clone/fetch.
16pub const DEFAULT_CLONE_TIMEOUT_SECS: u64 = 90;
17/// How long a cached clone is reused before a refresh fetch.
18const CACHE_TTL: Duration = Duration::from_hours(1);
19/// Stamp file written after a successful fetch; its mtime drives freshness.
20const STAMP: &str = ".lean-ctx-fetched";
21
22/// Ensure a fresh local checkout of `repo` exists and return its path.
23///
24/// Reuses the cached checkout while it is younger than the cache TTL; otherwise
25/// refreshes (or performs) a shallow fetch of the requested ref (default: the
26/// remote's `HEAD`).
27pub fn ensure_repo(repo: &RepoRef, timeout: Duration) -> Result<PathBuf, String> {
28    guard_clone_url(&repo.clone_url)?;
29
30    let dir = repo_cache_dir(repo)?;
31    if is_fresh(&dir) {
32        return Ok(dir);
33    }
34
35    if dir.join(".git").is_dir() {
36        if let Err(e) = refresh(repo, &dir, timeout) {
37            // A corrupt/partial cache shouldn't wedge the tool — reclone clean.
38            let _ = std::fs::remove_dir_all(&dir);
39            initial_fetch(repo, &dir, timeout)
40                .map_err(|e2| format!("refresh failed ({e}); reclone failed ({e2})"))?;
41        }
42    } else {
43        let _ = std::fs::remove_dir_all(&dir);
44        initial_fetch(repo, &dir, timeout)?;
45    }
46    Ok(dir)
47}
48
49/// Validate that the clone URL is an https URL that resolves to a public host.
50fn guard_clone_url(url: &str) -> Result<(), String> {
51    let safe = crate::core::web::url_guard::validate(url).map_err(|e| e.to_string())?;
52    safe.ensure_resolves_safely().map_err(|e| e.to_string())?;
53    Ok(())
54}
55
56/// Cache directory for a repo+ref, with every path segment sanitized so a
57/// hostile owner/repo/ref cannot escape the cache root.
58pub fn repo_cache_dir(repo: &RepoRef) -> Result<PathBuf, String> {
59    let mut dir = cache_root()?;
60    for seg in repo.cache_slug().split('/') {
61        dir.push(sanitize_segment(seg));
62    }
63    let ref_seg = repo
64        .git_ref
65        .as_deref()
66        .map_or_else(|| "_HEAD".to_string(), sanitize_segment);
67    dir.push(ref_seg);
68    Ok(dir)
69}
70
71fn cache_root() -> Result<PathBuf, String> {
72    Ok(crate::core::data_dir::lean_ctx_data_dir()?
73        .join("cache")
74        .join("repos"))
75}
76
77/// Map a path segment to a safe filesystem name: keep `[A-Za-z0-9._-]`, replace
78/// everything else with `_`, and never allow `.`/`..` traversal.
79fn sanitize_segment(seg: &str) -> String {
80    let cleaned: String = seg
81        .chars()
82        .map(|c| {
83            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
84                c
85            } else {
86                '_'
87            }
88        })
89        .collect();
90    match cleaned.as_str() {
91        "" | "." | ".." => "_".to_string(),
92        _ => cleaned,
93    }
94}
95
96fn is_fresh(dir: &Path) -> bool {
97    if !dir.join(".git").is_dir() {
98        return false;
99    }
100    let Ok(meta) = std::fs::metadata(dir.join(STAMP)) else {
101        return false;
102    };
103    let Ok(modified) = meta.modified() else {
104        return false;
105    };
106    SystemTime::now()
107        .duration_since(modified)
108        .is_ok_and(|age| age < CACHE_TTL)
109}
110
111/// Fresh clone via `init` + shallow `fetch` + checkout, which (unlike
112/// `clone --branch`) accepts branches, tags, and commit SHAs uniformly.
113fn initial_fetch(repo: &RepoRef, dir: &Path, timeout: Duration) -> Result<(), String> {
114    std::fs::create_dir_all(dir).map_err(|e| format!("cannot create cache dir: {e}"))?;
115    run_git(&["init", "-q"], dir, Duration::from_secs(15), &[])?.ok_stdout()?;
116    run_git(
117        &["remote", "add", "origin", &repo.clone_url],
118        dir,
119        Duration::from_secs(15),
120        &[],
121    )?
122    .ok_stdout()?;
123    fetch_and_checkout(repo, dir, timeout)
124}
125
126fn refresh(repo: &RepoRef, dir: &Path, timeout: Duration) -> Result<(), String> {
127    fetch_and_checkout(repo, dir, timeout)
128}
129
130fn fetch_and_checkout(repo: &RepoRef, dir: &Path, timeout: Duration) -> Result<(), String> {
131    let refspec = repo.git_ref.as_deref().unwrap_or("HEAD");
132    run_git(
133        &["fetch", "--depth", "1", "origin", refspec],
134        dir,
135        timeout,
136        &[],
137    )?
138    .ok_stdout()
139    .map_err(|e| format!("fetch '{refspec}' from {}: {e}", repo.clone_url))?;
140    run_git(
141        &["checkout", "-q", "-f", "FETCH_HEAD"],
142        dir,
143        Duration::from_secs(30),
144        &[],
145    )?
146    .ok_stdout()?;
147    let _ = std::fs::write(dir.join(STAMP), b"");
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn rr(url: &str) -> RepoRef {
156        crate::core::git::repo_url::parse(url).unwrap()
157    }
158
159    #[test]
160    fn cache_dir_nests_host_owner_repo_ref() {
161        let _lock = crate::core::data_dir::test_env_lock();
162        let tmp = std::env::temp_dir().join("lc_clone_cache_test");
163        std::env::set_var("LEAN_CTX_DATA_DIR", &tmp);
164        let dir = repo_cache_dir(&rr("https://github.com/o/r/blob/main/x.rs")).unwrap();
165        std::env::remove_var("LEAN_CTX_DATA_DIR");
166
167        // Normalize separators so the assertion holds on Windows (`\`) too.
168        let s = dir.to_string_lossy().replace('\\', "/");
169        assert!(s.contains("cache/repos/github.com/o/r/main"), "got {s}");
170    }
171
172    #[test]
173    fn cache_dir_uses_head_marker_without_ref() {
174        let _lock = crate::core::data_dir::test_env_lock();
175        let tmp = std::env::temp_dir().join("lc_clone_cache_test2");
176        std::env::set_var("LEAN_CTX_DATA_DIR", &tmp);
177        let dir = repo_cache_dir(&rr("https://github.com/o/r")).unwrap();
178        std::env::remove_var("LEAN_CTX_DATA_DIR");
179        // Component-wise check is separator-agnostic (Windows uses `\`).
180        assert!(dir.ends_with("_HEAD"), "got {}", dir.display());
181    }
182
183    #[test]
184    fn sanitize_blocks_traversal_and_weird_chars() {
185        assert_eq!(sanitize_segment(".."), "_");
186        assert_eq!(sanitize_segment("."), "_");
187        assert_eq!(sanitize_segment(""), "_");
188        assert_eq!(sanitize_segment("a/b"), "a_b");
189        assert_eq!(sanitize_segment("feat..x"), "feat..x"); // inner dots ok
190        assert_eq!(sanitize_segment("we ird*name"), "we_ird_name");
191        assert_eq!(sanitize_segment("ok-1.2_3"), "ok-1.2_3");
192    }
193
194    #[test]
195    fn ensure_repo_rejects_non_https_and_loopback() {
196        // url_guard must reject these before any network/git work.
197        assert!(ensure_repo(
198            &RepoRef {
199                host: "localhost".into(),
200                owner: "o".into(),
201                repo: "r".into(),
202                clone_url: "http://localhost/o/r.git".into(),
203                git_ref: None,
204                subpath: None,
205            },
206            Duration::from_secs(5)
207        )
208        .is_err());
209    }
210
211    #[test]
212    fn fresh_is_false_for_missing_dir() {
213        assert!(!is_fresh(Path::new("/nonexistent/lean-ctx/repo/cache")));
214    }
215}