manifest.rs (8383B)
1 #![allow(clippy::module_name_repetitions)] 2 #[cfg(not(feature = "std"))] 3 use alloc::{string::String, vec::Vec}; 4 use core::fmt; 5 6 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] 7 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 8 #[derive(Clone, Debug, PartialEq, Eq)] 9 pub struct RadrootsEventsIndexedShardMetadata { 10 pub file: String, 11 pub count: u32, 12 pub first_id: String, 13 pub last_id: String, 14 pub first_published_at: u32, 15 pub last_published_at: u32, 16 pub sha256: String, 17 } 18 19 #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] 20 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 21 #[derive(Clone, Debug, PartialEq, Eq)] 22 pub struct RadrootsEventsIndexedManifest { 23 pub country: String, 24 pub total: u32, 25 pub shard_size: u32, 26 pub first_published_at: u32, 27 pub last_published_at: u32, 28 pub shards: Vec<RadrootsEventsIndexedShardMetadata>, 29 } 30 31 #[derive(Debug, PartialEq, Eq)] 32 pub enum RadrootsEventsIndexedManifestError { 33 EmptyCountry, 34 EmptyShards, 35 EmptyFile(u32), 36 InvalidSha256(u32), 37 InconsistentTotals, 38 } 39 40 impl fmt::Display for RadrootsEventsIndexedManifestError { 41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 match self { 43 RadrootsEventsIndexedManifestError::EmptyCountry => write!(f, "country is empty"), 44 RadrootsEventsIndexedManifestError::EmptyShards => write!(f, "no shards in manifest"), 45 RadrootsEventsIndexedManifestError::EmptyFile(i) => { 46 write!(f, "shard {} has empty file name", i) 47 } 48 RadrootsEventsIndexedManifestError::InvalidSha256(i) => { 49 write!(f, "shard {} has invalid sha256", i) 50 } 51 RadrootsEventsIndexedManifestError::InconsistentTotals => { 52 write!(f, "total does not match sum of shard counts") 53 } 54 } 55 } 56 } 57 58 #[cfg(feature = "std")] 59 impl std::error::Error for RadrootsEventsIndexedManifestError {} 60 61 pub fn validate_manifest( 62 m: &RadrootsEventsIndexedManifest, 63 ) -> Result<(), RadrootsEventsIndexedManifestError> { 64 if m.country.trim().is_empty() { 65 return Err(RadrootsEventsIndexedManifestError::EmptyCountry); 66 } 67 if m.shards.is_empty() { 68 return Err(RadrootsEventsIndexedManifestError::EmptyShards); 69 } 70 let mut sum: u64 = 0; 71 for (i, s) in m.shards.iter().enumerate() { 72 if s.file.trim().is_empty() { 73 return Err(RadrootsEventsIndexedManifestError::EmptyFile(i as u32)); 74 } 75 if s.sha256.len() != 64 || !s.sha256.chars().all(|c| c.is_ascii_hexdigit()) { 76 return Err(RadrootsEventsIndexedManifestError::InvalidSha256(i as u32)); 77 } 78 sum += s.count as u64; 79 } 80 if sum > u32::MAX as u64 || sum != m.total as u64 { 81 return Err(RadrootsEventsIndexedManifestError::InconsistentTotals); 82 } 83 Ok(()) 84 } 85 86 #[cfg(test)] 87 mod tests { 88 use super::{ 89 RadrootsEventsIndexedManifest, RadrootsEventsIndexedManifestError, 90 RadrootsEventsIndexedShardMetadata, validate_manifest, 91 }; 92 #[cfg(not(feature = "std"))] 93 use alloc::{format, string::String, vec, vec::Vec}; 94 #[cfg(feature = "std")] 95 use std::{format, string::String, vec::Vec}; 96 97 fn shard(file: &str, count: u32, sha256: &str) -> RadrootsEventsIndexedShardMetadata { 98 RadrootsEventsIndexedShardMetadata { 99 file: String::from(file), 100 count, 101 first_id: String::from("a"), 102 last_id: String::from("b"), 103 first_published_at: 0, 104 last_published_at: 0, 105 sha256: String::from(sha256), 106 } 107 } 108 109 fn base_manifest() -> RadrootsEventsIndexedManifest { 110 RadrootsEventsIndexedManifest { 111 country: String::from("us"), 112 total: 1, 113 shard_size: 1, 114 first_published_at: 0, 115 last_published_at: 0, 116 shards: vec![shard( 117 "a.json", 118 1, 119 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 120 )], 121 } 122 } 123 124 #[test] 125 fn validate_manifest_rejects_empty_country() { 126 let mut m = base_manifest(); 127 m.country = String::from(" "); 128 let err = validate_manifest(&m).unwrap_err(); 129 assert_eq!(err, RadrootsEventsIndexedManifestError::EmptyCountry); 130 } 131 132 #[test] 133 fn validate_manifest_rejects_empty_shards() { 134 let mut m = base_manifest(); 135 m.shards = Vec::new(); 136 let err = validate_manifest(&m).unwrap_err(); 137 assert_eq!(err, RadrootsEventsIndexedManifestError::EmptyShards); 138 } 139 140 #[test] 141 fn validate_manifest_rejects_empty_file() { 142 let mut m = base_manifest(); 143 m.shards[0].file = String::from(""); 144 let err = validate_manifest(&m).unwrap_err(); 145 assert_eq!(err, RadrootsEventsIndexedManifestError::EmptyFile(0)); 146 } 147 148 #[test] 149 fn validate_manifest_rejects_invalid_sha256() { 150 let mut m = base_manifest(); 151 m.shards[0].sha256 = String::from("zz"); 152 let err = validate_manifest(&m).unwrap_err(); 153 assert_eq!(err, RadrootsEventsIndexedManifestError::InvalidSha256(0)); 154 } 155 156 #[test] 157 fn validate_manifest_rejects_invalid_sha256_with_valid_length() { 158 let mut m = base_manifest(); 159 m.shards[0].sha256 = 160 String::from("g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); 161 let err = validate_manifest(&m).unwrap_err(); 162 assert_eq!(err, RadrootsEventsIndexedManifestError::InvalidSha256(0)); 163 } 164 165 #[test] 166 fn validate_manifest_rejects_total_overflow() { 167 let m = RadrootsEventsIndexedManifest { 168 country: String::from("us"), 169 total: 1, 170 shard_size: 1, 171 first_published_at: 0, 172 last_published_at: 0, 173 shards: vec![ 174 RadrootsEventsIndexedShardMetadata { 175 file: String::from("a.json"), 176 count: u32::MAX, 177 first_id: String::from("a"), 178 last_id: String::from("b"), 179 first_published_at: 0, 180 last_published_at: 0, 181 sha256: String::from( 182 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 183 ), 184 }, 185 RadrootsEventsIndexedShardMetadata { 186 file: String::from("b.json"), 187 count: 1, 188 first_id: String::from("c"), 189 last_id: String::from("d"), 190 first_published_at: 0, 191 last_published_at: 0, 192 sha256: String::from( 193 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", 194 ), 195 }, 196 ], 197 }; 198 199 let err = validate_manifest(&m).unwrap_err(); 200 assert_eq!(err, RadrootsEventsIndexedManifestError::InconsistentTotals); 201 } 202 203 #[test] 204 fn validate_manifest_rejects_mismatched_total_without_overflow() { 205 let mut m = base_manifest(); 206 m.total = 2; 207 let err = validate_manifest(&m).unwrap_err(); 208 assert_eq!(err, RadrootsEventsIndexedManifestError::InconsistentTotals); 209 } 210 211 #[test] 212 fn validate_manifest_accepts_consistent_totals() { 213 let m = base_manifest(); 214 let result = validate_manifest(&m); 215 assert!(result.is_ok()); 216 } 217 218 #[test] 219 fn manifest_error_display_messages_are_stable() { 220 assert_eq!( 221 format!("{}", RadrootsEventsIndexedManifestError::EmptyCountry), 222 "country is empty" 223 ); 224 assert_eq!( 225 format!("{}", RadrootsEventsIndexedManifestError::EmptyShards), 226 "no shards in manifest" 227 ); 228 assert_eq!( 229 format!("{}", RadrootsEventsIndexedManifestError::EmptyFile(3)), 230 "shard 3 has empty file name" 231 ); 232 assert_eq!( 233 format!("{}", RadrootsEventsIndexedManifestError::InvalidSha256(4)), 234 "shard 4 has invalid sha256" 235 ); 236 assert_eq!( 237 format!("{}", RadrootsEventsIndexedManifestError::InconsistentTotals), 238 "total does not match sum of shard counts" 239 ); 240 } 241 }