1use crate::{Error, FileInfo, Result};
20use rustfs_utils::http::{contains_key_str, get_consistent_str, insert_str, remove_str};
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use std::collections::HashMap;
24use uuid::Uuid;
25
26pub const SUFFIX_SHARD_INTEGRITY: &str = "shard-integrity-v1";
27pub const SUFFIX_INLINE_INTEGRITY: &str = "shard-integrity-inline-v1";
28pub const SUFFIX_UPLOAD_INTEGRITY: &str = "shard-integrity-upload-v1";
29pub const MAX_PARTS: usize = 10_000;
30pub const MAX_DESCRIPTOR_BYTES: usize = 1024 * 1024;
31pub const MAX_INLINE_PROOF_BYTES: usize = 64 * 1024;
32pub const INDEX_HEADER_SIZE: usize = 64;
33const TABLE_HEADER_SIZE: usize = 32;
34const PART_RECORD_SIZE: usize = 64;
35const TABLE_MAGIC: &[u8; 8] = b"RFSI\x01\0\0\0";
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct IntegrityLayout {
39 pub data: u16,
40 pub parity: u16,
41 pub block_size: u32,
42 pub legacy: bool,
43}
44
45impl IntegrityLayout {
46 pub fn new(data: usize, parity: usize, block_size: usize, legacy: bool) -> Result<Self> {
47 let layout = Self {
48 data: data.try_into().map_err(|_| Error::FileCorrupt)?,
49 parity: parity.try_into().map_err(|_| Error::FileCorrupt)?,
50 block_size: block_size.try_into().map_err(|_| Error::FileCorrupt)?,
51 legacy,
52 };
53 layout.validate()?;
54 Ok(layout)
55 }
56
57 pub fn validate(&self) -> Result<()> {
58 if self.data == 0
59 || self.data < self.parity
60 || self.shards() > crate::fileinfo::MAX_ERASURE_SHARDS
61 || self.block_size == 0
62 || self.block_size > 64 * 1024 * 1024
63 {
64 return Err(Error::FileCorrupt);
65 }
66 Ok(())
67 }
68
69 pub fn shards(&self) -> usize {
70 usize::from(self.data) + usize::from(self.parity)
71 }
72
73 fn bytes(&self) -> [u8; 9] {
74 let mut out = [0; 9];
75 out[..2].copy_from_slice(&self.data.to_le_bytes());
76 out[2..4].copy_from_slice(&self.parity.to_le_bytes());
77 out[4..8].copy_from_slice(&self.block_size.to_le_bytes());
78 out[8] = u8::from(self.legacy);
79 out
80 }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub struct PartIntegrity {
87 pub layout: IntegrityLayout,
88 pub number: u32,
89 pub generation: Uuid,
90 pub size: u64,
91 pub stripes: u32,
92 pub root: [u8; 32],
93}
94
95impl PartIntegrity {
96 pub fn new(layout: IntegrityLayout, number: usize) -> Result<Self> {
97 let part = Self {
98 layout,
99 number: number.try_into().map_err(|_| Error::FileCorrupt)?,
100 generation: Uuid::new_v4(),
101 size: 0,
102 stripes: 0,
103 root: [0; 32],
104 };
105 part.validate()?;
106 Ok(part)
107 }
108
109 pub fn validate(&self) -> Result<()> {
110 self.layout.validate()?;
111 if self.number == 0
112 || usize::try_from(self.number).map_err(|_| Error::FileCorrupt)? > MAX_PARTS
113 || self.generation.is_nil()
114 || self.size > i64::MAX.unsigned_abs()
115 || self.size.div_ceil(u64::from(self.layout.block_size)) != u64::from(self.stripes)
116 {
117 return Err(Error::FileCorrupt);
118 }
119 Ok(())
120 }
121
122 pub fn file_name(&self) -> String {
123 format!("part.{}.integrity.{}", self.number, self.generation)
124 }
125
126 pub fn height(&self) -> u32 {
127 u32::BITS - self.stripes.max(1).saturating_sub(1).leading_zeros()
128 }
129
130 pub fn record_size(&self) -> usize {
131 32 * (self.layout.shards() + usize::try_from(self.height()).unwrap_or(32))
133 }
134
135 pub fn index_size(&self) -> Result<usize> {
136 usize::try_from(self.stripes)
137 .ok()
138 .and_then(|n| n.checked_mul(self.record_size()))
139 .and_then(|n| n.checked_add(INDEX_HEADER_SIZE))
140 .ok_or(Error::FileCorrupt)
141 }
142
143 pub fn record_offset(&self, stripe: u32) -> Result<usize> {
144 if stripe >= self.stripes {
145 return Err(Error::FileCorrupt);
146 }
147 usize::try_from(stripe)
148 .ok()
149 .and_then(|n| n.checked_mul(self.record_size()))
150 .and_then(|n| n.checked_add(INDEX_HEADER_SIZE))
151 .ok_or(Error::FileCorrupt)
152 }
153
154 pub fn index_header(&self) -> [u8; INDEX_HEADER_SIZE] {
155 let mut out = [0; INDEX_HEADER_SIZE];
156 out[..8].copy_from_slice(b"RFSP\x01\0\0\0");
157 out[8..17].copy_from_slice(&self.layout.bytes());
158 out[20..24].copy_from_slice(&self.number.to_le_bytes());
159 out[24..40].copy_from_slice(self.generation.as_bytes());
160 out[40..48].copy_from_slice(&self.size.to_le_bytes());
161 out[48..52].copy_from_slice(&self.stripes.to_le_bytes());
162 out
163 }
164
165 fn hash_context(&self, domain: &[u8]) -> Sha256 {
166 let mut hash = Sha256::new();
167 hash.update(domain);
168 hash.update(self.layout.bytes());
169 hash.update(self.number.to_le_bytes());
170 hash.update(self.generation.as_bytes());
171 hash
172 }
173
174 pub fn shard_digest(&self, stripe: u32, coding_index: usize, payload: &[u8]) -> Result<[u8; 32]> {
175 if coding_index >= self.layout.shards() {
176 return Err(Error::FileCorrupt);
177 }
178 let mut hash = self.hash_context(b"rustfs/shard-integrity/v1/payload\0");
179 hash.update(stripe.to_le_bytes());
180 hash.update(u16::try_from(coding_index).map_err(|_| Error::FileCorrupt)?.to_le_bytes());
181 hash.update(u64::try_from(payload.len()).map_err(|_| Error::FileCorrupt)?.to_le_bytes());
182 hash.update(payload);
183 Ok(hash.finalize().into())
184 }
185
186 pub fn leaf_digest(&self, stripe: u32, digests: &[u8]) -> Result<[u8; 32]> {
187 if digests.len() != self.layout.shards() * 32 {
188 return Err(Error::FileCorrupt);
189 }
190 let mut hash = self.hash_context(b"rustfs/shard-integrity/v1/leaf\0");
191 hash.update(stripe.to_le_bytes());
192 hash.update(digests);
193 Ok(hash.finalize().into())
194 }
195
196 pub fn padding_digest(&self, stripe: u32) -> [u8; 32] {
197 let mut hash = self.hash_context(b"rustfs/shard-integrity/v1/padding\0");
198 hash.update(stripe.to_le_bytes());
199 hash.finalize().into()
200 }
201
202 pub fn root_digest(&self, tree_root: &[u8; 32]) -> [u8; 32] {
203 let mut hash = self.hash_context(b"rustfs/shard-integrity/v1/root\0");
204 hash.update(self.size.to_le_bytes());
205 hash.update(self.stripes.to_le_bytes());
206 hash.update(tree_root);
207 hash.finalize().into()
208 }
209
210 pub fn verify_record(&self, stripe: u32, record: &[u8]) -> Result<()> {
211 self.record_offset(stripe)?;
212 if record.len() != self.record_size() {
213 return Err(Error::FileCorrupt);
214 }
215 let (digests, siblings) = record.split_at(self.layout.shards() * 32);
216 let mut digest = self.leaf_digest(stripe, digests)?;
217 let mut position = stripe;
218 for sibling in siblings.as_chunks::<32>().0 {
219 digest = if position & 1 == 0 {
220 node_digest(&digest, sibling)
221 } else {
222 node_digest(sibling, &digest)
223 };
224 position >>= 1;
225 }
226 if self.root_digest(&digest) != self.root {
227 return Err(Error::FileCorrupt);
228 }
229 Ok(())
230 }
231
232 pub fn verify_shard(&self, stripe: u32, coding_index: usize, payload: &[u8], record: &[u8]) -> Result<()> {
233 self.verify_record(stripe, record)?;
234 let digest = self.shard_digest(stripe, coding_index, payload)?;
235 if record.get(coding_index * 32..(coding_index + 1) * 32) != Some(digest.as_slice()) {
236 return Err(Error::FileCorrupt);
237 }
238 Ok(())
239 }
240}
241
242pub fn node_digest(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
243 let mut hash = Sha256::new();
244 hash.update(b"rustfs/shard-integrity/v1/node\0");
245 hash.update(left);
246 hash.update(right);
247 hash.finalize().into()
248}
249
250pub fn encode_descriptor(parts: &[PartIntegrity]) -> Result<String> {
251 let Some(first) = parts.first() else { return Err(Error::FileCorrupt) };
252 if parts.len() > MAX_PARTS {
253 return Err(Error::FileCorrupt);
254 }
255 let mut out = vec![0; TABLE_HEADER_SIZE];
256 out[..8].copy_from_slice(TABLE_MAGIC);
257 out[8..17].copy_from_slice(&first.layout.bytes());
258 out[28..32].copy_from_slice(&u32::try_from(parts.len()).map_err(|_| Error::FileCorrupt)?.to_le_bytes());
259 let mut previous = 0;
260 for part in parts {
261 part.validate()?;
262 if part.layout != first.layout || part.number <= previous {
263 return Err(Error::FileCorrupt);
264 }
265 previous = part.number;
266 out.extend_from_slice(&part.number.to_le_bytes());
267 out.extend_from_slice(part.generation.as_bytes());
268 out.extend_from_slice(&part.size.to_le_bytes());
269 out.extend_from_slice(&part.stripes.to_le_bytes());
270 out.extend_from_slice(&part.root);
271 }
272 Ok(base64_simd::STANDARD.encode_to_string(out))
273}
274
275pub fn decode_descriptor(value: &str) -> Result<Vec<PartIntegrity>> {
276 if value.len() > MAX_DESCRIPTOR_BYTES {
277 return Err(Error::FileCorrupt);
278 }
279 let bytes = base64_simd::STANDARD.decode_to_vec(value).map_err(|_| Error::FileCorrupt)?;
280 if bytes.len() < TABLE_HEADER_SIZE
282 || &bytes[..8] != TABLE_MAGIC
283 || bytes[16] > 1
284 || bytes[17..28].iter().any(|v| *v != 0)
285 || base64_simd::STANDARD.encode_to_string(&bytes) != value
286 {
287 return Err(Error::FileCorrupt);
288 }
289 let u32_at = |start| -> Result<u32> {
290 Ok(u32::from_le_bytes(
291 bytes
292 .get(start..start + 4)
293 .ok_or(Error::FileCorrupt)?
294 .try_into()
295 .map_err(|_| Error::FileCorrupt)?,
296 ))
297 };
298 let count = usize::try_from(u32_at(28)?).map_err(|_| Error::FileCorrupt)?;
299 if count == 0 || count > MAX_PARTS || bytes.len() != TABLE_HEADER_SIZE + count * PART_RECORD_SIZE {
300 return Err(Error::FileCorrupt);
301 }
302 let layout = IntegrityLayout {
303 data: u16::from_le_bytes([bytes[8], bytes[9]]),
304 parity: u16::from_le_bytes([bytes[10], bytes[11]]),
305 block_size: u32_at(12)?,
306 legacy: bytes[16] == 1,
307 };
308 layout.validate()?;
309 let mut parts = Vec::with_capacity(count);
310 let mut previous = 0;
311 for record in bytes[TABLE_HEADER_SIZE..].as_chunks::<PART_RECORD_SIZE>().0 {
312 let part = PartIntegrity {
313 layout,
314 number: u32::from_le_bytes(record[..4].try_into().map_err(|_| Error::FileCorrupt)?),
315 generation: Uuid::from_slice(&record[4..20])
316 .ok()
317 .filter(|id| !id.is_nil())
318 .ok_or(Error::FileCorrupt)?,
319 size: u64::from_le_bytes(record[20..28].try_into().map_err(|_| Error::FileCorrupt)?),
320 stripes: u32::from_le_bytes(record[28..32].try_into().map_err(|_| Error::FileCorrupt)?),
321 root: record[32..64].try_into().map_err(|_| Error::FileCorrupt)?,
322 };
323 part.validate()?;
324 if part.number <= previous {
325 return Err(Error::FileCorrupt);
326 }
327 previous = part.number;
328 parts.push(part);
329 }
330 Ok(parts)
331}
332
333pub fn descriptor_from_metadata(metadata: &HashMap<String, String>) -> Result<Option<Vec<PartIntegrity>>> {
334 if !contains_key_str(metadata, SUFFIX_SHARD_INTEGRITY) {
335 if contains_key_str(metadata, SUFFIX_INLINE_INTEGRITY) {
337 return Err(Error::FileCorrupt);
338 }
339 return Ok(None);
340 }
341 decode_descriptor(get_consistent_str(metadata, SUFFIX_SHARD_INTEGRITY).ok_or(Error::FileCorrupt)?).map(Some)
342}
343
344pub fn clear_integrity_metadata(metadata: &mut HashMap<String, String>) {
345 for suffix in [SUFFIX_SHARD_INTEGRITY, SUFFIX_INLINE_INTEGRITY, SUFFIX_UPLOAD_INTEGRITY] {
346 remove_str(metadata, suffix);
347 }
348}
349
350impl FileInfo {
351 pub fn hydrate_shard_integrity(&mut self) -> Result<()> {
354 if !self.parts.is_empty() && contains_key_str(&self.metadata, SUFFIX_UPLOAD_INTEGRITY) {
355 return Err(Error::FileCorrupt);
356 }
357 let Some(parts) = descriptor_from_metadata(&self.metadata)? else {
358 if self.parts.iter().any(|part| part.integrity.is_some()) {
359 return Err(Error::FileCorrupt);
360 }
361 return Ok(());
362 };
363 let layout = IntegrityLayout::new(
364 self.erasure.data_blocks,
365 self.erasure.parity_blocks,
366 self.erasure.block_size,
367 self.uses_legacy_checksum,
368 )?;
369 if parts.len() != self.parts.len() {
370 return Err(Error::FileCorrupt);
371 }
372 for (expected, actual) in parts.into_iter().zip(&mut self.parts) {
373 if expected.layout != layout
374 || usize::try_from(expected.number).map_err(|_| Error::FileCorrupt)? != actual.number
375 || usize::try_from(expected.size).map_err(|_| Error::FileCorrupt)? != actual.size
376 || actual.integrity.as_ref().is_some_and(|value| value != &expected)
377 {
378 return Err(Error::FileCorrupt);
379 }
380 actual.integrity = Some(expected);
381 }
382 Ok(())
383 }
384
385 pub fn persist_shard_integrity(&mut self) -> Result<()> {
386 let parts: Vec<_> = self.parts.iter().filter_map(|part| part.integrity.clone()).collect();
387 if parts.is_empty() {
388 clear_integrity_metadata(&mut self.metadata);
389 return Ok(());
390 }
391 if parts.len() != self.parts.len() {
392 return Err(Error::FileCorrupt);
393 }
394 insert_str(&mut self.metadata, SUFFIX_SHARD_INTEGRITY, encode_descriptor(&parts)?);
395 self.hydrate_shard_integrity()
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::ObjectPartInfo;
403
404 fn part(number: usize) -> PartIntegrity {
405 let mut part =
406 PartIntegrity::new(IntegrityLayout::new(12, 4, 1024 * 1024, false).expect("layout"), number).expect("part");
407 part.size = 1024 * 1024 + 123;
408 part.stripes = 2;
409 part.root = [7; 32];
410 part
411 }
412
413 #[test]
414 fn integrity_descriptor_is_bounded_canonical_and_complete() {
415 let parts: Vec<_> = (1..=MAX_PARTS).map(part).collect();
416 let encoded = encode_descriptor(&parts).expect("maximum multipart descriptor");
417 assert_eq!(encoded.len(), 853_376);
418 assert_eq!(decode_descriptor(&encoded).expect("decode maximum table"), parts);
419 assert!(encode_descriptor(&[part(2), part(1)]).is_err());
420 assert!(encode_descriptor(&[part(1), part(1)]).is_err());
421 assert!(decode_descriptor(&format!("{encoded} ")).is_err());
422 assert!(decode_descriptor(&"A".repeat(MAX_DESCRIPTOR_BYTES + 1)).is_err());
423 let mut bytes = base64_simd::STANDARD
424 .decode_to_vec(encode_descriptor(&[part(1)]).expect("encode"))
425 .expect("base64");
426 for offset in [4, 17, 28, 36] {
427 let mut invalid = bytes.clone();
428 if offset == 36 {
429 invalid[36..52].fill(0);
430 } else {
431 invalid[offset] = 255;
432 }
433 assert!(
434 decode_descriptor(&base64_simd::STANDARD.encode_to_string(invalid)).is_err(),
435 "offset {offset}"
436 );
437 }
438 bytes.push(0);
439 assert!(decode_descriptor(&base64_simd::STANDARD.encode_to_string(bytes)).is_err());
440 }
441
442 #[test]
443 fn integrity_metadata_distinguishes_absence_corruption_and_part_mismatch() {
444 let mut file = FileInfo::new("object", 12, 4);
445 file.add_object_part(1, String::new(), 1024 * 1024 + 123, None, 0, None, None);
446 assert!(descriptor_from_metadata(&file.metadata).expect("legacy absence").is_none());
447 file.parts[0].integrity = Some(part(1));
448 file.persist_shard_integrity().expect("persist");
449 let mut decoded = file.clone();
450 decoded.parts[0].integrity = None;
451 decoded.hydrate_shard_integrity().expect("hydrate old peer response");
452 assert_eq!(decoded.parts[0].integrity, file.parts[0].integrity);
453 decoded.parts[0].size += 1;
454 assert!(decoded.hydrate_shard_integrity().is_err());
455 file.metadata
456 .insert(format!("x-minio-internal-{SUFFIX_SHARD_INTEGRITY}"), "invalid".to_owned());
457 assert!(descriptor_from_metadata(&file.metadata).is_err());
458 }
459
460 #[test]
461 fn integrity_descriptor_matches_independent_v1_vector() {
462 let encoded = "UkZTSQEAAAACAAIACAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAABEiM0RVRneImaq7zN3u/xAAAAAAAAAAAgAAACH+n+LW6++vtoSsUQbqfbsZYr4G+/GrIZa8s/oaPC/G";
465 let parts = decode_descriptor(encoded).expect("known descriptor");
466 assert_eq!(parts.len(), 1);
467 let part = &parts[0];
468 assert_eq!(part.layout, IntegrityLayout::new(2, 2, 8, false).expect("layout"));
469 assert_eq!(part.generation.to_string(), "00112233-4455-4677-8899-aabbccddeeff");
470 assert_eq!((part.number, part.size, part.stripes), (1, 16, 2));
471 assert_eq!(
472 part.root,
473 [
474 33, 254, 159, 226, 214, 235, 239, 175, 182, 132, 172, 81, 6, 234, 125, 187, 25, 98, 190, 6, 251, 241, 171, 33,
475 150, 188, 179, 250, 26, 60, 47, 198
476 ]
477 );
478 assert_eq!(encode_descriptor(&parts).expect("encode known descriptor"), encoded);
479 }
480
481 #[derive(Serialize, Deserialize)]
482 struct ReleasePart {
483 etag: String,
484 number: usize,
485 size: usize,
486 actual_size: i64,
487 mod_time: Option<time::OffsetDateTime>,
488 index: Option<bytes::Bytes>,
489 checksums: Option<HashMap<String, String>>,
490 error: Option<String>,
491 }
492
493 #[test]
494 fn integrity_part_extension_is_readable_by_release_decoder_and_reads_old_arrays() {
495 let old = ReleasePart {
496 etag: "same-etag".to_owned(),
497 number: 1,
498 size: 5,
499 actual_size: 5,
500 mod_time: None,
501 index: None,
502 checksums: None,
503 error: None,
504 };
505 let tuple = rmp_serde::to_vec(&old).expect("release positional writer");
506 assert_eq!(tuple[0], 0x98, "release wrote eight positional fields");
507 let mut new = ObjectPartInfo::unmarshal(&tuple).expect("read release part");
508 assert!(new.integrity.is_none());
509 new.integrity = Some(part(1));
510 let bytes = new.marshal_msg().expect("new writer");
511 assert_eq!(bytes[0], 0x89, "extension uses nine named fields");
512 let decoded: ReleasePart = rmp_serde::from_slice(&bytes).expect("release decoder ignores unknown named field");
513 assert_eq!(decoded.etag, old.etag);
514 assert_eq!(decoded.size, 5);
515 assert_eq!(ObjectPartInfo::unmarshal(&bytes).expect("new decoder").integrity, new.integrity);
516 }
517}