Skip to main content

ferro_oci_server/
router.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Axum router factory that wires the `/v2/**` OCI endpoints.
3//!
4//! Spec: OCI Distribution Spec v1.1 ยง3 "API".
5//!
6//! The router is stateful โ€” it takes an [`AppState`] carrying the blob
7//! store and the registry-metadata plane. Callers build the state once
8//! at boot and then call [`router`] to obtain an `axum::Router` they
9//! can mount under `/`.
10
11use std::sync::Arc;
12
13use axum::Router;
14use axum::routing::{delete, get, post};
15
16use crate::handlers::{base, catalog};
17use crate::registry::RegistryMeta;
18use ferro_blob_store::SharedBlobStore;
19
20/// Shared HTTP handler state.
21pub struct AppState {
22    /// Blob-bytes plane.
23    pub blob_store: SharedBlobStore,
24    /// Metadata plane (manifests, tags, upload sessions, referrers).
25    pub registry: Arc<dyn RegistryMeta>,
26}
27
28/// Build the Axum router for every `/v2/**` OCI endpoint.
29pub fn router(state: Arc<AppState>) -> Router {
30    Router::new()
31        // Version / auth challenge.
32        .route("/v2/", get(base::version_check))
33        .route("/v2", get(base::version_check))
34        // Catalog and tag listing.
35        .route("/v2/_catalog", get(catalog::list_catalog))
36        .route("/v2/{*rest}", get(dispatch::dispatch_get))
37        .route("/v2/{*rest}", axum::routing::head(dispatch::dispatch_head))
38        .route("/v2/{*rest}", delete(dispatch::dispatch_delete))
39        // Blob uploads -- POST / PATCH / PUT.
40        .route(
41            "/v2/{*rest}",
42            post(dispatch::dispatch_post)
43                .patch(dispatch::dispatch_patch_inner)
44                .put(dispatch::dispatch_put_inner),
45        )
46        .with_state(state)
47}
48
49/// Small axum-aware dispatch layer.
50///
51/// Distribution routes use the `{name}` path parameter which can contain
52/// slashes. Axum's `{*rest}` wildcard allows us to greedily capture the
53/// path tail and then inspect the suffix ourselves. We then dispatch to
54/// the real handler based on the suffix shape โ€” `blobs/{digest}`,
55/// `blobs/uploads/{uuid?}`, `manifests/{reference}`, `tags/list`, or
56/// `referrers/{digest}`.
57pub mod dispatch {
58    use std::sync::Arc;
59
60    use axum::body::Bytes;
61    use axum::extract::{Path, Query, State};
62    use axum::http::{HeaderMap, Method, StatusCode};
63    use axum::response::{IntoResponse, Response};
64
65    use super::AppState;
66    use crate::error::{OciError, OciErrorCode};
67    use crate::handlers::{blob, blob_upload, manifest as manifest_h, referrers, tags};
68
69    /// Split `rest` into `(name, suffix)` where `suffix` is one of
70    /// `blobs/...`, `manifests/...`, `tags/list`, `referrers/...`.
71    fn split_rest(rest: &str) -> Option<(&str, &str)> {
72        // Walk the path and find the last segment boundary where the
73        // suffix starts with a known keyword. This accommodates
74        // multi-level names like `my-org/library/alpine`.
75        let keywords = ["blobs/", "manifests/", "tags/list", "referrers/"];
76        for kw in keywords {
77            if let Some(idx) = rest.rfind(kw) {
78                // Ensure the match is preceded by a `/` or is at idx 0
79                // after the name prefix.
80                if idx == 0 {
81                    return None;
82                }
83                if &rest[idx - 1..idx] != "/" {
84                    continue;
85                }
86                let name = &rest[..idx - 1];
87                let suffix = &rest[idx..];
88                return Some((name, suffix));
89            }
90        }
91        None
92    }
93
94    /// Decode the rest into (name, suffix) or return NAME_INVALID.
95    fn decode(rest: &str) -> Result<(String, String), OciError> {
96        let (name, suffix) = split_rest(rest).ok_or_else(|| {
97            OciError::new(OciErrorCode::NameUnknown, format!("cannot route `{rest}`"))
98        })?;
99        Ok((name.to_owned(), suffix.to_owned()))
100    }
101
102    /// GET dispatcher.
103    pub async fn dispatch_get(
104        State(state): State<Arc<AppState>>,
105        Path(rest): Path<String>,
106        Query(params): Query<std::collections::BTreeMap<String, String>>,
107        headers: HeaderMap,
108    ) -> Response {
109        let (name, suffix) = match decode(&rest) {
110            Ok(v) => v,
111            Err(e) => return e.into_response(),
112        };
113        dispatch_inner(
114            state,
115            name,
116            suffix,
117            Method::GET,
118            headers,
119            params,
120            Bytes::new(),
121        )
122        .await
123    }
124
125    /// HEAD dispatcher.
126    pub async fn dispatch_head(
127        State(state): State<Arc<AppState>>,
128        Path(rest): Path<String>,
129        headers: HeaderMap,
130    ) -> Response {
131        let (name, suffix) = match decode(&rest) {
132            Ok(v) => v,
133            Err(e) => return e.into_response(),
134        };
135        dispatch_inner(
136            state,
137            name,
138            suffix,
139            Method::HEAD,
140            headers,
141            std::collections::BTreeMap::default(),
142            Bytes::new(),
143        )
144        .await
145    }
146
147    /// DELETE dispatcher.
148    pub async fn dispatch_delete(
149        State(state): State<Arc<AppState>>,
150        Path(rest): Path<String>,
151        headers: HeaderMap,
152    ) -> Response {
153        let (name, suffix) = match decode(&rest) {
154            Ok(v) => v,
155            Err(e) => return e.into_response(),
156        };
157        dispatch_inner(
158            state,
159            name,
160            suffix,
161            Method::DELETE,
162            headers,
163            std::collections::BTreeMap::default(),
164            Bytes::new(),
165        )
166        .await
167    }
168
169    /// POST dispatcher (blob upload init).
170    pub async fn dispatch_post(
171        State(state): State<Arc<AppState>>,
172        Path(rest): Path<String>,
173        Query(params): Query<std::collections::BTreeMap<String, String>>,
174        headers: HeaderMap,
175        body: Bytes,
176    ) -> Response {
177        let (name, suffix) = match decode(&rest) {
178            Ok(v) => v,
179            Err(e) => return e.into_response(),
180        };
181        dispatch_inner(state, name, suffix, Method::POST, headers, params, body).await
182    }
183
184    /// PATCH dispatcher.
185    pub async fn dispatch_patch_inner(
186        State(state): State<Arc<AppState>>,
187        Path(rest): Path<String>,
188        headers: HeaderMap,
189        body: Bytes,
190    ) -> Response {
191        let (name, suffix) = match decode(&rest) {
192            Ok(v) => v,
193            Err(e) => return e.into_response(),
194        };
195        dispatch_inner(
196            state,
197            name,
198            suffix,
199            Method::PATCH,
200            headers,
201            std::collections::BTreeMap::default(),
202            body,
203        )
204        .await
205    }
206
207    /// PUT dispatcher.
208    pub async fn dispatch_put_inner(
209        State(state): State<Arc<AppState>>,
210        Path(rest): Path<String>,
211        Query(params): Query<std::collections::BTreeMap<String, String>>,
212        headers: HeaderMap,
213        body: Bytes,
214    ) -> Response {
215        let (name, suffix) = match decode(&rest) {
216            Ok(v) => v,
217            Err(e) => return e.into_response(),
218        };
219        dispatch_inner(state, name, suffix, Method::PUT, headers, params, body).await
220    }
221
222    #[allow(clippy::too_many_arguments)]
223    async fn dispatch_inner(
224        state: Arc<AppState>,
225        name: String,
226        suffix: String,
227        method: Method,
228        headers: HeaderMap,
229        params: std::collections::BTreeMap<String, String>,
230        body: Bytes,
231    ) -> Response {
232        // Tag listing.
233        if suffix == "tags/list" {
234            return if method == Method::GET {
235                tags::list_tags(&state, &name, &params)
236                    .await
237                    .into_response()
238            } else {
239                OciError::new(OciErrorCode::Unsupported, "unsupported method")
240                    .with_status(StatusCode::METHOD_NOT_ALLOWED)
241                    .into_response()
242            };
243        }
244        // Referrers.
245        if let Some(rest) = suffix.strip_prefix("referrers/") {
246            return if method == Method::GET {
247                referrers::get_referrers(&state, &name, rest, &params)
248                    .await
249                    .into_response()
250            } else {
251                OciError::new(OciErrorCode::Unsupported, "unsupported method")
252                    .with_status(StatusCode::METHOD_NOT_ALLOWED)
253                    .into_response()
254            };
255        }
256        // Manifests.
257        if let Some(rest) = suffix.strip_prefix("manifests/") {
258            return match method {
259                Method::GET => manifest_h::get_manifest(&state, &name, rest, &headers)
260                    .await
261                    .into_response(),
262                Method::HEAD => manifest_h::head_manifest(&state, &name, rest)
263                    .await
264                    .into_response(),
265                Method::PUT => manifest_h::put_manifest(&state, &name, rest, &headers, body)
266                    .await
267                    .into_response(),
268                Method::DELETE => manifest_h::delete_manifest(&state, &name, rest)
269                    .await
270                    .into_response(),
271                _ => OciError::new(OciErrorCode::Unsupported, "unsupported method")
272                    .with_status(StatusCode::METHOD_NOT_ALLOWED)
273                    .into_response(),
274            };
275        }
276        // Blob uploads.
277        if let Some(rest) = suffix.strip_prefix("blobs/uploads/") {
278            let uuid = rest.trim_end_matches('/');
279            return match method {
280                Method::POST => {
281                    // `rest` is "" for the "/blobs/uploads/" endpoint.
282                    blob_upload::init_upload(&state, &name, &headers, &params, body)
283                        .await
284                        .into_response()
285                }
286                Method::PATCH => blob_upload::patch_upload(&state, &name, uuid, &headers, body)
287                    .await
288                    .into_response(),
289                Method::PUT => blob_upload::finish_upload(&state, &name, uuid, &params, body)
290                    .await
291                    .into_response(),
292                Method::GET => blob_upload::get_upload_status(&state, &name, uuid)
293                    .await
294                    .into_response(),
295                Method::DELETE => blob_upload::cancel_upload(&state, &name, uuid)
296                    .await
297                    .into_response(),
298                _ => OciError::new(OciErrorCode::Unsupported, "unsupported method")
299                    .with_status(StatusCode::METHOD_NOT_ALLOWED)
300                    .into_response(),
301            };
302        }
303        // Blobs (by digest).
304        if let Some(rest) = suffix.strip_prefix("blobs/") {
305            return match method {
306                Method::GET => blob::get_blob(&state, &name, rest).await.into_response(),
307                Method::HEAD => blob::head_blob(&state, &name, rest).await.into_response(),
308                Method::DELETE => blob::delete_blob(&state, &name, rest).await.into_response(),
309                _ => OciError::new(OciErrorCode::Unsupported, "unsupported method")
310                    .with_status(StatusCode::METHOD_NOT_ALLOWED)
311                    .into_response(),
312            };
313        }
314        OciError::new(
315            OciErrorCode::NameUnknown,
316            format!("cannot route `{name}/{suffix}`"),
317        )
318        .into_response()
319    }
320
321    #[cfg(test)]
322    mod tests {
323        use super::split_rest;
324
325        #[test]
326        fn split_simple_manifest_path() {
327            let (name, suffix) = split_rest("alpine/manifests/latest").expect("split");
328            assert_eq!(name, "alpine");
329            assert_eq!(suffix, "manifests/latest");
330        }
331
332        #[test]
333        fn split_nested_blob_path() {
334            let (name, suffix) = split_rest("my-org/lib/alpine/blobs/uploads/abc").expect("split");
335            assert_eq!(name, "my-org/lib/alpine");
336            assert_eq!(suffix, "blobs/uploads/abc");
337        }
338
339        #[test]
340        fn split_tags_list() {
341            let (name, suffix) = split_rest("lib/alpine/tags/list").expect("split");
342            assert_eq!(name, "lib/alpine");
343            assert_eq!(suffix, "tags/list");
344        }
345
346        #[test]
347        fn split_referrers() {
348            let (name, suffix) = split_rest("lib/alpine/referrers/sha256:abcd").expect("split");
349            assert_eq!(name, "lib/alpine");
350            assert_eq!(suffix, "referrers/sha256:abcd");
351        }
352
353        #[test]
354        fn split_none_for_bare_name() {
355            assert!(split_rest("alpine").is_none());
356        }
357    }
358}