1use 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
18pub const APPLICATION_REIMPORT_CAMPAIGN_SCHEMA_V1: &str = "riffdb.application-reimport-campaign/v1";
20pub const MAX_APPLICATION_REIMPORT_CAMPAIGN_BYTES: usize = 2 * 1_024 * 1_024;
22pub const MAX_APPLICATION_REIMPORT_PAGE_HASHES: usize = 4_096;
24
25#[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 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 #[must_use]
55 pub const fn workflow(&self) -> &InstallationSymbol {
56 &self.workflow
57 }
58
59 #[must_use]
61 pub const fn checked_rows(&self) -> u64 {
62 self.checked_rows
63 }
64}
65
66#[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 #[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 #[must_use]
123 pub const fn export_manifest_hash(&self) -> ApplicationExportManifestHash {
124 self.export_manifest_hash
125 }
126
127 #[must_use]
129 pub const fn export_receipt_hash(&self) -> ApplicationExportReceiptHash {
130 self.export_receipt_hash
131 }
132
133 #[must_use]
135 pub const fn portability_manifest_hash(&self) -> ApplicationPortabilityManifestHash {
136 self.portability_manifest_hash
137 }
138
139 #[must_use]
141 pub const fn target_database_id(&self) -> DatabaseId {
142 self.target_database_id
143 }
144
145 #[must_use]
147 pub const fn source_database_id(&self) -> DatabaseId {
148 self.source_database_id
149 }
150
151 #[must_use]
153 pub const fn rows(&self) -> u64 {
154 self.rows
155 }
156
157 #[must_use]
159 pub fn page_hashes(&self) -> &[ApplicationExportPageHash] {
160 &self.page_hashes
161 }
162}
163
164#[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 #[must_use]
217 pub const fn class(&self) -> PortableRecordClass {
218 self.class
219 }
220
221 #[must_use]
223 pub const fn symbol(&self) -> &InstallationSymbol {
224 &self.symbol
225 }
226
227 #[must_use]
229 pub const fn records(&self) -> u64 {
230 self.records
231 }
232}
233
234#[derive(Clone, Copy, Debug, Eq, PartialEq)]
236pub enum ApplicationReimportCampaignPhaseV1 {
237 Applying,
239 Reconciling,
241 Reconciled,
243 Cancelled,
245 Failed,
247}
248
249#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub enum ApplicationReimportFailureV1 {
252 AuthorityChanged,
254 SourceMismatch,
256 CommandFailed,
258 ObservationMismatch,
260 Cancelled,
262}
263
264#[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 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#[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 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 #[must_use]
352 pub const fn source(&self) -> &ApplicationReimportSourceV1 {
353 &self.source
354 }
355
356 #[must_use]
358 pub fn portability_manifest_document(&self) -> &[u8] {
359 &self.portability_manifest_document
360 }
361
362 #[must_use]
364 pub const fn authority(&self) -> ApplicationReimportAuthorityV1 {
365 self.authority
366 }
367
368 #[must_use]
370 pub const fn scope(&self) -> CapabilityApplicationReimportScopeV1 {
371 self.scope
372 }
373
374 #[must_use]
376 pub fn mappings(&self) -> &[ReimportMappingProgressV1] {
377 &self.mappings
378 }
379
380 #[must_use]
382 pub const fn phase(&self) -> ApplicationReimportCampaignPhaseV1 {
383 self.phase
384 }
385
386 #[must_use]
388 pub const fn next_page(&self) -> NonZeroU64 {
389 self.next_page
390 }
391
392 #[must_use]
394 pub const fn rows_applied(&self) -> u64 {
395 self.rows_applied
396 }
397
398 #[must_use]
400 pub const fn failure(&self) -> Option<ApplicationReimportFailureV1> {
401 self.failure
402 }
403
404 #[must_use]
406 pub const fn receipt_hash(&self) -> Option<ApplicationReimportReceiptHash> {
407 self.receipt_hash
408 }
409
410 #[must_use]
412 pub fn receipt_document(&self) -> Option<&[u8]> {
413 self.receipt_document.as_deref()
414 }
415
416 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 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 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 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 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 pub fn encode_canonical(&self) -> Result<Vec<u8>, ApplicationReimportCampaignError> {
562 validate(self)?;
563 canonical_bytes(&CampaignDto::from_campaign(self))
564 }
565
566 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
597pub enum ApplicationReimportCampaignErrorKind {
598 InvalidEncoding,
600 NonCanonical,
602 LimitExceeded,
604 InvalidSource,
606 ManifestMismatch,
608 WorkflowNotQuiescent,
610 Duplicate,
612 InvalidProgress,
614 PageOutOfOrder,
616 PageMismatch,
618 MappingMismatch,
620 SourceCountMismatch,
622 ReconciliationMismatch,
624 AuthorityChanged,
626 AlreadyTerminal,
628}
629
630#[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 #[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}