Skip to main content

netget/server/oci_registry/
mod.rs

1//! OCI Distribution Specification v1.1 registry server (pull path).
2//!
3//! Speaks the container-registry API that Docker Hub, GHCR, ECR and friends speak,
4//! with the model deciding which repositories exist, what each manifest contains,
5//! and which layers a client is told to fetch.
6//!
7//! Every `sha256:` value on the wire is computed here over the exact bytes being
8//! sent — see the module docs in [`actions`] for how that is reconciled with
9//! NetGet's rule that a protocol may not implement storage.
10//!
11//! Pull only. Push endpoints answer 405 `UNSUPPORTED`; a registry with no blob
12//! store cannot honour an upload, and saying so is better than accepting one.
13
14pub mod actions;
15
16use std::convert::Infallible;
17use std::net::SocketAddr;
18use std::sync::Arc;
19
20use bytes::Bytes;
21use http_body_util::Full;
22use hyper::body::Incoming;
23use hyper::server::conn::http1;
24use hyper::service::service_fn;
25use hyper::{Method, Request, Response, StatusCode};
26use hyper_util::rt::TokioIo;
27use serde_json::{json, Value};
28use tokio::sync::mpsc;
29use tracing::{debug, error, trace, warn};
30
31use crate::llm::action_helper::call_llm;
32use crate::llm::ollama_client::OllamaClient;
33use crate::llm::ActionResult;
34use crate::logging::emit::Log;
35use crate::protocol::{Event, EventType};
36use crate::server::accept_bounded::{
37    accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
38};
39use crate::server::connection::ConnectionId;
40use crate::server::oci_registry::actions::{
41    oci_error_body, sha256_digest, OciRegistryProtocol, DEFAULT_BLOB_MEDIA_TYPE,
42};
43use crate::state::app_state::AppState;
44
45/// How `GET /v2/` is answered.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum VersionCheckMode {
48    /// Answer 200 in-process, no LLM call. The default: every client sends this
49    /// probe before every other request, and paying a model round-trip for it
50    /// makes `crane ls` twice as expensive for no decision worth making.
51    Auto,
52    /// Raise `oci_version_check` and let the model decide — including demanding a
53    /// bearer token via `send_oci_auth_challenge`.
54    Llm,
55}
56
57impl VersionCheckMode {
58    /// Parse the `version_check` startup parameter.
59    pub fn parse(s: &str) -> anyhow::Result<Self> {
60        match s.to_ascii_lowercase().as_str() {
61            "auto" | "static" => Ok(Self::Auto),
62            "llm" | "model" => Ok(Self::Llm),
63            other => Err(anyhow::anyhow!(
64                "version_check must be \"auto\" or \"llm\", got \"{}\"",
65                other
66            )),
67        }
68    }
69}
70
71/// A parsed `/v2/…` request path.
72///
73/// Repository names may contain `/` (`library/alpine`, `a/b/c`), so the endpoint
74/// suffix is located with `rfind` — the same way real registries disambiguate.
75/// `/blobs/uploads` is matched before `/blobs/` because the former contains the
76/// latter.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub enum OciRoute {
79    /// `/v2/` — the API version probe.
80    VersionCheck,
81    /// `/v2/_catalog`
82    Catalog,
83    /// `/v2/{name}/tags/list`
84    TagsList { name: String },
85    /// `/v2/{name}/manifests/{reference}` — reference is a tag or a digest.
86    Manifest { name: String, reference: String },
87    /// `/v2/{name}/blobs/{digest}`
88    Blob { name: String, digest: String },
89    /// `/v2/{name}/blobs/uploads/…` — the push path, unimplemented.
90    BlobUpload { name: String },
91    /// `/v2/{name}/referrers/{digest}` — optional in the spec, unimplemented.
92    Referrers { name: String },
93}
94
95/// Parse a request path into an [`OciRoute`], or `None` if it is not a `/v2/` route.
96pub fn parse_v2_path(path: &str) -> Option<OciRoute> {
97    if path == "/v2" || path == "/v2/" {
98        return Some(OciRoute::VersionCheck);
99    }
100    let rest = path.strip_prefix("/v2/")?;
101    if rest == "_catalog" || rest == "_catalog/" {
102        return Some(OciRoute::Catalog);
103    }
104    if let Some(name) = rest.strip_suffix("/tags/list") {
105        return Some(OciRoute::TagsList {
106            name: name.to_string(),
107        });
108    }
109    // Must precede the "/blobs/" arm: "/blobs/uploads/" contains "/blobs/".
110    if let Some(i) = rest.rfind("/blobs/uploads") {
111        return Some(OciRoute::BlobUpload {
112            name: rest[..i].to_string(),
113        });
114    }
115    if let Some(i) = rest.rfind("/manifests/") {
116        let reference = &rest[i + "/manifests/".len()..];
117        if !reference.is_empty() && !reference.contains('/') {
118            return Some(OciRoute::Manifest {
119                name: rest[..i].to_string(),
120                reference: reference.to_string(),
121            });
122        }
123    }
124    if let Some(i) = rest.rfind("/blobs/") {
125        let digest = &rest[i + "/blobs/".len()..];
126        if !digest.is_empty() && !digest.contains('/') {
127            return Some(OciRoute::Blob {
128                name: rest[..i].to_string(),
129                digest: digest.to_string(),
130            });
131        }
132    }
133    if let Some(i) = rest.rfind("/referrers/") {
134        return Some(OciRoute::Referrers {
135            name: rest[..i].to_string(),
136        });
137    }
138    None
139}
140
141/// OCI repository-name grammar (spec §Pulling manifests):
142/// `[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*` per path component, `/`-separated.
143///
144/// Checked rather than assumed because a client that sends an uppercase name
145/// expects `NAME_INVALID`, not a 200 for a repository that cannot exist.
146pub fn is_valid_repository_name(name: &str) -> bool {
147    if name.is_empty() || name.len() > 255 {
148        return false;
149    }
150    name.split('/').all(|component| {
151        let bytes = component.as_bytes();
152        let alphanumeric = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
153        let mut i = 0;
154        loop {
155            let start = i;
156            while i < bytes.len() && alphanumeric(bytes[i]) {
157                i += 1;
158            }
159            if i == start {
160                return false;
161            }
162            if i == bytes.len() {
163                return true;
164            }
165            match bytes[i] {
166                b'.' => i += 1,
167                b'_' => {
168                    i += 1;
169                    if bytes.get(i) == Some(&b'_') {
170                        i += 1;
171                    }
172                }
173                b'-' => {
174                    while bytes.get(i) == Some(&b'-') {
175                        i += 1;
176                    }
177                }
178                _ => return false,
179            }
180        }
181    })
182}
183
184/// Whether `reference` is a digest (`algo:hex`) rather than a tag.
185pub fn is_digest_reference(reference: &str) -> bool {
186    reference.contains(':')
187}
188
189/// Validate a digest string. Only sha256 is supported; the spec makes it the one
190/// mandatory algorithm and anything else is honestly rejected rather than faked.
191pub fn validate_sha256_digest(digest: &str) -> Result<(), String> {
192    let (algo, hex_part) = digest
193        .split_once(':')
194        .ok_or_else(|| format!("digest '{}' is not in <algorithm>:<hex> form", digest))?;
195    if algo != "sha256" {
196        return Err(format!(
197            "digest algorithm '{}' is not supported by this registry (only sha256)",
198            algo
199        ));
200    }
201    if hex_part.len() != 64 || !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
202        return Err(format!(
203            "digest '{}' is not 64 lowercase hex characters",
204            digest
205        ));
206    }
207    if hex_part.chars().any(|c| c.is_ascii_uppercase()) {
208        return Err(format!("digest '{}' must be lowercase hex", digest));
209    }
210    Ok(())
211}
212
213/// How long a peer that has produced nothing at all may hold this connection.
214///
215/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
216/// registry client sends it inside its dial path, so a peer that has connected and sent no byte
217/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
218/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
219///
220/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
221/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
222/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
223/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
224/// without consuming it, so the request line is still there for hyper afterwards.
225const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
226
227/// How long an established connection may sit **silent** between requests.
228///
229/// This bounds the silence and never a transfer. A request being answered holds
230/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
231/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
232/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
233/// ever runs on a connection with nothing in flight.
234///
235/// Five minutes, and the number is about the *gaps* in a pull rather than the pull. `crane pull`
236/// and `docker pull` fetch a manifest, then each blob, verifying digests and writing layers to
237/// disk in between; a blob being served is not silence - the response is built under a busy
238/// guard and written by hyper - so what is measured here is only the pause between two requests
239/// on a connection the client is keeping. Five minutes is four times nginx's `keepalive_timeout`
240/// default of 75s, and a client whose idle connection is closed simply opens another, as RFC
241/// 9112 requires of it.
242const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
243
244/// Concurrent connections this server admits.
245///
246/// Each admitted connection may hold one whole in-memory blob or manifest body, so the cap is
247/// what turns that per-connection bound into a total one. Registry clients pull layers with a
248/// small fixed concurrency (containerd's default is three), so 256 is far above any real pull
249/// and far below what socket exhaustion needs.
250const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
251
252/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
253///
254/// `503 Service Unavailable` with `Retry-After`, in OCI registry's own vocabulary:
255/// the distribution specification's own clients retry a 503 with
256/// backoff, and a human with `curl` reads it directly.
257/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
258/// reason, under `decision=fail_closed_connection_cap`.
259const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
260    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
261
262/// OCI registry server that delegates every decision to the LLM.
263pub struct OciRegistryServer;
264
265impl OciRegistryServer {
266    /// Bind and start serving.
267    ///
268    /// Bind failure is propagated so `server_startup` records `ServerStatus::Error`
269    /// rather than reporting `Running` on a port this process does not hold.
270    pub async fn spawn_with_llm_actions(
271        listen_addr: SocketAddr,
272        llm_client: OllamaClient,
273        app_state: Arc<AppState>,
274        status_tx: mpsc::UnboundedSender<String>,
275        server_id: crate::state::ServerId,
276        startup_params: Option<crate::protocol::StartupParams>,
277    ) -> anyhow::Result<SocketAddr> {
278        let version_check = match startup_params.as_ref() {
279            Some(params) => match params.get_optional_string("version_check") {
280                Ok(Some(s)) => VersionCheckMode::parse(&s)?,
281                Ok(None) => VersionCheckMode::Auto,
282                Err(e) => {
283                    return Err(anyhow::anyhow!(
284                        "OCI registry startup parameter error: {}",
285                        e
286                    ))
287                }
288            },
289            None => VersionCheckMode::Auto,
290        };
291
292        let listener =
293            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
294        let local_addr = listener.local_addr()?;
295        Log::new(Some(&status_tx)).info(format!(
296            "OCI registry listening on {} (version_check={:?}, pull-only)",
297            local_addr, version_check
298        ));
299
300        let protocol = Arc::new(OciRegistryProtocol::new());
301        let task_registrar = app_state.clone();
302
303        let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
304        let accept_handle = tokio::spawn(async move {
305            loop {
306                match accept_bounded(
307                    &listener,
308                    &limiter,
309                    CONNECTION_CAP_REFUSAL,
310                    "OCI",
311                    Some(&status_tx),
312                )
313                .await
314                {
315                    Ok((stream, remote_addr, permit)) => {
316                        let connection_id =
317                            ConnectionId::new(app_state.get_next_unified_id().await);
318                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
319                        Log::new(Some(&status_tx)).debug(format!(
320                            "OCI registry connection {} from {}",
321                            connection_id, remote_addr
322                        ));
323
324                        use crate::state::server::{
325                            ConnectionState as ServerConnectionState, ConnectionStatus,
326                            ProtocolConnectionInfo,
327                        };
328                        let now = crate::utils::clock::Instant::now();
329                        let conn_state = ServerConnectionState {
330                            id: connection_id,
331                            remote_addr,
332                            local_addr: local_addr_conn,
333                            bytes_sent: 0,
334                            bytes_received: 0,
335                            packets_sent: 0,
336                            packets_received: 0,
337                            last_activity: now,
338                            status: ConnectionStatus::Active,
339                            status_changed_at: now,
340                            protocol_info: ProtocolConnectionInfo::empty(),
341                        };
342                        app_state
343                            .add_connection_to_server(server_id, conn_state)
344                            .await;
345                        let _ = status_tx.send("__UPDATE_UI__".to_string());
346
347                        let llm_client = llm_client.clone();
348                        let app_state_conn = app_state.clone();
349                        let status_tx_conn = status_tx.clone();
350                        let protocol = protocol.clone();
351
352                        let task_owner = app_state.clone();
353                        task_owner
354                            .spawn_server_task(server_id, async move {
355                                // Held for the life of the connection, so the
356                                // cap counts live clients, not accepts.
357                                let _permit = permit;
358
359                                // The first-byte bound, before hyper sees the
360                                // socket. `peek` waits for data without
361                                // consuming it, so the request line is still
362                                // there for hyper afterwards, and it bounds
363                                // exactly the case that needs bounding: a peer
364                                // that connected and said nothing at all.
365                                let spoke = matches!(
366                                    tokio::time::timeout(
367                                        FIRST_BYTE_READ_TIMEOUT,
368                                        stream.peek(&mut [0u8; 1]),
369                                    )
370                                    .await,
371                                    Ok(Ok(n)) if n > 0
372                                );
373
374                                if spoke {
375                                    let io = TokioIo::new(stream);
376                                    let app_state_service = app_state_conn.clone();
377                                    let status_tx_service = status_tx_conn.clone();
378
379                                    // Whether this connection is answering
380                                    // anything. The watchdog below reads it, so
381                                    // only genuine silence - never work in
382                                    // flight - can close a connection.
383                                    let activity = Arc::new(ConnectionActivity::new());
384                                    let activity_for_service = Arc::clone(&activity);
385                                    let service = service_fn(move |req: Request<Incoming>| {
386                                        // Busy for the whole of this request - the
387                                        // model round-trip included, and a `manual`
388                                        // rule parked for a human - so the idle
389                                        // watchdog can never close the connection an
390                                        // answer belongs to.
391                                        let activity = Arc::clone(&activity_for_service);
392                                        // Cloned here rather than inside the future: the closure
393                                        // is an `Fn` and an `async move` block would move the
394                                        // captures out of it on the first call.
395                                        let llm_client = llm_client.clone();
396                                        let app_state_service = app_state_service.clone();
397                                        let status_tx_service = status_tx_service.clone();
398                                        let protocol = protocol.clone();
399                                        async move {
400                                            let _busy = activity.busy();
401                                            handle_oci_request(
402                                                req,
403                                                connection_id,
404                                                remote_addr,
405                                                llm_client,
406                                                app_state_service,
407                                                status_tx_service,
408                                                protocol,
409                                                server_id,
410                                                version_check,
411                                            )
412                                            .await
413                                        }
414                                    });
415
416                                    let conn = http1::Builder::new().serve_connection(io, service);
417                                    tokio::pin!(conn);
418                                    tokio::select! {
419                                        result = &mut conn => {
420                                            if let Err(err) = result {
421                                                debug!("OCI registry connection ended: {:?}", err);
422                                            }
423                                        }
424                                        // The idle bound, over `ConnectionActivity`
425                                        // rather than over a read: hyper owns every
426                                        // read once `serve_connection` starts, and
427                                        // keeps polling for frames while a request is
428                                        // being answered, so a deadline on those reads
429                                        // would fire mid-answer. A connection with work
430                                        // in flight is not idle at all.
431                                        _ = watch_idle(
432                                            Arc::clone(&activity),
433                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
434                                        ) => {
435                                            Log::new(Some(&status_tx_conn)).debug(format!(
436                                                "OCI registry connection {} idle for {}s; closing",
437                                                connection_id,
438                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
439                                            ));
440                                        }
441                                    }
442                                } else {
443                                    Log::new(Some(&status_tx_conn)).debug(format!(
444                                        "OCI registry peer {} sent no request within {}s; closing \
445                                         decision=fail_closed_first_byte_timeout",
446                                        remote_addr,
447                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
448                                    ));
449                                }
450
451                                app_state_conn
452                                    .close_connection_on_server(server_id, connection_id)
453                                    .await;
454                                let _ = status_tx_conn.send("__UPDATE_UI__".to_string());
455                            })
456                            .await;
457                    }
458                    Err(e) => {
459                        Log::new(Some(&status_tx))
460                            .error(format!("Failed to accept OCI registry connection: {}", e));
461                        break;
462                    }
463                }
464            }
465        });
466
467        task_registrar
468            .register_server_task(server_id, accept_handle)
469            .await;
470
471        Ok(local_addr)
472    }
473}
474
475// ---------------------------------------------------------------------------
476// Response construction
477// ---------------------------------------------------------------------------
478
479/// Build a response without ever panicking on model-influenced parts.
480fn build(status: u16, headers: Vec<(&str, String)>, body: Vec<u8>) -> Response<Full<Bytes>> {
481    let mut builder = Response::builder()
482        .status(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
483    for (name, value) in headers {
484        // A CR/LF smuggled through a model-supplied header value would be a
485        // response-splitting attempt; hyper rejects it and we drop that header
486        // rather than losing the whole response.
487        if value.contains(['\r', '\n']) {
488            warn!("OCI registry: dropping header '{}' containing CR/LF", name);
489            continue;
490        }
491        builder = builder.header(name, value);
492    }
493    builder
494        .header("Content-Length", body.len().to_string())
495        .body(Full::new(Bytes::from(body)))
496        .unwrap_or_else(|e| {
497            error!("OCI registry: failed to build response: {}", e);
498            Response::new(Full::new(Bytes::new()))
499        })
500}
501
502/// Build an OCI error envelope response.
503fn error_response(
504    status: u16,
505    code: &str,
506    message: &str,
507    detail: Option<Value>,
508) -> Response<Full<Bytes>> {
509    let body = oci_error_body(code, message, detail.as_ref());
510    build(
511        status,
512        vec![
513            ("Content-Type", "application/json".to_string()),
514            (
515                "Docker-Distribution-Api-Version",
516                "registry/2.0".to_string(),
517            ),
518        ],
519        body.into_bytes(),
520    )
521}
522
523/// The fail-closed answer when the LLM call itself errored.
524///
525/// The peer gets a *category*, never the error: no backend URL, no model name, no
526/// `anyhow` chain. The two categories are mapped onto different HTTP statuses so a
527/// client backs off rather than recording a permanent fault — saturation is 503 +
528/// `Retry-After`, which `crane`/`docker` retry, and anything else is 500. The full
529/// error goes to the log and the status stream instead.
530fn llm_error_response(err: &anyhow::Error) -> Response<Full<Bytes>> {
531    let failure = crate::utils::WireFailure::classify(err);
532    if failure.is_overloaded() {
533        return build(
534            503,
535            vec![
536                ("Content-Type", "application/json".to_string()),
537                (
538                    "Docker-Distribution-Api-Version",
539                    "registry/2.0".to_string(),
540                ),
541                ("Retry-After", "1".to_string()),
542            ],
543            oci_error_body("UNKNOWN", failure.text(), None).into_bytes(),
544        );
545    }
546    error_response(500, "UNKNOWN", failure.text(), None)
547}
548
549/// The fail-closed answer when the model produced nothing usable.
550///
551/// Deliberately not a plausible-looking empty catalog or a 404: an LLM outage and
552/// a deliberate "this image does not exist" must not be indistinguishable, which
553/// is the OAuth2 fail-open lesson. The model says no with `send_oci_error`; this
554/// is what silence looks like.
555fn no_answer_response(what: &str) -> Response<Full<Bytes>> {
556    error_response(
557        500,
558        "UNKNOWN",
559        &format!(
560            "the registry backend returned no usable response for the {} request",
561            what
562        ),
563        None,
564    )
565}
566
567// ---------------------------------------------------------------------------
568// Request handling
569// ---------------------------------------------------------------------------
570
571/// Approximate the bytes this request cost on the wire.
572///
573/// hyper hands us a parsed `Request`, so the original head is gone; this
574/// reconstructs its size from the parts that survive. It is an estimate, and
575/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
576/// "this peer sent nothing" rather than "we did not measure".
577fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
578    use hyper::body::Body;
579    let head: usize = req.method().as_str().len()
580        + req.uri().to_string().len()
581        + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
582        + req
583            .headers()
584            .iter()
585            .map(|(name, value)| name.as_str().len() + value.len() + 4)
586            .sum::<usize>();
587    head as u64 + req.body().size_hint().lower()
588}
589
590/// Record one request/response exchange against the connection's counters, then
591/// return the response unchanged.
592///
593/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
594/// connection-scoped task prompts read, and it is what keeps `last_activity`
595/// moving. Without it a busy OCI registry server draws every peer as idle having sent
596/// and received nothing.
597#[allow(clippy::too_many_arguments)]
598async fn handle_oci_request(
599    req: Request<Incoming>,
600    connection_id: ConnectionId,
601    remote_addr: SocketAddr,
602    llm_client: OllamaClient,
603    app_state: Arc<AppState>,
604    status_tx: mpsc::UnboundedSender<String>,
605    protocol: Arc<OciRegistryProtocol>,
606    server_id: crate::state::ServerId,
607    version_check: VersionCheckMode,
608) -> Result<Response<Full<Bytes>>, Infallible> {
609    let bytes_received = approximate_request_bytes(&req);
610    let response = handle_oci_request_inner(
611        req,
612        connection_id,
613        remote_addr,
614        llm_client,
615        app_state.clone(),
616        status_tx,
617        protocol,
618        server_id,
619        version_check,
620    )
621    .await;
622
623    let bytes_sent = response
624        .as_ref()
625        .ok()
626        .and_then(|resp| {
627            use hyper::body::Body;
628            resp.body().size_hint().exact()
629        })
630        .unwrap_or(0);
631    app_state
632        .update_connection_stats(
633            server_id,
634            connection_id,
635            Some(bytes_received),
636            Some(bytes_sent),
637            Some(1),
638            Some(1),
639        )
640        .await;
641
642    response
643}
644
645#[allow(clippy::too_many_arguments)]
646async fn handle_oci_request_inner(
647    req: Request<Incoming>,
648    connection_id: ConnectionId,
649    remote_addr: SocketAddr,
650    llm_client: OllamaClient,
651    app_state: Arc<AppState>,
652    status_tx: mpsc::UnboundedSender<String>,
653    protocol: Arc<OciRegistryProtocol>,
654    server_id: crate::state::ServerId,
655    version_check: VersionCheckMode,
656) -> Result<Response<Full<Bytes>>, Infallible> {
657    let method = req.method().clone();
658    let uri = req.uri().clone();
659    let path = uri.path().to_string();
660    let query = uri.query().unwrap_or("").to_string();
661
662    let accept = req
663        .headers()
664        .get_all(hyper::header::ACCEPT)
665        .iter()
666        .filter_map(|v| v.to_str().ok())
667        .flat_map(|v| v.split(',').map(|s| s.trim().to_string()))
668        .filter(|s| !s.is_empty())
669        .collect::<Vec<_>>();
670    let user_agent = req
671        .headers()
672        .get(hyper::header::USER_AGENT)
673        .and_then(|v| v.to_str().ok())
674        .unwrap_or("")
675        .to_string();
676    let authorization = req
677        .headers()
678        .get(hyper::header::AUTHORIZATION)
679        .and_then(|v| v.to_str().ok())
680        .map(|s| s.to_string());
681
682    Log::new(Some(&status_tx)).debug(format!("OCI {} {}", method, path));
683    trace!(
684        "OCI registry request: {} {} accept={:?} ua={}",
685        method,
686        path,
687        accept,
688        user_agent
689    );
690
691    let route = match parse_v2_path(&path) {
692        Some(r) => r,
693        None => {
694            return Ok(error_response(
695                404,
696                "UNSUPPORTED",
697                &format!(
698                    "'{}' is not an OCI Distribution v2 endpoint; this registry serves /v2/",
699                    path
700                ),
701                None,
702            ))
703        }
704    };
705
706    // Pull-only: every mutating method is refused up front, honestly.
707    if !matches!(method, Method::GET | Method::HEAD) {
708        Log::new(Some(&status_tx)).debug(format!(
709            "OCI {} {} refused: this registry is pull-only",
710            method, path
711        ));
712        return Ok(error_response(
713            405,
714            "UNSUPPORTED",
715            "this registry implements the OCI Distribution v1.1 pull path only; \
716             push (blob uploads and manifest PUT) is not supported",
717            Some(json!({"method": method.as_str(), "path": path})),
718        ));
719    }
720
721    match &route {
722        OciRoute::BlobUpload { .. } => {
723            return Ok(error_response(
724                405,
725                "UNSUPPORTED",
726                "blob uploads are not supported: this registry has no storage and could not \
727                 serve back what you pushed",
728                None,
729            ))
730        }
731        OciRoute::Referrers { .. } => {
732            return Ok(error_response(
733                404,
734                "UNSUPPORTED",
735                "the referrers API is not implemented by this registry",
736                None,
737            ))
738        }
739        _ => {}
740    }
741
742    // Repository-name validation before anything reaches the model.
743    let repo_name = match &route {
744        OciRoute::TagsList { name }
745        | OciRoute::Manifest { name, .. }
746        | OciRoute::Blob { name, .. } => Some(name.clone()),
747        _ => None,
748    };
749    if let Some(name) = &repo_name {
750        if !is_valid_repository_name(name) {
751            return Ok(error_response(
752                400,
753                "NAME_INVALID",
754                &format!("'{}' is not a valid OCI repository name", name),
755                None,
756            ));
757        }
758    }
759
760    // Digest syntax validation, likewise.
761    if let OciRoute::Blob { digest, .. } = &route {
762        if let Err(msg) = validate_sha256_digest(digest) {
763            return Ok(error_response(400, "DIGEST_INVALID", &msg, None));
764        }
765    }
766    if let OciRoute::Manifest { reference, .. } = &route {
767        if is_digest_reference(reference) {
768            if let Err(msg) = validate_sha256_digest(reference) {
769                return Ok(error_response(400, "DIGEST_INVALID", &msg, None));
770            }
771        }
772    }
773
774    // The version probe: answered in-process unless the operator asked for the model.
775    if matches!(route, OciRoute::VersionCheck) && version_check == VersionCheckMode::Auto {
776        trace!("OCI /v2/ answered in-process (version_check=auto)");
777        return Ok(build(
778            200,
779            vec![
780                ("Content-Type", "application/json".to_string()),
781                (
782                    "Docker-Distribution-Api-Version",
783                    "registry/2.0".to_string(),
784                ),
785            ],
786            b"{}".to_vec(),
787        ));
788    }
789
790    if app_state.get_instruction(server_id).await.is_none() {
791        error!("OCI registry: server {} not found", server_id);
792        return Ok(error_response(
793            500,
794            "UNKNOWN",
795            "registry instance is no longer running",
796            None,
797        ));
798    }
799
800    let query_params = parse_query(&query);
801    let (event_type, event_data): (&'static EventType, Value) = match &route {
802        OciRoute::VersionCheck => (
803            &actions::OCI_VERSION_CHECK,
804            json!({
805                "method": method.as_str(),
806                "path": path,
807                "authorization": authorization,
808                "user_agent": user_agent,
809                "client": remote_addr.to_string(),
810            }),
811        ),
812        OciRoute::Catalog => (
813            &actions::OCI_CATALOG_REQUEST,
814            json!({
815                "method": method.as_str(),
816                "path": path,
817                "page_size": query_params.get("n"),
818                "last": query_params.get("last"),
819                "authorization": authorization,
820                "user_agent": user_agent,
821                "client": remote_addr.to_string(),
822            }),
823        ),
824        OciRoute::TagsList { name } => (
825            &actions::OCI_TAGS_REQUEST,
826            json!({
827                "method": method.as_str(),
828                "path": path,
829                "name": name,
830                "page_size": query_params.get("n"),
831                "last": query_params.get("last"),
832                "authorization": authorization,
833                "user_agent": user_agent,
834                "client": remote_addr.to_string(),
835            }),
836        ),
837        OciRoute::Manifest { name, reference } => (
838            &actions::OCI_MANIFEST_REQUEST,
839            json!({
840                "method": method.as_str(),
841                "path": path,
842                "name": name,
843                "reference": reference,
844                "by_digest": is_digest_reference(reference),
845                "accept": accept,
846                "authorization": authorization,
847                "user_agent": user_agent,
848                "client": remote_addr.to_string(),
849            }),
850        ),
851        OciRoute::Blob { name, digest } => (
852            &actions::OCI_BLOB_REQUEST,
853            json!({
854                "method": method.as_str(),
855                "path": path,
856                "name": name,
857                "digest": digest,
858                "accept": accept,
859                "authorization": authorization,
860                "user_agent": user_agent,
861                "client": remote_addr.to_string(),
862            }),
863        ),
864        // Handled above.
865        OciRoute::BlobUpload { .. } | OciRoute::Referrers { .. } => unreachable!(),
866    };
867
868    let event = Event::new(event_type, event_data);
869    let llm_result = call_llm(
870        &llm_client,
871        &app_state,
872        server_id,
873        Some(connection_id),
874        &event,
875        protocol.as_ref(),
876    )
877    .await;
878
879    let execution = match llm_result {
880        Ok(e) => e,
881        Err(e) => {
882            // Three outcomes must stay distinguishable in the log: the model
883            // refused (`decision=model_reject`, logged in `build_from_action`),
884            // the model answered nothing (`decision=fail_closed_no_action`), and
885            // the LLM call errored (here). The tag is stable so an operator can
886            // grep `decision=fail_closed_` for every request netget could not
887            // answer. The error text is logged; it never reaches the socket.
888            let failure = crate::utils::WireFailure::classify(&e);
889            error!(
890                "OCI registry {} {} decision=fail_closed_llm_error category={:?}: {}",
891                method, path, failure, e
892            );
893            Log::new(Some(&status_tx)).warn(format!(
894                "OCI registry {} {} decision=fail_closed_llm_error category={:?}",
895                method, path, failure
896            ));
897            return Ok(llm_error_response(&e));
898        }
899    };
900
901    for result in execution.protocol_results {
902        if let Some(response) = build_from_action(result, &route, &status_tx) {
903            return Ok(response);
904        }
905    }
906
907    Log::new(Some(&status_tx)).warn(format!(
908        "OCI registry {} {} decision=fail_closed_no_action - no usable action, refusing",
909        method, path
910    ));
911    Ok(no_answer_response(route_label(&route)))
912}
913
914fn route_label(route: &OciRoute) -> &'static str {
915    match route {
916        OciRoute::VersionCheck => "version check",
917        OciRoute::Catalog => "catalog",
918        OciRoute::TagsList { .. } => "tag list",
919        OciRoute::Manifest { .. } => "manifest",
920        OciRoute::Blob { .. } => "blob",
921        OciRoute::BlobUpload { .. } => "blob upload",
922        OciRoute::Referrers { .. } => "referrers",
923    }
924}
925
926fn parse_query(query: &str) -> std::collections::HashMap<String, String> {
927    query
928        .split('&')
929        .filter(|s| !s.is_empty())
930        .filter_map(|pair| {
931            let (k, v) = pair.split_once('=')?;
932            Some((k.to_string(), v.to_string()))
933        })
934        .collect()
935}
936
937/// Turn one action result into a response, or `None` if it is not one of ours so
938/// the caller can keep scanning (a model may legitimately emit `set_memory`
939/// alongside the real answer).
940fn build_from_action(
941    result: ActionResult,
942    route: &OciRoute,
943    status_tx: &mpsc::UnboundedSender<String>,
944) -> Option<Response<Full<Bytes>>> {
945    let (name, data) = match result {
946        ActionResult::Custom { name, data } => (name, data),
947        _ => return None,
948    };
949
950    match name.as_str() {
951        "oci_version_ok" => Some(build(
952            200,
953            vec![
954                ("Content-Type", "application/json".to_string()),
955                (
956                    "Docker-Distribution-Api-Version",
957                    "registry/2.0".to_string(),
958                ),
959            ],
960            b"{}".to_vec(),
961        )),
962
963        "oci_auth_challenge" => {
964            let realm = data.get("realm").and_then(|v| v.as_str()).unwrap_or("");
965            let mut challenge = format!("Bearer realm=\"{}\"", realm);
966            if let Some(service) = data.get("service").and_then(|v| v.as_str()) {
967                challenge.push_str(&format!(",service=\"{}\"", service));
968            }
969            if let Some(scope) = data.get("scope").and_then(|v| v.as_str()) {
970                challenge.push_str(&format!(",scope=\"{}\"", scope));
971            }
972            let message = data
973                .get("message")
974                .and_then(|v| v.as_str())
975                .unwrap_or("authentication required");
976            Log::new(Some(status_tx)).debug(format!("OCI -> 401 auth challenge realm={}", realm));
977            Some(build(
978                401,
979                vec![
980                    ("Content-Type", "application/json".to_string()),
981                    ("WWW-Authenticate", challenge),
982                    (
983                        "Docker-Distribution-Api-Version",
984                        "registry/2.0".to_string(),
985                    ),
986                ],
987                oci_error_body("UNAUTHORIZED", message, None).into_bytes(),
988            ))
989        }
990
991        "oci_catalog" => {
992            let repositories = data
993                .get("repositories")
994                .cloned()
995                .unwrap_or_else(|| json!([]));
996            let body = json!({"repositories": repositories}).to_string();
997            let mut headers = vec![
998                ("Content-Type", "application/json".to_string()),
999                (
1000                    "Docker-Distribution-Api-Version",
1001                    "registry/2.0".to_string(),
1002                ),
1003            ];
1004            if let Some(last) = data.get("next_last").and_then(|v| v.as_str()) {
1005                headers.push((
1006                    "Link",
1007                    format!("</v2/_catalog?last={}>; rel=\"next\"", last),
1008                ));
1009            }
1010            Log::new(Some(status_tx)).debug(format!(
1011                "OCI -> catalog ({} repositories)",
1012                repositories.as_array().map(|a| a.len()).unwrap_or(0)
1013            ));
1014            Some(build(200, headers, body.into_bytes()))
1015        }
1016
1017        "oci_tags" => {
1018            let requested_name = match route {
1019                OciRoute::TagsList { name } => name.clone(),
1020                _ => String::new(),
1021            };
1022            let name = data
1023                .get("name")
1024                .and_then(|v| v.as_str())
1025                .filter(|s| !s.is_empty())
1026                .map(|s| s.to_string())
1027                .unwrap_or(requested_name);
1028            let tags = data.get("tags").cloned().unwrap_or_else(|| json!([]));
1029            let body = json!({"name": name, "tags": tags}).to_string();
1030            Log::new(Some(status_tx)).debug(format!(
1031                "OCI -> tag list for {} ({} tags)",
1032                name,
1033                tags.as_array().map(|a| a.len()).unwrap_or(0)
1034            ));
1035            Some(build(
1036                200,
1037                vec![
1038                    ("Content-Type", "application/json".to_string()),
1039                    (
1040                        "Docker-Distribution-Api-Version",
1041                        "registry/2.0".to_string(),
1042                    ),
1043                ],
1044                body.into_bytes(),
1045            ))
1046        }
1047
1048        "oci_manifest" => {
1049            let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("");
1050            let digest = data.get("digest").and_then(|v| v.as_str()).unwrap_or("");
1051            let media_type = data
1052                .get("media_type")
1053                .and_then(|v| v.as_str())
1054                .unwrap_or(actions::DEFAULT_MANIFEST_MEDIA_TYPE);
1055
1056            // Re-hash the bytes we are about to write. `execute_manifest` already
1057            // computed this over the same string; recomputing here means the header
1058            // is derived from the wire bytes on this code path too, not carried
1059            // along in a field that a future refactor could desynchronise.
1060            let computed = sha256_digest(body.as_bytes());
1061            if computed != digest {
1062                error!(
1063                    "OCI manifest digest desynchronised: action said {} but the body hashes \
1064                     to {}; refusing",
1065                    digest, computed
1066                );
1067                return Some(error_response(
1068                    500,
1069                    "MANIFEST_INVALID",
1070                    "internal digest mismatch while building the manifest response",
1071                    None,
1072                ));
1073            }
1074
1075            // Requested by digest? Then the content must be the content that digest
1076            // names. Fail closed — serving other bytes under that name is precisely
1077            // what content addressing exists to prevent.
1078            if let OciRoute::Manifest { reference, .. } = route {
1079                if is_digest_reference(reference) && reference != &computed {
1080                    Log::new(Some(status_tx)).warn(format!(
1081                        "OCI -> 404 MANIFEST_UNKNOWN: requested {} but content hashes to {}",
1082                        reference, computed
1083                    ));
1084                    return Some(error_response(
1085                        404,
1086                        "MANIFEST_UNKNOWN",
1087                        "the content supplied for this manifest does not hash to the requested \
1088                         digest, so it cannot be served under it",
1089                        Some(json!({"requested": reference, "computed": computed})),
1090                    ));
1091                }
1092            }
1093
1094            Log::new(Some(status_tx)).debug(format!(
1095                "OCI -> manifest {} ({}, {} bytes)",
1096                computed,
1097                media_type,
1098                body.len()
1099            ));
1100            Some(build(
1101                200,
1102                vec![
1103                    ("Content-Type", media_type.to_string()),
1104                    ("Docker-Content-Digest", computed.clone()),
1105                    ("Etag", format!("\"{}\"", computed)),
1106                    (
1107                        "Docker-Distribution-Api-Version",
1108                        "registry/2.0".to_string(),
1109                    ),
1110                ],
1111                body.as_bytes().to_vec(),
1112            ))
1113        }
1114
1115        "oci_blob" => {
1116            let bytes = match data.get("body_hex").and_then(|v| v.as_str()) {
1117                Some(h) => match hex::decode(h) {
1118                    Ok(b) => b,
1119                    Err(e) => {
1120                        error!("OCI blob: internal hex decode failed: {}", e);
1121                        return Some(error_response(
1122                            500,
1123                            "UNKNOWN",
1124                            "internal error decoding blob content",
1125                            None,
1126                        ));
1127                    }
1128                },
1129                None => return Some(no_answer_response("blob")),
1130            };
1131            let computed = sha256_digest(&bytes);
1132            let media_type = data
1133                .get("media_type")
1134                .and_then(|v| v.as_str())
1135                .unwrap_or(DEFAULT_BLOB_MEDIA_TYPE);
1136
1137            if let OciRoute::Blob { digest, .. } = route {
1138                if digest != &computed {
1139                    Log::new(Some(status_tx)).warn(format!(
1140                        "OCI -> 404 BLOB_UNKNOWN: requested {} but content hashes to {}",
1141                        digest, computed
1142                    ));
1143                    return Some(error_response(
1144                        404,
1145                        "BLOB_UNKNOWN",
1146                        "the content supplied for this blob does not hash to the requested \
1147                         digest, so it cannot be served under it",
1148                        Some(json!({"requested": digest, "computed": computed})),
1149                    ));
1150                }
1151            }
1152
1153            Log::new(Some(status_tx)).debug(format!(
1154                "OCI -> blob {} ({} bytes)",
1155                computed,
1156                bytes.len()
1157            ));
1158            Some(build(
1159                200,
1160                vec![
1161                    ("Content-Type", media_type.to_string()),
1162                    ("Docker-Content-Digest", computed),
1163                    (
1164                        "Docker-Distribution-Api-Version",
1165                        "registry/2.0".to_string(),
1166                    ),
1167                ],
1168                bytes,
1169            ))
1170        }
1171
1172        "oci_error" => {
1173            let code = data
1174                .get("code")
1175                .and_then(|v| v.as_str())
1176                .unwrap_or("UNKNOWN");
1177            let message = data
1178                .get("message")
1179                .and_then(|v| v.as_str())
1180                .unwrap_or("request refused");
1181            let status = data
1182                .get("status")
1183                .and_then(|v| v.as_u64())
1184                .map(|s| s as u16)
1185                .unwrap_or(500);
1186            let detail = data.get("detail").cloned().filter(|d| !d.is_null());
1187            Log::new(Some(status_tx)).debug(format!(
1188                "OCI -> {} {} decision=model_reject: {}",
1189                status, code, message
1190            ));
1191            Some(error_response(status, code, message, detail))
1192        }
1193
1194        other => {
1195            trace!("OCI registry: ignoring non-OCI action result '{}'", other);
1196            None
1197        }
1198    }
1199}