Skip to main content

rustfs_filemeta/
shard_integrity.rs

1// Copyright 2026 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Optional, quorum-owned SHA-256 commitments. The existing part payload and
16//! HighwayHash framing are unchanged. A proof file is only an untrusted index;
17//! authority comes from the root stored in the selected object's metadata.
18
19use 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/// One immutable UploadPart/PUT generation. A metadata-only copy retains this
84/// identity; overwriting even an identical part creates a new generation.
85#[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        // At most 16 digests and 32 siblings, independent of untrusted sizes.
132        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    // Reject non-canonical encodings as well as unsupported versions/reserved bits.
281    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        // An orphaned inline proof must not silently turn a protected object into legacy.
336        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    /// Decode once at the metadata boundary and retain each part's validated
352    /// commitment. Reading part n must not reparse a 10,000-part descriptor.
353    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        // Header, UUID byte order, dimensions and root frozen independently
463        // with Python struct/hashlib, rather than a Rust round trip.
464        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}