Skip to main content

mkit_server/pipeline/
parts.rs

1//! Stateless multipart upload operations. The authenticated ticket and part
2//! receipts carry everything needed to finish without a metadata read.
3
4use 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
50/// One part stream. The store owns its bounded hasher and staged bytes; this
51/// handle keeps the byte count, authenticated commitment and at most 256 KiB
52/// of private buffering before an authority-checked storage write.
53pub 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    /// Write one nonempty chunk, rejecting an overrun before storing it.
80    ///
81    /// # Errors
82    /// `invalid_argument` for an empty or overlong chunk; storage errors
83    /// retain the fixed redacted message.
84    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    /// Verify and commit the part, returning its authenticated receipt.
135    ///
136    /// # Errors
137    /// `invalid_argument` for a short part or mismatched subtree.
138    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    /// Discard an incomplete part. An older committed copy of this index is
191    /// untouched.
192    pub async fn abort(mut self) {
193        if let Some(sink) = self.sink.take() {
194            sink.abort().await;
195        }
196    }
197
198    /// Discard an incomplete stream and record the error returned to the client.
199    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    /// Records a part-path error the Connect handlers raise outside the pipeline.
207    #[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    /// Validate a part header and ticket before opening any part sink.
254    ///
255    /// # Errors
256    /// Invalid or mismatched ticket, commitment or part geometry; storage
257    /// errors while opening the part.
258    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    /// Authenticate all receipts and the merged pack root before any store
335    /// call; completion writes no metadata rows.
336    ///
337    /// # Errors
338    /// Invalid ticket, receipt, root, length or storage session.
339    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                    // Keep a valid receipt for another ticket indistinguishable
366                    // from an untrusted receipt with an invalid MAC.
367                    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    /// Called only after every receipt, the total length and the merged root
406    /// have been checked without accessing storage.
407    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        // The marker is a shared proof, not repository membership. A revoked
464        // ticket cannot consume it, and this call must not report acceptance.
465        self.check_ticket_generation(namespace, claims.authority_generation)
466            .await?;
467        Ok(())
468    }
469}