Skip to main content

loonfs_api/
control.rs

1//! Durable control-object shapes: the discovery hint,
2//! checkpoint records, upload sessions, and their envelopes (format spec,
3//! "Control objects").
4
5use crate::envelope::EnvelopeCodecError;
6use crate::{
7    ChangeSeq, CheckpointId, ChecksumAlgorithm, CommitId, ContentId, ContentRef, ContentStoreId,
8    ManifestNo, NamespaceId, SubjectId, UploadId,
9};
10use crate::{WriterEpoch, WriterId};
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Deserializer, Serialize};
13use std::num::NonZeroU64;
14
15/// Selects one independently versioned control-object family.
16///
17/// See [control and manifest payloads](../../../docs/specs/format.md#a4-control-and-manifest-payloads).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ControlObjectKind {
21    /// Starts forward discovery of numbered manifests.
22    Hint,
23    /// Pins a manifest basis for a user or fork lifecycle.
24    CheckpointRecord,
25    /// Tracks staged content through upload completion or cleanup.
26    UploadSession,
27    /// Identifies the content domain held by a backend.
28    ContentStore,
29}
30
31impl ControlObjectKind {
32    /// Lists every registered control-object family in stable registry order.
33    pub const ALL: [Self; 4] = [
34        Self::Hint,
35        Self::CheckpointRecord,
36        Self::UploadSession,
37        Self::ContentStore,
38    ];
39
40    /// Durable format version for this control object kind.
41    ///
42    /// Versions are tracked per kind so one kind's payload schema can make a
43    /// breaking change without invalidating every other control object.
44    /// Version 1 is a JSON envelope document carrying the current payload as
45    /// a raw JSON fragment whose checksum covers its exact bytes.
46    pub const fn format_version(self) -> u32 {
47        match self {
48            Self::Hint => 1,
49            Self::CheckpointRecord => 1,
50            Self::UploadSession => 1,
51            Self::ContentStore => 1,
52        }
53    }
54
55    /// Returns the frozen envelope discriminator for this control-object family.
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Hint => "hint",
59            Self::CheckpointRecord => "checkpoint_record",
60            Self::UploadSession => "upload_session",
61            Self::ContentStore => "content_store",
62        }
63    }
64
65    /// Parses a registered envelope discriminator, returning `None` for future families.
66    pub fn parse(value: &str) -> Option<Self> {
67        Self::ALL.into_iter().find(|kind| kind.as_str() == value)
68    }
69}
70
71/// Identifies a content domain in its physical backend.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct ContentStoreState {
75    /// Domain whose objects share this descriptor's prefix.
76    pub content_store_id: ContentStoreId,
77    /// Unix-millisecond stamp from the domain's creation context.
78    pub created_at_ms: u64,
79}
80
81/// Starts manifest discovery without selecting the current version.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct HintState {
85    /// Namespace whose manifest collection is probed.
86    pub namespace_id: NamespaceId,
87    /// Positive manifest number from which discovery begins.
88    pub manifest_no: ManifestNo,
89    /// Highest acknowledged WAL number known to the publisher.
90    pub wal_no: crate::WalNo,
91}
92
93/// One reference to a namespace manifest.
94///
95/// Durable objects embed this shape under `manifest`. It identifies the
96/// manifest and provides the checksum required to verify it.
97///
98/// See [control and manifest payloads](../../../docs/specs/format.md#a4-control-and-manifest-payloads).
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct ManifestRef {
102    /// Namespace under whose prefix the manifest and its segments live.
103    pub owner_namespace_id: NamespaceId,
104    /// Monotonic logical position of the referenced manifest.
105    pub manifest_no: ManifestNo,
106    /// Greatest owner-namespace sequence the referenced manifest materializes.
107    pub manifest_head_seq: ChangeSeq,
108    /// Must equal `payload_checksum` in the referenced manifest envelope.
109    pub manifest_payload_checksum: String,
110}
111
112/// Durable owner and expiry policy of a checkpoint record.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
115pub enum CheckpointOwner {
116    /// An operator-created pin, deleted explicitly by checkpoint id or by
117    /// its declared expiry. The name is a label, not a key: several records
118    /// may carry the same name over different bases.
119    User {
120        /// Operator-facing label that need not be unique.
121        name: String,
122        /// When garbage collection may delete the pin without an explicit request.
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        expires_at_ms: Option<u64>,
125    },
126    /// Keeps the source manifest and its runs readable by the target.
127    Fork {
128        /// Fork namespace whose continued existence keeps the source basis pinned.
129        target_namespace_id: NamespaceId,
130    },
131    /// An application-created read view with a required expiry.
132    Snapshot {
133        /// Application-facing label that need not be unique.
134        name: String,
135        /// When garbage collection may release the pin.
136        expires_at_ms: u64,
137    },
138}
139
140impl CheckpointOwner {
141    /// When garbage collection may release this record without asking its owner.
142    pub fn expires_at_ms(&self) -> Option<u64> {
143        match self {
144            Self::User { expires_at_ms, .. } => *expires_at_ms,
145            Self::Fork { .. } => None,
146            Self::Snapshot { expires_at_ms, .. } => Some(*expires_at_ms),
147        }
148    }
149}
150
151/// A pin stored under `pins/`; see format specification section 8.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct CheckpointRecordState {
155    /// Namespace containing the pinned manifest.
156    pub namespace_id: NamespaceId,
157    /// Positions this record at its manifest number.
158    pub pin_id: CheckpointId,
159    /// Must equal the number in `pin_id`.
160    pub manifest_no: ManifestNo,
161    /// Greatest sequence in the pinned manifest.
162    pub manifest_head_seq: ChangeSeq,
163    /// Verifies the referenced manifest payload.
164    pub manifest_payload_checksum: String,
165    /// Commit at the pinned manifest head.
166    pub head_commit_id: CommitId,
167    /// Creation time used by collection grace.
168    pub created_at_ms: u64,
169    /// Determines when collection may delete this record.
170    pub owner: CheckpointOwner,
171}
172
173impl CheckpointRecordState {
174    /// Builds the reference for reads through this pin.
175    pub fn manifest(&self) -> ManifestRef {
176        ManifestRef {
177            owner_namespace_id: self.namespace_id.clone(),
178            manifest_no: self.pin_id.manifest_no(),
179            manifest_head_seq: self.manifest_head_seq,
180            manifest_payload_checksum: self.manifest_payload_checksum.clone(),
181        }
182    }
183}
184
185/// Who most recently acquired the writer epoch, and when.
186///
187/// Writer label and acquisition time; the epoch determines fencing.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct WriterBlock {
191    /// Stable writer label supplied by the embedding process for diagnostics.
192    pub writer_id: WriterId,
193    /// Unix-millisecond stamp of the epoch acquisition.
194    pub acquired_at_ms: u64,
195}
196
197/// Captures the writer identity and fencing epoch a session must retain while publishing.
198///
199/// See [control and manifest payloads](../../../docs/specs/format.md#a4-control-and-manifest-payloads).
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct AcquiredWriter {
202    /// Stable writer label copied into the manifest's writer block.
203    pub writer_id: WriterId,
204    /// Fencing epoch every commit publication from this session must match.
205    pub writer_epoch: WriterEpoch,
206}
207
208/// Terminal namespace status.
209///
210/// A namespace is either active or permanently deleted. Missing and unknown
211/// status values fail decoding.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
214pub enum NamespaceStatus {
215    /// The namespace serves reads and accepts commits.
216    ///
217    /// The braces make serde reject a stray field; a unit variant would
218    /// silently accept and discard one.
219    Active {},
220    /// Terminal: the namespace's history has ended. Reads, commits, forks,
221    /// and re-creation of the same id are all refused.
222    Deleted {
223        /// Earliest owner-prefix collection time once dependencies are gone.
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        reclaim_after_ms: Option<u64>,
226    },
227}
228
229impl NamespaceStatus {
230    /// Returns whether the namespace is permanently deleted.
231    pub const fn is_deleted(&self) -> bool {
232        matches!(self, Self::Deleted { .. })
233    }
234    /// Returns the irrevocable collection deadline, if retirement is established.
235    pub const fn reclaim_after_ms(&self) -> Option<u64> {
236        match self {
237            Self::Deleted { reclaim_after_ms } => *reclaim_after_ms,
238            Self::Active {} => None,
239        }
240    }
241}
242
243/// Immutable fork provenance matched against the source checkpoint by GC.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct ForkBasis {
247    /// Source manifest used as the target's initial state. Its owner must
248    /// differ from the target namespace. `manifest_head_seq` is the target's
249    /// initial sequence.
250    pub manifest: ManifestRef,
251    /// Source checkpoint record pinning the basis for as long as the target lives.
252    pub source_checkpoint_id: CheckpointId,
253}
254
255const GENESIS_COMMIT_ID: &str = "c_00000000000000000000000000000000";
256
257/// The commit id every namespace's sequence zero carries, before any commit
258/// has landed.
259pub fn genesis_commit_id() -> CommitId {
260    CommitId::parse(GENESIS_COMMIT_ID).expect("genesis commit id is valid")
261}
262
263/// Staging progress for a service-proxied upload.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum ProxiedStaging {
266    /// No request owns the staging slot and no staged reference is retained.
267    Idle,
268    /// One request owns the staging slot.
269    Claimed,
270    /// Content that passed validation and was recorded by the session.
271    Staged(ContentRef),
272}
273
274impl Serialize for ProxiedStaging {
275    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
276    where
277        S: serde::Serializer,
278    {
279        #[derive(Serialize)]
280        #[serde(tag = "kind", rename_all = "snake_case")]
281        enum Shape<'a> {
282            Idle {},
283            Claimed {},
284            Staged { content_ref: &'a ContentRef },
285        }
286
287        match self {
288            Self::Idle => Shape::Idle {}.serialize(serializer),
289            Self::Claimed => Shape::Claimed {}.serialize(serializer),
290            Self::Staged(content_ref) => Shape::Staged { content_ref }.serialize(serializer),
291        }
292    }
293}
294
295impl<'de> Deserialize<'de> for ProxiedStaging {
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: Deserializer<'de>,
299    {
300        StrictProxiedStaging::deserialize(deserializer).map(Into::into)
301    }
302}
303
304/// Upload mode and its mode-specific state. The mode never changes.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
307pub enum UploadSessionMode {
308    /// The service receives the bytes and writes the content object itself,
309    /// so it learns size and digest from the bytes as they pass.
310    ServiceProxied {
311        /// Exclusive staging progress, which applies only to this mode.
312        staging: ProxiedStaging,
313    },
314    /// The client writes the whole object through one presigned request.
315    DirectPut {
316        /// Checksum algorithm chosen when the session began.
317        checksum_algorithm: ChecksumAlgorithm,
318    },
319    /// The client uploads parts and the provider assembles the object.
320    ///
321    /// Multipart sessions do not store a content reference at creation because
322    /// one-pass and streaming clients may not know the final size or checksum.
323    /// The client supplies those values at completion, when LoonFS verifies the
324    /// assembled object.
325    DirectMultipart {
326        /// The provider-side upload the parts assemble through, and the
327        /// only provider handle LoonFS keeps: parts are the client's
328        /// bookkeeping, exactly as they are in the provider's own API, so
329        /// there is no durable record per part.
330        provider_upload_id: String,
331        /// Byte length of every part except the last, settled at begin.
332        ///
333        /// A session resumed after a lost begin response reads its geometry
334        /// from here rather than being told a second, possibly different,
335        /// one. Zero is not a geometry, so it is not representable.
336        part_size_bytes: NonZeroU64,
337        /// Checksum algorithm chosen when the session began. Part signing and
338        /// completion continue to use it after a restart.
339        checksum_algorithm: ChecksumAlgorithm,
340    },
341}
342
343impl UploadSessionMode {
344    /// Returns the checksum algorithm fixed by a direct upload mode.
345    pub fn checksum_algorithm(&self) -> Option<ChecksumAlgorithm> {
346        match self {
347            Self::ServiceProxied { .. } => None,
348            Self::DirectPut { checksum_algorithm }
349            | Self::DirectMultipart {
350                checksum_algorithm, ..
351            } => Some(*checksum_algorithm),
352        }
353    }
354
355    /// Returns the content reference stored by this mode, when present.
356    fn content_ref(&self) -> Option<&ContentRef> {
357        match self {
358            Self::ServiceProxied {
359                staging: ProxiedStaging::Staged(content_ref),
360            } => Some(content_ref),
361            Self::ServiceProxied { .. } | Self::DirectPut { .. } | Self::DirectMultipart { .. } => {
362                None
363            }
364        }
365    }
366}
367
368/// Monotonic status of a durable upload session.
369///
370/// A session starts open and ends as completed or aborted. The terminal update
371/// uses compare-and-swap and cannot be reversed.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
374pub enum UploadSessionRecordStatus {
375    /// Accepts staged bytes until its lease expires.
376    Open {
377        /// Unix-millisecond instant after which the session is abandoned.
378        /// The record carries it so no session transition depends on an
379        /// object's provider timestamp.
380        expires_at_ms: u64,
381    },
382    /// The content is durable and verified. Only completed sessions can issue
383    /// receipts or replay completion.
384    Completed {
385        /// Unix-millisecond stamp written by the completing compare-and-swap,
386        /// and the only input to when the content may be reclaimed.
387        completed_at_ms: u64,
388        /// Verified immutable content produced by this session.
389        content_ref: ContentRef,
390    },
391    /// The session cannot publish content. Its unreferenced object is deleted.
392    Aborted {
393        /// Unix-millisecond stamp written by the aborting compare-and-swap,
394        /// and the only input to when the record may be deleted.
395        aborted_at_ms: u64,
396    },
397}
398
399impl UploadSessionRecordStatus {
400    /// Returns the completed content reference, if present.
401    fn content_ref(&self) -> Option<&ContentRef> {
402        match self {
403            Self::Open { .. } => None,
404            Self::Completed { content_ref, .. } => Some(content_ref),
405            Self::Aborted { .. } => None,
406        }
407    }
408}
409
410impl std::fmt::Display for UploadSessionRecordStatus {
411    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        let status = match self {
413            Self::Open { .. } => "open",
414            Self::Completed { .. } => "completed",
415            Self::Aborted { .. } => "aborted",
416        };
417        formatter.write_str(status)
418    }
419}
420
421/// Tracks one durable content-upload workflow independently of commit publication.
422///
423/// The tagged mode and status variants permit only valid field
424/// combinations.
425///
426/// See [upload before publish](../../../docs/specs/format.md#5-uploading-content).
427#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
428pub struct UploadSessionState {
429    /// Namespace authorized to consume the staged content.
430    pub namespace_id: NamespaceId,
431    /// Durable session identity used by staging and completion requests.
432    pub upload_id: UploadId,
433    /// Content object this session writes, allocated when the session began.
434    ///
435    /// The identity exists before any byte is read, so the final object key
436    /// is known up front and belongs to exactly this session. Every
437    /// reference the record holds names this object; see `validate` below,
438    /// which refuses a record that disagrees with itself.
439    pub content_id: ContentId,
440    /// Unix-millisecond creation stamp.
441    pub created_at_ms: u64,
442    /// The subject that opened the session, recorded in an ACL namespace.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub subject_id: Option<SubjectId>,
445    /// How the bytes reach object storage, settled when the session opened.
446    pub mode: UploadSessionMode,
447    /// The session's status, and the field every upload operation
448    /// compare-and-swaps against.
449    pub status: UploadSessionRecordStatus,
450}
451
452impl UploadSessionState {
453    fn validate(&self) -> Result<(), String> {
454        if !matches!(self.status, UploadSessionRecordStatus::Open { .. })
455            && self.mode.content_ref().is_some()
456        {
457            return Err(format!(
458                "upload session `{}` is {} but still holds a staged content reference",
459                self.upload_id, self.status
460            ));
461        }
462        for content_ref in self
463            .mode
464            .content_ref()
465            .into_iter()
466            .chain(self.status.content_ref())
467        {
468            content_ref.validate().map_err(|error| {
469                format!(
470                    "upload session `{}` holds an invalid content ref: {error}",
471                    self.upload_id
472                )
473            })?;
474            if content_ref.content_id != self.content_id {
475                return Err(format!(
476                    "upload session `{}` owns content `{}` but holds a reference to `{}`",
477                    self.upload_id, self.content_id, content_ref.content_id
478                ));
479            }
480        }
481        if let (
482            Some(checksum_algorithm),
483            UploadSessionRecordStatus::Completed { content_ref, .. },
484        ) = (self.mode.checksum_algorithm(), &self.status)
485        {
486            if content_ref.checksum.algorithm != checksum_algorithm {
487                return Err(format!(
488                    "upload session `{}` requires `{checksum_algorithm}` but its completed \
489                     content uses `{}`",
490                    self.upload_id, content_ref.checksum.algorithm
491                ));
492            }
493        }
494        Ok(())
495    }
496}
497
498#[derive(Deserialize)]
499#[serde(deny_unknown_fields)]
500struct StrictUploadSessionState {
501    namespace_id: NamespaceId,
502    upload_id: UploadId,
503    content_id: ContentId,
504    created_at_ms: u64,
505    #[serde(default)]
506    subject_id: Option<SubjectId>,
507    mode: StrictUploadSessionMode,
508    status: StrictUploadSessionRecordStatus,
509}
510
511/// Strict upload-mode shape used while decoding a session record.
512#[derive(Deserialize)]
513#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
514enum StrictUploadSessionMode {
515    ServiceProxied {
516        staging: StrictProxiedStaging,
517    },
518    DirectPut {
519        checksum_algorithm: ChecksumAlgorithm,
520    },
521    DirectMultipart {
522        provider_upload_id: String,
523        part_size_bytes: NonZeroU64,
524        checksum_algorithm: ChecksumAlgorithm,
525    },
526}
527
528impl From<StrictUploadSessionMode> for UploadSessionMode {
529    fn from(mode: StrictUploadSessionMode) -> Self {
530        match mode {
531            StrictUploadSessionMode::ServiceProxied { staging } => Self::ServiceProxied {
532                staging: staging.into(),
533            },
534            StrictUploadSessionMode::DirectPut { checksum_algorithm } => {
535                Self::DirectPut { checksum_algorithm }
536            }
537            StrictUploadSessionMode::DirectMultipart {
538                provider_upload_id,
539                part_size_bytes,
540                checksum_algorithm,
541            } => Self::DirectMultipart {
542                provider_upload_id,
543                part_size_bytes,
544                checksum_algorithm,
545            },
546        }
547    }
548}
549
550#[derive(Deserialize)]
551#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
552enum StrictProxiedStaging {
553    Idle {},
554    Claimed {},
555    Staged { content_ref: ContentRef },
556}
557
558impl From<StrictProxiedStaging> for ProxiedStaging {
559    fn from(staging: StrictProxiedStaging) -> Self {
560        match staging {
561            StrictProxiedStaging::Idle {} => Self::Idle,
562            StrictProxiedStaging::Claimed {} => Self::Claimed,
563            StrictProxiedStaging::Staged { content_ref } => Self::Staged(content_ref),
564        }
565    }
566}
567
568#[derive(Deserialize)]
569#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
570enum StrictUploadSessionRecordStatus {
571    Open {
572        expires_at_ms: u64,
573    },
574    Completed {
575        completed_at_ms: u64,
576        content_ref: ContentRef,
577    },
578    Aborted {
579        aborted_at_ms: u64,
580    },
581}
582
583impl From<StrictUploadSessionRecordStatus> for UploadSessionRecordStatus {
584    fn from(status: StrictUploadSessionRecordStatus) -> Self {
585        match status {
586            StrictUploadSessionRecordStatus::Open { expires_at_ms } => Self::Open { expires_at_ms },
587            StrictUploadSessionRecordStatus::Completed {
588                completed_at_ms,
589                content_ref,
590            } => Self::Completed {
591                completed_at_ms,
592                content_ref,
593            },
594            StrictUploadSessionRecordStatus::Aborted { aborted_at_ms } => {
595                Self::Aborted { aborted_at_ms }
596            }
597        }
598    }
599}
600
601impl<'de> Deserialize<'de> for UploadSessionState {
602    /// Reads one session record and refuses one that `validate` finds
603    /// disagreeing with itself, like any other corruption and with no shim
604    /// or salvage.
605    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
606    where
607        D: Deserializer<'de>,
608    {
609        let record = StrictUploadSessionState::deserialize(deserializer)?;
610        let session = Self {
611            namespace_id: record.namespace_id,
612            upload_id: record.upload_id,
613            content_id: record.content_id,
614            created_at_ms: record.created_at_ms,
615            subject_id: record.subject_id,
616            mode: record.mode.into(),
617            status: record.status.into(),
618        };
619        session.validate().map_err(serde::de::Error::custom)?;
620        Ok(session)
621    }
622}
623
624/// Control state decoded through its checked durable codec.
625pub type ControlObjectEnvelope<T> = crate::envelope::VerifiedEnvelope<T>;
626
627/// Encodes control state once, deriving its checksum and family version.
628pub fn encode_control_state<T: Serialize>(
629    kind: ControlObjectKind,
630    state: &T,
631) -> Result<Vec<u8>, EnvelopeCodecError> {
632    crate::envelope::encode_json_envelope(kind.as_str(), kind.format_version(), state)
633        .map(crate::envelope::EncodedEnvelope::into_bytes)
634}
635
636/// Decodes and verifies a durable JSON control object of `expected_kind`.
637///
638/// Decoding fails for invalid JSON, an unknown or mismatched kind, an
639/// unsupported family version, a checksum mismatch, or an invalid `T`. See
640/// [control and manifest payloads](../../../docs/specs/format.md#a4-control-and-manifest-payloads).
641pub fn decode_control_object<T>(
642    bytes: &[u8],
643    expected_kind: ControlObjectKind,
644) -> Result<ControlObjectEnvelope<T>, EnvelopeCodecError>
645where
646    T: DeserializeOwned,
647{
648    let decoded = crate::envelope::decode_json_envelope(
649        bytes,
650        expected_kind.format_version(),
651        // The kind registry reports unknown kinds distinctly from
652        // registered-but-mismatched ones.
653        |found| match ControlObjectKind::parse(found) {
654            None => Err(EnvelopeCodecError::UnknownKind {
655                found: found.to_owned(),
656            }),
657            Some(kind) if kind != expected_kind => Err(EnvelopeCodecError::KindMismatch {
658                expected: expected_kind.as_str().to_owned(),
659                found: found.to_owned(),
660            }),
661            Some(_) => Ok(()),
662        },
663    )?;
664
665    Ok(decoded)
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::{Checksum, ContentRefKind};
672
673    #[test]
674    fn completed_proxied_session_rejects_conflicting_staged_size() {
675        let content_ref = ContentRef {
676            kind: ContentRefKind::BlobV1,
677            owner_namespace_id: crate::NamespaceId::parse("demo").expect("namespace id"),
678            content_id: ContentId::parse("con_0123456789abcdef0123456789abcdef")
679                .expect("content id"),
680            size_bytes: 5,
681            checksum: Checksum::sha256(b"hello"),
682        };
683        let mut staged = content_ref.clone();
684        staged.size_bytes += 1;
685        let session = UploadSessionState {
686            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
687            upload_id: UploadId::parse("upl_0123456789abcdef0123456789abcdef").expect("upload id"),
688            content_id: content_ref.content_id.clone(),
689            created_at_ms: 1_000,
690            subject_id: None,
691            mode: UploadSessionMode::ServiceProxied {
692                staging: ProxiedStaging::Staged(staged),
693            },
694            status: UploadSessionRecordStatus::Completed {
695                completed_at_ms: 2_000,
696                content_ref,
697            },
698        };
699
700        let error = session.validate().expect_err("conflicting staged size");
701        assert_eq!(
702            error,
703            format!(
704                "upload session `{}` is completed but still holds a staged content reference",
705                session.upload_id
706            )
707        );
708    }
709
710    #[test]
711    fn terminal_upload_modes_reject_only_retained_staged_references() {
712        let content_ref = ContentRef {
713            kind: ContentRefKind::BlobV1,
714            owner_namespace_id: crate::NamespaceId::parse("demo").expect("namespace id"),
715            content_id: ContentId::parse("con_0123456789abcdef0123456789abcdef")
716                .expect("content id"),
717            size_bytes: 5,
718            checksum: Checksum::sha256(b"hello"),
719        };
720        let modes = [
721            UploadSessionMode::ServiceProxied {
722                staging: ProxiedStaging::Idle,
723            },
724            UploadSessionMode::ServiceProxied {
725                staging: ProxiedStaging::Claimed,
726            },
727            UploadSessionMode::ServiceProxied {
728                staging: ProxiedStaging::Staged(content_ref.clone()),
729            },
730            UploadSessionMode::DirectPut {
731                checksum_algorithm: ChecksumAlgorithm::Sha256,
732            },
733            UploadSessionMode::DirectMultipart {
734                provider_upload_id: "provider-upload".to_owned(),
735                part_size_bytes: NonZeroU64::new(8 * 1024 * 1024).expect("part size"),
736                checksum_algorithm: ChecksumAlgorithm::Sha256,
737            },
738        ];
739        for mode in modes {
740            for status in [
741                UploadSessionRecordStatus::Completed {
742                    completed_at_ms: 2_000,
743                    content_ref: content_ref.clone(),
744                },
745                UploadSessionRecordStatus::Aborted {
746                    aborted_at_ms: 2_000,
747                },
748            ] {
749                let session = UploadSessionState {
750                    namespace_id: NamespaceId::parse("demo").expect("namespace id"),
751                    upload_id: UploadId::parse("upl_0123456789abcdef0123456789abcdef")
752                        .expect("upload id"),
753                    content_id: content_ref.content_id.clone(),
754                    created_at_ms: 1_000,
755                    subject_id: None,
756                    mode: mode.clone(),
757                    status,
758                };
759                let encoded = serde_json::to_value(&session).expect("encode session");
760                let decoded = serde_json::from_value::<UploadSessionState>(encoded);
761                if mode.content_ref().is_some() {
762                    let error = decoded
763                        .expect_err("terminal staging is corrupt")
764                        .to_string();
765                    assert!(error.contains(session.upload_id.as_str()));
766                    assert!(error.contains("still holds a staged content reference"));
767                } else {
768                    assert_eq!(decoded.expect("valid terminal session"), session);
769                }
770            }
771        }
772    }
773
774    #[test]
775    fn control_object_kind_strings_round_trip_and_match_serde() {
776        for kind in ControlObjectKind::ALL {
777            assert_eq!(ControlObjectKind::parse(kind.as_str()), Some(kind));
778            let serialized = serde_json::to_value(kind).expect("serialize kind");
779            assert_eq!(serialized, serde_json::Value::from(kind.as_str()));
780        }
781        assert_eq!(ControlObjectKind::parse("not_a_kind"), None);
782    }
783}