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 }