1use 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
37const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json";
39
40pub const INCOMING_DIR: &str = ".incoming";
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Outcome {
48 Hit,
49 Miss,
50}
51
52#[derive(Clone)]
53pub struct LfsConfig {
54 pub upstream_base: String,
56 pub cache_root: PathBuf,
57 pub upstream_auth_header: Option<String>,
60 pub serve_token: Option<String>,
63}
64
65pub struct Lfs {
66 cfg: LfsConfig,
67 client: reqwest::Client,
70 index: Option<Arc<CacheIndex>>,
73 slots: Mutex<HashMap<String, Arc<Mutex<()>>>>,
78 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 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 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 let slot = self.slot(oid).await;
134 let _guard = slot.lock().await;
135 if self.cached(&path).await {
136 self.touch(oid); 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 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 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 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 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
289fn 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
335fn 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
369fn 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
380async 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 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" } } } ],
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 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 let no_action = serde_json::json!({ "objects": [{ "oid": "x", "size": 0 }] });
504 assert!(parse_download_action(&no_action).is_err());
505 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 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 assert!(parse_header_line("not a header").is_none());
532 }
533
534 #[tokio::test]
535 async fn sha256_matches_known_vectors() {
536 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}