1pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum VersionCheckMode {
48 Auto,
52 Llm,
55}
56
57impl VersionCheckMode {
58 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#[derive(Clone, Debug, PartialEq, Eq)]
78pub enum OciRoute {
79 VersionCheck,
81 Catalog,
83 TagsList { name: String },
85 Manifest { name: String, reference: String },
87 Blob { name: String, digest: String },
89 BlobUpload { name: String },
91 Referrers { name: String },
93}
94
95pub 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 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
141pub 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
184pub fn is_digest_reference(reference: &str) -> bool {
186 reference.contains(':')
187}
188
189pub 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
213const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
226
227const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
243
244const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
251
252const 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
262pub struct OciRegistryServer;
264
265impl OciRegistryServer {
266 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 let _permit = permit;
358
359 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 let activity = Arc::new(ConnectionActivity::new());
384 let activity_for_service = Arc::clone(&activity);
385 let service = service_fn(move |req: Request<Incoming>| {
386 let activity = Arc::clone(&activity_for_service);
392 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 _ = 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
475fn 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 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
502fn 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
523fn 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
549fn 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
567fn 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 + 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#[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 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 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 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 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 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 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
937fn 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 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 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}