ferro-oci-server 0.0.1

OCI Distribution Specification v1.1 server-side primitives — manifest / blob / tag / referrers handlers, chunked uploads, in-memory metadata plane. Backed by ferro-blob-store. Extracted from the Ferro ecosystem.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Blob-upload session state machine.
//!
//! Spec: OCI Distribution Spec v1.1 §4.3 "Pushing blobs".
//!
//! An upload session is created by `POST /v2/<name>/blobs/uploads/`
//! and identified by a UUID that appears in the `Location` header of
//! the response. Clients can then:
//!
//! - append chunks via `PATCH /v2/<name>/blobs/uploads/<uuid>` with a
//!   `Content-Range: <start>-<end>` header;
//! - finalize via `PUT /v2/<name>/blobs/uploads/<uuid>?digest=<digest>`;
//! - cancel via `DELETE /v2/<name>/blobs/uploads/<uuid>`.
//!
//! This module holds the data-only `UploadState` struct plus helpers
//! for parsing the `Content-Range` header. The actual persistence is
//! delegated to the `RegistryMeta` trait — the in-memory impl is
//! provided in [`crate::registry`].

use bytes::{Bytes, BytesMut};

/// State of an in-flight blob upload.
///
/// Stored per upload UUID. Chunk bytes are accumulated in `buffer`
/// until the final `PUT` arrives and the client-declared digest is
/// compared against a recompute over the buffer.
#[derive(Debug, Clone)]
pub struct UploadState {
    /// Repository name the upload belongs to.
    pub name: String,
    /// Upload UUID generated by [`RegistryMeta::start_upload`].
    pub uuid: String,
    /// Accumulated bytes.
    pub buffer: BytesMut,
}

impl UploadState {
    /// Build a new empty upload state.
    #[must_use]
    pub fn new(name: impl Into<String>, uuid: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            uuid: uuid.into(),
            buffer: BytesMut::new(),
        }
    }

    /// Current byte offset (= number of bytes buffered so far).
    #[must_use]
    pub fn offset(&self) -> u64 {
        self.buffer.len() as u64
    }

    /// Append a chunk, returning the new offset.
    pub fn append(&mut self, chunk: &Bytes) -> u64 {
        self.buffer.extend_from_slice(chunk);
        self.offset()
    }

    /// Take the accumulated bytes, leaving the buffer empty.
    pub fn take_bytes(&mut self) -> Bytes {
        std::mem::take(&mut self.buffer).freeze()
    }
}

/// Error returned when a `Content-Range` header cannot be parsed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ContentRangeParseError {
    /// The string did not match the expected `<start>-<end>` form.
    #[error("malformed Content-Range")]
    Malformed,
    /// `<start>` was greater than `<end>`.
    #[error("reversed range (start > end)")]
    Reversed,
}

/// Parsed `Content-Range: <start>-<end>` header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentRange {
    /// Inclusive start byte offset.
    pub start: u64,
    /// Inclusive end byte offset.
    pub end: u64,
}

impl ContentRange {
    /// Parse a `Content-Range` header value as defined by Distribution
    /// Spec v1.1 §4.3 (different from RFC 7233 — no `bytes ` prefix,
    /// no total-length suffix).
    ///
    /// # Errors
    ///
    /// Returns [`ContentRangeParseError`] when the value is not `N-M`
    /// with `N <= M`.
    pub fn parse(value: &str) -> Result<Self, ContentRangeParseError> {
        let value = value.trim();
        // Accept both the `bytes N-M` (RFC 7233) and the bare `N-M`
        // forms so clients that serialize either one interoperate.
        let payload = value.strip_prefix("bytes ").unwrap_or(value);
        let payload = payload.split('/').next().unwrap_or(payload);
        let (start, end) = payload
            .split_once('-')
            .ok_or(ContentRangeParseError::Malformed)?;
        let start: u64 = start
            .trim()
            .parse()
            .map_err(|_| ContentRangeParseError::Malformed)?;
        let end: u64 = end
            .trim()
            .parse()
            .map_err(|_| ContentRangeParseError::Malformed)?;
        if start > end {
            return Err(ContentRangeParseError::Reversed);
        }
        Ok(Self { start, end })
    }

    /// Inclusive byte length.
    #[must_use]
    pub const fn length(self) -> u64 {
        self.end - self.start + 1
    }
}

#[cfg(test)]
mod tests {
    use super::{ContentRange, UploadState};
    use bytes::Bytes;

    #[test]
    fn append_updates_offset() {
        let mut s = UploadState::new("lib/alpine", "abc");
        assert_eq!(s.offset(), 0);
        let n1 = s.append(&Bytes::from_static(b"hello"));
        assert_eq!(n1, 5);
        let n2 = s.append(&Bytes::from_static(b"!"));
        assert_eq!(n2, 6);
    }

    #[test]
    fn take_bytes_returns_everything_and_resets() {
        let mut s = UploadState::new("lib/alpine", "abc");
        s.append(&Bytes::from_static(b"hello"));
        let out = s.take_bytes();
        assert_eq!(&out[..], b"hello");
        assert_eq!(s.offset(), 0);
    }

    #[test]
    fn content_range_parse_bare_form() {
        let r = ContentRange::parse("0-1023").expect("parse");
        assert_eq!(
            r,
            ContentRange {
                start: 0,
                end: 1023
            }
        );
        assert_eq!(r.length(), 1024);
    }

    #[test]
    fn content_range_parse_bytes_prefix() {
        let r = ContentRange::parse("bytes 100-199").expect("parse");
        assert_eq!(
            r,
            ContentRange {
                start: 100,
                end: 199
            }
        );
    }

    #[test]
    fn content_range_parse_with_total() {
        let r = ContentRange::parse("bytes 0-9/100").expect("parse");
        assert_eq!(r, ContentRange { start: 0, end: 9 });
    }

    #[test]
    fn content_range_rejects_reversed() {
        assert!(ContentRange::parse("10-5").is_err());
    }

    #[test]
    fn content_range_rejects_garbage() {
        assert!(ContentRange::parse("not-a-range").is_err());
        assert!(ContentRange::parse("").is_err());
    }
}