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 std::time::Instant;
21
22use bytes::{Bytes, BytesMut};
23
24/// Maximum number of bytes a single in-flight upload session may
25/// accumulate before the server refuses further chunks.
26///
27/// The OCI Distribution Spec does not mandate a hard maximum blob size,
28/// but §4.3 chunked uploads buffer bytes server-side, and an
29/// unauthenticated client can otherwise open sessions and append
30/// sub-limit chunks until process memory is exhausted (a
31/// memory-exhaustion `DoS`). We bound each session at 4 GiB — large enough
32/// for the multi-gigabyte layers real images carry, while still being a
33/// concrete ceiling. When a session would exceed this, the handler
34/// returns `413 Payload Too Large` with `BLOB_UPLOAD_INVALID` and the
35/// session buffer is dropped.
36///
37/// Follow-up (tracked for the CHANGELOG): the current in-memory session
38/// store keeps the whole upload in RAM. Spooling large uploads to disk
39/// and expiring idle sessions are larger refactors; the size cap here is
40/// the immediate closure of the unbounded-growth `DoS`.
41pub const MAX_UPLOAD_SESSION_BYTES: u64 = 4 * 1024 * 1024 * 1024;
42
43/// State of an in-flight blob upload.
44///
45/// Stored per upload UUID. Chunk bytes are accumulated in `buffer`
46/// until the final `PUT` arrives and the client-declared digest is
47/// compared against a recompute over the buffer.
48#[derive(Debug, Clone)]
49pub struct UploadState {
50    /// Repository name the upload belongs to.
51    pub name: String,
52    /// Upload UUID generated by [`crate::registry::RegistryMeta::start_upload`].
53    pub uuid: String,
54    /// Accumulated bytes.
55    pub buffer: BytesMut,
56    /// Wall-clock instant of the last activity on this session (creation
57    /// or the most recent appended chunk). Used by the registry to evict
58    /// idle sessions after a TTL (R2-7).
59    pub last_activity: Instant,
60}
61
62impl UploadState {
63    /// Build a new empty upload state.
64    #[must_use]
65    pub fn new(name: impl Into<String>, uuid: impl Into<String>) -> Self {
66        Self {
67            name: name.into(),
68            uuid: uuid.into(),
69            buffer: BytesMut::new(),
70            last_activity: Instant::now(),
71        }
72    }
73
74    /// Current byte offset (= number of bytes buffered so far).
75    #[must_use]
76    pub fn offset(&self) -> u64 {
77        self.buffer.len() as u64
78    }
79
80    /// Append a chunk, returning the new offset. Refreshes the
81    /// last-activity timestamp so an actively-progressing upload is not
82    /// swept by the idle-session TTL.
83    pub fn append(&mut self, chunk: &Bytes) -> u64 {
84        self.buffer.extend_from_slice(chunk);
85        self.last_activity = Instant::now();
86        self.offset()
87    }
88
89    /// True when this session has been idle (no creation/append activity)
90    /// for at least `ttl` measured against `now`.
91    #[must_use]
92    pub fn is_idle_for(&self, now: Instant, ttl: std::time::Duration) -> bool {
93        now.saturating_duration_since(self.last_activity) >= ttl
94    }
95
96    /// Take the accumulated bytes, leaving the buffer empty.
97    pub fn take_bytes(&mut self) -> Bytes {
98        std::mem::take(&mut self.buffer).freeze()
99    }
100}
101
102/// Error returned when a `Content-Range` header cannot be parsed.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
104pub enum ContentRangeParseError {
105    /// The string did not match the expected `<start>-<end>` form.
106    #[error("malformed Content-Range")]
107    Malformed,
108    /// `<start>` was greater than `<end>`.
109    #[error("reversed range (start > end)")]
110    Reversed,
111}
112
113/// Parsed `Content-Range: <start>-<end>` header.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct ContentRange {
116    /// Inclusive start byte offset.
117    pub start: u64,
118    /// Inclusive end byte offset.
119    pub end: u64,
120}
121
122impl ContentRange {
123    /// Parse a `Content-Range` header value as defined by Distribution
124    /// Spec v1.1 §4.3 (different from RFC 7233 — no `bytes ` prefix,
125    /// no total-length suffix).
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ContentRangeParseError`] when the value is not `N-M`
130    /// with `N <= M`.
131    pub fn parse(value: &str) -> Result<Self, ContentRangeParseError> {
132        let value = value.trim();
133        // Accept both the `bytes N-M` (RFC 7233) and the bare `N-M`
134        // forms so clients that serialize either one interoperate.
135        let payload = value.strip_prefix("bytes ").unwrap_or(value);
136        let payload = payload.split('/').next().unwrap_or(payload);
137        let (start, end) = payload
138            .split_once('-')
139            .ok_or(ContentRangeParseError::Malformed)?;
140        let start: u64 = start
141            .trim()
142            .parse()
143            .map_err(|_| ContentRangeParseError::Malformed)?;
144        let end: u64 = end
145            .trim()
146            .parse()
147            .map_err(|_| ContentRangeParseError::Malformed)?;
148        if start > end {
149            return Err(ContentRangeParseError::Reversed);
150        }
151        Ok(Self { start, end })
152    }
153
154    /// Inclusive byte length, or `None` when the span overflows `u64`.
155    ///
156    /// The inclusive length is `end - start + 1`. For the degenerate
157    /// range `0-u64::MAX` this is `u64::MAX + 1`, which overflows: in a
158    /// debug build the naive `end - start + 1` panics, and in release it
159    /// wraps to `0`, letting an empty `PATCH` body claim a full-range
160    /// span (`0` bytes "==" a `0`-length body). We compute with
161    /// `checked_*` so callers can reject the overflowing range as
162    /// `BLOB_UPLOAD_INVALID` rather than crash or mis-validate.
163    #[must_use]
164    pub const fn checked_length(self) -> Option<u64> {
165        match self.end.checked_sub(self.start) {
166            Some(span) => span.checked_add(1),
167            None => None,
168        }
169    }
170
171    /// Inclusive byte length.
172    ///
173    /// Saturates at `u64::MAX` when the true inclusive length would
174    /// overflow (the `0-u64::MAX` edge). Prefer [`Self::checked_length`]
175    /// when the overflow must be surfaced as an error; this convenience
176    /// accessor never panics and never wraps to `0`.
177    #[must_use]
178    pub const fn length(self) -> u64 {
179        match self.checked_length() {
180            Some(len) => len,
181            None => u64::MAX,
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::{ContentRange, UploadState};
189    use bytes::Bytes;
190
191    #[test]
192    fn append_updates_offset() {
193        let mut s = UploadState::new("lib/alpine", "abc");
194        assert_eq!(s.offset(), 0);
195        let n1 = s.append(&Bytes::from_static(b"hello"));
196        assert_eq!(n1, 5);
197        let n2 = s.append(&Bytes::from_static(b"!"));
198        assert_eq!(n2, 6);
199    }
200
201    #[test]
202    fn take_bytes_returns_everything_and_resets() {
203        let mut s = UploadState::new("lib/alpine", "abc");
204        s.append(&Bytes::from_static(b"hello"));
205        let out = s.take_bytes();
206        assert_eq!(&out[..], b"hello");
207        assert_eq!(s.offset(), 0);
208    }
209
210    #[test]
211    fn content_range_parse_bare_form() {
212        let r = ContentRange::parse("0-1023").expect("parse");
213        assert_eq!(
214            r,
215            ContentRange {
216                start: 0,
217                end: 1023
218            }
219        );
220        assert_eq!(r.length(), 1024);
221    }
222
223    #[test]
224    fn content_range_parse_bytes_prefix() {
225        let r = ContentRange::parse("bytes 100-199").expect("parse");
226        assert_eq!(
227            r,
228            ContentRange {
229                start: 100,
230                end: 199
231            }
232        );
233    }
234
235    #[test]
236    fn content_range_parse_with_total() {
237        let r = ContentRange::parse("bytes 0-9/100").expect("parse");
238        assert_eq!(r, ContentRange { start: 0, end: 9 });
239    }
240
241    #[test]
242    fn content_range_rejects_reversed() {
243        assert!(ContentRange::parse("10-5").is_err());
244    }
245
246    #[test]
247    fn content_range_rejects_garbage() {
248        assert!(ContentRange::parse("not-a-range").is_err());
249        assert!(ContentRange::parse("").is_err());
250    }
251
252    #[test]
253    fn checked_length_handles_full_u64_range_without_overflow() {
254        // R2-2: `0-u64::MAX` would overflow `end - start + 1`. In debug
255        // this panics; in release it wraps to 0. `checked_length` must
256        // return `None` (the caller rejects), and the panicking `length`
257        // accessor must saturate rather than wrap.
258        let r = ContentRange::parse(&format!("0-{}", u64::MAX)).expect("parse full range");
259        assert_eq!(r.checked_length(), None, "full-u64 span has no exact length");
260        assert_eq!(r.length(), u64::MAX, "length() saturates, never wraps to 0");
261    }
262
263    #[test]
264    fn checked_length_normal_range_is_exact() {
265        let r = ContentRange::parse("0-1023").expect("parse");
266        assert_eq!(r.checked_length(), Some(1024));
267    }
268
269    #[test]
270    fn equal_start_end_is_a_valid_single_byte_range() {
271        // Boundary for `if start > end`: `5-5` is the inclusive single
272        // byte at offset 5 and MUST parse (length 1). Mutating `>` to
273        // `>=` would reject this equal-bounds range. The reversed `6-5`
274        // must still be rejected (so the comparison is not removed).
275        let r = ContentRange::parse("5-5").expect("equal bounds is one byte");
276        assert_eq!(r, ContentRange { start: 5, end: 5 });
277        assert_eq!(r.length(), 1, "inclusive length of N-N is 1");
278        assert!(
279            ContentRange::parse("6-5").is_err(),
280            "a genuinely reversed range stays rejected"
281        );
282    }
283}