field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

recovery_completion.rs (5715B)


      1 use crate::{
      2     FfiPreparedMediaInput, FfiRuntimeChangeKind, FfiSubmissionUploadResponse,
      3     MOBILE_FFI_SCHEMA_VERSION, TeraAppError, TeraRuntime,
      4     dto::{PreparedMedia, decode_id},
      5 };
      6 use tera_core::runtime::product_surface::{
      7     SubmissionMediaResponse,
      8     recovery_completion::{
      9         RecoveryCompletionError, RecoveryCompletionReceipt, RecoveryNativeIdentity,
     10         RecoveryNativeMedia, RecoveryNativeReceipt,
     11     },
     12 };
     13 
     14 #[derive(Clone, uniffi::Record)]
     15 pub struct FfiRecoveryUploadReceipt {
     16     pub schema_version: u16,
     17     pub parent: String,
     18     pub revision: u64,
     19     pub attempt: String,
     20     pub upload_url: String,
     21     pub sha256: String,
     22     pub media_type: String,
     23     pub byte_size: u64,
     24     pub response: FfiSubmissionUploadResponse,
     25 }
     26 
     27 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     28 pub struct FfiRecoveryCompletionReceipt {
     29     pub schema_version: u16,
     30     pub parent: String,
     31     pub attempt: String,
     32     pub canonical_url: String,
     33     pub sha256: String,
     34     pub media_type: String,
     35     pub byte_size: u64,
     36     pub verified_at_unix_ms: u64,
     37 }
     38 
     39 impl TryFrom<FfiRecoveryUploadReceipt> for RecoveryNativeReceipt {
     40     type Error = TeraAppError;
     41     fn try_from(value: FfiRecoveryUploadReceipt) -> Result<Self, Self::Error> {
     42         let invalid = || TeraAppError::invalid_argument("invalid_recovery_receipt");
     43         if value.schema_version != MOBILE_FFI_SCHEMA_VERSION
     44             || value.response.schema_version != MOBILE_FFI_SCHEMA_VERSION
     45             || value.sha256.len() != 64
     46             || value.media_type.len() > 8192
     47         {
     48             return Err(invalid());
     49         }
     50         let identity = RecoveryNativeIdentity::new(
     51             decode_id(&value.parent, "invalid_recovery_parent")?,
     52             value.revision,
     53             decode_id(&value.attempt, "invalid_recovery_attempt")?,
     54             value.upload_url,
     55         )?;
     56         let hash = radroots_blossom::Sha256::from_hex(&value.sha256).map_err(|_| invalid())?;
     57         let kind = radroots_blossom::MediaType::parse(&value.media_type).map_err(|_| invalid())?;
     58         let media = RecoveryNativeMedia::new(hash, kind, value.byte_size)?;
     59         let response = SubmissionMediaResponse::new(
     60             value.response.status_code,
     61             value.response.media_type,
     62             value.response.content_encoding,
     63             value.response.body,
     64         )?;
     65         Ok(Self::new(identity, media, response))
     66     }
     67 }
     68 
     69 impl From<RecoveryCompletionReceipt> for FfiRecoveryCompletionReceipt {
     70     fn from(value: RecoveryCompletionReceipt) -> Self {
     71         Self {
     72             schema_version: MOBILE_FFI_SCHEMA_VERSION,
     73             parent: hex::encode(value.parent),
     74             attempt: hex::encode(value.attempt),
     75             canonical_url: value.canonical_url,
     76             sha256: value.sha256,
     77             media_type: value.media_type,
     78             byte_size: value.byte_size,
     79             verified_at_unix_ms: value.verified_at_unix_ms,
     80         }
     81     }
     82 }
     83 
     84 #[cfg_attr(not(coverage_nightly), uniffi::export(async_runtime = "tokio"))]
     85 impl TeraRuntime {
     86     pub async fn recover_native_upload(
     87         &self,
     88         receipt: FfiRecoveryUploadReceipt,
     89         media: FfiPreparedMediaInput,
     90     ) -> Result<FfiRecoveryCompletionReceipt, TeraAppError> {
     91         // Bound native evidence before opening or hashing any caller-owned file.
     92         let native = receipt.try_into()?;
     93         let source = PreparedMedia::try_from(media)?.into_recovery_media()?;
     94         let result = self.inner.recover_native_upload(native, source).await;
     95         self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
     96         self.subscriptions
     97             .notify(FfiRuntimeChangeKind::Drafts, None);
     98         result.map(Into::into).map_err(|error| match error {
     99             RecoveryCompletionError::Draft(error) => error.into(),
    100             RecoveryCompletionError::Submission(error) => error.into(),
    101         })
    102     }
    103 }
    104 
    105 #[cfg(test)]
    106 mod tests {
    107     use super::*;
    108 
    109     fn input() -> FfiRecoveryUploadReceipt {
    110         FfiRecoveryUploadReceipt {
    111             schema_version: 1,
    112             parent: "01".repeat(16),
    113             revision: 2,
    114             attempt: "02".repeat(16),
    115             upload_url: "http://127.0.0.1:3000/upload".into(),
    116             sha256: "03".repeat(32),
    117             media_type: "image/png".into(),
    118             byte_size: 24,
    119             response: FfiSubmissionUploadResponse {
    120                 schema_version: 1,
    121                 status_code: 200,
    122                 media_type: Some("application/json".into()),
    123                 content_encoding: None,
    124                 body: vec![1; 16_368],
    125             },
    126         }
    127     }
    128 
    129     #[test]
    130     fn recovery_native_evidence_bounds_reject_before_owned_media_admission() {
    131         // Exactly 16 KiB across the body and headers is admitted for subsequent
    132         // semantic validation; the next byte must fail at this boundary.
    133         assert!(RecoveryNativeReceipt::try_from(input()).is_ok());
    134         for case in 0..10 {
    135             let mut value = input();
    136             match case {
    137                 0 => value.response.body.push(1),
    138                 1 => value.schema_version = 2,
    139                 2 => value.response.schema_version = 2,
    140                 3 => value.parent = "00".repeat(16),
    141                 4 => value.attempt = "00".repeat(16),
    142                 5 => value.revision = 0,
    143                 6 => value.revision = u64::MAX,
    144                 7 => value.upload_url = "x".repeat(4097),
    145                 8 => value.byte_size = 10 * 1024 * 1024 + 1,
    146                 _ => value.media_type = "x".repeat(8193),
    147             }
    148             assert!(
    149                 RecoveryNativeReceipt::try_from(value).is_err(),
    150                 "case {case}"
    151             );
    152         }
    153     }
    154 }