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 }