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}