Skip to main content

oci_zero/
server.rs

1//! A read-only OCI Distribution router, independent of HTTP and storage runtimes.
2//!
3//! Supply a [`Store`] (borrowed memory, flash, or a memfs), and pass an HTTP method
4//! and origin-form request target to [`serve`]. Only listings use the caller's
5//! scratch buffer; manifests and blobs are borrowed directly from the store.
6//! Transports must send the response metadata, including HEAD's content length,
7//! and may add CORS, authentication, caching, or TLS outside this module.
8//! Uploads, deletion, referrers, ranges, and Accept negotiation are not supported.
9
10use core::fmt::{self, Write};
11
12use sha2::{Digest as _, Sha256};
13
14use crate::digest::Digest;
15
16/// Immutable bytes and their OCI media type and content digest.
17#[derive(Clone, Copy, Debug)]
18pub struct Content<'a> {
19    pub bytes: &'a [u8],
20    pub media_type: &'a str,
21    pub digest: Digest,
22}
23
24impl<'a> Content<'a> {
25    /// Hash once when constructing the store, rather than on each request.
26    pub fn new(bytes: &'a [u8], media_type: &'a str) -> Self {
27        Self {
28            bytes,
29            media_type,
30            digest: Digest::from_bytes(Sha256::digest(bytes).into()),
31        }
32    }
33}
34
35/// Storage contract for a read-only registry.
36///
37/// Names must follow OCI repository/tag grammar and be unique. Indexed lists
38/// terminate at the first `None`; their order need not be sorted. Content digests
39/// must describe the exact bytes returned. Blob lookup is repository scoped.
40pub trait Store {
41    fn repository(&self, index: usize) -> Option<&str>;
42    fn tag(&self, repository: &str, index: usize) -> Option<&str>;
43    fn contains_repository(&self, repository: &str) -> bool;
44    fn manifest(&self, repository: &str, reference: &str) -> Option<Content<'_>>;
45    fn blob(&self, repository: &str, digest: Digest) -> Option<Content<'_>>;
46}
47
48/// A tag alias for a manifest in [`Repository::manifests`].
49#[derive(Clone, Copy, Debug)]
50pub struct Tag<'a> {
51    pub name: &'a str,
52    pub digest: Digest,
53}
54
55/// A borrowed repository, including untagged manifests (e.g. index children).
56#[derive(Clone, Copy, Debug)]
57pub struct Repository<'a> {
58    pub name: &'a str,
59    pub tags: &'a [Tag<'a>],
60    pub manifests: &'a [Content<'a>],
61    pub blobs: &'a [Content<'a>],
62}
63
64/// An allocation-free store of borrowed slices. Fields may point to ROM,
65/// `include_bytes!` assets, or buffers owned by the caller.
66#[derive(Clone, Copy, Debug)]
67pub struct MemoryStore<'a>(pub &'a [Repository<'a>]);
68
69impl MemoryStore<'_> {
70    fn find(&self, name: &str) -> Option<&Repository<'_>> {
71        self.0.iter().find(|repo| repo.name == name)
72    }
73}
74
75impl Store for MemoryStore<'_> {
76    fn repository(&self, index: usize) -> Option<&str> {
77        self.0.get(index).map(|repo| repo.name)
78    }
79
80    fn tag(&self, repository: &str, index: usize) -> Option<&str> {
81        self.find(repository)?.tags.get(index).map(|tag| tag.name)
82    }
83
84    fn contains_repository(&self, repository: &str) -> bool {
85        self.find(repository).is_some()
86    }
87
88    fn manifest(&self, repository: &str, reference: &str) -> Option<Content<'_>> {
89        let repo = self.find(repository)?;
90        let digest = if let Ok(digest) = Digest::parse(reference) {
91            digest
92        } else {
93            repo.tags.iter().find(|tag| tag.name == reference)?.digest
94        };
95        repo.manifests
96            .iter()
97            .find(|item| item.digest == digest)
98            .copied()
99    }
100
101    fn blob(&self, repository: &str, digest: Digest) -> Option<Content<'_>> {
102        self.find(repository)?
103            .blobs
104            .iter()
105            .find(|item| item.digest == digest)
106            .copied()
107    }
108}
109
110/// Metadata and body to pass to an HTTP transport.
111#[derive(Debug)]
112pub struct Response<'a> {
113    pub status: u16,
114    pub media_type: &'a str,
115    pub body: &'a [u8],
116    /// GET representation length, even when HEAD suppresses the body.
117    pub content_length: usize,
118    pub digest: Option<Digest>,
119    pub next: Option<NextLink<'a>>,
120}
121
122/// Format as an HTTP `Link` header. The cursor is percent encoded.
123#[derive(Clone, Copy, Debug)]
124pub struct NextLink<'a> {
125    pub path: &'a str,
126    pub last: &'a str,
127    pub n: usize,
128}
129
130impl fmt::Display for NextLink<'_> {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write!(f, "<{}?n={}&last=", self.path, self.n)?;
133        for byte in self.last.bytes() {
134            if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
135                write!(f, "{}", byte as char)?;
136            } else {
137                write!(f, "%{byte:02X}")?;
138            }
139        }
140        f.write_str(">; rel=\"next\"")
141    }
142}
143
144/// The scratch buffer cannot hold the generated listing. No partial response
145/// is returned; the transport can retry with more space or return HTTP 500.
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub struct BufferTooSmall;
148
149impl fmt::Display for BufferTooSmall {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.write_str("registry listing buffer too small")
152    }
153}
154
155impl From<fmt::Error> for BufferTooSmall {
156    fn from(_: fmt::Error) -> Self {
157        Self
158    }
159}
160
161/// Serve GET/HEAD and OPTIONS from a read-only registry. Pagination accepts
162/// nonnegative `n` and a percent-encoded `last` cursor (maximum 256 decoded bytes).
163/// `n=0` returns an empty page without a continuation link.
164/// Unknown query parameters are ignored. Malformed known parameters return 400.
165/// Listings are lexically sorted even if the store's names are unsorted.
166pub fn serve<'a>(
167    store: &'a impl Store,
168    method: &str,
169    target: &'a str,
170    scratch: &'a mut [u8],
171) -> Result<Response<'a>, BufferTooSmall> {
172    let head = method == "HEAD";
173    if method == "OPTIONS" {
174        return Ok(response(204, b"", "application/json", None, false));
175    }
176    if method != "GET" && !head {
177        return Ok(error(405, "UNSUPPORTED", head));
178    }
179    let (path, query) = target.split_once('?').unwrap_or((target, ""));
180    if path == "/v2/" || path == "/v2" {
181        return Ok(response(200, b"{}", "application/json", None, head));
182    }
183    let Some(rest) = path.strip_prefix("/v2/") else {
184        return Ok(error(404, "NAME_UNKNOWN", head));
185    };
186    let listing = if rest == "_catalog" {
187        Some(None)
188    } else {
189        rest.strip_suffix("/tags/list").map(Some)
190    };
191    if let Some(repository) = listing {
192        if repository.is_some_and(|repo| !store.contains_repository(repo)) {
193            return Ok(error(404, "NAME_UNKNOWN", head));
194        }
195        let mut cursor = [0u8; 256];
196        let (n, last) = match pagination(query, &mut cursor) {
197            Some(value) => value,
198            None => return Ok(error(400, "UNSUPPORTED", head)),
199        };
200        let name_at = |index| match repository {
201            Some(repo) => store.tag(repo, index),
202            None => store.repository(index),
203        };
204        let mut output = Output {
205            bytes: scratch,
206            len: 0,
207        };
208        if let Some(repo) = repository {
209            write!(output, r#"{{"name":"{repo}","tags":["#)?;
210        } else {
211            output.write_str(r#"{"repositories":["#)?;
212        }
213        let mut previous = last;
214        let mut count = 0;
215        let mut emitted = None;
216        let mut next = None;
217        loop {
218            if n == 0 {
219                break;
220            }
221            // Selection avoids allocation and imposes no sorting requirement on
222            // small embedded stores. Large stores can implement indexed access.
223            let Some(name) = (0..)
224                .map_while(&name_at)
225                .filter(|name| *name > previous)
226                .min()
227            else {
228                break;
229            };
230            if count == n {
231                next = Some(NextLink {
232                    path,
233                    last: emitted.unwrap(),
234                    n,
235                });
236                break;
237            }
238            if count != 0 {
239                output.write_str(",")?;
240            }
241            // Store names follow OCI grammar: ASCII with no JSON escapes.
242            write!(output, "\"{name}\"")?;
243            previous = name;
244            emitted = Some(name);
245            count += 1;
246        }
247        output.write_str("]}")?;
248        let length = output.len;
249        let mut result = response(200, &scratch[..length], "application/json", None, head);
250        result.next = next;
251        return Ok(result);
252    }
253    let Some((prefix, reference)) = rest.rsplit_once('/') else {
254        return Ok(error(404, "NAME_UNKNOWN", head));
255    };
256    let Some((repo, operation)) = prefix.rsplit_once('/') else {
257        return Ok(error(404, "NAME_UNKNOWN", head));
258    };
259    if !store.contains_repository(repo) {
260        return Ok(error(404, "NAME_UNKNOWN", head));
261    }
262    let (content, missing) = match operation {
263        "manifests" => (store.manifest(repo, reference), "MANIFEST_UNKNOWN"),
264        "blobs" => {
265            let Ok(digest) = Digest::parse(reference) else {
266                return Ok(error(400, "DIGEST_INVALID", head));
267            };
268            (
269                store.blob(repo, digest).map(|content| Content {
270                    media_type: "application/octet-stream",
271                    ..content
272                }),
273                "BLOB_UNKNOWN",
274            )
275        }
276        _ => return Ok(error(404, "NAME_UNKNOWN", head)),
277    };
278    Ok(match content {
279        Some(content) => response(
280            200,
281            content.bytes,
282            content.media_type,
283            Some(content.digest),
284            head,
285        ),
286        None => error(404, missing, head),
287    })
288}
289
290fn response<'a>(
291    status: u16,
292    bytes: &'a [u8],
293    media_type: &'a str,
294    digest: Option<Digest>,
295    head: bool,
296) -> Response<'a> {
297    Response {
298        status,
299        media_type,
300        body: if head { b"" } else { bytes },
301        content_length: bytes.len(),
302        digest,
303        next: None,
304    }
305}
306
307fn error(status: u16, code: &str, head: bool) -> Response<'static> {
308    let bytes: &[u8] = match code {
309        "MANIFEST_UNKNOWN" => {
310            br#"{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown"}]}"#
311        }
312        "BLOB_UNKNOWN" => br#"{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown"}]}"#,
313        "DIGEST_INVALID" => br#"{"errors":[{"code":"DIGEST_INVALID","message":"invalid digest"}]}"#,
314        "UNSUPPORTED" => br#"{"errors":[{"code":"UNSUPPORTED","message":"unsupported request"}]}"#,
315        _ => br#"{"errors":[{"code":"NAME_UNKNOWN","message":"repository unknown"}]}"#,
316    };
317    response(status, bytes, "application/json", None, head)
318}
319
320fn pagination<'a>(query: &str, cursor: &'a mut [u8]) -> Option<(usize, &'a str)> {
321    let mut n = None;
322    let mut last = None;
323    for parameter in query.split('&').filter(|item| !item.is_empty()) {
324        let (key, value) = parameter.split_once('=').unwrap_or((parameter, ""));
325        match key {
326            "n" => {
327                if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
328                    return None;
329                }
330                if n.replace(value.parse().ok()?).is_some() {
331                    return None;
332                }
333            }
334            "last" if last.replace(value).is_some() => return None,
335            _ => {}
336        }
337    }
338    let mut bytes = last.unwrap_or("").bytes();
339    let mut len = 0;
340    while let Some(byte) = bytes.next() {
341        let decoded = if byte == b'%' {
342            let high = (bytes.next()? as char).to_digit(16)?;
343            let low = (bytes.next()? as char).to_digit(16)?;
344            (high * 16 + low) as u8
345        } else if byte == b'+' {
346            b' '
347        } else {
348            byte
349        };
350        *cursor.get_mut(len)? = decoded;
351        len += 1;
352    }
353    Some((
354        n.unwrap_or(usize::MAX),
355        core::str::from_utf8(&cursor[..len]).ok()?,
356    ))
357}
358
359struct Output<'a> {
360    bytes: &'a mut [u8],
361    len: usize,
362}
363
364impl Write for Output<'_> {
365    fn write_str(&mut self, value: &str) -> fmt::Result {
366        let end = self.len.checked_add(value.len()).ok_or(fmt::Error)?;
367        self.bytes
368            .get_mut(self.len..end)
369            .ok_or(fmt::Error)?
370            .copy_from_slice(value.as_bytes());
371        self.len = end;
372        Ok(())
373    }
374}