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::{buffer::BufferWriter, 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, derive_more::Display)]
147#[display("registry listing buffer too small")]
148pub struct BufferTooSmall;
149
150impl From<fmt::Error> for BufferTooSmall {
151    fn from(_: fmt::Error) -> Self {
152        Self
153    }
154}
155
156/// Serve GET/HEAD and OPTIONS from a read-only registry. Pagination accepts
157/// nonnegative `n` and a percent-encoded `last` cursor (maximum 256 decoded bytes).
158/// `n=0` returns an empty page without a continuation link.
159/// Unknown query parameters are ignored. Malformed known parameters return 400.
160/// Listings are lexically sorted even if the store's names are unsorted.
161pub fn serve<'a>(
162    store: &'a impl Store,
163    method: &str,
164    target: &'a str,
165    scratch: &'a mut [u8],
166) -> Result<Response<'a>, BufferTooSmall> {
167    let head = method == "HEAD";
168    if method == "OPTIONS" {
169        return Ok(response(204, b"", "application/json", None, false));
170    }
171    if method != "GET" && !head {
172        return Ok(error(405, "UNSUPPORTED", head));
173    }
174    let (path, query) = target.split_once('?').unwrap_or((target, ""));
175    if path == "/v2/" || path == "/v2" {
176        return Ok(response(200, b"{}", "application/json", None, head));
177    }
178    let Some(rest) = path.strip_prefix("/v2/") else {
179        return Ok(error(404, "NAME_UNKNOWN", head));
180    };
181    let listing = if rest == "_catalog" {
182        Some(None)
183    } else {
184        rest.strip_suffix("/tags/list").map(Some)
185    };
186    if let Some(repository) = listing {
187        return serve_listing(store, repository, path, query, scratch, head);
188    }
189    let Some((prefix, reference)) = rest.rsplit_once('/') else {
190        return Ok(error(404, "NAME_UNKNOWN", head));
191    };
192    let Some((repo, operation)) = prefix.rsplit_once('/') else {
193        return Ok(error(404, "NAME_UNKNOWN", head));
194    };
195    if !store.contains_repository(repo) {
196        return Ok(error(404, "NAME_UNKNOWN", head));
197    }
198    let (content, missing) = match operation {
199        "manifests" => (store.manifest(repo, reference), "MANIFEST_UNKNOWN"),
200        "blobs" => {
201            let Ok(digest) = Digest::parse(reference) else {
202                return Ok(error(400, "DIGEST_INVALID", head));
203            };
204            (
205                store.blob(repo, digest).map(|content| Content {
206                    media_type: "application/octet-stream",
207                    ..content
208                }),
209                "BLOB_UNKNOWN",
210            )
211        }
212        _ => return Ok(error(404, "NAME_UNKNOWN", head)),
213    };
214    Ok(match content {
215        Some(content) => response(
216            200,
217            content.bytes,
218            content.media_type,
219            Some(content.digest),
220            head,
221        ),
222        None => error(404, missing, head),
223    })
224}
225
226fn serve_listing<'a>(
227    store: &'a impl Store,
228    repository: Option<&'a str>,
229    path: &'a str,
230    query: &str,
231    scratch: &'a mut [u8],
232    head: bool,
233) -> Result<Response<'a>, BufferTooSmall> {
234    if repository.is_some_and(|repo| !store.contains_repository(repo)) {
235        return Ok(error(404, "NAME_UNKNOWN", head));
236    }
237    let mut cursor = [0u8; 256];
238    let (n, last) = match pagination(query, &mut cursor) {
239        Some(value) => value,
240        None => return Ok(error(400, "UNSUPPORTED", head)),
241    };
242    let name_at = |index| match repository {
243        Some(repo) => store.tag(repo, index),
244        None => store.repository(index),
245    };
246    let mut output = BufferWriter::new(scratch);
247    if let Some(repo) = repository {
248        write!(output, r#"{{"name":"{repo}","tags":["#)?;
249    } else {
250        output.write_str(r#"{"repositories":["#)?;
251    }
252    let mut previous = last;
253    let mut count = 0;
254    let mut emitted = None;
255    let mut next = None;
256    loop {
257        if n == 0 {
258            break;
259        }
260        // Selection avoids allocation and imposes no sorting requirement on
261        // small embedded stores. Large stores can implement indexed access.
262        let Some(name) = (0..)
263            .map_while(&name_at)
264            .filter(|name| *name > previous)
265            .min()
266        else {
267            break;
268        };
269        if count == n {
270            next = Some(NextLink {
271                path,
272                last: emitted.unwrap(),
273                n,
274            });
275            break;
276        }
277        if count != 0 {
278            output.write_str(",")?;
279        }
280        // Store names follow OCI grammar: ASCII with no JSON escapes.
281        write!(output, "\"{name}\"")?;
282        previous = name;
283        emitted = Some(name);
284        count += 1;
285    }
286    output.write_str("]}")?;
287    let length = output.len();
288    let mut result = response(200, &scratch[..length], "application/json", None, head);
289    result.next = next;
290    Ok(result)
291}
292
293fn response<'a>(
294    status: u16,
295    bytes: &'a [u8],
296    media_type: &'a str,
297    digest: Option<Digest>,
298    head: bool,
299) -> Response<'a> {
300    Response {
301        status,
302        media_type,
303        body: if head { b"" } else { bytes },
304        content_length: bytes.len(),
305        digest,
306        next: None,
307    }
308}
309
310fn error(status: u16, code: &str, head: bool) -> Response<'static> {
311    let bytes: &[u8] = match code {
312        "MANIFEST_UNKNOWN" => {
313            br#"{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown"}]}"#
314        }
315        "BLOB_UNKNOWN" => br#"{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown"}]}"#,
316        "DIGEST_INVALID" => br#"{"errors":[{"code":"DIGEST_INVALID","message":"invalid digest"}]}"#,
317        "UNSUPPORTED" => br#"{"errors":[{"code":"UNSUPPORTED","message":"unsupported request"}]}"#,
318        _ => br#"{"errors":[{"code":"NAME_UNKNOWN","message":"repository unknown"}]}"#,
319    };
320    response(status, bytes, "application/json", None, head)
321}
322
323fn pagination<'a>(query: &str, cursor: &'a mut [u8]) -> Option<(usize, &'a str)> {
324    let mut n = None;
325    let mut last = None;
326    for parameter in query.split('&').filter(|item| !item.is_empty()) {
327        let (key, value) = parameter.split_once('=').unwrap_or((parameter, ""));
328        match key {
329            "n" => {
330                if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
331                    return None;
332                }
333                if n.replace(value.parse().ok()?).is_some() {
334                    return None;
335                }
336            }
337            "last" if last.replace(value).is_some() => return None,
338            _ => {}
339        }
340    }
341    let mut bytes = last.unwrap_or("").bytes();
342    let mut len = 0;
343    while let Some(byte) = bytes.next() {
344        let decoded = if byte == b'%' {
345            let high = (bytes.next()? as char).to_digit(16)?;
346            let low = (bytes.next()? as char).to_digit(16)?;
347            (high * 16 + low) as u8
348        } else if byte == b'+' {
349            b' '
350        } else {
351            byte
352        };
353        *cursor.get_mut(len)? = decoded;
354        len += 1;
355    }
356    Some((
357        n.unwrap_or(usize::MAX),
358        core::str::from_utf8(&cursor[..len]).ok()?,
359    ))
360}