Skip to main content

git_cache_proxy/
lfs.rs

1// SPDX-License-Identifier: Apache-2.0
2//! git-LFS caching: proxy the batch API to upstream and cache objects on disk.
3//!
4//! git-cache-proxy serves the git wire protocol (see `git`), but LFS objects use a
5//! different HTTP API: a JSON "batch" negotiation that hands back a per-object
6//! download URL, then a content-addressed GET of the object itself. Because clones
7//! are routed through the proxy, a client's git-lfs derives its LFS endpoint from the
8//! proxy URL and talks LFS to us - so we must answer it, or every LFS-tracked file
9//! fails to check out.
10//!
11//! Flow:
12//!   POST <repo>/info/lfs/objects/batch
13//!     -> forward to upstream, then rewrite every download href to point back here
14//!        (`<repo>/info/lfs/objects/<oid>`) so the object fetch is cached.
15//!   GET <repo>/info/lfs/objects/<oid>
16//!     -> serve from the on-disk cache (content-addressed by oid), or on a miss fetch
17//!        it once (re-batch for a fresh authorized URL, download, verify sha256 ==
18//!        oid, store) then serve. Objects are immutable, so a cached object is never
19//!        stale and is shared across every repo that references the same oid.
20
21use std::collections::HashMap;
22use std::fmt::Write as _;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::Duration;
27
28use anyhow::{Context, Result, bail};
29use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
30use serde_json::Value;
31use tokio::io::AsyncWriteExt;
32use tokio::sync::Mutex;
33
34use crate::evict::CacheIndex;
35use crate::repo;
36
37/// The git-LFS batch API content type, sent and expected on the batch endpoint.
38const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json";
39
40/// Reserved subdir of the LFS store holding in-flight downloads before their atomic
41/// rename into place. On the same filesystem as the final object so the rename is
42/// atomic; skipped by the eviction scan.
43pub const INCOMING_DIR: &str = ".incoming";
44
45/// Whether an object request was served from cache or fetched from upstream.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Outcome {
48    Hit,
49    Miss,
50}
51
52#[derive(Clone)]
53pub struct LfsConfig {
54    /// Upstream base URL, trailing slash trimmed (same value the git side uses).
55    pub upstream_base: String,
56    pub cache_root: PathBuf,
57    /// Full upstream auth header line (e.g. `Authorization: Basic <b64>`), or `None`
58    /// for anonymous.
59    pub upstream_auth_header: Option<String>,
60    /// The proxy's own serve token, if configured; embedded in each rewritten download
61    /// action so git-lfs re-presents it on the (auth-checked) object GET.
62    pub serve_token: Option<String>,
63}
64
65pub struct Lfs {
66    cfg: LfsConfig,
67    /// One shared client (connection pooling) using rustls+ring - no native-tls, so
68    /// the binary stays static and free of an OpenSSL dependency.
69    client: reqwest::Client,
70    /// Present only when a cache cap is configured; cached objects are recorded here
71    /// so they share the mirrors' byte budget and LRU eviction.
72    index: Option<Arc<CacheIndex>>,
73    /// Per-oid single-flight: collapses a burst of concurrent misses for one object
74    /// into a single upstream download. Only populated on a miss (a hit returns
75    /// before locking), so it holds at most one entry per distinct object fetched
76    /// since startup - each tiny, and never on the hot cached path.
77    slots: Mutex<HashMap<String, Arc<Mutex<()>>>>,
78    /// Names the per-download temp file the object is streamed to before its atomic
79    /// rename into the content-addressed cache path.
80    tmp_counter: AtomicU64,
81}
82
83impl Lfs {
84    pub fn new(cfg: LfsConfig, index: Option<Arc<CacheIndex>>) -> Self {
85        let client = reqwest::Client::builder()
86            .connect_timeout(Duration::from_secs(30))
87            .build()
88            .expect("build reqwest client");
89        Self {
90            cfg,
91            client,
92            index,
93            slots: Mutex::new(HashMap::new()),
94            tmp_counter: AtomicU64::new(0),
95        }
96    }
97
98    /// Proxy a client batch request to upstream and rewrite the download hrefs so the
99    /// objects are fetched back through this proxy (and thus cached). `advertise_base`
100    /// is the proxy's own `scheme://host` as the client reached it. Returns the
101    /// rewritten batch JSON.
102    pub async fn batch(&self, repo: &str, body: &[u8], advertise_base: &str) -> Result<Vec<u8>> {
103        let url = format!("{}/{repo}/info/lfs/objects/batch", self.cfg.upstream_base);
104        let resp = self
105            .post_batch(&url, body)
106            .await
107            .context("upstream lfs batch")?;
108        let mut json: Value = serde_json::from_slice(&resp).context("parse lfs batch response")?;
109        rewrite_download_hrefs(
110            &mut json,
111            advertise_base,
112            repo,
113            self.cfg.serve_token.as_deref(),
114        );
115        serde_json::to_vec(&json).context("serialize lfs batch response")
116    }
117
118    /// Ensure the object identified by `oid` is on disk and return its path plus
119    /// whether it was a cache hit. On a miss `size` is required (the upstream batch
120    /// API needs it); it is not needed for a hit.
121    pub async fn ensure_object(
122        &self,
123        repo: &str,
124        oid: &str,
125        size: Option<u64>,
126    ) -> Result<(PathBuf, Outcome)> {
127        let path = repo::lfs_object_path(&self.cfg.cache_root, oid);
128        if self.cached(&path).await {
129            self.touch(oid);
130            return Ok((path, Outcome::Hit));
131        }
132        // Serialize concurrent misses for the same oid onto one download.
133        let slot = self.slot(oid).await;
134        let _guard = slot.lock().await;
135        if self.cached(&path).await {
136            self.touch(oid); // another task fetched it while we waited on the lock
137            return Ok((path, Outcome::Hit));
138        }
139        let size = size.context("cache miss without an object size")?;
140        self.fetch_object(repo, oid, size, &path).await?;
141        Ok((path, Outcome::Miss))
142    }
143
144    async fn cached(&self, path: &Path) -> bool {
145        tokio::fs::try_exists(path).await.unwrap_or(false)
146    }
147
148    fn touch(&self, oid: &str) {
149        if let Some(idx) = &self.index {
150            idx.touch(&repo::lfs_object_key(oid));
151        }
152    }
153
154    /// Download one object from upstream into the cache: re-batch for a fresh,
155    /// authorized download URL (the batch JWT is short-lived, so it is fetched per
156    /// download, never cached), stream it to a temp file, verify the content hashes
157    /// to `oid`, then atomically move it into place.
158    async fn fetch_object(
159        &self,
160        repo: &str,
161        oid: &str,
162        size: u64,
163        final_path: &Path,
164    ) -> Result<()> {
165        let (href, headers) = self.download_action(repo, oid, size).await?;
166        let tmp = self.tmp_path().await?;
167        if let Err(e) = self.download_to_file(&href, headers, &tmp).await {
168            let _ = tokio::fs::remove_file(&tmp).await;
169            return Err(e);
170        }
171        let got = sha256_file(tmp.clone()).await?;
172        if got != oid {
173            let _ = tokio::fs::remove_file(&tmp).await;
174            bail!("lfs object {oid} failed integrity check (upstream returned {got})");
175        }
176        if let Some(parent) = final_path.parent() {
177            tokio::fs::create_dir_all(parent)
178                .await
179                .context("create lfs shard dir")?;
180        }
181        let bytes = tokio::fs::metadata(&tmp)
182            .await
183            .map(|m| m.len())
184            .unwrap_or(0);
185        tokio::fs::rename(&tmp, final_path)
186            .await
187            .context("store lfs object")?;
188        if let Some(idx) = &self.index {
189            idx.record_blob(&repo::lfs_object_key(oid), bytes);
190        }
191        Ok(())
192    }
193
194    /// Re-batch upstream for a single object and return its download href plus the
195    /// headers to send when fetching it (the batch response embeds a short-lived
196    /// authorization for the object URL).
197    async fn download_action(
198        &self,
199        repo: &str,
200        oid: &str,
201        size: u64,
202    ) -> Result<(String, HeaderMap)> {
203        let url = format!("{}/{repo}/info/lfs/objects/batch", self.cfg.upstream_base);
204        let req = serde_json::json!({
205            "operation": "download",
206            "transfers": ["basic"],
207            "objects": [{ "oid": oid, "size": size }],
208        });
209        let body = serde_json::to_vec(&req).context("build lfs re-batch request")?;
210        let resp = self
211            .post_batch(&url, &body)
212            .await
213            .context("upstream lfs re-batch")?;
214        let json: Value = serde_json::from_slice(&resp).context("parse lfs re-batch response")?;
215        parse_download_action(&json)
216    }
217
218    /// POST `body` to an upstream LFS batch URL with the LFS content type and (if set)
219    /// the upstream credential, and return the response bytes.
220    async fn post_batch(&self, url: &str, body: &[u8]) -> Result<Vec<u8>> {
221        let mut req = self
222            .client
223            .post(url)
224            .header(CONTENT_TYPE, LFS_CONTENT_TYPE)
225            .header(ACCEPT, LFS_CONTENT_TYPE)
226            .body(body.to_vec());
227        if let Some(line) = &self.cfg.upstream_auth_header
228            && let Some((name, value)) = parse_header_line(line)
229        {
230            req = req.header(name, value);
231        }
232        let resp = req
233            .send()
234            .await
235            .context("send lfs batch")?
236            .error_for_status()
237            .context("lfs batch http status")?;
238        Ok(resp.bytes().await.context("read lfs batch body")?.to_vec())
239    }
240
241    /// Stream an object href to `out`, sending the batch-supplied `headers`. reqwest
242    /// follows redirects (LFS hrefs commonly redirect to object storage) and drops the
243    /// auth header on a cross-host hop.
244    async fn download_to_file(&self, url: &str, headers: HeaderMap, out: &Path) -> Result<()> {
245        let mut resp = self
246            .client
247            .get(url)
248            .headers(headers)
249            .send()
250            .await
251            .context("send lfs download")?
252            .error_for_status()
253            .context("lfs download http status")?;
254        let mut file = tokio::fs::File::create(out)
255            .await
256            .context("create lfs temp file")?;
257        while let Some(chunk) = resp.chunk().await.context("read lfs object chunk")? {
258            file.write_all(&chunk)
259                .await
260                .context("write lfs object chunk")?;
261        }
262        file.flush().await.context("flush lfs object")?;
263        Ok(())
264    }
265
266    async fn tmp_path(&self) -> Result<PathBuf> {
267        let dir = self
268            .cfg
269            .cache_root
270            .join(repo::LFS_OBJECTS_DIR)
271            .join(INCOMING_DIR);
272        tokio::fs::create_dir_all(&dir)
273            .await
274            .context("create lfs incoming dir")?;
275        let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
276        Ok(dir.join(format!("{}-{n}", std::process::id())))
277    }
278
279    async fn slot(&self, oid: &str) -> Arc<Mutex<()>> {
280        self.slots
281            .lock()
282            .await
283            .entry(oid.to_string())
284            .or_insert_with(|| Arc::new(Mutex::new(())))
285            .clone()
286    }
287}
288
289/// Rewrite each object's `download` href to point back at this proxy so the fetch is
290/// cached, and replace the upstream authorization header with the proxy's serve token
291/// (or drop it when the proxy serves anonymously) - the client authenticates to the
292/// proxy, not upstream. Objects carrying an `error`, or an `upload` action, are left
293/// untouched. Malformed entries are skipped rather than failing the batch.
294fn rewrite_download_hrefs(
295    json: &mut Value,
296    advertise_base: &str,
297    repo: &str,
298    serve_token: Option<&str>,
299) {
300    let Some(objects) = json.get_mut("objects").and_then(Value::as_array_mut) else {
301        return;
302    };
303    for obj in objects {
304        let Some(oid) = obj.get("oid").and_then(Value::as_str).map(str::to_string) else {
305            continue;
306        };
307        let size = obj.get("size").and_then(Value::as_u64).unwrap_or(0);
308        let Some(download) = obj
309            .get_mut("actions")
310            .and_then(|a| a.get_mut("download"))
311            .and_then(Value::as_object_mut)
312        else {
313            continue;
314        };
315        download.insert(
316            "href".to_string(),
317            Value::String(format!(
318                "{advertise_base}/{repo}/info/lfs/objects/{oid}?size={size}"
319            )),
320        );
321        match serve_token {
322            Some(token) => {
323                download.insert(
324                    "header".to_string(),
325                    serde_json::json!({ "Authorization": format!("Bearer {token}") }),
326                );
327            }
328            None => {
329                download.remove("header");
330            }
331        }
332    }
333}
334
335/// Extract the download href and object-transfer headers from a batch response for a
336/// single requested object. Errors if upstream reported the object missing or omitted
337/// a usable download action.
338fn parse_download_action(json: &Value) -> Result<(String, HeaderMap)> {
339    let obj = json
340        .get("objects")
341        .and_then(Value::as_array)
342        .and_then(|a| a.first())
343        .context("lfs batch: no objects in response")?;
344    if let Some(err) = obj.get("error") {
345        bail!("lfs batch: upstream object error {err}");
346    }
347    let download = obj
348        .get("actions")
349        .and_then(|a| a.get("download"))
350        .context("lfs batch: no download action")?;
351    let href = download
352        .get("href")
353        .and_then(Value::as_str)
354        .context("lfs batch: download action has no href")?
355        .to_string();
356    let mut headers = HeaderMap::new();
357    if let Some(map) = download.get("header").and_then(Value::as_object) {
358        for (k, v) in map {
359            if let (Ok(name), Some(val)) = (HeaderName::from_bytes(k.as_bytes()), v.as_str())
360                && let Ok(value) = HeaderValue::from_str(val)
361            {
362                headers.insert(name, value);
363            }
364        }
365    }
366    Ok((href, headers))
367}
368
369/// Parse a full header line (`Name: value`) into a typed name/value pair, marking the
370/// value sensitive so it is redacted from any debug output. `None` if either half is
371/// not a valid header token.
372fn parse_header_line(line: &str) -> Option<(HeaderName, HeaderValue)> {
373    let (name, value) = line.split_once(':')?;
374    let name = HeaderName::from_bytes(name.trim().as_bytes()).ok()?;
375    let mut value = HeaderValue::from_str(value.trim()).ok()?;
376    value.set_sensitive(true);
377    Some((name, value))
378}
379
380/// Hex-encoded sha256 of a file, computed on the blocking pool (the object can be
381/// large, and this runs off the request's async path).
382async fn sha256_file(path: PathBuf) -> Result<String> {
383    tokio::task::spawn_blocking(move || -> Result<String> {
384        use std::io::Read;
385
386        use sha2::{Digest, Sha256};
387
388        let mut f = std::fs::File::open(&path).context("open lfs object to hash")?;
389        let mut hasher = Sha256::new();
390        let mut buf = [0u8; 64 * 1024];
391        loop {
392            let n = f.read(&mut buf).context("read lfs object to hash")?;
393            if n == 0 {
394                break;
395            }
396            hasher.update(&buf[..n]);
397        }
398        let mut hex = String::with_capacity(64);
399        for b in hasher.finalize() {
400            let _ = write!(hex, "{b:02x}");
401        }
402        Ok(hex)
403    })
404    .await
405    .context("join sha256 task")?
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn rewrites_download_href_and_strips_auth() {
414        let mut json = serde_json::json!({
415            "transfer": "basic",
416            "objects": [{
417                "oid": "abc123",
418                "size": 42,
419                "actions": {
420                    "download": {
421                        "href": "https://upstream.example/storage/abc123",
422                        "header": { "Authorization": "Bearer secret" }
423                    }
424                }
425            }],
426        });
427        rewrite_download_hrefs(&mut json, "http://proxy:8080", "g/r.git", None);
428        let dl = &json["objects"][0]["actions"]["download"];
429        assert_eq!(
430            dl["href"],
431            "http://proxy:8080/g/r.git/info/lfs/objects/abc123?size=42"
432        );
433        assert!(
434            dl.get("header").is_none(),
435            "upstream auth header must be stripped when serving anonymously"
436        );
437    }
438
439    #[test]
440    fn embeds_serve_token_in_download_header() {
441        let mut json = serde_json::json!({
442            "objects": [{
443                "oid": "abc123",
444                "size": 1,
445                "actions": { "download": {
446                    "href": "https://upstream/storage/abc123",
447                    "header": { "Authorization": "Bearer upstream-secret" }
448                }}
449            }],
450        });
451        rewrite_download_hrefs(
452            &mut json,
453            "http://proxy:8080",
454            "g/r.git",
455            Some("serve-secret"),
456        );
457        let dl = &json["objects"][0]["actions"]["download"];
458        // The upstream credential is replaced by the proxy's serve token so git-lfs
459        // re-presents it on the (auth-checked) object GET.
460        assert_eq!(dl["header"]["Authorization"], "Bearer serve-secret");
461    }
462
463    #[test]
464    fn leaves_error_and_upload_objects_untouched() {
465        let mut json = serde_json::json!({
466            "objects": [
467                { "oid": "bad", "size": 0, "error": { "code": 404, "message": "missing" } },
468                { "oid": "up", "size": 1, "actions": { "upload": { "href": "https://upstream/put" } } },
469                { "size": 2, "actions": { "download": { "href": "https://x" } } } // no oid -> skipped
470            ],
471        });
472        let before = json.clone();
473        rewrite_download_hrefs(&mut json, "http://proxy:8080", "g/r.git", None);
474        assert_eq!(json, before, "no download action -> nothing rewritten");
475    }
476
477    #[test]
478    fn parse_download_action_extracts_href_and_headers() {
479        let json = serde_json::json!({
480            "objects": [{
481                "oid": "abc",
482                "size": 3,
483                "actions": { "download": {
484                    "href": "https://storage/abc",
485                    "header": { "Authorization": "Bearer jwt", "X-Extra": "1" }
486                }}
487            }],
488        });
489        let (href, headers) = parse_download_action(&json).unwrap();
490        assert_eq!(href, "https://storage/abc");
491        assert_eq!(headers.get("authorization").unwrap(), "Bearer jwt");
492        assert_eq!(headers.get("x-extra").unwrap(), "1");
493    }
494
495    #[test]
496    fn parse_download_action_reports_upstream_and_shape_errors() {
497        // An object upstream flagged as an error propagates as an error.
498        let err_obj = serde_json::json!({
499            "objects": [{ "oid": "x", "size": 0, "error": { "code": 404, "message": "gone" } }]
500        });
501        assert!(parse_download_action(&err_obj).is_err());
502        // No download action (e.g. an upload-only response) is an error.
503        let no_action = serde_json::json!({ "objects": [{ "oid": "x", "size": 0 }] });
504        assert!(parse_download_action(&no_action).is_err());
505        // A download action without an href is an error.
506        let no_href = serde_json::json!({
507            "objects": [{ "oid": "x", "size": 0, "actions": { "download": {} } }]
508        });
509        assert!(parse_download_action(&no_href).is_err());
510        // An empty response has no first object.
511        assert!(parse_download_action(&serde_json::json!({ "objects": [] })).is_err());
512    }
513
514    #[test]
515    fn parse_download_action_tolerates_a_missing_header_map() {
516        let json = serde_json::json!({
517            "objects": [{ "oid": "x", "size": 0, "actions": { "download": { "href": "https://s/x" } } }]
518        });
519        let (href, headers) = parse_download_action(&json).unwrap();
520        assert_eq!(href, "https://s/x");
521        assert!(headers.is_empty());
522    }
523
524    #[test]
525    fn parse_header_line_splits_and_trims() {
526        let (name, value) = parse_header_line("Authorization: Basic abc123").unwrap();
527        assert_eq!(name, "authorization");
528        assert_eq!(value, "Basic abc123");
529        assert!(value.is_sensitive());
530        // A line with no colon is not a header.
531        assert!(parse_header_line("not a header").is_none());
532    }
533
534    #[tokio::test]
535    async fn sha256_matches_known_vectors() {
536        // The well-known empty and "abc" sha256 digests.
537        let dir = std::env::temp_dir();
538        let empty = dir.join(format!("gcp-lfs-empty-{}", std::process::id()));
539        tokio::fs::write(&empty, b"").await.unwrap();
540        assert_eq!(
541            sha256_file(empty.clone()).await.unwrap(),
542            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
543        );
544        let abc = dir.join(format!("gcp-lfs-abc-{}", std::process::id()));
545        tokio::fs::write(&abc, b"abc").await.unwrap();
546        assert_eq!(
547            sha256_file(abc.clone()).await.unwrap(),
548            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
549        );
550        let _ = tokio::fs::remove_file(&empty).await;
551        let _ = tokio::fs::remove_file(&abc).await;
552    }
553}