Skip to main content

netget/server/git/
mod.rs

1//! Git Smart HTTP server implementation
2//!
3//! Serves virtual repositories over Git's Smart HTTP transport. The LLM (or a script/static
4//! handler) supplies the repository *content*; this module compiles it into real Git objects
5//! and frames them for the wire.
6//!
7//! Protocol URLs:
8//! - GET  /<repo>/info/refs?service=git-upload-pack  - Reference discovery
9//! - POST /<repo>/git-upload-pack                    - Object transfer
10//!
11//! Read-only (clone/fetch). There is no `git-receive-pack` endpoint, so pushes are refused.
12//!
13//! Both endpoints raise an [`Event`] through [`call_llm`], which means script and static
14//! `event_handlers` work here exactly as they do for every other protocol - and a static
15//! handler is the only way to guarantee the two requests of a clone see the same snapshot.
16
17pub mod actions;
18pub mod pack;
19pub mod pktline;
20
21use std::convert::Infallible;
22use std::net::SocketAddr;
23use std::sync::Arc;
24
25use bytes::Bytes;
26use http_body_util::{BodyExt, Full};
27use hyper::body::Incoming;
28use hyper::server::conn::http1;
29use hyper::service::service_fn;
30use hyper::{Method, Request, Response, StatusCode};
31use hyper_util::rt::TokioIo;
32use serde_json::Value;
33use tokio::sync::mpsc;
34use tracing::{debug, error};
35
36use crate::llm::action_helper::call_llm;
37use crate::llm::actions::protocol_trait::ActionResult;
38use crate::llm::ollama_client::OllamaClient;
39use crate::logging::emit::Log;
40use crate::protocol::Event;
41use crate::server::connection::ConnectionId;
42use crate::server::git::actions::{
43    GitProtocol, DEFAULT_COMMIT_TIMESTAMP, GIT_INFO_REFS_EVENT, GIT_UPLOAD_PACK_EVENT,
44};
45use crate::server::git::pack::{build_repo, hex_id, write_pack, BuiltRepo, CommitMeta, RepoFile};
46use crate::state::app_state::AppState;
47use crate::utils::WireFailure;
48
49/// Capabilities advertised on the first ref line.
50///
51/// Deliberately minimal. `side-band-64k` is *not* offered: the multiplexed form is only
52/// correct if the server can read which capabilities the client selected, and git compresses
53/// the `git-upload-pack` request body, which this server cannot always decompress. Refusing
54/// the capability keeps every response in the one framing that is always right, at the cost of
55/// no progress or error side-channel during transfer.
56const BASE_CAPABILITIES: &str = "no-progress agent=netget";
57
58/// Largest `POST /git-upload-pack` body this server will buffer, in bytes.
59///
60/// The body is a list of `want`/`have` lines and is tiny in practice - a clone of a
61/// single-commit repository sends a few hundred bytes. An unbounded `collect()` lets one
62/// unauthenticated client grow the process by whatever it cares to send, so the read is
63/// capped and an oversized body is refused with 413.
64pub const MAX_UPLOAD_PACK_BYTES: usize = 1024 * 1024;
65
66/// How long to wait for a peer's first byte after it connects.
67///
68/// Smart HTTP is client-speaks-first — the server says nothing until it has a request line —
69/// so a peer that has connected and sent nothing has begun no request at all, which is the
70/// state an unauthenticated flood lives in. Enforced with `TcpStream::peek` *before* the socket
71/// reaches hyper, so the request line is still there for hyper to parse afterwards.
72const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
73
74/// How long a connection may do nothing at all between requests.
75///
76/// **This bounds the silence, not the transfer.** A clone is two requests on one keep-alive
77/// connection and the second one carries the whole pack; that pack is built into a
78/// `Full<Bytes>` before the response is returned, so hyper writes an already-complete body and
79/// a slow reader is draining bytes rather than idling. The watchdog reads `ConnectionActivity`,
80/// which reports a connection with a request in flight as **not idle at all**, so building a
81/// repository — including an LLM round-trip, or a `manual` rule parked for a human — can never
82/// close the connection it is an answer for.
83///
84/// Fifteen minutes is the wait between one request finishing and the next one starting. git
85/// itself sets no keep-alive interval and curl's default idle reuse is far shorter; a git
86/// client that has taken fifteen minutes to decide on its next request has gone away.
87const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
88
89/// Concurrent connections this server admits.
90///
91/// Each admitted connection may buffer a request body of up to [`MAX_UPLOAD_PACK_BYTES`] and
92/// build a repository of whatever size the handler describes, so the cap is what turns that
93/// per-connection bound into a total one.
94const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
95
96/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
97///
98/// Smart HTTP *is* HTTP, so the protocol's own vocabulary for "come back later" is a `503` with
99/// `Retry-After` — which `git` surfaces as `unable to access … The requested URL returned
100/// error: 503` rather than as an unexplained connection reset.
101const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
102    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
103
104/// Shared per-request context, so each handler does not take nine positional arguments.
105struct RequestContext {
106    llm_client: OllamaClient,
107    app_state: Arc<AppState>,
108    status_tx: mpsc::UnboundedSender<String>,
109    protocol: Arc<GitProtocol>,
110    connection_id: ConnectionId,
111    server_id: crate::state::ServerId,
112    remote_addr: SocketAddr,
113    default_branch: String,
114}
115
116/// Git Smart HTTP server
117pub struct GitServer;
118
119impl GitServer {
120    /// Spawn the Git server with integrated LLM actions
121    pub async fn spawn_with_llm_actions(
122        listen_addr: SocketAddr,
123        llm_client: OllamaClient,
124        app_state: Arc<AppState>,
125        status_tx: mpsc::UnboundedSender<String>,
126        default_branch: String,
127        server_id: crate::state::ServerId,
128    ) -> anyhow::Result<SocketAddr> {
129        let listener =
130            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
131        let local_addr = listener.local_addr()?;
132        Log::new(Some(&status_tx)).info(format!("Git server listening on {}", local_addr));
133
134        let protocol = Arc::new(GitProtocol::new());
135
136        // Spawn server loop
137        let task_registrar = app_state.clone();
138        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
139        let accept_handle = tokio::spawn(async move {
140            loop {
141                match crate::server::accept_bounded::accept_bounded(
142                    &listener,
143                    &limiter,
144                    CONNECTION_CAP_REFUSAL,
145                    "Git",
146                    Some(&status_tx),
147                )
148                .await
149                {
150                    Ok((stream, remote_addr, permit)) => {
151                        let connection_id =
152                            ConnectionId::new(app_state.get_next_unified_id().await);
153                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
154                        Log::new(Some(&status_tx))
155                            .info(format!("Git connection from {}", remote_addr));
156
157                        // Add connection to ServerInstance
158                        use crate::state::server::{
159                            ConnectionState as ServerConnectionState, ConnectionStatus,
160                            ProtocolConnectionInfo,
161                        };
162                        let now = crate::utils::clock::Instant::now();
163                        let conn_state = ServerConnectionState {
164                            id: connection_id,
165                            remote_addr,
166                            local_addr: local_addr_conn,
167                            bytes_sent: 0,
168                            bytes_received: 0,
169                            packets_sent: 0,
170                            packets_received: 0,
171                            last_activity: now,
172                            status: ConnectionStatus::Active,
173                            status_changed_at: now,
174                            protocol_info: ProtocolConnectionInfo::empty(),
175                        };
176                        app_state
177                            .add_connection_to_server(server_id, conn_state)
178                            .await;
179                        let _ = status_tx.send("__UPDATE_UI__".to_string());
180
181                        let llm_client_clone = llm_client.clone();
182                        let app_state_clone = app_state.clone();
183                        let status_tx_clone = status_tx.clone();
184                        let protocol_clone = protocol.clone();
185                        let default_branch_clone = default_branch.clone();
186
187                        // Spawn a task to handle this connection
188                        // Tracked, not detached: stop_server must abort this task too.
189                        let task_owner = app_state.clone();
190                        task_owner
191                            .spawn_server_task(server_id, async move {
192                                // Held for the life of the connection: dropping it early
193                                // releases the slot while the peer is still here, which
194                                // silently un-caps the server.
195                                let _permit = permit;
196
197                                // First-byte bound, before hyper sees the socket. hyper owns
198                                // every read once `serve_connection` starts and keeps polling
199                                // for frames *while a request is being answered*, so a deadline
200                                // on reads would fire in the middle of an LLM round-trip.
201                                // `peek` waits for data without consuming it, so the request
202                                // line is still there for hyper afterwards.
203                                match tokio::time::timeout(
204                                    FIRST_BYTE_READ_TIMEOUT,
205                                    stream.peek(&mut [0u8; 1]),
206                                )
207                                .await
208                                {
209                                    Ok(Ok(0)) | Ok(Err(_)) => {
210                                        app_state_clone
211                                            .close_connection_on_server(server_id, connection_id)
212                                            .await;
213                                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
214                                        return;
215                                    }
216                                    Ok(Ok(_)) => {}
217                                    Err(_) => {
218                                        Log::new(Some(&status_tx_clone)).debug(format!(
219                                            "Git peer {} sent nothing for {}s; closing before \
220                                             the request line",
221                                            remote_addr,
222                                            FIRST_BYTE_READ_TIMEOUT.as_secs()
223                                        ));
224                                        app_state_clone
225                                            .close_connection_on_server(server_id, connection_id)
226                                            .await;
227                                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
228                                        return;
229                                    }
230                                }
231
232                                let io = TokioIo::new(stream);
233
234                                // Clone for service closure
235                                let status_for_service = status_tx_clone.clone();
236                                let app_state_for_service = app_state_clone.clone();
237
238                                // Tracks whether this connection is answering anything. A
239                                // request waiting on the model, or parked for a human at the
240                                // dashboard, holds the count above zero, so the idle watchdog
241                                // below cannot close the connection the answer belongs to
242                                // however long it takes — only genuine silence counts.
243                                let activity = Arc::new(
244                                    crate::server::accept_bounded::ConnectionActivity::new(),
245                                );
246                                let activity_for_service = Arc::clone(&activity);
247
248                                // Create a service that handles Git Smart HTTP requests with LLM
249                                let service = service_fn(move |req: Request<Incoming>| {
250                                    let ctx = RequestContext {
251                                        llm_client: llm_client_clone.clone(),
252                                        app_state: app_state_for_service.clone(),
253                                        status_tx: status_for_service.clone(),
254                                        protocol: protocol_clone.clone(),
255                                        connection_id,
256                                        server_id,
257                                        remote_addr,
258                                        default_branch: default_branch_clone.clone(),
259                                    };
260                                    let activity = Arc::clone(&activity_for_service);
261                                    async move {
262                                        let _busy = activity.busy();
263                                        handle_git_request(req, ctx).await
264                                    }
265                                });
266
267                                // Serve HTTP/1 on this connection
268                                let conn = http1::Builder::new().serve_connection(io, service);
269                                tokio::pin!(conn);
270                                tokio::select! {
271                                    result = &mut conn => {
272                                        if let Err(err) = result {
273                                            error!("Error serving Git connection: {:?}", err);
274                                        }
275                                    }
276                                    _ = crate::server::accept_bounded::watch_idle(
277                                        Arc::clone(&activity),
278                                        IDLE_BETWEEN_REQUESTS_TIMEOUT,
279                                    ) => {
280                                        debug!(
281                                            "Git connection {} idle for {}s; closing",
282                                            connection_id,
283                                            IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
284                                        );
285                                    }
286                                }
287
288                                // Mark connection as closed
289                                app_state_clone
290                                    .close_connection_on_server(server_id, connection_id)
291                                    .await;
292                                Log::new(Some(&status_tx_clone))
293                                    .info(format!("Git connection {} closed", connection_id));
294                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
295                            })
296                            .await;
297                    }
298                    Err(e) => {
299                        Log::new(Some(&status_tx))
300                            .error(format!("Failed to accept Git connection: {}", e));
301                        break;
302                    }
303                }
304            }
305        });
306
307        task_registrar
308            .register_server_task(server_id, accept_handle)
309            .await;
310
311        Ok(local_addr)
312    }
313}
314
315/// Handle a Git Smart HTTP request
316async fn handle_git_request(
317    req: Request<Incoming>,
318    ctx: RequestContext,
319) -> Result<Response<Full<Bytes>>, Infallible> {
320    let method = req.method().clone();
321    let uri = req.uri().clone();
322    let path = uri.path().to_string();
323    let user_agent = req
324        .headers()
325        .get(hyper::header::USER_AGENT)
326        .and_then(|v| v.to_str().ok())
327        .unwrap_or("")
328        .to_string();
329
330    Log::new(Some(&ctx.status_tx)).debug(format!("Git {} {}", method, path));
331
332    // Parse repository name from path
333    // Format: /<repo>/info/refs or /<repo>/git-upload-pack
334    let repo = parse_repo_name(&path);
335
336    // Track repository access
337    track_repo_access(&ctx.app_state, ctx.server_id, ctx.connection_id, &repo).await;
338
339    // Route based on path
340    match (&method, path.as_str()) {
341        // Reference discovery: GET /info/refs?service=git-upload-pack
342        (&Method::GET, p) if p.ends_with("/info/refs") => {
343            let query = uri.query().unwrap_or("");
344            if query.contains("service=git-upload-pack") {
345                Ok(handle_info_refs(&ctx, &repo, &user_agent).await)
346            } else if query.contains("service=git-receive-pack") {
347                // Be explicit: this is a read-only server, not a broken one.
348                Ok(build_error_response(
349                    StatusCode::FORBIDDEN,
350                    "Push is not supported: this server implements git-upload-pack only",
351                ))
352            } else {
353                // Dumb HTTP protocol not supported
354                Ok(build_error_response(
355                    StatusCode::FORBIDDEN,
356                    "Dumb HTTP protocol not supported, use Smart HTTP (git-upload-pack service)",
357                ))
358            }
359        }
360
361        // Object transfer: POST /git-upload-pack
362        (&Method::POST, p) if p.ends_with("/git-upload-pack") => {
363            let content_encoding = req
364                .headers()
365                .get(hyper::header::CONTENT_ENCODING)
366                .and_then(|v| v.to_str().ok())
367                .unwrap_or("")
368                .to_string();
369
370            // Bounded read: `Limited` errors as soon as the cap is passed rather than after
371            // buffering the whole thing, so an oversized body costs at most the cap.
372            let limited = http_body_util::Limited::new(req.into_body(), MAX_UPLOAD_PACK_BYTES);
373            let body_bytes = match limited.collect().await {
374                Ok(collected) => collected.to_bytes(),
375                Err(e) => {
376                    Log::new(Some(&ctx.status_tx)).error(format!(
377                        "Git refusing upload-pack body ({}); limit is {} bytes",
378                        e, MAX_UPLOAD_PACK_BYTES
379                    ));
380                    return Ok(build_error_response(
381                        StatusCode::PAYLOAD_TOO_LARGE,
382                        "git-upload-pack request body is too large",
383                    ));
384                }
385            };
386
387            Log::new(Some(&ctx.status_tx)).trace(format!(
388                "Git upload-pack request body ({} bytes, encoding {:?})",
389                body_bytes.len(),
390                content_encoding
391            ));
392
393            Ok(handle_upload_pack(&ctx, &repo, &body_bytes, &content_encoding).await)
394        }
395
396        // Push endpoint: answer honestly rather than 404.
397        (_, p) if p.ends_with("/git-receive-pack") => Ok(build_error_response(
398            StatusCode::FORBIDDEN,
399            "Push is not supported: this server implements git-upload-pack only",
400        )),
401
402        // Unsupported endpoint
403        _ => Ok(build_error_response(
404            StatusCode::NOT_FOUND,
405            &format!("Endpoint not found: {} {}", method, path),
406        )),
407    }
408}
409
410/// Parse repository name from URL path
411fn parse_repo_name(path: &str) -> String {
412    // Path formats:
413    // /repo-name/info/refs
414    // /repo-name/git-upload-pack
415    // /info/refs (root repository)
416    let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
417
418    match parts.first() {
419        None | Some(&"") | Some(&"info") | Some(&"git-upload-pack") | Some(&"git-receive-pack") => {
420            "default".to_string()
421        }
422        Some(name) => (*name).to_string(),
423    }
424}
425
426/// Handle GET /info/refs?service=git-upload-pack
427async fn handle_info_refs(
428    ctx: &RequestContext,
429    repo: &str,
430    user_agent: &str,
431) -> Response<Full<Bytes>> {
432    let event = Event::new(
433        &GIT_INFO_REFS_EVENT,
434        serde_json::json!({
435            "repository": repo,
436            "user_agent": user_agent,
437            "client_ip": ctx.remote_addr.ip().to_string(),
438        }),
439    );
440
441    let outcome = match resolve_repository(ctx, &event).await {
442        Ok(outcome) => outcome,
443        Err(response) => return response,
444    };
445
446    let repo_build = match outcome {
447        Outcome::Repository(build) => build,
448        Outcome::Error(response) => return response,
449    };
450
451    let body = build_refs_advertisement(&repo_build);
452    Log::new(Some(&ctx.status_tx)).info(format!(
453        "Git advertised {} at {} for '{}'",
454        repo_build.branch,
455        &repo_build.commit_hex()[..8],
456        repo
457    ));
458    record_bytes_sent(ctx, body.len()).await;
459
460    Response::builder()
461        .status(StatusCode::OK)
462        .header(
463            "Content-Type",
464            "application/x-git-upload-pack-advertisement",
465        )
466        .header("Cache-Control", "no-cache")
467        .body(Full::new(Bytes::from(body)))
468        .expect("static header values are valid")
469}
470
471/// Handle POST /git-upload-pack
472async fn handle_upload_pack(
473    ctx: &RequestContext,
474    repo: &str,
475    body: &[u8],
476    content_encoding: &str,
477) -> Response<Full<Bytes>> {
478    record_bytes_received(ctx, body.len()).await;
479
480    // git compresses small RPC bodies. Without a decompressor the wants cannot be read; the
481    // request is still answered, because for a single-commit repository the answer does not
482    // depend on them - but say so rather than silently reporting "no wants" to the model.
483    let compressed = !content_encoding.is_empty() && content_encoding != "identity";
484    if compressed {
485        debug!(
486            "Git upload-pack body uses Content-Encoding: {} - negotiation details unavailable",
487            content_encoding
488        );
489    }
490
491    let request = if compressed {
492        pktline::UploadPackRequest::default()
493    } else {
494        pktline::parse_upload_pack_request(body)
495    };
496
497    // A negotiation round that sends `have` lines without `done` expects acknowledgements
498    // only. Answering it with a pack would desynchronise the client.
499    if !request.done && !request.haves.is_empty() {
500        debug!(
501            "Git upload-pack negotiation round ({} haves, no done) - replying NAK",
502            request.haves.len()
503        );
504        let mut body = Vec::new();
505        body.extend_from_slice(&pktline::encode(b"NAK\n"));
506        return upload_pack_response(ctx, body).await;
507    }
508
509    let event = Event::new(
510        &GIT_UPLOAD_PACK_EVENT,
511        serde_json::json!({
512            "repository": repo,
513            "wants": request.wants,
514            "haves": request.haves,
515            "capabilities": request.capabilities,
516            "client_ip": ctx.remote_addr.ip().to_string(),
517        }),
518    );
519
520    let outcome = match resolve_repository(ctx, &event).await {
521        Ok(outcome) => outcome,
522        Err(response) => return response,
523    };
524
525    let repo_build = match outcome {
526        Outcome::Repository(build) => build,
527        Outcome::Error(response) => return response,
528    };
529
530    let commit_hex = repo_build.commit_hex();
531    if !request.wants.is_empty() && !request.wants.contains(&commit_hex) {
532        // The two halves of the clone disagreed. Say exactly why, because the client-side
533        // error ("did not send all necessary objects") gives no hint.
534        Log::new(Some(&ctx.status_tx)).error(format!(
535            "Git '{}': advertised commit and packed commit differ ({} vs {}); clone will \
536             fail. The git_info_refs and git_upload_pack events returned different repository \
537             content. Pin the repository content with a static event handler.",
538            repo,
539            request.wants.first().map(|w| &w[..8]).unwrap_or("?"),
540            &commit_hex[..8]
541        ));
542    }
543
544    let pack = write_pack(&repo_build.objects);
545    debug!(
546        "Git pack for '{}': {} objects, {} bytes, commit {}",
547        repo,
548        repo_build.objects.len(),
549        pack.len(),
550        commit_hex
551    );
552
553    let mut body = Vec::with_capacity(pack.len() + 16);
554    body.extend_from_slice(&pktline::encode(b"NAK\n"));
555    body.extend_from_slice(&pack);
556
557    Log::new(Some(&ctx.status_tx)).info(format!(
558        "Git sent pack for '{}' ({} objects, {} bytes)",
559        repo,
560        repo_build.objects.len(),
561        pack.len()
562    ));
563
564    upload_pack_response(ctx, body).await
565}
566
567async fn upload_pack_response(ctx: &RequestContext, body: Vec<u8>) -> Response<Full<Bytes>> {
568    record_bytes_sent(ctx, body.len()).await;
569    Response::builder()
570        .status(StatusCode::OK)
571        .header("Content-Type", "application/x-git-upload-pack-result")
572        .header("Cache-Control", "no-cache")
573        .body(Full::new(Bytes::from(body)))
574        .expect("static header values are valid")
575}
576
577/// What the model decided to do with a request.
578enum Outcome {
579    Repository(BuiltRepo),
580    Error(Response<Full<Bytes>>),
581}
582
583/// Raise the event, then turn whatever the handler or model produced into a repository.
584///
585/// Three failure shapes are kept apart by a `decision=` tag, as in `src/server/radius/`:
586/// the call itself failed (`fail_closed_overloaded` / `fail_closed_llm_error`), the model
587/// answered with a refusal (`model_reject`), or the model answered nothing this endpoint
588/// can use (`fail_closed_no_action`). The peer is told only a
589/// [`WireFailure`] category — 503 + `Retry-After` when the backend is saturated so git
590/// backs off, 500 otherwise — never the error text, which names the backend, the model and
591/// our own retry machinery.
592async fn resolve_repository(
593    ctx: &RequestContext,
594    event: &Event,
595) -> Result<Outcome, Response<Full<Bytes>>> {
596    let execution_result = match call_llm(
597        &ctx.llm_client,
598        &ctx.app_state,
599        ctx.server_id,
600        Some(ctx.connection_id),
601        event,
602        ctx.protocol.as_ref(),
603    )
604    .await
605    {
606        Ok(result) => result,
607        Err(e) => {
608            let failure = WireFailure::classify(&e);
609            let decision = if failure.is_overloaded() {
610                "fail_closed_overloaded"
611            } else {
612                "fail_closed_llm_error"
613            };
614            Log::new(Some(&ctx.status_tx)).error(format!(
615                "Git {} decision={} error: {}",
616                event.id(),
617                decision,
618                e
619            ));
620            return Err(if failure.is_overloaded() {
621                build_retryable_error_response(failure.text())
622            } else {
623                build_error_response(StatusCode::INTERNAL_SERVER_ERROR, failure.text())
624            });
625        }
626    };
627
628    for message in &execution_result.messages {
629        Log::new(Some(&ctx.status_tx)).info(format!("{}", message));
630    }
631
632    for result in execution_result.protocol_results {
633        match result {
634            ActionResult::Custom { name, data } if name == "git_repository_response" => {
635                match snapshot_to_repo(&data, &ctx.default_branch) {
636                    Ok(build) => return Ok(Outcome::Repository(build)),
637                    Err(e) => {
638                        Log::new(Some(&ctx.status_tx))
639                            .error(format!("Git: invalid repository description: {}", e));
640                        return Err(build_error_response(
641                            StatusCode::INTERNAL_SERVER_ERROR,
642                            &format!("Invalid repository description: {}", e),
643                        ));
644                    }
645                }
646            }
647            ActionResult::Custom { name, data } if name == "git_error_response" => {
648                let message = data
649                    .get("message")
650                    .and_then(|v| v.as_str())
651                    .unwrap_or("Error");
652                let code = data.get("code").and_then(|v| v.as_u64()).unwrap_or(500) as u16;
653                // The model answered, and its answer was a refusal — distinct in the log
654                // from both "answered nothing" and "the call errored".
655                Log::new(Some(&ctx.status_tx)).info(format!(
656                    "Git {} decision=model_reject code={}",
657                    event.id(),
658                    code
659                ));
660                return Ok(Outcome::Error(build_error_response(
661                    StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
662                    message,
663                )));
664            }
665            _ => {}
666        }
667    }
668
669    // The call succeeded and the answer contained neither a repository nor a refusal.
670    Log::new(Some(&ctx.status_tx)).warn(format!(
671        "Git {} decision=fail_closed_no_action (expected git_repository or git_error)",
672        event.id()
673    ));
674    Err(build_error_response(
675        StatusCode::INTERNAL_SERVER_ERROR,
676        WireFailure::Unavailable.text(),
677    ))
678}
679
680/// Turn the `git_repository` action payload into Git objects.
681fn snapshot_to_repo(data: &Value, default_branch: &str) -> anyhow::Result<BuiltRepo> {
682    let branch = data
683        .get("branch")
684        .and_then(|v| v.as_str())
685        .filter(|b| !b.is_empty())
686        .unwrap_or(default_branch);
687
688    let mut files = Vec::new();
689    if let Some(entries) = data.get("files").and_then(|v| v.as_array()) {
690        for entry in entries {
691            let path = entry
692                .get("path")
693                .and_then(|v| v.as_str())
694                .ok_or_else(|| anyhow::anyhow!("A file entry has no 'path'"))?;
695            let content = entry
696                .get("content")
697                .and_then(|v| v.as_str())
698                .unwrap_or("")
699                .to_string();
700            let executable = entry
701                .get("executable")
702                .and_then(|v| v.as_bool())
703                .unwrap_or(false);
704            files.push(RepoFile {
705                path: path.to_string(),
706                content: content.into_bytes(),
707                executable,
708            });
709        }
710    }
711
712    let meta = CommitMeta {
713        message: data
714            .get("commit_message")
715            .and_then(|v| v.as_str())
716            .unwrap_or("Initial commit")
717            .to_string(),
718        author_name: data
719            .get("author_name")
720            .and_then(|v| v.as_str())
721            .unwrap_or("NetGet")
722            .to_string(),
723        author_email: data
724            .get("author_email")
725            .and_then(|v| v.as_str())
726            .unwrap_or("netget@localhost")
727            .to_string(),
728        timestamp: data
729            .get("timestamp")
730            .and_then(|v| v.as_i64())
731            .unwrap_or(DEFAULT_COMMIT_TIMESTAMP),
732    };
733
734    build_repo(branch, &files, &meta)
735}
736
737/// Build the pkt-line reference advertisement for `GET /info/refs`.
738fn build_refs_advertisement(repo: &BuiltRepo) -> Vec<u8> {
739    let sha = hex_id(&repo.commit_id);
740    let branch_ref = format!("refs/heads/{}", repo.branch);
741
742    let mut out = Vec::new();
743    out.extend_from_slice(&pktline::encode(b"# service=git-upload-pack\n"));
744    out.extend_from_slice(pktline::FLUSH);
745
746    // The first ref line carries the capability list after a NUL. symref tells the client
747    // which branch HEAD points at, without which it warns and checks out nothing.
748    let capabilities = format!("{BASE_CAPABILITIES} symref=HEAD:{branch_ref}");
749    out.extend_from_slice(&pktline::encode(
750        format!("{sha} HEAD\0{capabilities}\n").as_bytes(),
751    ));
752    out.extend_from_slice(&pktline::encode(format!("{sha} {branch_ref}\n").as_bytes()));
753    out.extend_from_slice(pktline::FLUSH);
754    out
755}
756
757/// Track repository access in connection state
758async fn track_repo_access(
759    app_state: &Arc<AppState>,
760    server_id: crate::state::ServerId,
761    connection_id: ConnectionId,
762    repo_name: &str,
763) {
764    app_state
765        .with_server_mut(server_id, |server| {
766            if let Some(conn) = server.connections.get_mut(&connection_id) {
767                if let Some(obj) = conn.protocol_info.data.as_object_mut() {
768                    let mut recent_repos: Vec<String> = obj
769                        .get("recent_repos")
770                        .and_then(|v| serde_json::from_value(v.clone()).ok())
771                        .unwrap_or_default();
772                    if !recent_repos.contains(&repo_name.to_string()) {
773                        recent_repos.push(repo_name.to_string());
774                        // Keep only last 10 repos
775                        if recent_repos.len() > 10 {
776                            recent_repos.remove(0);
777                        }
778                    }
779                    obj.insert(
780                        "recent_repos".to_string(),
781                        serde_json::to_value(&recent_repos).unwrap_or(serde_json::json!([])),
782                    );
783                }
784            }
785        })
786        .await;
787}
788
789async fn record_bytes_sent(ctx: &RequestContext, len: usize) {
790    ctx.app_state
791        .update_connection_stats(
792            ctx.server_id,
793            ctx.connection_id,
794            None,
795            Some(len as u64),
796            None,
797            Some(1),
798        )
799        .await;
800}
801
802async fn record_bytes_received(ctx: &RequestContext, len: usize) {
803    ctx.app_state
804        .update_connection_stats(
805            ctx.server_id,
806            ctx.connection_id,
807            Some(len as u64),
808            None,
809            Some(1),
810            None,
811        )
812        .await;
813}
814
815/// Build an HTTP error response
816fn build_error_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
817    Response::builder()
818        .status(status)
819        .header("Content-Type", "text/plain")
820        .body(Full::new(Bytes::from(format!("Error: {}\n", message))))
821        .expect("static header values are valid")
822}
823
824/// A retryable failure: 503 with `Retry-After`, so git reports "server unavailable, retry"
825/// rather than recording a permanent fault as a bare 500 would. Same shape as
826/// `src/server/http_common/handler.rs` and `src/server/mercurial/mod.rs`.
827fn build_retryable_error_response(message: &str) -> Response<Full<Bytes>> {
828    Response::builder()
829        .status(StatusCode::SERVICE_UNAVAILABLE)
830        .header("Content-Type", "text/plain")
831        .header(hyper::header::RETRY_AFTER, "1")
832        .body(Full::new(Bytes::from(format!("Error: {}\n", message))))
833        .expect("static header values are valid")
834}