manifest.rs (32037B)
1 //! Canonical v1 service backup manifest model. 2 3 use core::{fmt, num::NonZeroU32}; 4 use std::error::Error; 5 6 use radroots_runtime_paths::{InstanceId, ServiceId}; 7 use radroots_storage::event::SourceGeneration; 8 use serde::{Deserialize, Serialize}; 9 use sha2::{Digest, Sha256}; 10 11 use crate::ServiceDatabaseMetadata; 12 13 /// Exact v1 backup manifest schema identifier. 14 pub const BACKUP_MANIFEST_SCHEMA: &str = "radroots.service-backup"; 15 /// Exact v1 backup manifest schema version. 16 pub const BACKUP_MANIFEST_SCHEMA_VERSION: u32 = 1; 17 /// Maximum accepted or emitted canonical manifest size. 18 pub const BACKUP_MANIFEST_CANONICAL_MAX_BYTES: usize = 1_024; 19 /// Sole member name admitted by the v1 manifest. 20 pub const BACKUP_STATE_MEMBER_NAME: &str = "state.sqlite"; 21 22 const INTEGRITY_OK: &str = "ok"; 23 24 /// Injected positive backup creation time in Unix milliseconds. 25 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] 26 pub struct BackupCreatedAtUnixMs(u64); 27 28 impl BackupCreatedAtUnixMs { 29 /// Validates a timestamp representable by SQLite's signed integer range. 30 pub const fn new(value: u64) -> Result<Self, BackupManifestContractError> { 31 if value == 0 || value > i64::MAX as u64 { 32 return Err(BackupManifestContractError::InvalidCreationTime); 33 } 34 Ok(Self(value)) 35 } 36 37 /// Returns milliseconds since the Unix epoch. 38 #[must_use] 39 pub const fn get(self) -> u64 { 40 self.0 41 } 42 } 43 44 /// Exact SHA-256 of one backup member. 45 #[derive(Clone, Copy, Hash, PartialEq, Eq)] 46 pub struct BackupMemberSha256([u8; 32]); 47 48 impl BackupMemberSha256 { 49 /// Constructs a digest from independently computed bytes. 50 #[must_use] 51 pub const fn from_bytes(bytes: [u8; 32]) -> Self { 52 Self(bytes) 53 } 54 55 /// Returns the exact digest bytes. 56 #[must_use] 57 pub const fn as_bytes(&self) -> &[u8; 32] { 58 &self.0 59 } 60 } 61 62 impl fmt::Debug for BackupMemberSha256 { 63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 64 formatter.write_str("BackupMemberSha256([redacted])") 65 } 66 } 67 68 /// SHA-256 of the exact canonical manifest bytes. 69 #[derive(Clone, Copy, Hash, PartialEq, Eq)] 70 pub struct BackupManifestSha256([u8; 32]); 71 72 impl BackupManifestSha256 { 73 /// Constructs a digest pinned by independently protected provenance. 74 #[must_use] 75 pub const fn from_bytes(bytes: [u8; 32]) -> Self { 76 Self(bytes) 77 } 78 79 /// Returns the exact digest bytes. 80 #[must_use] 81 pub const fn as_bytes(&self) -> &[u8; 32] { 82 &self.0 83 } 84 } 85 86 impl fmt::Debug for BackupManifestSha256 { 87 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 88 formatter.write_str("BackupManifestSha256([redacted])") 89 } 90 } 91 92 /// Exact successful integrity projection frozen by the v1 manifest. 93 #[derive(Clone, Copy, PartialEq, Eq)] 94 pub struct BackupManifestIntegrity { 95 _private: (), 96 } 97 98 impl BackupManifestIntegrity { 99 /// Returns the exact SQLite integrity result. 100 #[must_use] 101 pub const fn sqlite(self) -> &'static str { 102 INTEGRITY_OK 103 } 104 105 /// Returns the exact foreign-key integrity result. 106 #[must_use] 107 pub const fn foreign_keys(self) -> &'static str { 108 INTEGRITY_OK 109 } 110 } 111 112 impl fmt::Debug for BackupManifestIntegrity { 113 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 114 formatter.write_str("BackupManifestIntegrity") 115 } 116 } 117 118 /// Sole state database member carried by a v1 manifest. 119 #[derive(Clone, PartialEq, Eq)] 120 pub struct ServiceBackupMember { 121 byte_length: u64, 122 sha256: BackupMemberSha256, 123 } 124 125 impl ServiceBackupMember { 126 /// Returns the fixed v1 member name. 127 #[must_use] 128 pub const fn name(&self) -> &'static str { 129 BACKUP_STATE_MEMBER_NAME 130 } 131 132 /// Returns the exact nonzero captured byte length. 133 #[must_use] 134 pub const fn byte_length(&self) -> u64 { 135 self.byte_length 136 } 137 138 /// Returns the exact captured member digest. 139 #[must_use] 140 pub const fn sha256(&self) -> BackupMemberSha256 { 141 self.sha256 142 } 143 } 144 145 impl fmt::Debug for ServiceBackupMember { 146 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 147 formatter 148 .debug_struct("ServiceBackupMember") 149 .field("name", &BACKUP_STATE_MEMBER_NAME) 150 .field("byte_length", &self.byte_length) 151 .field("sha256", &"[redacted]") 152 .finish() 153 } 154 } 155 156 /// Structurally valid canonical v1 backup manifest. 157 #[derive(Clone, PartialEq, Eq)] 158 pub struct ServiceBackupManifest { 159 service: ServiceId, 160 instance: InstanceId, 161 source_generation: SourceGeneration, 162 state_schema_version: NonZeroU32, 163 created_at_unix_ms: BackupCreatedAtUnixMs, 164 member: ServiceBackupMember, 165 canonical_bytes: Box<[u8]>, 166 digest: BackupManifestSha256, 167 } 168 169 impl ServiceBackupManifest { 170 /// Parses only exact compact canonical v1 bytes. 171 /// 172 /// Structural parsing does not verify a member file. Step 065's verifier 173 /// owns expected-intent, length, digest, SQLite identity, and integrity 174 /// qualification. 175 pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, BackupManifestContractError> { 176 require_manifest_condition( 177 !bytes.is_empty(), 178 BackupManifestContractError::MalformedEncoding, 179 )?; 180 require_manifest_condition( 181 bytes.len() <= BACKUP_MANIFEST_CANONICAL_MAX_BYTES, 182 BackupManifestContractError::ManifestTooLarge, 183 )?; 184 let wire: WireManifest = serde_json::from_slice(bytes) 185 .map_err(|_| BackupManifestContractError::MalformedEncoding)?; 186 let manifest = Self::from_wire(wire)?; 187 require_manifest_condition( 188 manifest.canonical_bytes.as_ref() == bytes, 189 BackupManifestContractError::NonCanonicalEncoding, 190 )?; 191 Ok(manifest) 192 } 193 194 /// Returns the exact compact canonical UTF-8 JSON bytes. 195 #[must_use] 196 pub fn canonical_bytes(&self) -> &[u8] { 197 &self.canonical_bytes 198 } 199 200 /// Returns SHA-256 over the exact canonical bytes. 201 #[must_use] 202 pub const fn digest(&self) -> BackupManifestSha256 { 203 self.digest 204 } 205 206 #[must_use] 207 pub const fn schema(&self) -> &'static str { 208 BACKUP_MANIFEST_SCHEMA 209 } 210 211 #[must_use] 212 pub const fn schema_version(&self) -> u32 { 213 BACKUP_MANIFEST_SCHEMA_VERSION 214 } 215 216 #[must_use] 217 pub fn service(&self) -> &ServiceId { 218 &self.service 219 } 220 221 #[must_use] 222 pub fn instance(&self) -> &InstanceId { 223 &self.instance 224 } 225 226 #[must_use] 227 pub const fn source_generation(&self) -> SourceGeneration { 228 self.source_generation 229 } 230 231 #[must_use] 232 pub const fn state_schema_version(&self) -> NonZeroU32 { 233 self.state_schema_version 234 } 235 236 #[must_use] 237 pub const fn created_at_unix_ms(&self) -> BackupCreatedAtUnixMs { 238 self.created_at_unix_ms 239 } 240 241 #[must_use] 242 pub fn members(&self) -> &[ServiceBackupMember] { 243 core::slice::from_ref(&self.member) 244 } 245 246 #[must_use] 247 pub const fn integrity(&self) -> BackupManifestIntegrity { 248 BackupManifestIntegrity { _private: () } 249 } 250 251 #[must_use] 252 pub const fn protected_material_included(&self) -> bool { 253 false 254 } 255 256 #[allow(dead_code)] 257 pub(crate) fn from_capture( 258 metadata: &ServiceDatabaseMetadata, 259 created_at_unix_ms: BackupCreatedAtUnixMs, 260 state_byte_length: u64, 261 state_sha256: BackupMemberSha256, 262 ) -> Result<Self, BackupManifestContractError> { 263 Self::build( 264 metadata.service().clone(), 265 metadata.instance().clone(), 266 metadata.source_generation(), 267 metadata.state_schema_version(), 268 created_at_unix_ms, 269 state_byte_length, 270 state_sha256, 271 ) 272 } 273 274 fn from_wire(wire: WireManifest) -> Result<Self, BackupManifestContractError> { 275 require_manifest_condition( 276 wire.schema == BACKUP_MANIFEST_SCHEMA, 277 BackupManifestContractError::InvalidSchema, 278 )?; 279 require_manifest_condition( 280 wire.schema_version == BACKUP_MANIFEST_SCHEMA_VERSION, 281 BackupManifestContractError::UnsupportedVersion, 282 )?; 283 let service = ServiceId::new(wire.service) 284 .map_err(|_| BackupManifestContractError::InvalidServiceIdentity)?; 285 let instance = InstanceId::new(wire.instance) 286 .map_err(|_| BackupManifestContractError::InvalidInstanceIdentity)?; 287 let source_generation = decode_hex_32(&wire.source_generation) 288 .and_then(|value| SourceGeneration::new(value).ok()) 289 .ok_or(BackupManifestContractError::InvalidSourceGeneration)?; 290 let state_schema_version = NonZeroU32::new(wire.state_schema_version) 291 .ok_or(BackupManifestContractError::InvalidStateSchemaVersion)?; 292 let created_at_unix_ms = BackupCreatedAtUnixMs::new(wire.created_at_unix_ms)?; 293 let [member] = wire.members.as_slice() else { 294 return Err(BackupManifestContractError::InvalidMemberInventory); 295 }; 296 require_manifest_condition( 297 member.name == BACKUP_STATE_MEMBER_NAME, 298 BackupManifestContractError::InvalidMemberName, 299 )?; 300 require_manifest_condition( 301 member.byte_length != 0, 302 BackupManifestContractError::InvalidMemberLength, 303 )?; 304 let member_sha256 = BackupMemberSha256( 305 decode_hex_32(&member.sha256) 306 .ok_or(BackupManifestContractError::InvalidMemberDigest)?, 307 ); 308 require_manifest_condition( 309 wire.integrity.sqlite == INTEGRITY_OK && wire.integrity.foreign_keys == INTEGRITY_OK, 310 BackupManifestContractError::InvalidIntegrity, 311 )?; 312 require_manifest_condition( 313 !wire.protected_material_included, 314 BackupManifestContractError::ProtectedMaterialIncluded, 315 )?; 316 Self::build( 317 service, 318 instance, 319 source_generation, 320 state_schema_version, 321 created_at_unix_ms, 322 member.byte_length, 323 member_sha256, 324 ) 325 } 326 327 #[allow(clippy::too_many_arguments)] 328 fn build( 329 service: ServiceId, 330 instance: InstanceId, 331 source_generation: SourceGeneration, 332 state_schema_version: NonZeroU32, 333 created_at_unix_ms: BackupCreatedAtUnixMs, 334 state_byte_length: u64, 335 state_sha256: BackupMemberSha256, 336 ) -> Result<Self, BackupManifestContractError> { 337 require_manifest_condition( 338 state_byte_length != 0, 339 BackupManifestContractError::InvalidMemberLength, 340 )?; 341 let source_generation_hex = encode_hex(source_generation.as_bytes()); 342 let state_sha256_hex = encode_hex(state_sha256.as_bytes()); 343 let canonical = CanonicalManifest { 344 schema: BACKUP_MANIFEST_SCHEMA, 345 schema_version: BACKUP_MANIFEST_SCHEMA_VERSION, 346 service: service.as_str(), 347 instance: instance.as_str(), 348 source_generation: &source_generation_hex, 349 state_schema_version: state_schema_version.get(), 350 created_at_unix_ms: created_at_unix_ms.get(), 351 members: [CanonicalMember { 352 name: BACKUP_STATE_MEMBER_NAME, 353 byte_length: state_byte_length, 354 sha256: &state_sha256_hex, 355 }], 356 integrity: CanonicalIntegrity { 357 sqlite: INTEGRITY_OK, 358 foreign_keys: INTEGRITY_OK, 359 }, 360 protected_material_included: false, 361 }; 362 let canonical_bytes = serde_json::to_vec(&canonical) 363 .map_err(|_| BackupManifestContractError::EncodingFailure)?; 364 require_manifest_condition( 365 canonical_bytes.len() <= BACKUP_MANIFEST_CANONICAL_MAX_BYTES, 366 BackupManifestContractError::ManifestTooLarge, 367 )?; 368 let digest = BackupManifestSha256(Sha256::digest(&canonical_bytes).into()); 369 Ok(Self { 370 service, 371 instance, 372 source_generation, 373 state_schema_version, 374 created_at_unix_ms, 375 member: ServiceBackupMember { 376 byte_length: state_byte_length, 377 sha256: state_sha256, 378 }, 379 canonical_bytes: canonical_bytes.into_boxed_slice(), 380 digest, 381 }) 382 } 383 } 384 385 fn require_manifest_condition( 386 condition: bool, 387 error: BackupManifestContractError, 388 ) -> Result<(), BackupManifestContractError> { 389 condition.then_some(()).ok_or(error) 390 } 391 392 impl fmt::Debug for ServiceBackupManifest { 393 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 394 formatter 395 .debug_struct("ServiceBackupManifest") 396 .field("schema_version", &BACKUP_MANIFEST_SCHEMA_VERSION) 397 .field("service", &"[redacted]") 398 .field("instance", &"[redacted]") 399 .field("source_generation", &"[redacted]") 400 .field("state_schema_version", &self.state_schema_version) 401 .field("created_at_unix_ms", &self.created_at_unix_ms) 402 .field("member", &self.member) 403 .field("digest", &"[redacted]") 404 .field("protected_material_included", &false) 405 .finish() 406 } 407 } 408 409 /// Stable, source-free backup manifest validation failure. 410 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 411 pub enum BackupManifestContractError { 412 ManifestTooLarge, 413 MalformedEncoding, 414 NonCanonicalEncoding, 415 EncodingFailure, 416 InvalidSchema, 417 UnsupportedVersion, 418 InvalidServiceIdentity, 419 InvalidInstanceIdentity, 420 InvalidSourceGeneration, 421 InvalidStateSchemaVersion, 422 InvalidCreationTime, 423 InvalidMemberInventory, 424 InvalidMemberName, 425 InvalidMemberLength, 426 InvalidMemberDigest, 427 InvalidIntegrity, 428 ProtectedMaterialIncluded, 429 } 430 431 impl fmt::Display for BackupManifestContractError { 432 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 433 formatter.write_str(match self { 434 Self::ManifestTooLarge => "backup manifest exceeds its byte limit", 435 Self::MalformedEncoding => "backup manifest encoding is malformed", 436 Self::NonCanonicalEncoding => "backup manifest encoding is not canonical", 437 Self::EncodingFailure => "backup manifest could not be encoded", 438 Self::InvalidSchema => "backup manifest schema is invalid", 439 Self::UnsupportedVersion => "backup manifest version is unsupported", 440 Self::InvalidServiceIdentity => "backup manifest service identity is invalid", 441 Self::InvalidInstanceIdentity => "backup manifest instance identity is invalid", 442 Self::InvalidSourceGeneration => "backup manifest source generation is invalid", 443 Self::InvalidStateSchemaVersion => "backup manifest state schema version is invalid", 444 Self::InvalidCreationTime => "backup manifest creation time is invalid", 445 Self::InvalidMemberInventory => "backup manifest member inventory is invalid", 446 Self::InvalidMemberName => "backup manifest member name is invalid", 447 Self::InvalidMemberLength => "backup manifest member length is invalid", 448 Self::InvalidMemberDigest => "backup manifest member digest is invalid", 449 Self::InvalidIntegrity => "backup manifest integrity result is invalid", 450 Self::ProtectedMaterialIncluded => "backup manifest includes protected material", 451 }) 452 } 453 } 454 455 impl Error for BackupManifestContractError {} 456 457 #[derive(Deserialize)] 458 #[serde(deny_unknown_fields)] 459 struct WireManifest { 460 schema: String, 461 schema_version: u32, 462 service: String, 463 instance: String, 464 source_generation: String, 465 state_schema_version: u32, 466 created_at_unix_ms: u64, 467 members: Vec<WireMember>, 468 integrity: WireIntegrity, 469 protected_material_included: bool, 470 } 471 472 #[derive(Deserialize)] 473 #[serde(deny_unknown_fields)] 474 struct WireMember { 475 name: String, 476 byte_length: u64, 477 sha256: String, 478 } 479 480 #[derive(Deserialize)] 481 #[serde(deny_unknown_fields)] 482 struct WireIntegrity { 483 sqlite: String, 484 foreign_keys: String, 485 } 486 487 #[derive(Serialize)] 488 struct CanonicalManifest<'a> { 489 schema: &'a str, 490 schema_version: u32, 491 service: &'a str, 492 instance: &'a str, 493 source_generation: &'a str, 494 state_schema_version: u32, 495 created_at_unix_ms: u64, 496 members: [CanonicalMember<'a>; 1], 497 integrity: CanonicalIntegrity<'a>, 498 protected_material_included: bool, 499 } 500 501 #[derive(Serialize)] 502 struct CanonicalMember<'a> { 503 name: &'a str, 504 byte_length: u64, 505 sha256: &'a str, 506 } 507 508 #[derive(Serialize)] 509 struct CanonicalIntegrity<'a> { 510 sqlite: &'a str, 511 foreign_keys: &'a str, 512 } 513 514 fn encode_hex(bytes: &[u8; 32]) -> String { 515 const HEX: &[u8; 16] = b"0123456789abcdef"; 516 let mut encoded = String::with_capacity(64); 517 for byte in bytes { 518 encoded.push(char::from(HEX[usize::from(byte >> 4)])); 519 encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); 520 } 521 encoded 522 } 523 524 fn decode_hex_32(value: &str) -> Option<[u8; 32]> { 525 (value.len() == 64).then_some(())?; 526 let mut decoded = [0_u8; 32]; 527 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { 528 let high = decode_lower_hex(pair[0])?; 529 let low = decode_lower_hex(pair[1])?; 530 decoded[index] = (high << 4) | low; 531 } 532 Some(decoded) 533 } 534 535 const fn decode_lower_hex(value: u8) -> Option<u8> { 536 match value { 537 b'0'..=b'9' => Some(value - b'0'), 538 b'a'..=b'f' => Some(value - b'a' + 10), 539 _ => None, 540 } 541 } 542 543 #[cfg(test)] 544 mod tests { 545 use super::*; 546 use crate::{ServiceSqliteApplicationId, ServiceSqlitePaths}; 547 use radroots_runtime_paths::{ 548 RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, 549 RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, 550 }; 551 use std::path::PathBuf; 552 553 const GENERATION: &str = "0101010101010101010101010101010101010101010101010101010101010101"; 554 const MEMBER_SHA: &str = "abababababababababababababababababababababababababababababababab"; 555 const CANONICAL: &str = concat!( 556 "{\"schema\":\"radroots.service-backup\",\"schema_version\":1,", 557 "\"service\":\"myc\",\"instance\":\"primary\",", 558 "\"source_generation\":\"0101010101010101010101010101010101010101010101010101010101010101\",", 559 "\"state_schema_version\":1,\"created_at_unix_ms\":1700000000000,", 560 "\"members\":[{\"name\":\"state.sqlite\",\"byte_length\":12345,", 561 "\"sha256\":\"abababababababababababababababababababababababababababababababab\"}],", 562 "\"integrity\":{\"sqlite\":\"ok\",\"foreign_keys\":\"ok\"},", 563 "\"protected_material_included\":false}" 564 ); 565 566 fn parsed() -> ServiceBackupManifest { 567 ServiceBackupManifest::from_canonical_bytes(CANONICAL.as_bytes()) 568 .expect("canonical backup manifest") 569 } 570 571 fn replace_once(old: &str, new: &str) -> String { 572 CANONICAL.replacen(old, new, 1) 573 } 574 575 fn paths() -> ServiceSqlitePaths { 576 let context = RuntimeContext::resolve( 577 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 578 RuntimeContextBootstrap::new( 579 RadrootsPathProfile::RepoLocal, 580 Some(PathBuf::from("/isolated/backup-manifest")), 581 RuntimeContextSource::BootstrapCli, 582 RuntimeContextSource::BootstrapCli, 583 ) 584 .expect("bootstrap"), 585 ServiceId::new("myc").expect("service"), 586 InstanceId::new("primary").expect("instance"), 587 ) 588 .expect("runtime context"); 589 ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths") 590 } 591 592 #[test] 593 fn canonical_bytes_and_digest_are_frozen() { 594 let manifest = parsed(); 595 assert_eq!(manifest.canonical_bytes(), CANONICAL.as_bytes()); 596 assert_eq!(manifest.schema(), BACKUP_MANIFEST_SCHEMA); 597 assert_eq!(manifest.schema_version(), BACKUP_MANIFEST_SCHEMA_VERSION); 598 assert_eq!(manifest.service().as_str(), "myc"); 599 assert_eq!(manifest.instance().as_str(), "primary"); 600 assert_eq!( 601 encode_hex(manifest.source_generation().as_bytes()), 602 GENERATION 603 ); 604 assert_eq!(manifest.state_schema_version().get(), 1); 605 assert_eq!(manifest.created_at_unix_ms().get(), 1_700_000_000_000); 606 assert_eq!(manifest.members().len(), 1); 607 assert_eq!(manifest.members()[0].name(), BACKUP_STATE_MEMBER_NAME); 608 assert_eq!(manifest.members()[0].byte_length(), 12_345); 609 assert_eq!( 610 encode_hex(manifest.members()[0].sha256().as_bytes()), 611 MEMBER_SHA 612 ); 613 assert_eq!(manifest.integrity().sqlite(), INTEGRITY_OK); 614 assert_eq!(manifest.integrity().foreign_keys(), INTEGRITY_OK); 615 assert_eq!( 616 format!("{:?}", manifest.integrity()), 617 "BackupManifestIntegrity" 618 ); 619 assert_eq!( 620 format!("{:?}", BackupMemberSha256::from_bytes([0xab; 32])), 621 "BackupMemberSha256([redacted])" 622 ); 623 assert_eq!( 624 format!("{:?}", BackupManifestSha256::from_bytes([0xcd; 32])), 625 "BackupManifestSha256([redacted])" 626 ); 627 assert!(!manifest.protected_material_included()); 628 assert_eq!( 629 encode_hex(manifest.digest().as_bytes()), 630 "1e9212b4a8e8db0d96134fc9fc10392b63de1ebea2e84cc201615bbb75cd7fd8" 631 ); 632 } 633 634 #[test] 635 fn capture_constructor_reuses_exact_database_identity() { 636 let paths = paths(); 637 let metadata = ServiceDatabaseMetadata::new( 638 &paths, 639 SourceGeneration::new([1; 32]).expect("generation"), 640 NonZeroU32::new(1).expect("schema"), 641 1_600_000_000_000, 642 ServiceSqliteApplicationId::new(7).expect("application ID"), 643 ) 644 .expect("metadata"); 645 let manifest = ServiceBackupManifest::from_capture( 646 &metadata, 647 BackupCreatedAtUnixMs::new(1_700_000_000_000).expect("backup time"), 648 12_345, 649 BackupMemberSha256::from_bytes([0xab; 32]), 650 ) 651 .expect("captured manifest model"); 652 assert_eq!(manifest.canonical_bytes(), CANONICAL.as_bytes()); 653 assert_eq!( 654 ServiceBackupManifest::from_capture( 655 &metadata, 656 BackupCreatedAtUnixMs::new(1_700_000_000_000).expect("backup time"), 657 0, 658 BackupMemberSha256::from_bytes([0xab; 32]), 659 ), 660 Err(BackupManifestContractError::InvalidMemberLength) 661 ); 662 } 663 664 #[test] 665 fn noncanonical_and_ambiguous_encodings_fail_closed() { 666 assert_eq!( 667 ServiceBackupManifest::from_canonical_bytes(b""), 668 Err(BackupManifestContractError::MalformedEncoding) 669 ); 670 let reordered = CANONICAL.replacen( 671 "\"schema\":\"radroots.service-backup\",\"schema_version\":1", 672 "\"schema_version\":1,\"schema\":\"radroots.service-backup\"", 673 1, 674 ); 675 for bytes in [format!(" {CANONICAL}"), format!("{CANONICAL}\n"), reordered] { 676 assert_eq!( 677 ServiceBackupManifest::from_canonical_bytes(bytes.as_bytes()), 678 Err(BackupManifestContractError::NonCanonicalEncoding) 679 ); 680 } 681 for bytes in [ 682 format!("\u{feff}{CANONICAL}"), 683 replace_once( 684 "\"schema_version\":1", 685 "\"schema_version\":1,\"schema_version\":1", 686 ), 687 replace_once( 688 "\"schema_version\":1", 689 "\"schema_version\":1,\"unknown\":false", 690 ), 691 replace_once("\"service\":\"myc\"", "\"service\":null"), 692 ] { 693 assert_eq!( 694 ServiceBackupManifest::from_canonical_bytes(bytes.as_bytes()), 695 Err(BackupManifestContractError::MalformedEncoding) 696 ); 697 } 698 assert_eq!( 699 ServiceBackupManifest::from_canonical_bytes(&vec![b'x'; 1_025]), 700 Err(BackupManifestContractError::ManifestTooLarge) 701 ); 702 } 703 704 #[test] 705 fn schema_identity_generation_version_and_time_are_exact() { 706 for (old, new, expected) in [ 707 ( 708 "radroots.service-backup", 709 "radroots.other-backup", 710 BackupManifestContractError::InvalidSchema, 711 ), 712 ( 713 "\"schema_version\":1", 714 "\"schema_version\":2", 715 BackupManifestContractError::UnsupportedVersion, 716 ), 717 ( 718 "\"service\":\"myc\"", 719 "\"service\":\"../myc\"", 720 BackupManifestContractError::InvalidServiceIdentity, 721 ), 722 ( 723 "\"instance\":\"primary\"", 724 "\"instance\":\"PRIMARY\"", 725 BackupManifestContractError::InvalidInstanceIdentity, 726 ), 727 ( 728 "\"state_schema_version\":1", 729 "\"state_schema_version\":0", 730 BackupManifestContractError::InvalidStateSchemaVersion, 731 ), 732 ( 733 "\"created_at_unix_ms\":1700000000000", 734 "\"created_at_unix_ms\":0", 735 BackupManifestContractError::InvalidCreationTime, 736 ), 737 ] { 738 assert_eq!( 739 ServiceBackupManifest::from_canonical_bytes(replace_once(old, new).as_bytes()), 740 Err(expected) 741 ); 742 } 743 for generation in [ 744 "0".repeat(64), 745 "AB".repeat(32), 746 "01".repeat(31), 747 format!("{}g", &GENERATION[..63]), 748 ] { 749 assert_eq!( 750 ServiceBackupManifest::from_canonical_bytes( 751 replace_once(GENERATION, &generation).as_bytes() 752 ), 753 Err(BackupManifestContractError::InvalidSourceGeneration) 754 ); 755 } 756 assert_eq!( 757 BackupCreatedAtUnixMs::new(i64::MAX as u64).map(BackupCreatedAtUnixMs::get), 758 Ok(i64::MAX as u64) 759 ); 760 assert_eq!( 761 BackupCreatedAtUnixMs::new(i64::MAX as u64 + 1), 762 Err(BackupManifestContractError::InvalidCreationTime) 763 ); 764 } 765 766 #[test] 767 fn member_inventory_length_and_digest_are_exact() { 768 let singleton = format!( 769 "[{{\"name\":\"state.sqlite\",\"byte_length\":12345,\"sha256\":\"{MEMBER_SHA}\"}}]" 770 ); 771 for (members, expected) in [ 772 ( 773 "[]".to_owned(), 774 BackupManifestContractError::InvalidMemberInventory, 775 ), 776 ( 777 format!( 778 "[{{\"name\":\"state.sqlite\",\"byte_length\":12345,\"sha256\":\"{MEMBER_SHA}\"}},{{\"name\":\"state.sqlite\",\"byte_length\":12345,\"sha256\":\"{MEMBER_SHA}\"}}]" 779 ), 780 BackupManifestContractError::InvalidMemberInventory, 781 ), 782 ] { 783 assert_eq!( 784 ServiceBackupManifest::from_canonical_bytes( 785 replace_once(&singleton, &members).as_bytes() 786 ), 787 Err(expected) 788 ); 789 } 790 for name in ["", "state.db", "../state.sqlite"] { 791 assert_eq!( 792 ServiceBackupManifest::from_canonical_bytes( 793 replace_once("state.sqlite", name).as_bytes() 794 ), 795 Err(BackupManifestContractError::InvalidMemberName) 796 ); 797 } 798 assert_eq!( 799 ServiceBackupManifest::from_canonical_bytes( 800 replace_once("\"byte_length\":12345", "\"byte_length\":0").as_bytes() 801 ), 802 Err(BackupManifestContractError::InvalidMemberLength) 803 ); 804 assert!( 805 ServiceBackupManifest::from_canonical_bytes( 806 replace_once( 807 "\"byte_length\":12345", 808 "\"byte_length\":18446744073709551615" 809 ) 810 .as_bytes() 811 ) 812 .is_ok() 813 ); 814 for digest in [ 815 MEMBER_SHA.to_uppercase(), 816 "ab".repeat(31), 817 format!("{}g", &MEMBER_SHA[..63]), 818 ] { 819 assert_eq!( 820 ServiceBackupManifest::from_canonical_bytes( 821 replace_once(MEMBER_SHA, &digest).as_bytes() 822 ), 823 Err(BackupManifestContractError::InvalidMemberDigest) 824 ); 825 } 826 } 827 828 #[test] 829 fn integrity_and_protected_material_fail_closed() { 830 for invalid in [ 831 replace_once("\"sqlite\":\"ok\"", "\"sqlite\":\"failed\""), 832 replace_once("\"foreign_keys\":\"ok\"", "\"foreign_keys\":\"failed\""), 833 ] { 834 assert_eq!( 835 ServiceBackupManifest::from_canonical_bytes(invalid.as_bytes()), 836 Err(BackupManifestContractError::InvalidIntegrity) 837 ); 838 } 839 assert_eq!( 840 ServiceBackupManifest::from_canonical_bytes( 841 replace_once( 842 "\"protected_material_included\":false", 843 "\"protected_material_included\":true" 844 ) 845 .as_bytes() 846 ), 847 Err(BackupManifestContractError::ProtectedMaterialIncluded) 848 ); 849 } 850 851 #[test] 852 fn diagnostics_are_source_free_and_redacted() { 853 let debug = format!("{:?}", parsed()); 854 for forbidden in ["myc", "primary", GENERATION, MEMBER_SHA] { 855 assert!(!debug.contains(forbidden)); 856 } 857 let error = BackupManifestContractError::InvalidMemberDigest; 858 assert!(error.source().is_none()); 859 assert_eq!( 860 error.to_string(), 861 "backup manifest member digest is invalid" 862 ); 863 for error in [ 864 BackupManifestContractError::ManifestTooLarge, 865 BackupManifestContractError::MalformedEncoding, 866 BackupManifestContractError::NonCanonicalEncoding, 867 BackupManifestContractError::EncodingFailure, 868 BackupManifestContractError::InvalidSchema, 869 BackupManifestContractError::UnsupportedVersion, 870 BackupManifestContractError::InvalidServiceIdentity, 871 BackupManifestContractError::InvalidInstanceIdentity, 872 BackupManifestContractError::InvalidSourceGeneration, 873 BackupManifestContractError::InvalidStateSchemaVersion, 874 BackupManifestContractError::InvalidCreationTime, 875 BackupManifestContractError::InvalidMemberInventory, 876 BackupManifestContractError::InvalidMemberName, 877 BackupManifestContractError::InvalidMemberLength, 878 BackupManifestContractError::InvalidMemberDigest, 879 BackupManifestContractError::InvalidIntegrity, 880 BackupManifestContractError::ProtectedMaterialIncluded, 881 ] { 882 assert!(!error.to_string().is_empty()); 883 assert!(error.source().is_none()); 884 } 885 for error in [ 886 BackupManifestContractError::ManifestTooLarge, 887 BackupManifestContractError::MalformedEncoding, 888 BackupManifestContractError::NonCanonicalEncoding, 889 BackupManifestContractError::InvalidSchema, 890 BackupManifestContractError::UnsupportedVersion, 891 BackupManifestContractError::InvalidMemberName, 892 BackupManifestContractError::InvalidMemberLength, 893 BackupManifestContractError::InvalidIntegrity, 894 BackupManifestContractError::ProtectedMaterialIncluded, 895 ] { 896 assert!(require_manifest_condition(true, error).is_ok()); 897 assert_eq!(require_manifest_condition(false, error), Err(error)); 898 } 899 } 900 }