1use bytes::Bytes;
5use mkit_core::protocol::PackKey;
6use mkit_core::upload_parts::{PartPlan, merge_to_root};
7use mkit_core::write_auth::PartCommitment;
8
9use super::outcome::Outcome;
10use super::{AuthMode, Authenticated, HookSet, Pipeline, ServerError, StorageOp, ms, store_error};
11use crate::Code;
12use crate::op::{Commitment, Procedure};
13use crate::store::{BlobKey, MultipartBlobStore, NamespaceStore, PartRef, PartSink, StoreError};
14use crate::telemetry::METRIC_UPLOAD_BYTES;
15use crate::upload::marker::write_upload_marker;
16use crate::upload::receipt;
17use crate::upload::ticket_auth::verify_ticket;
18use crate::upload::token::TicketClaims;
19
20fn invalid_ticket() -> ServerError {
21 ServerError::failed_precondition("invalid or expired upload ticket")
22}
23
24#[cfg(test)]
25#[path = "parts_tests.rs"]
26mod tests;
27
28fn binding_mismatch() -> ServerError {
29 ServerError::new(Code::PermissionDenied, "upload ticket binding mismatch")
30}
31
32fn part_error(err: mkit_core::upload_parts::PartError) -> ServerError {
33 ServerError::invalid_argument(err.to_string())
34}
35
36fn multipart_error(op: StorageOp, err: StoreError) -> ServerError {
37 match err {
38 StoreError::SessionGone => invalid_ticket(),
39 StoreError::PartSubtreeMismatch => {
40 ServerError::invalid_argument("part subtree hash does not match its commitment")
41 }
42 StoreError::Invalid(detail) => {
43 tracing::warn!(%detail, "multipart storage rejected request");
44 ServerError::invalid_argument("invalid multipart upload state")
45 }
46 other => store_error(op, other),
47 }
48}
49
50pub struct PartUploadSession<'p, B: MultipartBlobStore, N, H> {
54 pipe: &'p Pipeline<B, N, H>,
55 ticket: [u8; 32],
56 namespace: crate::repo::NamespaceKey,
57 generation: Option<u64>,
58 index: u32,
59 subtree: [u8; 32],
60 len: u64,
61 seen: u64,
62 staging: super::staging::StagingBuffer,
63 sink: Option<B::PartSink>,
64 failed: Option<ServerError>,
65 outcome: Outcome,
66}
67
68impl<B: MultipartBlobStore, N, H> core::fmt::Debug for PartUploadSession<'_, B, N, H> {
69 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
70 f.debug_struct("PartUploadSession")
71 .field("index", &self.index)
72 .field("len", &self.len)
73 .field("seen", &self.seen)
74 .finish_non_exhaustive()
75 }
76}
77
78impl<B: MultipartBlobStore, N: NamespaceStore, H: HookSet> PartUploadSession<'_, B, N, H> {
79 pub async fn push(&mut self, chunk: Bytes) -> Result<(), ServerError> {
85 if let Some(err) = &self.failed {
86 return Err(err.clone());
87 }
88 let result = self.push_inner(chunk).await;
89 if let Err(err) = &result {
90 self.outcome.record(Err(err));
91 self.failed = Some(err.clone());
92 }
93 result
94 }
95
96 async fn push_inner(&mut self, mut chunk: Bytes) -> Result<(), ServerError> {
97 if chunk.is_empty() {
98 return Err(ServerError::invalid_argument("empty upload part chunk"));
99 }
100 let next = self
101 .seen
102 .checked_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX))
103 .ok_or_else(|| ServerError::invalid_argument("part data exceeds the part length"))?;
104 if next > self.len {
105 return Err(ServerError::invalid_argument(
106 "part data exceeds the part length",
107 ));
108 }
109 while !chunk.is_empty() {
110 if let Some(bytes) = self.staging.push(&mut chunk) {
111 self.stage(bytes).await?;
112 }
113 }
114 self.seen = next;
115 Ok(())
116 }
117
118 async fn stage(&mut self, bytes: Bytes) -> Result<(), ServerError> {
119 self.pipe
120 .check_ticket_generation(&self.namespace, self.generation)
121 .await?;
122 self.sink
123 .as_mut()
124 .ok_or_else(|| ServerError::internal("part stream is closed", "missing part sink"))?
125 .write(bytes)
126 .await
127 .map_err(|e| multipart_error(StorageOp::MultipartPart, e))?;
128 self.pipe
129 .check_ticket_generation(&self.namespace, self.generation)
130 .await?;
131 Ok(())
132 }
133
134 pub async fn finish(mut self) -> Result<Vec<u8>, ServerError> {
139 if let Some(err) = self.failed.take() {
140 self.abort().await;
141 return Err(err);
142 }
143 if self.seen != self.len {
144 let err = ServerError::invalid_argument("part data is shorter than the part length");
145 self.outcome.record(Err(&err));
146 self.abort().await;
147 return Err(err);
148 }
149 let result = self.finish_inner().await;
150 if result.is_ok() {
151 self.pipe.metrics.incr(METRIC_UPLOAD_BYTES, &[], self.len);
152 }
153 self.outcome.record(result.as_ref().map(|_| ()));
154 result
155 }
156
157 async fn finish_inner(&mut self) -> Result<Vec<u8>, ServerError> {
158 if let Some(bytes) = self.staging.finish() {
159 self.stage(bytes).await?;
160 }
161 self.pipe
162 .check_ticket_generation(&self.namespace, self.generation)
163 .await?;
164 let sink = self
165 .sink
166 .take()
167 .ok_or_else(|| ServerError::internal("part stream is closed", "missing part sink"))?;
168 let tag = sink.commit().await.map_err(|e| match e {
169 StoreError::PartSubtreeMismatch => {
170 ServerError::invalid_argument("part subtree hash does not match its commitment")
171 }
172 other => multipart_error(StorageOp::MultipartPart, other),
173 })?;
174 self.pipe
175 .check_ticket_generation(&self.namespace, self.generation)
176 .await?;
177 let keys = self.pipe.cfg.ticket_keys.as_ref().ok_or_else(|| {
178 ServerError::internal("upload tickets are not configured", "ticket keys vanished")
179 })?;
180 receipt::mint(
181 keys,
182 &self.ticket,
183 self.index,
184 &self.subtree,
185 self.len,
186 &tag,
187 )
188 }
189
190 pub async fn abort(mut self) {
193 if let Some(sink) = self.sink.take() {
194 sink.abort().await;
195 }
196 }
197
198 pub async fn abort_with(mut self, err: &ServerError) {
200 self.outcome.record(Err(err));
201 self.abort().await;
202 }
203}
204
205impl<B: MultipartBlobStore, N: NamespaceStore, H: HookSet> Pipeline<B, N, H> {
206 #[cfg(feature = "connect")]
208 pub(crate) fn record_part_error(&self, a: &Authenticated, err: &ServerError) {
209 self.outcome(a).record(Err(err));
210 }
211
212 async fn part_ticket(
213 &self,
214 a: &Authenticated,
215 expected: Procedure,
216 token: &[u8],
217 ) -> Result<TicketClaims, ServerError> {
218 if a.procedure() != expected {
219 return Err(ServerError::unauthenticated(
220 "credentials were checked for another procedure",
221 ));
222 }
223 let keys = self
224 .cfg
225 .ticket_keys
226 .as_ref()
227 .ok_or_else(|| ServerError::unimplemented("upload tickets are not configured"))?;
228 let AuthMode::AuthV2(cfg) = &self.cfg.auth else {
229 let name = if expected == Procedure::UploadPart {
230 "UploadPart requires auth v2"
231 } else {
232 "CompleteUpload requires auth v2"
233 };
234 return Err(ServerError::unimplemented(name));
235 };
236 let auth = a
237 .auth
238 .as_ref()
239 .ok_or_else(|| ServerError::unauthenticated("missing auth v2 authorization"))?;
240 let claims = verify_ticket(
241 keys,
242 token,
243 ms(self.clock.now_ms().saturating_add(a.business_skew_ms)),
244 cfg.audience(),
245 &a.repo().identity,
246 &auth.signer,
247 )?;
248 self.check_ticket_generation(&a.repo().repo.namespace, claims.authority_generation)
249 .await?;
250 Ok(claims)
251 }
252
253 pub async fn open_part(
259 &self,
260 a: &Authenticated,
261 token: &[u8],
262 index: u32,
263 ) -> Result<PartUploadSession<'_, B, N, H>, ServerError> {
264 let mut outcome = self.outcome(a);
265 let opened = async {
266 let claims = self.part_ticket(a, Procedure::UploadPart, token).await?;
267 let Some(auth) = &a.auth else {
268 return Err(ServerError::unauthenticated(
269 "missing auth v2 authorization",
270 ));
271 };
272 let Commitment::Part {
273 ticket,
274 index: committed_index,
275 subtree,
276 len,
277 } = auth.commitment
278 else {
279 return Err(binding_mismatch());
280 };
281 if ticket != claims.ticket_id || committed_index != index {
282 return Err(binding_mismatch());
283 }
284 let plan = PartPlan::new(claims.bytes, claims.part_size, self.cfg.max_parts)
285 .map_err(part_error)?;
286 plan.check(&PartCommitment {
287 ticket,
288 index,
289 subtree,
290 len,
291 })
292 .map_err(part_error)?;
293 if claims.upload_session.is_empty() {
294 return Err(invalid_ticket());
295 }
296 let key: BlobKey = PackKey(claims.pack_id).into();
297 let sink = self
298 .blobs
299 .begin_part(key, &claims.upload_session, &plan, index, subtree)
300 .await
301 .map_err(|e| multipart_error(StorageOp::MultipartPart, e))?;
302 if let Err(err) = self
303 .check_ticket_generation(&a.repo().repo.namespace, claims.authority_generation)
304 .await
305 {
306 sink.abort().await;
307 return Err(err);
308 }
309 Ok((ticket, subtree, len, sink, claims.authority_generation))
310 }
311 .await;
312 match opened {
313 Ok((ticket, subtree, len, sink, generation)) => Ok(PartUploadSession {
314 pipe: self,
315 namespace: a.repo().repo.namespace.clone(),
316 generation,
317 ticket,
318 index,
319 subtree,
320 len,
321 seen: 0,
322 staging: super::staging::StagingBuffer::default(),
323 sink: Some(sink),
324 failed: None,
325 outcome,
326 }),
327 Err(err) => {
328 outcome.record(Err(&err));
329 Err(err)
330 }
331 }
332 }
333
334 pub async fn complete_upload(
340 &self,
341 a: &Authenticated,
342 token: &[u8],
343 receipts: &[Vec<u8>],
344 ) -> Result<(), ServerError> {
345 self.observe(a, async {
346 let claims = self
347 .part_ticket(a, Procedure::CompleteUpload, token)
348 .await?;
349 let plan = PartPlan::new(claims.bytes, claims.part_size, self.cfg.max_parts)
350 .map_err(part_error)?;
351 if u32::try_from(receipts.len()) != Ok(plan.count()) {
352 return Err(ServerError::invalid_argument(
353 "wrong number of upload part receipts",
354 ));
355 }
356 let keys = self.cfg.ticket_keys.as_ref().ok_or_else(|| {
357 ServerError::internal("upload tickets are not configured", "ticket keys vanished")
358 })?;
359 let mut cvs = Vec::with_capacity(receipts.len());
360 let mut parts = Vec::with_capacity(receipts.len());
361 let mut sum = 0_u64;
362 for (position, raw) in receipts.iter().enumerate() {
363 let receipt = receipt::verify(keys, raw)?;
364 if receipt.ticket_id != claims.ticket_id {
365 return Err(ServerError::invalid_argument("invalid upload part receipt"));
368 }
369 let index = u32::try_from(position).map_err(|_| {
370 ServerError::invalid_argument("wrong number of upload part receipts")
371 })?;
372 if receipt.index != index
373 || receipt.len != plan.expected_len(index).map_err(part_error)?
374 {
375 return Err(ServerError::invalid_argument(
376 "upload part receipt order or length mismatch",
377 ));
378 }
379 sum = sum.checked_add(receipt.len).ok_or_else(|| {
380 ServerError::invalid_argument("upload part lengths do not match the ticket")
381 })?;
382 cvs.push(receipt.subtree);
383 parts.push(PartRef {
384 index,
385 len: receipt.len,
386 tag: receipt.tag,
387 });
388 }
389 if sum != claims.bytes {
390 return Err(ServerError::invalid_argument(
391 "upload part lengths do not match the ticket",
392 ));
393 }
394 if merge_to_root(&plan, &cvs).map_err(part_error)? != claims.pack_id {
395 return Err(ServerError::invalid_argument(
396 "merged part root does not match the ticket",
397 ));
398 }
399 self.publish_verified_upload(&a.repo().repo.namespace, &claims, &plan, &parts)
400 .await
401 })
402 .await
403 }
404
405 async fn publish_verified_upload(
408 &self,
409 namespace: &crate::repo::NamespaceKey,
410 claims: &TicketClaims,
411 plan: &PartPlan,
412 parts: &[PartRef],
413 ) -> Result<(), ServerError> {
414 if claims.upload_session.is_empty() {
415 return Err(invalid_ticket());
416 }
417 let key: BlobKey = PackKey(claims.pack_id).into();
418 let present = self
419 .blobs
420 .head(&key)
421 .await
422 .map_err(|e| multipart_error(StorageOp::BlobHead, e))?;
423 if present.is_some_and(|meta| meta.len != claims.bytes) {
424 return Err(ServerError::invalid_argument(
425 "stored pack length does not match the ticket",
426 ));
427 }
428 if present.is_some() {
429 if let Err(err) = self.blobs.abort(key, &claims.upload_session).await {
430 tracing::warn!(error = %err, "failed to abort completed multipart session");
431 }
432 } else {
433 match self
434 .blobs
435 .complete(key, &claims.upload_session, plan, parts)
436 .await
437 {
438 Ok(_) => {}
439 Err(StoreError::SessionGone) => {
440 let found = self
441 .blobs
442 .head(&key)
443 .await
444 .map_err(|e| multipart_error(StorageOp::BlobHead, e))?;
445 match found {
446 Some(meta) if meta.len == claims.bytes => {}
447 Some(_) => {
448 return Err(ServerError::invalid_argument(
449 "stored pack length does not match the ticket",
450 ));
451 }
452 None => return Err(invalid_ticket()),
453 }
454 }
455 Err(e) => return Err(multipart_error(StorageOp::MultipartSession, e)),
456 }
457 }
458 self.check_ticket_generation(namespace, claims.authority_generation)
459 .await?;
460 write_upload_marker(&self.blobs, &claims.ticket_id, &claims.pack_id)
461 .await
462 .map_err(|e| store_error(StorageOp::BlobPut, e))?;
463 self.check_ticket_generation(namespace, claims.authority_generation)
466 .await?;
467 Ok(())
468 }
469}