Skip to main content

git_cache_proxy/
server.rs

1// SPDX-License-Identifier: Apache-2.0
2//! HTTP surface: the git smart-HTTP endpoints plus health/metrics.
3//!
4//! Routing is method + path suffix based (git paths have arbitrary depth), so
5//! the git handler is registered as the router fallback and dispatches:
6//!   GET  <repo>/info/refs?service=git-upload-pack  -> ref advertisement
7//!   POST <repo>/git-upload-pack                    -> packfile (streamed)
8//!   anything git-receive-pack                      -> 403 (read-only)
9
10use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use axum::Router;
15use axum::body::{Body, Bytes};
16use axum::extract::State;
17use axum::http::{HeaderMap, Method, Request, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use axum::routing::get;
20use subtle::ConstantTimeEq;
21use tower::limit::GlobalConcurrencyLimitLayer;
22
23use crate::git::GitCache;
24use crate::lfs::{Lfs, Outcome};
25use crate::metrics::{LfsResult, Metrics, RequestKind, Status};
26use crate::repo;
27
28const MAX_BODY: usize = 64 * 1024 * 1024;
29const UPLOAD_PACK: &str = "git-upload-pack";
30const RECEIVE_PACK: &str = "git-receive-pack";
31const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json";
32const LFS_BATCH_SUFFIX: &str = "/info/lfs/objects/batch";
33
34#[derive(Clone)]
35pub struct AppState {
36    pub cache: Arc<GitCache>,
37    pub lfs: Arc<Lfs>,
38    pub upstream_base: String,
39    pub cache_root: PathBuf,
40    pub serve_token: Option<String>,
41    /// Upper bound (bytes) on a decoded upload-pack request body. See
42    /// `Config::max_decoded_body_mb`.
43    pub max_decoded_body: usize,
44    /// Max concurrent in-flight requests (`0` = unlimited). See
45    /// `Config::max_concurrent_requests`.
46    pub max_concurrent: usize,
47    pub metrics: Arc<Metrics>,
48}
49
50pub fn router(state: AppState) -> Router {
51    let max_concurrent = state.max_concurrent;
52
53    // Observability endpoints are deliberately kept *out* of the concurrency
54    // limit below: a liveness/readiness probe or a metrics scrape must stay
55    // responsive even when a burst of clones has saturated the semaphore -
56    // otherwise a healthy-but-busy pod fails its probes and gets restarted.
57    let observability = Router::new()
58        .route("/healthz", get(|| async { "ok" }))
59        .route("/readyz", get(readyz))
60        .route("/metrics", get(metrics_handler))
61        .with_state(state.clone());
62
63    // The git smart-HTTP surface has arbitrary-depth paths, so it is the router
64    // fallback, and it is the only thing the concurrency limit wraps (`0`
65    // disables it). One global semaphore shared across every per-connection
66    // clone of the service (axum clones it per connection) makes the cap
67    // process-wide rather than per-connection. `merge` keeps the (limited)
68    // fallback as the merged fallback, while the observability routes above are
69    // matched first and bypass it.
70    let mut git = Router::new().fallback(handle_git).with_state(state);
71    if max_concurrent != 0 {
72        git = git.layer(GlobalConcurrencyLimitLayer::new(max_concurrent));
73    }
74
75    observability.merge(git)
76}
77
78async fn metrics_handler(State(st): State<AppState>) -> Response {
79    Response::builder()
80        .header(header::CONTENT_TYPE, "text/plain; version=0.0.4")
81        .body(Body::from(st.metrics.gather()))
82        .expect("valid response")
83}
84
85/// Readiness probe. Liveness (`/healthz`) only says the process is up; readiness
86/// additionally verifies the proxy can do its one job - write bare mirrors into
87/// the cache root - so a detached, unmounted, read-only, or unwritable cache
88/// volume surfaces as `503 Service Unavailable` here instead of a later flood of
89/// upstream `502`s. Kept out of the concurrency limit (see `router`).
90async fn readyz(State(st): State<AppState>) -> Response {
91    match cache_writable(&st.cache_root).await {
92        Ok(()) => (StatusCode::OK, "ok").into_response(),
93        Err(e) => {
94            tracing::warn!(
95                cache_root = %st.cache_root.display(),
96                error = %e,
97                "readiness check failed"
98            );
99            err(StatusCode::SERVICE_UNAVAILABLE, "cache root not writable")
100        }
101    }
102}
103
104/// Confirm the cache root exists and is writable by creating and removing a probe
105/// file - the same directory the mirrors live in, so it tests the real target
106/// rather than a proxy for it. A single create + unlink is cheap enough to run
107/// per probe and catches the failure modes that make "ready" a lie: a missing
108/// directory, a read-only remount, or a permissions problem.
109async fn cache_writable(cache_root: &Path) -> std::io::Result<()> {
110    tokio::fs::create_dir_all(cache_root).await?;
111    let probe = cache_root.join(".readyz-probe");
112    tokio::fs::write(&probe, b"").await?;
113    let _ = tokio::fs::remove_file(&probe).await;
114    Ok(())
115}
116
117async fn handle_git(State(st): State<AppState>, req: Request<Body>) -> Response {
118    let (parts, body) = req.into_parts();
119    let path = parts.uri.path().to_string();
120    let query = parts.uri.query().unwrap_or("").to_string();
121    let git_protocol = parts
122        .headers
123        .get("git-protocol")
124        .and_then(|v| v.to_str().ok())
125        .map(str::to_string);
126
127    if let Some(resp) = check_auth(&st, &parts.headers) {
128        st.metrics
129            .record_request(RequestKind::Auth, Status::Unauthorized, "-");
130        return resp;
131    }
132
133    // Read-only: refuse anything that would write upstream.
134    if path.ends_with(&format!("/{RECEIVE_PACK}"))
135        || query.contains(&format!("service={RECEIVE_PACK}"))
136    {
137        st.metrics
138            .record_request(RequestKind::ReceivePack, Status::Rejected, "-");
139        return err(
140            StatusCode::FORBIDDEN,
141            "read-only proxy: pushes are not allowed",
142        );
143    }
144
145    if parts.method == Method::GET && path.ends_with("/info/refs") {
146        if !query.contains(&format!("service={UPLOAD_PACK}")) {
147            st.metrics
148                .record_request(RequestKind::InfoRefs, Status::Error, "-");
149            return err(
150                StatusCode::BAD_REQUEST,
151                "only smart-http git-upload-pack is supported",
152            );
153        }
154        return info_refs(st, &path, git_protocol.as_deref()).await;
155    }
156
157    if parts.method == Method::POST && path.ends_with(&format!("/{UPLOAD_PACK}")) {
158        let body = match axum::body::to_bytes(body, MAX_BODY).await {
159            Ok(b) => b,
160            Err(_) => return err(StatusCode::BAD_REQUEST, "failed to read request body"),
161        };
162        // Git's smart-HTTP client gzips the upload-pack request body (any
163        // normally-sized want/have negotiation) and sends `Content-Encoding:
164        // gzip`. `git upload-pack` reads its stdin as raw pkt-lines, so we must
165        // undo the transport encoding before handing the body over - otherwise
166        // it chokes on the gzip magic with "bad line length character".
167        let content_encoding = parts
168            .headers
169            .get(header::CONTENT_ENCODING)
170            .and_then(|v| v.to_str().ok());
171        let body = match decode_body(content_encoding, body, st.max_decoded_body) {
172            Ok(b) => b,
173            Err(_) => return err(StatusCode::BAD_REQUEST, "failed to decode request body"),
174        };
175        return upload_pack(st, &path, git_protocol.as_deref(), body).await;
176    }
177
178    // git-LFS uses a different HTTP API alongside the git endpoints.
179    if parts.method == Method::POST && path.ends_with(LFS_BATCH_SUFFIX) {
180        let body = match axum::body::to_bytes(body, MAX_BODY).await {
181            Ok(b) => b,
182            Err(_) => return err(StatusCode::BAD_REQUEST, "failed to read request body"),
183        };
184        return lfs_batch(st, &path, &parts.headers, body).await;
185    }
186    if parts.method == Method::GET
187        && let Some((repo_name, oid)) = repo::lfs_object_from_path(&path)
188    {
189        return lfs_object(st, repo_name, oid, &query).await;
190    }
191
192    err(StatusCode::NOT_FOUND, "not a git smart-http endpoint")
193}
194
195/// Proxy an LFS batch request to upstream, rewriting each object's download URL back
196/// to this proxy so the object fetch is cached here. An `upload` batch is refused:
197/// the proxy is read-only, like `git-receive-pack`.
198async fn lfs_batch(st: AppState, path: &str, headers: &HeaderMap, body: Bytes) -> Response {
199    let Some(name) = repo::lfs_batch_repo(path) else {
200        st.metrics
201            .record_request(RequestKind::LfsBatch, Status::Error, "-");
202        return err(StatusCode::NOT_FOUND, "bad lfs batch path");
203    };
204    if is_lfs_upload(&body) {
205        st.metrics
206            .record_request(RequestKind::LfsBatch, Status::Rejected, "-");
207        return err(
208            StatusCode::FORBIDDEN,
209            "read-only proxy: lfs upload is not allowed",
210        );
211    }
212    let advertise = advertise_base(headers);
213    match st.lfs.batch(&name, &body, &advertise).await {
214        Ok(json) => {
215            st.metrics
216                .record_request(RequestKind::LfsBatch, Status::Ok, &name);
217            Response::builder()
218                .header(header::CONTENT_TYPE, LFS_CONTENT_TYPE)
219                .header(header::CACHE_CONTROL, "no-cache")
220                .body(Body::from(json))
221                .expect("valid response")
222        }
223        Err(e) => {
224            st.metrics
225                .record_request(RequestKind::LfsBatch, Status::UpstreamError, "-");
226            tracing::warn!(repo = %name, error = %e, "lfs batch failed");
227            err(StatusCode::BAD_GATEWAY, "upstream lfs batch failed")
228        }
229    }
230}
231
232/// Serve a cached LFS object, fetching and caching it from upstream on a miss. The
233/// `repo` label is `-` because objects are content-addressed and shared across repos
234/// (the cache hit/miss is tracked separately in `lfs_objects_total`).
235async fn lfs_object(st: AppState, repo_name: String, oid: String, query: &str) -> Response {
236    let size = size_from_query(query);
237    match st.lfs.ensure_object(&repo_name, &oid, size).await {
238        Ok((path, outcome)) => {
239            st.metrics.record_lfs(match outcome {
240                Outcome::Hit => LfsResult::Hit,
241                Outcome::Miss => LfsResult::Miss,
242            });
243            match lfs_file_response(&path).await {
244                Ok(resp) => {
245                    st.metrics
246                        .record_request(RequestKind::LfsObject, Status::Ok, "-");
247                    resp
248                }
249                Err(e) => {
250                    st.metrics
251                        .record_request(RequestKind::LfsObject, Status::Error, "-");
252                    tracing::warn!(oid = %oid, error = %e, "serve cached lfs object failed");
253                    err(StatusCode::INTERNAL_SERVER_ERROR, "serve lfs object failed")
254                }
255            }
256        }
257        Err(e) => {
258            st.metrics.record_lfs(LfsResult::Error);
259            st.metrics
260                .record_request(RequestKind::LfsObject, Status::UpstreamError, "-");
261            tracing::warn!(oid = %oid, error = %e, "lfs object fetch failed");
262            err(StatusCode::BAD_GATEWAY, "upstream lfs object fetch failed")
263        }
264    }
265}
266
267/// Stream a cached LFS object file to the client with its length.
268async fn lfs_file_response(path: &Path) -> std::io::Result<Response> {
269    let file = tokio::fs::File::open(path).await?;
270    let len = file.metadata().await?.len();
271    let stream = tokio_util::io::ReaderStream::new(file);
272    Ok(Response::builder()
273        .header(header::CONTENT_TYPE, "application/octet-stream")
274        .header(header::CONTENT_LENGTH, len)
275        .body(Body::from_stream(stream))
276        .expect("valid response"))
277}
278
279/// Whether an LFS batch request asks to upload (write). Best-effort: an unparsable
280/// body is treated as not-upload and forwarded, letting upstream decide.
281fn is_lfs_upload(body: &[u8]) -> bool {
282    let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else {
283        return false;
284    };
285    v.get("operation").and_then(serde_json::Value::as_str) == Some("upload")
286}
287
288/// The `size` query parameter of an object request, which the proxy embeds in the
289/// download URLs it advertises (the upstream batch API needs it on a cache miss).
290fn size_from_query(query: &str) -> Option<u64> {
291    query
292        .split('&')
293        .find_map(|kv| kv.strip_prefix("size="))
294        .and_then(|v| v.parse().ok())
295}
296
297/// The proxy's own base URL (`scheme://host`) as the client reached it, used to
298/// rewrite LFS object download URLs back to this proxy. Honors `X-Forwarded-Proto` /
299/// `X-Forwarded-Host` from a TLS-terminating ingress and otherwise falls back to the
300/// request `Host` over plain http, which is what the proxy itself speaks.
301fn advertise_base(headers: &HeaderMap) -> String {
302    let first = |v: &axum::http::HeaderValue| {
303        v.to_str()
304            .ok()
305            .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
306    };
307    let scheme = headers
308        .get("x-forwarded-proto")
309        .and_then(first)
310        .filter(|s| !s.is_empty())
311        .unwrap_or_else(|| "http".to_string());
312    let host = headers
313        .get("x-forwarded-host")
314        .or_else(|| headers.get(header::HOST))
315        .and_then(first)
316        .unwrap_or_default();
317    format!("{scheme}://{host}")
318}
319
320async fn info_refs(st: AppState, path: &str, git_protocol: Option<&str>) -> Response {
321    let Some(name) = repo::repo_name_from_path(path, "/info/refs") else {
322        st.metrics
323            .record_request(RequestKind::InfoRefs, Status::Error, "-");
324        return err(StatusCode::NOT_FOUND, "bad path");
325    };
326    let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
327        Ok(r) => r,
328        Err(e) => {
329            st.metrics
330                .record_request(RequestKind::InfoRefs, Status::Error, "-");
331            return err(StatusCode::BAD_REQUEST, &e.to_string());
332        }
333    };
334
335    // The upstream clone/fetch counters (per repo) are recorded inside `GitCache`;
336    // here we only account for the client request. The `repo` label is emitted only
337    // once a request is served; failures use `-` so a flood of distinct but doomed
338    // repo paths cannot inflate label cardinality (see `metrics`).
339    if let Err(e) = st.cache.ensure_fresh(&repo, true).await {
340        st.metrics
341            .record_request(RequestKind::InfoRefs, Status::UpstreamError, "-");
342        tracing::warn!(repo = %name, error = %e, "ensure_fresh failed");
343        return err(StatusCode::BAD_GATEWAY, "upstream fetch failed");
344    }
345
346    match st.cache.advertise_refs(&repo, git_protocol).await {
347        Ok(body) => {
348            st.metrics
349                .record_request(RequestKind::InfoRefs, Status::Ok, &name);
350            Response::builder()
351                .header(
352                    header::CONTENT_TYPE,
353                    "application/x-git-upload-pack-advertisement",
354                )
355                .header(header::CACHE_CONTROL, "no-cache")
356                .body(Body::from(body))
357                .expect("valid response")
358        }
359        Err(e) => {
360            st.metrics
361                .record_request(RequestKind::InfoRefs, Status::Error, "-");
362            tracing::warn!(repo = %name, error = %e, "advertise_refs failed");
363            err(StatusCode::INTERNAL_SERVER_ERROR, "advertise-refs failed")
364        }
365    }
366}
367
368async fn upload_pack(
369    st: AppState,
370    path: &str,
371    git_protocol: Option<&str>,
372    body: Bytes,
373) -> Response {
374    let Some(name) = repo::repo_name_from_path(path, &format!("/{UPLOAD_PACK}")) else {
375        st.metrics
376            .record_request(RequestKind::UploadPack, Status::Error, "-");
377        return err(StatusCode::NOT_FOUND, "bad path");
378    };
379    let repo = match repo::resolve(&name, &st.upstream_base, &st.cache_root) {
380        Ok(r) => r,
381        Err(e) => {
382            st.metrics
383                .record_request(RequestKind::UploadPack, Status::Error, "-");
384            return err(StatusCode::BAD_REQUEST, &e.to_string());
385        }
386    };
387
388    // The preceding info/refs already refreshed; here just ensure the mirror is
389    // present (a client could POST against a not-yet-cloned repo).
390    if let Err(e) = st.cache.ensure_fresh(&repo, false).await {
391        st.metrics
392            .record_request(RequestKind::UploadPack, Status::UpstreamError, "-");
393        tracing::warn!(repo = %name, error = %e, "ensure mirror exists failed");
394        return err(StatusCode::BAD_GATEWAY, "upstream unavailable");
395    }
396
397    match st.cache.upload_pack_rpc(&repo, git_protocol, body).await {
398        Ok(stream) => {
399            st.metrics
400                .record_request(RequestKind::UploadPack, Status::Ok, &name);
401            Response::builder()
402                .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result")
403                .header(header::CACHE_CONTROL, "no-cache")
404                .body(Body::from_stream(stream))
405                .expect("valid response")
406        }
407        Err(e) => {
408            st.metrics
409                .record_request(RequestKind::UploadPack, Status::Error, "-");
410            tracing::warn!(repo = %name, error = %e, "upload_pack_rpc failed");
411            err(StatusCode::INTERNAL_SERVER_ERROR, "upload-pack failed")
412        }
413    }
414}
415
416/// When a serve token is configured, require `Authorization: Bearer <token>`.
417/// Returns `Some(401)` to short-circuit, `None` to allow.
418fn check_auth(st: &AppState, headers: &HeaderMap) -> Option<Response> {
419    let expected = st.serve_token.as_ref()?;
420    let provided = headers
421        .get(header::AUTHORIZATION)
422        .and_then(|v| v.to_str().ok())
423        .and_then(|v| v.strip_prefix("Bearer "));
424    if provided.is_some_and(|t| token_matches(t, expected)) {
425        None
426    } else {
427        Some(err(
428            StatusCode::UNAUTHORIZED,
429            "missing or invalid bearer token",
430        ))
431    }
432}
433
434/// Compare a client-supplied bearer token against the expected one in constant
435/// time, so response latency does not leak how many leading bytes matched. Only
436/// the length can differ observably, which is not sensitive for a shared secret.
437fn token_matches(provided: &str, expected: &str) -> bool {
438    provided.as_bytes().ct_eq(expected.as_bytes()).into()
439}
440
441/// Undo the request's `Content-Encoding`. Git only ever gzips, so that is the
442/// single encoding we decode; an absent/`identity` header passes through
443/// untouched, and any other encoding is rejected by the caller as a bad body.
444///
445/// The decoded size is capped at `max_decoded`: DEFLATE reaches ~1000:1, so an
446/// unbounded read here would let a small compressed body expand into an
447/// out-of-memory kill (a decompression bomb). We read at most `max_decoded + 1`
448/// bytes so we can tell "exactly at the limit" from "over it" and reject the
449/// latter.
450fn decode_body(
451    content_encoding: Option<&str>,
452    body: Bytes,
453    max_decoded: usize,
454) -> std::io::Result<Bytes> {
455    match content_encoding.map(str::trim) {
456        Some(enc) if enc.eq_ignore_ascii_case("gzip") || enc.eq_ignore_ascii_case("x-gzip") => {
457            let mut out = Vec::new();
458            let limit = max_decoded as u64 + 1;
459            flate2::read::GzDecoder::new(&body[..])
460                .take(limit)
461                .read_to_end(&mut out)?;
462            within_limit(Bytes::from(out), max_decoded)
463        }
464        // Uncompressed: no expansion risk, but still enforce the cap so
465        // `--max-decoded-body-mb` bounds an identity request the same as a gzipped
466        // one - otherwise only the coarser transport cap (`MAX_BODY`) would apply.
467        None => within_limit(body, max_decoded),
468        Some(enc) if enc.is_empty() || enc.eq_ignore_ascii_case("identity") => {
469            within_limit(body, max_decoded)
470        }
471        Some(other) => Err(std::io::Error::new(
472            std::io::ErrorKind::InvalidData,
473            format!("unsupported content-encoding: {other}"),
474        )),
475    }
476}
477
478/// Reject a decoded body that exceeds the configured cap. Shared by every encoding
479/// branch so the limit is enforced uniformly, not just when decoding gzip.
480fn within_limit(body: Bytes, max_decoded: usize) -> std::io::Result<Bytes> {
481    if body.len() > max_decoded {
482        return Err(std::io::Error::new(
483            std::io::ErrorKind::InvalidData,
484            "decoded request body exceeds limit",
485        ));
486    }
487    Ok(body)
488}
489
490fn err(status: StatusCode, msg: &str) -> Response {
491    (status, format!("{msg}\n")).into_response()
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use std::io::Write;
498
499    fn gzip(bytes: &[u8]) -> Bytes {
500        let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
501        enc.write_all(bytes).unwrap();
502        Bytes::from(enc.finish().unwrap())
503    }
504
505    #[test]
506    fn identity_and_absent_encoding_pass_through() {
507        let raw = Bytes::from_static(b"want ...\n");
508        assert_eq!(decode_body(None, raw.clone(), 1024).unwrap(), raw);
509        assert_eq!(
510            decode_body(Some("identity"), raw.clone(), 1024).unwrap(),
511            raw
512        );
513        assert_eq!(decode_body(Some(""), raw.clone(), 1024).unwrap(), raw);
514    }
515
516    #[test]
517    fn identity_body_over_limit_is_rejected() {
518        // An uncompressed body must honor the decoded-body cap too, not just the
519        // gzip path. Exactly at the limit is accepted; one byte over is rejected.
520        let body = Bytes::from(vec![b'x'; 2048]);
521        for enc in [None, Some("identity"), Some("")] {
522            assert!(decode_body(enc, body.clone(), 2048).is_ok());
523            assert!(decode_body(enc, body.clone(), 2047).is_err());
524        }
525    }
526
527    #[test]
528    fn gzip_within_limit_decodes() {
529        let payload = b"command=ls-refs\n";
530        let decoded = decode_body(Some("gzip"), gzip(payload), 1024).unwrap();
531        assert_eq!(&decoded[..], payload);
532        // Case-insensitive and the `x-gzip` alias both decode.
533        assert_eq!(
534            &decode_body(Some("GZIP"), gzip(payload), 1024).unwrap()[..],
535            payload
536        );
537        assert_eq!(
538            &decode_body(Some("x-gzip"), gzip(payload), 1024).unwrap()[..],
539            payload
540        );
541    }
542
543    #[test]
544    fn gzip_decompression_bomb_is_rejected() {
545        // 1 MiB of zeros compresses to ~1 KiB but must not be allowed to expand
546        // past the cap. Exactly-at-limit is accepted; one byte over is rejected.
547        let big = vec![0u8; 1024 * 1024];
548        assert!(decode_body(Some("gzip"), gzip(&big), 1024).is_err());
549        assert!(decode_body(Some("gzip"), gzip(&big), big.len()).is_ok());
550        assert!(decode_body(Some("gzip"), gzip(&big), big.len() - 1).is_err());
551    }
552
553    #[test]
554    fn unsupported_encoding_is_rejected() {
555        assert!(decode_body(Some("br"), Bytes::from_static(b"x"), 1024).is_err());
556        assert!(decode_body(Some("deflate"), Bytes::from_static(b"x"), 1024).is_err());
557    }
558
559    #[test]
560    fn token_matches_only_the_exact_token() {
561        assert!(token_matches("s3cret", "s3cret"));
562        assert!(!token_matches("s3creT", "s3cret")); // last byte differs
563        assert!(!token_matches("s3cre", "s3cret")); // prefix, shorter
564        assert!(!token_matches("s3cret-extra", "s3cret")); // longer
565        assert!(!token_matches("", "s3cret"));
566        assert!(token_matches("", "")); // degenerate empty token
567    }
568
569    #[test]
570    fn advertise_base_uses_forwarded_headers_then_host() {
571        use axum::http::HeaderValue;
572
573        let mut h = HeaderMap::new();
574        h.insert(header::HOST, HeaderValue::from_static("svc.local:8080"));
575        assert_eq!(advertise_base(&h), "http://svc.local:8080");
576        // A TLS-terminating ingress advertises via X-Forwarded-*.
577        h.insert("x-forwarded-proto", HeaderValue::from_static("https"));
578        h.insert(
579            "x-forwarded-host",
580            HeaderValue::from_static("proxy.example"),
581        );
582        assert_eq!(advertise_base(&h), "https://proxy.example");
583        // A comma-listed forwarded chain uses the first hop.
584        h.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
585        assert_eq!(advertise_base(&h), "https://proxy.example");
586    }
587
588    #[test]
589    fn size_from_query_parses_only_a_valid_size() {
590        assert_eq!(size_from_query("size=42"), Some(42));
591        assert_eq!(size_from_query("a=1&size=7&b=2"), Some(7));
592        assert_eq!(size_from_query(""), None);
593        assert_eq!(size_from_query("size=notanumber"), None);
594    }
595
596    #[test]
597    fn is_lfs_upload_detects_the_operation() {
598        assert!(is_lfs_upload(br#"{"operation":"upload","objects":[]}"#));
599        assert!(!is_lfs_upload(br#"{"operation":"download"}"#));
600        assert!(!is_lfs_upload(b"not json")); // unparsable -> forwarded, not upload
601        assert!(!is_lfs_upload(b"{}"));
602    }
603}