lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }