field_ios

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

backup.rs (8683B)


      1 //! Bounded native file-owner bridge for local application backup.
      2 use crate::TeraAppError;
      3 use tera_core::runtime::backup::{BackupError, BackupMediaLease, BackupRequest};
      4 
      5 mod adapter;
      6 pub(crate) use adapter::BackupHostAdapter;
      7 
      8 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
      9 pub struct FfiBackupLimits {
     10     pub schema_version: u16,
     11     pub manifest_bytes: u64,
     12     pub media_bytes: u64,
     13     pub media_references: u64,
     14 }
     15 
     16 #[uniffi::export]
     17 pub fn application_backup_limits() -> FfiBackupLimits {
     18     use tera_core::runtime::{backup, product_surface::media_gc};
     19     FfiBackupLimits {
     20         schema_version: backup::APPLICATION_BACKUP_VERSION,
     21         manifest_bytes: backup::BACKUP_MANIFEST_MAX_BYTES as u64,
     22         media_bytes: backup::BACKUP_MEDIA_MAX_BYTES,
     23         media_references: media_gc::MEDIA_REFERENCE_BUDGET as u64,
     24     }
     25 }
     26 
     27 #[uniffi::export]
     28 pub fn validate_application_backup_request(request: FfiBackupRequest) -> Result<(), TeraAppError> {
     29     request.decode().map(|_| ()).map_err(Into::into)
     30 }
     31 
     32 #[uniffi::export]
     33 pub fn validate_application_backup_media(
     34     request: FfiBackupRequest,
     35     media: Vec<FfiBackupMedia>,
     36 ) -> Result<(), TeraAppError> {
     37     let request = request.decode()?;
     38     let leases: Vec<_> = media.into_iter().map(Into::into).collect();
     39     request.validate_media(&leases).map_err(Into::into)
     40 }
     41 
     42 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     43 pub struct FfiBackupRequest {
     44     pub schema_version: u16,
     45     pub backup_id: String,
     46     pub public_key: String,
     47     pub source_generation: String,
     48     pub requested_at_unix_ms: u64,
     49     pub maximum_bytes: u64,
     50 }
     51 
     52 impl FfiBackupRequest {
     53     pub(crate) fn decode(&self) -> Result<BackupRequest, BackupError> {
     54         if self.schema_version != tera_core::runtime::backup::APPLICATION_BACKUP_VERSION {
     55             return Err(BackupError::UnsupportedFormat);
     56         }
     57         BackupRequest::new(
     58             bytes(&self.backup_id)?,
     59             bytes(&self.public_key)?,
     60             bytes(&self.source_generation)?,
     61             self.requested_at_unix_ms,
     62             self.maximum_bytes,
     63         )
     64     }
     65 }
     66 
     67 fn bytes<const N: usize>(value: &str) -> Result<[u8; N], BackupError> {
     68     if value.len() != N * 2
     69         || !value
     70             .bytes()
     71             .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v))
     72     {
     73         return Err(BackupError::InvalidRequest);
     74     }
     75     let mut bytes = [0; N];
     76     hex::decode_to_slice(value, &mut bytes).map_err(|_| BackupError::InvalidRequest)?;
     77     Ok(bytes)
     78 }
     79 
     80 impl From<&BackupRequest> for FfiBackupRequest {
     81     fn from(value: &BackupRequest) -> Self {
     82         Self {
     83             schema_version: tera_core::runtime::backup::APPLICATION_BACKUP_VERSION,
     84             backup_id: hex::encode(value.id()),
     85             public_key: hex::encode(value.author()),
     86             source_generation: hex::encode(value.generation()),
     87             requested_at_unix_ms: value.requested_at_ms(),
     88             maximum_bytes: value.maximum_bytes(),
     89         }
     90     }
     91 }
     92 
     93 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     94 pub struct FfiBackupMedia {
     95     pub sha256: String,
     96     pub byte_length: u64,
     97     pub lease_identifier: String,
     98 }
     99 
    100 impl From<FfiBackupMedia> for BackupMediaLease {
    101     fn from(value: FfiBackupMedia) -> Self {
    102         Self {
    103             sha256: value.sha256,
    104             byte_length: value.byte_length,
    105             identifier: value.lease_identifier,
    106         }
    107     }
    108 }
    109 
    110 /// Complete is returned only after owner verification and durable native
    111 /// publication. The bytes are the canonical application manifest, not DB data.
    112 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    113 pub struct FfiBackupManifest {
    114     pub request: FfiBackupRequest,
    115     pub media: Vec<FfiBackupMedia>,
    116     pub manifest: Vec<u8>,
    117 }
    118 
    119 #[uniffi::export(callback_interface)]
    120 #[async_trait::async_trait]
    121 pub trait TeraBackupHost: Send + Sync {
    122     async fn load_candidate(
    123         &self,
    124         request: FfiBackupRequest,
    125     ) -> Result<Option<Vec<u8>>, TeraAppError>;
    126     async fn retain_media(
    127         &self,
    128         request: FfiBackupRequest,
    129         media: Vec<FfiBackupMedia>,
    130     ) -> Result<Vec<FfiBackupMedia>, TeraAppError>;
    131     async fn persist_candidate(&self, manifest: FfiBackupManifest) -> Result<(), TeraAppError>;
    132     async fn publish_complete(&self, manifest: FfiBackupManifest) -> Result<(), TeraAppError>;
    133 }
    134 
    135 impl From<BackupError> for TeraAppError {
    136     fn from(value: BackupError) -> Self {
    137         Self::failure(
    138             value.code(),
    139             "backup",
    140             matches!(
    141                 value,
    142                 BackupError::Busy | BackupError::Unavailable | BackupError::PublicationIncomplete
    143             ),
    144             &["inspect_local_stores"],
    145             &value.to_string(),
    146         )
    147     }
    148 }
    149 
    150 pub(crate) fn host_error(value: TeraAppError) -> BackupError {
    151     match value.report().code.as_str() {
    152         "backup_busy" => BackupError::Busy,
    153         "backup_invalid_request" => BackupError::InvalidRequest,
    154         "backup_identity_mismatch" => BackupError::IdentityMismatch,
    155         "backup_generation_mismatch" => BackupError::GenerationMismatch,
    156         "backup_media_unavailable" => BackupError::MediaUnavailable,
    157         "backup_capacity_exceeded" => BackupError::CapacityExceeded,
    158         "backup_verification_failed" => BackupError::VerificationFailed,
    159         "backup_publication_incomplete" => BackupError::PublicationIncomplete,
    160         "backup_conflict" => BackupError::Conflict,
    161         _ => BackupError::Unavailable,
    162     }
    163 }
    164 
    165 #[cfg(test)]
    166 mod tests {
    167     use super::*;
    168 
    169     #[test]
    170     fn request_wire_rejects_noncanonical_identity_and_future_version() {
    171         let request = BackupRequest::new(
    172             [1; 16],
    173             hex::decode("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
    174                 .unwrap()
    175                 .try_into()
    176                 .unwrap(),
    177             [7; 32],
    178             200,
    179             128 * 1024 * 1024,
    180         )
    181         .unwrap();
    182         let encoded = FfiBackupRequest::from(&request);
    183         assert_eq!(encoded.decode().unwrap(), request);
    184         let mut bad = encoded.clone();
    185         bad.backup_id = "../outside".into();
    186         assert_eq!(bad.decode().unwrap_err(), BackupError::InvalidRequest);
    187         bad = encoded.clone();
    188         bad.public_key = bad.public_key.to_ascii_uppercase();
    189         assert_eq!(bad.decode().unwrap_err(), BackupError::InvalidRequest);
    190         bad = encoded;
    191         bad.schema_version += 1;
    192         assert_eq!(bad.decode().unwrap_err(), BackupError::UnsupportedFormat);
    193     }
    194 
    195     #[test]
    196     fn unknown_native_error_payload_never_crosses_the_safe_boundary() {
    197         let supplied = TeraAppError::failure(
    198             "unknown",
    199             "unsafe",
    200             true,
    201             &["unsafe"],
    202             "PRIVATE_PATH_OR_CONTENT",
    203         );
    204         let sanitized: TeraAppError = host_error(supplied).into();
    205         assert_eq!(sanitized.report().code, "backup_unavailable");
    206         assert!(!format!("{sanitized:?}").contains("PRIVATE_PATH_OR_CONTENT"));
    207         let limits = application_backup_limits();
    208         assert_eq!(limits.schema_version, 1);
    209         assert_eq!(limits.manifest_bytes, 16 * 1024 * 1024);
    210     }
    211 
    212     #[test]
    213     fn native_media_admission_uses_canonical_binding_and_capacity_policy() {
    214         let request = FfiBackupRequest {
    215             schema_version: 1,
    216             backup_id: "01".repeat(16),
    217             public_key: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".into(),
    218             source_generation: "07".repeat(32),
    219             requested_at_unix_ms: 200,
    220             maximum_bytes: 100,
    221         };
    222         let hash = "a".repeat(64);
    223         let media = FfiBackupMedia {
    224             lease_identifier: request.decode().unwrap().lease_identifier(&hash),
    225             sha256: hash,
    226             byte_length: 20,
    227         };
    228         validate_application_backup_media(request.clone(), vec![media.clone()]).unwrap();
    229         let mut foreign = media.clone();
    230         foreign.lease_identifier = "another_backup".into();
    231         let error = validate_application_backup_media(request.clone(), vec![foreign]).unwrap_err();
    232         assert_eq!(error.report().code, "backup_media_unavailable");
    233         let error =
    234             validate_application_backup_media(request.clone(), vec![media.clone(), media.clone()])
    235                 .unwrap_err();
    236         assert_eq!(error.report().code, "backup_media_unavailable");
    237         let mut oversized = media;
    238         oversized.byte_length = request.maximum_bytes + 1;
    239         let error = validate_application_backup_media(request, vec![oversized]).unwrap_err();
    240         assert_eq!(error.report().code, "backup_capacity_exceeded");
    241     }
    242 }