Skip to main content

riffdb_application/
reimport_campaign.rs

1//! Canonical durable progress for one compiler-owned application reimport stage.
2
3use std::{error::Error, fmt, num::NonZeroU64};
4
5use riffdb_types::{
6    ApplicationExportManifestHash, ApplicationExportPageHash, ApplicationExportReceiptHash,
7    ApplicationPortabilityManifestHash, ApplicationReimportAuthorityV1,
8    ApplicationReimportReceiptHash, CapabilityApplicationReimportScopeV1, CapabilityId, DatabaseId,
9    GeneratedArtifactHash, hash_application_reimport_receipt,
10};
11use serde::{Deserialize, Serialize};
12
13use crate::{
14    ApplicationPortabilityManifest, ApplicationReimportReceipt, InstallationSymbol,
15    PortableRecordClass, ReimportMappingResult, ReimportObservationResult,
16};
17
18/// Canonical durable reimport checkpoint schema.
19pub const APPLICATION_REIMPORT_CAMPAIGN_SCHEMA_V1: &str = "riffdb.application-reimport-campaign/v1";
20/// Maximum canonical bytes nested into one installation campaign.
21pub const MAX_APPLICATION_REIMPORT_CAMPAIGN_BYTES: usize = 2 * 1_024 * 1_024;
22/// Maximum source page identities retained by one alpha campaign.
23pub const MAX_APPLICATION_REIMPORT_PAGE_HASHES: usize = 4_096;
24
25/// One source-snapshot workflow proof with no row values.
26#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
27pub struct ReimportWorkflowQuiescenceV1 {
28    workflow: InstallationSymbol,
29    checked_rows: u64,
30    quiescent_rows: u64,
31}
32
33impl ReimportWorkflowQuiescenceV1 {
34    /// Accepts only complete evidence that every checked row was quiescent.
35    pub fn new(
36        workflow: InstallationSymbol,
37        checked_rows: u64,
38        quiescent_rows: u64,
39        non_quiescent_rows: u64,
40    ) -> Result<Self, ApplicationReimportCampaignError> {
41        if checked_rows != quiescent_rows || non_quiescent_rows != 0 {
42            return Err(ApplicationReimportCampaignError::new(
43                ApplicationReimportCampaignErrorKind::WorkflowNotQuiescent,
44            ));
45        }
46        Ok(Self {
47            workflow,
48            checked_rows,
49            quiescent_rows,
50        })
51    }
52
53    /// Symbolic workflow name.
54    #[must_use]
55    pub const fn workflow(&self) -> &InstallationSymbol {
56        &self.workflow
57    }
58
59    /// Rows checked at the exact export snapshot.
60    #[must_use]
61    pub const fn checked_rows(&self) -> u64 {
62        self.checked_rows
63    }
64}
65
66/// Exact completed portability-export source accepted at campaign start.
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct ApplicationReimportSourceV1 {
69    export_manifest_hash: ApplicationExportManifestHash,
70    export_receipt_hash: ApplicationExportReceiptHash,
71    portability_manifest_hash: ApplicationPortabilityManifestHash,
72    source_database_id: DatabaseId,
73    target_database_id: DatabaseId,
74    rows: u64,
75    page_hashes: Vec<ApplicationExportPageHash>,
76    workflow_quiescence: Vec<ReimportWorkflowQuiescenceV1>,
77}
78
79impl ApplicationReimportSourceV1 {
80    /// Checks a completed, nonempty, exact portability source boundary.
81    #[allow(clippy::too_many_arguments)]
82    pub fn new(
83        export_manifest_hash: ApplicationExportManifestHash,
84        export_receipt_hash: ApplicationExportReceiptHash,
85        portability_manifest_hash: ApplicationPortabilityManifestHash,
86        source_database_id: DatabaseId,
87        target_database_id: DatabaseId,
88        rows: u64,
89        page_hashes: Vec<ApplicationExportPageHash>,
90        mut workflow_quiescence: Vec<ReimportWorkflowQuiescenceV1>,
91    ) -> Result<Self, ApplicationReimportCampaignError> {
92        if source_database_id == target_database_id
93            || page_hashes.is_empty()
94            || page_hashes.len() > MAX_APPLICATION_REIMPORT_PAGE_HASHES
95        {
96            return Err(ApplicationReimportCampaignError::new(
97                ApplicationReimportCampaignErrorKind::InvalidSource,
98            ));
99        }
100        workflow_quiescence.sort();
101        if workflow_quiescence
102            .windows(2)
103            .any(|pair| pair[0].workflow == pair[1].workflow)
104        {
105            return Err(ApplicationReimportCampaignError::new(
106                ApplicationReimportCampaignErrorKind::Duplicate,
107            ));
108        }
109        Ok(Self {
110            export_manifest_hash,
111            export_receipt_hash,
112            portability_manifest_hash,
113            source_database_id,
114            target_database_id,
115            rows,
116            page_hashes,
117            workflow_quiescence,
118        })
119    }
120
121    /// Exact completed export manifest identity.
122    #[must_use]
123    pub const fn export_manifest_hash(&self) -> ApplicationExportManifestHash {
124        self.export_manifest_hash
125    }
126
127    /// Exact completed export receipt identity.
128    #[must_use]
129    pub const fn export_receipt_hash(&self) -> ApplicationExportReceiptHash {
130        self.export_receipt_hash
131    }
132
133    /// Exact adapter-owned portability manifest identity.
134    #[must_use]
135    pub const fn portability_manifest_hash(&self) -> ApplicationPortabilityManifestHash {
136        self.portability_manifest_hash
137    }
138
139    /// New destination database identity.
140    #[must_use]
141    pub const fn target_database_id(&self) -> DatabaseId {
142        self.target_database_id
143    }
144
145    /// Original database identity proving this is not an in-place import.
146    #[must_use]
147    pub const fn source_database_id(&self) -> DatabaseId {
148        self.source_database_id
149    }
150
151    /// Exact exported row count across every page.
152    #[must_use]
153    pub const fn rows(&self) -> u64 {
154        self.rows
155    }
156
157    /// Exact source page identities in original one-based order.
158    #[must_use]
159    pub fn page_hashes(&self) -> &[ApplicationExportPageHash] {
160        &self.page_hashes
161    }
162}
163
164/// One compiler-selected mapping's durable aggregate progress.
165#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
166pub struct ReimportMappingProgressV1 {
167    class: PortableRecordClass,
168    symbol: InstallationSymbol,
169    records: u64,
170    succeeded: u64,
171    replayed: u64,
172    outcome_hash: GeneratedArtifactHash,
173}
174
175impl ReimportMappingProgressV1 {
176    fn empty(class: PortableRecordClass, symbol: InstallationSymbol) -> Self {
177        Self {
178            class,
179            symbol,
180            records: 0,
181            succeeded: 0,
182            replayed: 0,
183            outcome_hash: GeneratedArtifactHash::from_bytes([0; 32]),
184        }
185    }
186
187    fn apply(
188        &mut self,
189        records: u64,
190        replayed: bool,
191        outcome_hash: GeneratedArtifactHash,
192    ) -> Result<(), ApplicationReimportCampaignError> {
193        self.records = self.records.checked_add(records).ok_or_else(limit)?;
194        if replayed {
195            self.replayed = self.replayed.checked_add(records).ok_or_else(limit)?;
196        } else {
197            self.succeeded = self.succeeded.checked_add(records).ok_or_else(limit)?;
198        }
199        self.outcome_hash = outcome_hash;
200        Ok(())
201    }
202
203    fn terminal(&self) -> Result<ReimportMappingResult, ApplicationReimportCampaignError> {
204        ReimportMappingResult::new(
205            self.class,
206            self.symbol.clone(),
207            self.records,
208            self.succeeded,
209            self.replayed,
210            self.outcome_hash,
211        )
212        .map_err(|_| integrity())
213    }
214
215    /// Portable record class selected by the compiler.
216    #[must_use]
217    pub const fn class(&self) -> PortableRecordClass {
218        self.class
219    }
220
221    /// Contract symbol selected by the compiler.
222    #[must_use]
223    pub const fn symbol(&self) -> &InstallationSymbol {
224        &self.symbol
225    }
226
227    /// Total durable records applied or replayed.
228    #[must_use]
229    pub const fn records(&self) -> u64 {
230        self.records
231    }
232}
233
234/// Closed lifecycle of the unpublished reimport stage.
235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
236pub enum ApplicationReimportCampaignPhaseV1 {
237    /// Exact source accepted; the next source page may be applied.
238    Applying,
239    /// Every page is durable; named observations remain.
240    Reconciling,
241    /// Receipt sealed and eligible for installation-stage completion.
242    Reconciled,
243    /// Operator cancelled; destination remains not ready.
244    Cancelled,
245    /// Terminal safe failure; destination remains not ready.
246    Failed,
247}
248
249/// Closed redaction-safe terminal failure.
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub enum ApplicationReimportFailureV1 {
252    /// Current capability identity or revision changed.
253    AuthorityChanged,
254    /// A page, record, or hash did not match the completed source.
255    SourceMismatch,
256    /// A compiled reimport command selected a terminal non-success outcome.
257    CommandFailed,
258    /// Final named observations did not reconcile.
259    ObservationMismatch,
260    /// Operator cancelled the unpublished campaign.
261    Cancelled,
262}
263
264/// Exact result of one atomic compiler-owned page command.
265#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct ReimportPageMappingOutcomeV1 {
267    class: PortableRecordClass,
268    symbol: InstallationSymbol,
269    records: u64,
270    replayed: bool,
271    outcome_hash: GeneratedArtifactHash,
272}
273
274impl ReimportPageMappingOutcomeV1 {
275    /// Constructs one nonempty value-free mapping outcome.
276    pub fn new(
277        class: PortableRecordClass,
278        symbol: InstallationSymbol,
279        records: u64,
280        replayed: bool,
281        outcome_hash: GeneratedArtifactHash,
282    ) -> Result<Self, ApplicationReimportCampaignError> {
283        if records == 0 {
284            return Err(ApplicationReimportCampaignError::new(
285                ApplicationReimportCampaignErrorKind::InvalidProgress,
286            ));
287        }
288        Ok(Self {
289            class,
290            symbol,
291            records,
292            replayed,
293            outcome_hash,
294        })
295    }
296}
297
298/// Durable resumable checkpoint nested into one exact installation campaign.
299#[derive(Clone, Eq, PartialEq)]
300pub struct ApplicationReimportCampaignV1 {
301    source: ApplicationReimportSourceV1,
302    portability_manifest_document: Vec<u8>,
303    authority: ApplicationReimportAuthorityV1,
304    scope: CapabilityApplicationReimportScopeV1,
305    phase: ApplicationReimportCampaignPhaseV1,
306    next_page: NonZeroU64,
307    rows_applied: u64,
308    mappings: Vec<ReimportMappingProgressV1>,
309    failure: Option<ApplicationReimportFailureV1>,
310    receipt_hash: Option<ApplicationReimportReceiptHash>,
311    receipt_document: Option<Vec<u8>>,
312}
313
314impl ApplicationReimportCampaignV1 {
315    /// Starts one exact source-bound campaign with compiler-selected mappings.
316    pub fn start(
317        source: ApplicationReimportSourceV1,
318        authority: ApplicationReimportAuthorityV1,
319        scope: CapabilityApplicationReimportScopeV1,
320        manifest: &ApplicationPortabilityManifest,
321    ) -> Result<Self, ApplicationReimportCampaignError> {
322        if source.portability_manifest_hash != manifest.identity() {
323            return Err(ApplicationReimportCampaignError::new(
324                ApplicationReimportCampaignErrorKind::ManifestMismatch,
325            ));
326        }
327        let mappings = manifest
328            .input()
329            .mappings
330            .iter()
331            .map(|mapping| {
332                ReimportMappingProgressV1::empty(mapping.class(), mapping.symbol().clone())
333            })
334            .collect();
335        Ok(Self {
336            source,
337            portability_manifest_document: manifest.canonical_bytes().to_vec(),
338            authority,
339            scope,
340            phase: ApplicationReimportCampaignPhaseV1::Applying,
341            next_page: NonZeroU64::MIN,
342            rows_applied: 0,
343            mappings,
344            failure: None,
345            receipt_hash: None,
346            receipt_document: None,
347        })
348    }
349
350    /// Exact source evidence.
351    #[must_use]
352    pub const fn source(&self) -> &ApplicationReimportSourceV1 {
353        &self.source
354    }
355
356    /// Canonical adapter manifest retained for restart and reconciliation.
357    #[must_use]
358    pub fn portability_manifest_document(&self) -> &[u8] {
359        &self.portability_manifest_document
360    }
361
362    /// Current authority identity frozen at start.
363    #[must_use]
364    pub const fn authority(&self) -> ApplicationReimportAuthorityV1 {
365        self.authority
366    }
367
368    /// Capability scope frozen for this campaign.
369    #[must_use]
370    pub const fn scope(&self) -> CapabilityApplicationReimportScopeV1 {
371        self.scope
372    }
373
374    /// Aggregate progress in compiler-selected mapping order.
375    #[must_use]
376    pub fn mappings(&self) -> &[ReimportMappingProgressV1] {
377        &self.mappings
378    }
379
380    /// Current phase.
381    #[must_use]
382    pub const fn phase(&self) -> ApplicationReimportCampaignPhaseV1 {
383        self.phase
384    }
385
386    /// Next exact one-based source page.
387    #[must_use]
388    pub const fn next_page(&self) -> NonZeroU64 {
389        self.next_page
390    }
391
392    /// Number of source records durably applied through compiler-owned commands.
393    #[must_use]
394    pub const fn rows_applied(&self) -> u64 {
395        self.rows_applied
396    }
397
398    /// Safe terminal failure, if any.
399    #[must_use]
400    pub const fn failure(&self) -> Option<ApplicationReimportFailureV1> {
401        self.failure
402    }
403
404    /// Final reconciliation receipt, present only after success.
405    #[must_use]
406    pub const fn receipt_hash(&self) -> Option<ApplicationReimportReceiptHash> {
407        self.receipt_hash
408    }
409
410    /// Canonical terminal receipt bytes retained across restart.
411    #[must_use]
412    pub fn receipt_document(&self) -> Option<&[u8]> {
413        self.receipt_document.as_deref()
414    }
415
416    /// Fails closed if routine capability administration changed campaign authority.
417    pub fn verify_authority(
418        &mut self,
419        authority: ApplicationReimportAuthorityV1,
420    ) -> Result<(), ApplicationReimportCampaignError> {
421        if self.authority == authority {
422            return Ok(());
423        }
424        self.fail(ApplicationReimportFailureV1::AuthorityChanged);
425        Err(ApplicationReimportCampaignError::new(
426            ApplicationReimportCampaignErrorKind::AuthorityChanged,
427        ))
428    }
429
430    /// Advances exactly one source page after its ordinary command commits.
431    pub fn complete_page(
432        &mut self,
433        page_number: NonZeroU64,
434        page_hash: ApplicationExportPageHash,
435        outcomes: Vec<ReimportPageMappingOutcomeV1>,
436    ) -> Result<(), ApplicationReimportCampaignError> {
437        if self.phase != ApplicationReimportCampaignPhaseV1::Applying
438            || page_number != self.next_page
439        {
440            return Err(ApplicationReimportCampaignError::new(
441                ApplicationReimportCampaignErrorKind::PageOutOfOrder,
442            ));
443        }
444        let index = usize::try_from(page_number.get() - 1).map_err(|_| limit())?;
445        if self.source.page_hashes.get(index) != Some(&page_hash) {
446            self.fail(ApplicationReimportFailureV1::SourceMismatch);
447            return Err(ApplicationReimportCampaignError::new(
448                ApplicationReimportCampaignErrorKind::PageMismatch,
449            ));
450        }
451        let mut next_mappings = self.mappings.clone();
452        let mut page_rows = 0_u64;
453        for outcome in outcomes {
454            let mapping = next_mappings
455                .iter_mut()
456                .find(|mapping| mapping.class == outcome.class && mapping.symbol == outcome.symbol)
457                .ok_or_else(|| {
458                    ApplicationReimportCampaignError::new(
459                        ApplicationReimportCampaignErrorKind::MappingMismatch,
460                    )
461                })?;
462            mapping.apply(outcome.records, outcome.replayed, outcome.outcome_hash)?;
463            page_rows = page_rows.checked_add(outcome.records).ok_or_else(limit)?;
464        }
465        let rows_applied = self.rows_applied.checked_add(page_rows).ok_or_else(limit)?;
466        if index + 1 == self.source.page_hashes.len() {
467            if rows_applied != self.source.rows {
468                self.fail(ApplicationReimportFailureV1::SourceMismatch);
469                return Err(ApplicationReimportCampaignError::new(
470                    ApplicationReimportCampaignErrorKind::SourceCountMismatch,
471                ));
472            }
473            self.phase = ApplicationReimportCampaignPhaseV1::Reconciling;
474        } else {
475            self.next_page = NonZeroU64::new(page_number.get().checked_add(1).ok_or_else(limit)?)
476                .ok_or_else(limit)?;
477        }
478        self.mappings = next_mappings;
479        self.rows_applied = rows_applied;
480        Ok(())
481    }
482
483    /// Seals terminal reconciliation only against the exact manifest and observations.
484    pub fn reconcile(
485        &mut self,
486        manifest: &ApplicationPortabilityManifest,
487        observations: Vec<ReimportObservationResult>,
488    ) -> Result<ApplicationReimportReceipt, ApplicationReimportCampaignError> {
489        if self.phase != ApplicationReimportCampaignPhaseV1::Reconciling
490            || manifest.identity() != self.source.portability_manifest_hash
491        {
492            return Err(ApplicationReimportCampaignError::new(
493                ApplicationReimportCampaignErrorKind::InvalidProgress,
494            ));
495        }
496        let mappings = self
497            .mappings
498            .iter()
499            .map(ReimportMappingProgressV1::terminal)
500            .collect::<Result<Vec<_>, _>>()?;
501        let result = ApplicationReimportReceipt::reconcile(
502            manifest,
503            self.source.export_manifest_hash,
504            self.source.target_database_id,
505            mappings,
506            observations,
507        );
508        let receipt = match result {
509            Ok(receipt) => receipt,
510            Err(_) => {
511                self.fail(ApplicationReimportFailureV1::ObservationMismatch);
512                return Err(ApplicationReimportCampaignError::new(
513                    ApplicationReimportCampaignErrorKind::ReconciliationMismatch,
514                ));
515            }
516        };
517        self.phase = ApplicationReimportCampaignPhaseV1::Reconciled;
518        self.failure = None;
519        self.receipt_hash = Some(receipt.identity());
520        self.receipt_document = Some(receipt.canonical_bytes().to_vec());
521        Ok(receipt)
522    }
523
524    /// Cancels an unpublished nonterminal destination.
525    pub fn cancel(&mut self) -> Result<(), ApplicationReimportCampaignError> {
526        match self.phase {
527            ApplicationReimportCampaignPhaseV1::Applying
528            | ApplicationReimportCampaignPhaseV1::Reconciling => {
529                self.phase = ApplicationReimportCampaignPhaseV1::Cancelled;
530                self.failure = Some(ApplicationReimportFailureV1::Cancelled);
531                Ok(())
532            }
533            _ => Err(ApplicationReimportCampaignError::new(
534                ApplicationReimportCampaignErrorKind::AlreadyTerminal,
535            )),
536        }
537    }
538
539    /// Records a terminal typed command failure without exposing its row values.
540    pub fn fail_command(&mut self) -> Result<(), ApplicationReimportCampaignError> {
541        match self.phase {
542            ApplicationReimportCampaignPhaseV1::Applying
543            | ApplicationReimportCampaignPhaseV1::Reconciling => {
544                self.fail(ApplicationReimportFailureV1::CommandFailed);
545                Ok(())
546            }
547            _ => Err(ApplicationReimportCampaignError::new(
548                ApplicationReimportCampaignErrorKind::AlreadyTerminal,
549            )),
550        }
551    }
552
553    fn fail(&mut self, failure: ApplicationReimportFailureV1) {
554        self.phase = ApplicationReimportCampaignPhaseV1::Failed;
555        self.failure = Some(failure);
556        self.receipt_hash = None;
557        self.receipt_document = None;
558    }
559
560    /// Encodes exact bounded canonical durable state.
561    pub fn encode_canonical(&self) -> Result<Vec<u8>, ApplicationReimportCampaignError> {
562        validate(self)?;
563        canonical_bytes(&CampaignDto::from_campaign(self))
564    }
565
566    /// Strictly decodes, validates, and checks canonical bytes.
567    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, ApplicationReimportCampaignError> {
568        if bytes.is_empty() || bytes.len() > MAX_APPLICATION_REIMPORT_CAMPAIGN_BYTES {
569            return Err(limit());
570        }
571        let dto: CampaignDto = serde_json::from_slice(bytes).map_err(|_| invalid())?;
572        let campaign = dto.into_campaign()?;
573        validate(&campaign)?;
574        if campaign.encode_canonical()? != bytes {
575            return Err(ApplicationReimportCampaignError::new(
576                ApplicationReimportCampaignErrorKind::NonCanonical,
577            ));
578        }
579        Ok(campaign)
580    }
581}
582
583impl fmt::Debug for ApplicationReimportCampaignV1 {
584    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
585        formatter
586            .debug_struct("ApplicationReimportCampaignV1")
587            .field("phase", &self.phase)
588            .field("next_page", &self.next_page)
589            .field("rows_applied", &self.rows_applied)
590            .field("mapping_count", &self.mappings.len())
591            .finish_non_exhaustive()
592    }
593}
594
595/// Closed durable-checkpoint failure.
596#[derive(Clone, Copy, Debug, Eq, PartialEq)]
597pub enum ApplicationReimportCampaignErrorKind {
598    /// Bytes are not the closed canonical schema.
599    InvalidEncoding,
600    /// Valid JSON did not use the sole canonical representation.
601    NonCanonical,
602    /// A bounded collection or encoded checkpoint exceeded its maximum.
603    LimitExceeded,
604    /// Source and destination evidence cannot describe a portability reimport.
605    InvalidSource,
606    /// The exact portability manifest does not match the source receipt.
607    ManifestMismatch,
608    /// A workflow contained an active or partially cleared lease.
609    WorkflowNotQuiescent,
610    /// A supposedly unique symbol or evidence row was repeated.
611    Duplicate,
612    /// Durable counters or lifecycle fields are internally inconsistent.
613    InvalidProgress,
614    /// The submitted page was not the sole expected next page.
615    PageOutOfOrder,
616    /// The submitted page did not match its completed export identity.
617    PageMismatch,
618    /// A page outcome named something outside the compiler-selected schedule.
619    MappingMismatch,
620    /// Applied records did not equal the completed export's declared total.
621    SourceCountMismatch,
622    /// Named destination observations did not match the portability manifest.
623    ReconciliationMismatch,
624    /// Current capability identity or revision changed at a safe point.
625    AuthorityChanged,
626    /// A terminal campaign cannot be advanced or cancelled again.
627    AlreadyTerminal,
628}
629
630/// Static redaction-safe durable-checkpoint error.
631#[derive(Clone, Copy, Debug, Eq, PartialEq)]
632pub struct ApplicationReimportCampaignError {
633    kind: ApplicationReimportCampaignErrorKind,
634}
635
636impl ApplicationReimportCampaignError {
637    const fn new(kind: ApplicationReimportCampaignErrorKind) -> Self {
638        Self { kind }
639    }
640
641    /// Returns the closed machine-readable failure class.
642    #[must_use]
643    pub const fn kind(self) -> ApplicationReimportCampaignErrorKind {
644        self.kind
645    }
646}
647
648impl fmt::Display for ApplicationReimportCampaignError {
649    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
650        formatter.write_str("application reimport campaign evidence is invalid")
651    }
652}
653
654impl Error for ApplicationReimportCampaignError {}
655
656#[derive(Serialize, Deserialize)]
657#[serde(deny_unknown_fields)]
658struct CampaignDto {
659    schema: String,
660    export_manifest_hash: String,
661    export_receipt_hash: String,
662    portability_manifest_hash: String,
663    portability_manifest_document: String,
664    source_database_id: String,
665    target_database_id: String,
666    rows: String,
667    page_hashes: Vec<String>,
668    workflow_quiescence: Vec<WorkflowDto>,
669    capability_id: String,
670    capability_revision: String,
671    scope: String,
672    phase: String,
673    next_page: String,
674    rows_applied: String,
675    mappings: Vec<MappingDto>,
676    failure: Option<String>,
677    receipt_hash: Option<String>,
678    receipt_document: Option<String>,
679}
680
681#[derive(Serialize, Deserialize)]
682#[serde(deny_unknown_fields)]
683struct WorkflowDto {
684    workflow: String,
685    checked_rows: String,
686    quiescent_rows: String,
687}
688
689#[derive(Serialize, Deserialize)]
690#[serde(deny_unknown_fields)]
691struct MappingDto {
692    class: String,
693    symbol: String,
694    records: String,
695    succeeded: String,
696    replayed: String,
697    outcome_hash: String,
698}
699
700impl CampaignDto {
701    fn from_campaign(value: &ApplicationReimportCampaignV1) -> Self {
702        Self {
703            schema: APPLICATION_REIMPORT_CAMPAIGN_SCHEMA_V1.to_owned(),
704            export_manifest_hash: hex32(value.source.export_manifest_hash.as_bytes()),
705            export_receipt_hash: hex32(value.source.export_receipt_hash.as_bytes()),
706            portability_manifest_hash: hex32(value.source.portability_manifest_hash.as_bytes()),
707            portability_manifest_document: String::from_utf8(
708                value.portability_manifest_document.clone(),
709            )
710            .expect("canonical portability manifest is JSON UTF-8"),
711            source_database_id: value.source.source_database_id.to_string(),
712            target_database_id: value.source.target_database_id.to_string(),
713            rows: value.source.rows.to_string(),
714            page_hashes: value
715                .source
716                .page_hashes
717                .iter()
718                .map(|hash| hex32(hash.as_bytes()))
719                .collect(),
720            workflow_quiescence: value
721                .source
722                .workflow_quiescence
723                .iter()
724                .map(|evidence| WorkflowDto {
725                    workflow: evidence.workflow.as_str().to_owned(),
726                    checked_rows: evidence.checked_rows.to_string(),
727                    quiescent_rows: evidence.quiescent_rows.to_string(),
728                })
729                .collect(),
730            capability_id: value.authority.capability_id().to_string(),
731            capability_revision: value.authority.capability_revision().to_string(),
732            scope: match value.scope {
733                CapabilityApplicationReimportScopeV1::PrincipalFiltered => "principal_filtered",
734                CapabilityApplicationReimportScopeV1::WholeApplication => "whole_application",
735            }
736            .to_owned(),
737            phase: phase_name(value.phase).to_owned(),
738            next_page: value.next_page.to_string(),
739            rows_applied: value.rows_applied.to_string(),
740            mappings: value
741                .mappings
742                .iter()
743                .map(|mapping| MappingDto {
744                    class: class_name(mapping.class).to_owned(),
745                    symbol: mapping.symbol.as_str().to_owned(),
746                    records: mapping.records.to_string(),
747                    succeeded: mapping.succeeded.to_string(),
748                    replayed: mapping.replayed.to_string(),
749                    outcome_hash: hex32(mapping.outcome_hash.as_bytes()),
750                })
751                .collect(),
752            failure: value.failure.map(failure_name).map(str::to_owned),
753            receipt_hash: value.receipt_hash.map(|hash| hex32(hash.as_bytes())),
754            receipt_document: value.receipt_document.as_ref().map(|document| {
755                String::from_utf8(document.clone()).expect("canonical receipt is JSON UTF-8")
756            }),
757        }
758    }
759
760    fn into_campaign(
761        self,
762    ) -> Result<ApplicationReimportCampaignV1, ApplicationReimportCampaignError> {
763        if self.schema != APPLICATION_REIMPORT_CAMPAIGN_SCHEMA_V1 {
764            return Err(invalid());
765        }
766        let source = ApplicationReimportSourceV1::new(
767            ApplicationExportManifestHash::from_bytes(parse_hex32(&self.export_manifest_hash)?),
768            ApplicationExportReceiptHash::from_bytes(parse_hex32(&self.export_receipt_hash)?),
769            ApplicationPortabilityManifestHash::from_bytes(parse_hex32(
770                &self.portability_manifest_hash,
771            )?),
772            DatabaseId::from_bytes(parse_uuid(&self.source_database_id)?).map_err(|_| invalid())?,
773            DatabaseId::from_bytes(parse_uuid(&self.target_database_id)?).map_err(|_| invalid())?,
774            parse_u64(&self.rows)?,
775            self.page_hashes
776                .iter()
777                .map(|value| parse_hex32(value).map(ApplicationExportPageHash::from_bytes))
778                .collect::<Result<_, _>>()?,
779            self.workflow_quiescence
780                .into_iter()
781                .map(|value| {
782                    ReimportWorkflowQuiescenceV1::new(
783                        InstallationSymbol::new(value.workflow).map_err(|_| invalid())?,
784                        parse_u64(&value.checked_rows)?,
785                        parse_u64(&value.quiescent_rows)?,
786                        0,
787                    )
788                })
789                .collect::<Result<_, _>>()?,
790        )?;
791        let capability_id =
792            CapabilityId::from_bytes(parse_uuid(&self.capability_id)?).map_err(|_| invalid())?;
793        let capability_revision =
794            NonZeroU64::new(parse_u64(&self.capability_revision)?).ok_or_else(invalid)?;
795        let scope = match self.scope.as_str() {
796            "principal_filtered" => CapabilityApplicationReimportScopeV1::PrincipalFiltered,
797            "whole_application" => CapabilityApplicationReimportScopeV1::WholeApplication,
798            _ => return Err(invalid()),
799        };
800        let portability_manifest_document = self.portability_manifest_document.into_bytes();
801        let phase = parse_phase(&self.phase)?;
802        let mappings = self
803            .mappings
804            .into_iter()
805            .map(|mapping| {
806                Ok(ReimportMappingProgressV1 {
807                    class: parse_class(&mapping.class)?,
808                    symbol: InstallationSymbol::new(mapping.symbol).map_err(|_| invalid())?,
809                    records: parse_u64(&mapping.records)?,
810                    succeeded: parse_u64(&mapping.succeeded)?,
811                    replayed: parse_u64(&mapping.replayed)?,
812                    outcome_hash: GeneratedArtifactHash::from_bytes(parse_hex32(
813                        &mapping.outcome_hash,
814                    )?),
815                })
816            })
817            .collect::<Result<Vec<_>, ApplicationReimportCampaignError>>()?;
818        let failure = self.failure.as_deref().map(parse_failure).transpose()?;
819        let receipt_hash = self
820            .receipt_hash
821            .as_deref()
822            .map(parse_hex32)
823            .transpose()?
824            .map(ApplicationReimportReceiptHash::from_bytes);
825        let receipt_document = self.receipt_document.map(String::into_bytes);
826        Ok(ApplicationReimportCampaignV1 {
827            source,
828            portability_manifest_document,
829            authority: ApplicationReimportAuthorityV1::new(capability_id, capability_revision),
830            scope,
831            phase,
832            next_page: NonZeroU64::new(parse_u64(&self.next_page)?).ok_or_else(invalid)?,
833            rows_applied: parse_u64(&self.rows_applied)?,
834            mappings,
835            failure,
836            receipt_hash,
837            receipt_document,
838        })
839    }
840}
841
842fn validate(value: &ApplicationReimportCampaignV1) -> Result<(), ApplicationReimportCampaignError> {
843    let manifest =
844        ApplicationPortabilityManifest::decode_canonical(&value.portability_manifest_document)
845            .map_err(|_| invalid())?;
846    if manifest.identity() != value.source.portability_manifest_hash
847        || manifest.input().mappings.len() != value.mappings.len()
848        || manifest
849            .input()
850            .mappings
851            .iter()
852            .zip(&value.mappings)
853            .any(|(expected, actual)| {
854                expected.class() != actual.class || expected.symbol() != &actual.symbol
855            })
856        || value.mappings.is_empty()
857        || value.mappings.len() > crate::MAX_APPLICATION_PORTABLE_MAPPINGS
858        || value
859            .mappings
860            .windows(2)
861            .any(|pair| (pair[0].class, &pair[0].symbol) >= (pair[1].class, &pair[1].symbol))
862        || value
863            .mappings
864            .iter()
865            .any(|mapping| mapping.succeeded.checked_add(mapping.replayed) != Some(mapping.records))
866    {
867        return Err(ApplicationReimportCampaignError::new(
868            ApplicationReimportCampaignErrorKind::InvalidProgress,
869        ));
870    }
871    let completed_pages = match value.phase {
872        ApplicationReimportCampaignPhaseV1::Applying => value.next_page.get() - 1,
873        ApplicationReimportCampaignPhaseV1::Reconciling
874        | ApplicationReimportCampaignPhaseV1::Reconciled => {
875            u64::try_from(value.source.page_hashes.len()).map_err(|_| limit())?
876        }
877        ApplicationReimportCampaignPhaseV1::Cancelled
878        | ApplicationReimportCampaignPhaseV1::Failed => value.next_page.get() - 1,
879    };
880    if completed_pages > u64::try_from(value.source.page_hashes.len()).map_err(|_| limit())?
881        || value.rows_applied
882            != value
883                .mappings
884                .iter()
885                .try_fold(0_u64, |sum, mapping| sum.checked_add(mapping.records))
886                .ok_or_else(limit)?
887    {
888        return Err(ApplicationReimportCampaignError::new(
889            ApplicationReimportCampaignErrorKind::InvalidProgress,
890        ));
891    }
892    let terminal_failure = matches!(
893        value.phase,
894        ApplicationReimportCampaignPhaseV1::Cancelled | ApplicationReimportCampaignPhaseV1::Failed
895    );
896    let reconciled = value.phase == ApplicationReimportCampaignPhaseV1::Reconciled;
897    if terminal_failure != value.failure.is_some()
898        || reconciled != value.receipt_hash.is_some()
899        || reconciled != value.receipt_document.is_some()
900        || value
901            .receipt_document
902            .as_ref()
903            .zip(value.receipt_hash)
904            .is_some_and(|(document, expected)| {
905                hash_application_reimport_receipt(document) != expected
906            })
907    {
908        return Err(ApplicationReimportCampaignError::new(
909            ApplicationReimportCampaignErrorKind::InvalidProgress,
910        ));
911    }
912    Ok(())
913}
914
915fn canonical_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, ApplicationReimportCampaignError> {
916    let mut bytes = serde_json::to_vec(value).map_err(|_| invalid())?;
917    bytes.push(b'\n');
918    if bytes.len() > MAX_APPLICATION_REIMPORT_CAMPAIGN_BYTES {
919        return Err(limit());
920    }
921    Ok(bytes)
922}
923
924fn phase_name(value: ApplicationReimportCampaignPhaseV1) -> &'static str {
925    match value {
926        ApplicationReimportCampaignPhaseV1::Applying => "applying",
927        ApplicationReimportCampaignPhaseV1::Reconciling => "reconciling",
928        ApplicationReimportCampaignPhaseV1::Reconciled => "reconciled",
929        ApplicationReimportCampaignPhaseV1::Cancelled => "cancelled",
930        ApplicationReimportCampaignPhaseV1::Failed => "failed",
931    }
932}
933
934fn parse_phase(
935    value: &str,
936) -> Result<ApplicationReimportCampaignPhaseV1, ApplicationReimportCampaignError> {
937    match value {
938        "applying" => Ok(ApplicationReimportCampaignPhaseV1::Applying),
939        "reconciling" => Ok(ApplicationReimportCampaignPhaseV1::Reconciling),
940        "reconciled" => Ok(ApplicationReimportCampaignPhaseV1::Reconciled),
941        "cancelled" => Ok(ApplicationReimportCampaignPhaseV1::Cancelled),
942        "failed" => Ok(ApplicationReimportCampaignPhaseV1::Failed),
943        _ => Err(invalid()),
944    }
945}
946
947fn failure_name(value: ApplicationReimportFailureV1) -> &'static str {
948    match value {
949        ApplicationReimportFailureV1::AuthorityChanged => "authority_changed",
950        ApplicationReimportFailureV1::SourceMismatch => "source_mismatch",
951        ApplicationReimportFailureV1::CommandFailed => "command_failed",
952        ApplicationReimportFailureV1::ObservationMismatch => "observation_mismatch",
953        ApplicationReimportFailureV1::Cancelled => "cancelled",
954    }
955}
956
957fn parse_failure(
958    value: &str,
959) -> Result<ApplicationReimportFailureV1, ApplicationReimportCampaignError> {
960    match value {
961        "authority_changed" => Ok(ApplicationReimportFailureV1::AuthorityChanged),
962        "source_mismatch" => Ok(ApplicationReimportFailureV1::SourceMismatch),
963        "command_failed" => Ok(ApplicationReimportFailureV1::CommandFailed),
964        "observation_mismatch" => Ok(ApplicationReimportFailureV1::ObservationMismatch),
965        "cancelled" => Ok(ApplicationReimportFailureV1::Cancelled),
966        _ => Err(invalid()),
967    }
968}
969
970fn class_name(value: PortableRecordClass) -> &'static str {
971    match value {
972        PortableRecordClass::Entity => "entity",
973        PortableRecordClass::Event => "event",
974    }
975}
976
977fn parse_class(value: &str) -> Result<PortableRecordClass, ApplicationReimportCampaignError> {
978    match value {
979        "entity" => Ok(PortableRecordClass::Entity),
980        "event" => Ok(PortableRecordClass::Event),
981        _ => Err(invalid()),
982    }
983}
984
985fn parse_u64(value: &str) -> Result<u64, ApplicationReimportCampaignError> {
986    if value.is_empty()
987        || (value.len() > 1 && value.starts_with('0'))
988        || !value.bytes().all(|byte| byte.is_ascii_digit())
989    {
990        return Err(invalid());
991    }
992    value.parse().map_err(|_| invalid())
993}
994
995fn parse_uuid(value: &str) -> Result<[u8; 16], ApplicationReimportCampaignError> {
996    if value.len() != 36 {
997        return Err(invalid());
998    }
999    let mut compact = [0_u8; 32];
1000    let mut output = 0_usize;
1001    for (index, byte) in value.bytes().enumerate() {
1002        if matches!(index, 8 | 13 | 18 | 23) {
1003            if byte != b'-' {
1004                return Err(invalid());
1005            }
1006        } else {
1007            if output == compact.len() {
1008                return Err(invalid());
1009            }
1010            compact[output] = byte;
1011            output += 1;
1012        }
1013    }
1014    if output != compact.len() {
1015        return Err(invalid());
1016    }
1017    let mut bytes = [0_u8; 16];
1018    for (index, pair) in compact.chunks_exact(2).enumerate() {
1019        bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
1020    }
1021    Ok(bytes)
1022}
1023
1024fn hex32(value: &[u8; 32]) -> String {
1025    value.iter().map(|byte| format!("{byte:02x}")).collect()
1026}
1027
1028fn parse_hex32(value: &str) -> Result<[u8; 32], ApplicationReimportCampaignError> {
1029    if value.len() != 64 {
1030        return Err(invalid());
1031    }
1032    let mut bytes = [0_u8; 32];
1033    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
1034        bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
1035    }
1036    Ok(bytes)
1037}
1038
1039fn hex_nibble(value: u8) -> Result<u8, ApplicationReimportCampaignError> {
1040    match value {
1041        b'0'..=b'9' => Ok(value - b'0'),
1042        b'a'..=b'f' => Ok(value - b'a' + 10),
1043        _ => Err(invalid()),
1044    }
1045}
1046
1047const fn invalid() -> ApplicationReimportCampaignError {
1048    ApplicationReimportCampaignError::new(ApplicationReimportCampaignErrorKind::InvalidEncoding)
1049}
1050
1051const fn limit() -> ApplicationReimportCampaignError {
1052    ApplicationReimportCampaignError::new(ApplicationReimportCampaignErrorKind::LimitExceeded)
1053}
1054
1055const fn integrity() -> ApplicationReimportCampaignError {
1056    ApplicationReimportCampaignError::new(ApplicationReimportCampaignErrorKind::InvalidProgress)
1057}