Skip to main content

ferro_oci_server/
upload.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Blob-upload session state machine.
3//!
4//! Spec: OCI Distribution Spec v1.1 §4.3 "Pushing blobs".
5//!
6//! An upload session is created by `POST /v2/<name>/blobs/uploads/`
7//! and identified by a UUID that appears in the `Location` header of
8//! the response. Clients can then:
9//!
10//! - append chunks via `PATCH /v2/<name>/blobs/uploads/<uuid>` with a
11//!   `Content-Range: <start>-<end>` header;
12//! - finalize via `PUT /v2/<name>/blobs/uploads/<uuid>?digest=<digest>`;
13//! - cancel via `DELETE /v2/<name>/blobs/uploads/<uuid>`.
14//!
15//! This module holds the data-only `UploadState` struct plus helpers
16//! for parsing the `Content-Range` header. The actual persistence is
17//! delegated to the `RegistryMeta` trait — the in-memory impl is
18//! provided in [`crate::registry`].
19
20use bytes::{Bytes, BytesMut};
21
22/// State of an in-flight blob upload.
23///
24/// Stored per upload UUID. Chunk bytes are accumulated in `buffer`
25/// until the final `PUT` arrives and the client-declared digest is
26/// compared against a recompute over the buffer.
27#[derive(Debug, Clone)]
28pub struct UploadState {
29    /// Repository name the upload belongs to.
30    pub name: String,
31    /// Upload UUID generated by [`RegistryMeta::start_upload`].
32    pub uuid: String,
33    /// Accumulated bytes.
34    pub buffer: BytesMut,
35}
36
37impl UploadState {
38    /// Build a new empty upload state.
39    #[must_use]
40    pub fn new(name: impl Into<String>, uuid: impl Into<String>) -> Self {
41        Self {
42            name: name.into(),
43            uuid: uuid.into(),
44            buffer: BytesMut::new(),
45        }
46    }
47
48    /// Current byte offset (= number of bytes buffered so far).
49    #[must_use]
50    pub fn offset(&self) -> u64 {
51        self.buffer.len() as u64
52    }
53
54    /// Append a chunk, returning the new offset.
55    pub fn append(&mut self, chunk: &Bytes) -> u64 {
56        self.buffer.extend_from_slice(chunk);
57        self.offset()
58    }
59
60    /// Take the accumulated bytes, leaving the buffer empty.
61    pub fn take_bytes(&mut self) -> Bytes {
62        std::mem::take(&mut self.buffer).freeze()
63    }
64}
65
66/// Error returned when a `Content-Range` header cannot be parsed.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
68pub enum ContentRangeParseError {
69    /// The string did not match the expected `<start>-<end>` form.
70    #[error("malformed Content-Range")]
71    Malformed,
72    /// `<start>` was greater than `<end>`.
73    #[error("reversed range (start > end)")]
74    Reversed,
75}
76
77/// Parsed `Content-Range: <start>-<end>` header.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct ContentRange {
80    /// Inclusive start byte offset.
81    pub start: u64,
82    /// Inclusive end byte offset.
83    pub end: u64,
84}
85
86impl ContentRange {
87    /// Parse a `Content-Range` header value as defined by Distribution
88    /// Spec v1.1 §4.3 (different from RFC 7233 — no `bytes ` prefix,
89    /// no total-length suffix).
90    ///
91    /// # Errors
92    ///
93    /// Returns [`ContentRangeParseError`] when the value is not `N-M`
94    /// with `N <= M`.
95    pub fn parse(value: &str) -> Result<Self, ContentRangeParseError> {
96        let value = value.trim();
97        // Accept both the `bytes N-M` (RFC 7233) and the bare `N-M`
98        // forms so clients that serialize either one interoperate.
99        let payload = value.strip_prefix("bytes ").unwrap_or(value);
100        let payload = payload.split('/').next().unwrap_or(payload);
101        let (start, end) = payload
102            .split_once('-')
103            .ok_or(ContentRangeParseError::Malformed)?;
104        let start: u64 = start
105            .trim()
106            .parse()
107            .map_err(|_| ContentRangeParseError::Malformed)?;
108        let end: u64 = end
109            .trim()
110            .parse()
111            .map_err(|_| ContentRangeParseError::Malformed)?;
112        if start > end {
113            return Err(ContentRangeParseError::Reversed);
114        }
115        Ok(Self { start, end })
116    }
117
118    /// Inclusive byte length.
119    #[must_use]
120    pub const fn length(self) -> u64 {
121        self.end - self.start + 1
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::{ContentRange, UploadState};
128    use bytes::Bytes;
129
130    #[test]
131    fn append_updates_offset() {
132        let mut s = UploadState::new("lib/alpine", "abc");
133        assert_eq!(s.offset(), 0);
134        let n1 = s.append(&Bytes::from_static(b"hello"));
135        assert_eq!(n1, 5);
136        let n2 = s.append(&Bytes::from_static(b"!"));
137        assert_eq!(n2, 6);
138    }
139
140    #[test]
141    fn take_bytes_returns_everything_and_resets() {
142        let mut s = UploadState::new("lib/alpine", "abc");
143        s.append(&Bytes::from_static(b"hello"));
144        let out = s.take_bytes();
145        assert_eq!(&out[..], b"hello");
146        assert_eq!(s.offset(), 0);
147    }
148
149    #[test]
150    fn content_range_parse_bare_form() {
151        let r = ContentRange::parse("0-1023").expect("parse");
152        assert_eq!(
153            r,
154            ContentRange {
155                start: 0,
156                end: 1023
157            }
158        );
159        assert_eq!(r.length(), 1024);
160    }
161
162    #[test]
163    fn content_range_parse_bytes_prefix() {
164        let r = ContentRange::parse("bytes 100-199").expect("parse");
165        assert_eq!(
166            r,
167            ContentRange {
168                start: 100,
169                end: 199
170            }
171        );
172    }
173
174    #[test]
175    fn content_range_parse_with_total() {
176        let r = ContentRange::parse("bytes 0-9/100").expect("parse");
177        assert_eq!(r, ContentRange { start: 0, end: 9 });
178    }
179
180    #[test]
181    fn content_range_rejects_reversed() {
182        assert!(ContentRange::parse("10-5").is_err());
183    }
184
185    #[test]
186    fn content_range_rejects_garbage() {
187        assert!(ContentRange::parse("not-a-range").is_err());
188        assert!(ContentRange::parse("").is_err());
189    }
190}