Skip to main content

rustfs_filemeta/filemeta/
version.rs

1// Copyright 2024 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//! Version meta parsing with legacy compatibility.
16//!
17//! **Rule**: To parse version meta bytes (from `FileMetaShallowVersion.meta` or raw `&[u8]`),
18//! always use one of:
19//! - `FileMetaShallowVersion::parse_version_meta()` or `into_fileinfo()`
20//! - `FileMetaVersion::try_from(buf)`
21//!
22//! Do NOT use `FileMetaVersion::default()` + `unmarshal_msg()` directly, as that fails on
23//! legacy (rmp_serde) format. `try_from` falls back to rmp_serde when hand-written decode fails.
24
25use super::msgp_decode::{
26    PrependByteReader, prealloc_hint, read_exact_vec, read_nil_or_array_len, read_nil_or_map_len, skip_msgp_value,
27};
28use super::*;
29use crate::{ChecksumInfo, TransitionVersionState};
30use rustfs_utils::HashAlgorithm;
31use rustfs_utils::http::{
32    RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_FREE_VERSION, SUFFIX_INLINE_DATA, SUFFIX_PART_CHECKSUMS, SUFFIX_PURGESTATUS,
33    SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX, SUFFIX_REPLICATION_RESET_ARN_PREFIX, SUFFIX_RESTORE_OPERATION_ID,
34    SUFFIX_RESTORE_WORKER_LOCK, SUFFIX_TIER_FV_ID, SUFFIX_TIER_FV_MARKER, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER,
35    SUFFIX_TRANSITION_TIER_DESTINATION_ID, SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID,
36    SUFFIX_TRANSITIONED_VERSION_STATE, contains_key_bytes, get_bytes, get_consistent_bytes, get_str, has_internal_suffix,
37    insert_bytes, is_internal_key, remove_bytes, strip_internal_prefix, strip_internal_prefix_preserving_case,
38    target_delete_marker_versions,
39};
40use sha2::{Digest as _, Sha256};
41
42const MSGPACK_EXT8: u8 = 0xc7;
43const MSGPACK_EXT16: u8 = 0xc8;
44const MSGPACK_EXT32: u8 = 0xc9;
45const MSGPACK_FIXEXT4: u8 = 0xd6;
46const MSGPACK_FIXEXT8: u8 = 0xd7;
47const MSGPACK_TIME_EXT_LEGACY: i8 = 5;
48const MSGPACK_TIME_EXT_OFFICIAL: i8 = -1;
49const MSGPACK_TIME_LEN: u8 = 12;
50const MAX_TRANSITION_VERSION_LEN: usize = 1024;
51
52/// Sentinel signature returned when a version has no computable body (invalid /
53/// missing inner object). Mirrors MinIO's `signatureErr` so such versions never
54/// collide with a real all-zero legacy signature.
55const SIGNATURE_ERR: [u8; 4] = [b'e', b'r', b'r', 0];
56
57/// Order-independent hash of a `String -> String` map, mirroring MinIO's
58/// `hashDeterministicString`. Msgpack map serialization order is not stable
59/// across disks, so map fields must be folded in with XOR (order-independent)
60/// rather than left in the marshaled body. Uses xxh64 (RustFS-internal only;
61/// signatures are recomputed on write and never compared against MinIO's).
62fn hash_deterministic_string(m: &HashMap<String, String>) -> u64 {
63    let mut crc: u64 = 0xc2b4_0bba_c11a_7295;
64    for (k, v) in m {
65        crc ^= (xxhash_rust::xxh64::xxh64(k.as_bytes(), 0) ^ 0x4ee3_bbaf_7ab2_506b)
66            .wrapping_add(xxhash_rust::xxh64::xxh64(v.as_bytes(), 0) ^ 0x8da4_c8da_6619_4257);
67    }
68    crc
69}
70
71/// Order-independent hash of a `String -> Vec<u8>` map, mirroring MinIO's
72/// `hashDeterministicBytes`. See [`hash_deterministic_string`].
73fn hash_deterministic_bytes(m: &HashMap<String, Vec<u8>>) -> u64 {
74    let mut crc: u64 = 0x1bbc_7e1d_de65_4743;
75    for (k, v) in m {
76        crc ^= (xxhash_rust::xxh64::xxh64(k.as_bytes(), 0) ^ 0x4ee3_bbaf_7ab2_506b)
77            .wrapping_add(xxhash_rust::xxh64::xxh64(v, 0) ^ 0x8da4_c8da_6619_4257);
78    }
79    crc
80}
81
82/// Fold a 64-bit crc into the 4-byte header signature, matching MinIO's
83/// `binary.LittleEndian.PutUint32(tmp, uint32(crc ^ (crc>>32)))`.
84fn fold_signature(crc: u64) -> [u8; 4] {
85    ((crc ^ (crc >> 32)) as u32).to_le_bytes()
86}
87
88fn read_msgp_string<R: std::io::Read>(rd: &mut R) -> Result<String> {
89    let len = rmp::decode::read_str_len(rd)? as usize;
90    let buf = read_exact_vec(rd, len)?;
91    Ok(String::from_utf8(buf)?)
92}
93
94fn read_msgp_bin<R: std::io::Read>(rd: &mut R) -> Result<Vec<u8>> {
95    let len = rmp::decode::read_bin_len(rd)? as usize;
96    read_exact_vec(rd, len)
97}
98
99const MSGP_OBJECT_KEY_STACK_CAP: usize = 16;
100
101/// Writes an `OffsetDateTime` as the ext8 / legacy (type 5, 12-byte
102/// seconds+nanos) msgpack time encoding used by the V1 (Legacy) object body.
103/// `read_msgp_time` decodes exactly this shape via `MSGPACK_TIME_EXT_LEGACY`.
104fn write_msgp_time<W: std::io::Write>(wr: &mut W, t: OffsetDateTime) -> Result<()> {
105    wr.write_all(&[MSGPACK_EXT8, MSGPACK_TIME_LEN, MSGPACK_TIME_EXT_LEGACY as u8])?;
106    let mut buf = [0u8; MSGPACK_TIME_LEN as usize];
107    buf[0..8].copy_from_slice(&t.unix_timestamp().to_be_bytes());
108    buf[8..12].copy_from_slice(&t.nanosecond().to_be_bytes());
109    wr.write_all(&buf)?;
110    Ok(())
111}
112
113fn deserialize_legacy_uuid_bytes<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
114where
115    D: serde::Deserializer<'de>,
116{
117    struct LegacyUuidBytesVisitor;
118
119    impl<'de> serde::de::Visitor<'de> for LegacyUuidBytesVisitor {
120        type Value = Vec<u8>;
121
122        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123            formatter.write_str("nil or binary UUID bytes")
124        }
125
126        fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
127        where
128            E: serde::de::Error,
129        {
130            Ok(Vec::new())
131        }
132
133        fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
134        where
135            E: serde::de::Error,
136        {
137            Ok(Vec::new())
138        }
139
140        fn visit_bytes<E>(self, value: &[u8]) -> std::result::Result<Self::Value, E>
141        where
142            E: serde::de::Error,
143        {
144            Ok(value.to_vec())
145        }
146
147        fn visit_byte_buf<E>(self, value: Vec<u8>) -> std::result::Result<Self::Value, E>
148        where
149            E: serde::de::Error,
150        {
151            Ok(value)
152        }
153
154        fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
155        where
156            A: serde::de::SeqAccess<'de>,
157        {
158            let mut value = Vec::new();
159            while let Some(byte) = seq.next_element()? {
160                value.push(byte);
161            }
162            Ok(value)
163        }
164    }
165
166    deserializer.deserialize_any(LegacyUuidBytesVisitor)
167}
168
169fn decode_msgp_time_payload(ext_type: i8, payload: &[u8]) -> Result<OffsetDateTime> {
170    let (secs, nanos) = match (ext_type, payload.len()) {
171        (MSGPACK_TIME_EXT_LEGACY, 12) => {
172            let secs = i64::from_be_bytes(payload[..8].try_into().unwrap());
173            let nanos = u32::from_be_bytes(payload[8..12].try_into().unwrap());
174            (secs, nanos)
175        }
176        (MSGPACK_TIME_EXT_OFFICIAL, 4) => (u32::from_be_bytes(payload.try_into().unwrap()) as i64, 0),
177        (MSGPACK_TIME_EXT_OFFICIAL, 8) => {
178            let v = u64::from_be_bytes(payload.try_into().unwrap());
179            let nanos = (v >> 34) as u32;
180            let secs = (v & ((1 << 34) - 1)) as i64;
181            (secs, nanos)
182        }
183        (MSGPACK_TIME_EXT_OFFICIAL, 12) => {
184            let nanos = u32::from_be_bytes(payload[..4].try_into().unwrap());
185            let secs = i64::from_be_bytes(payload[4..12].try_into().unwrap());
186            (secs, nanos)
187        }
188        _ => {
189            return Err(Error::other(format!(
190                "unsupported msgpack time ext type {ext_type} len {}",
191                payload.len()
192            )));
193        }
194    };
195
196    if nanos > 999_999_999 {
197        return Err(Error::other(format!("invalid msgpack time nanos: {nanos}")));
198    }
199
200    OffsetDateTime::from_unix_timestamp_nanos(secs as i128 * 1_000_000_000 + nanos as i128).map_err(Error::from)
201}
202
203fn read_msgp_time<R: std::io::Read>(rd: &mut R) -> Result<OffsetDateTime> {
204    let mut tag = [0u8; 1];
205    rd.read_exact(&mut tag)?;
206
207    let (len, ext_type) = match tag[0] {
208        MSGPACK_FIXEXT4 => {
209            let mut typ = [0u8; 1];
210            rd.read_exact(&mut typ)?;
211            (4usize, typ[0] as i8)
212        }
213        MSGPACK_FIXEXT8 => {
214            let mut typ = [0u8; 1];
215            rd.read_exact(&mut typ)?;
216            (8usize, typ[0] as i8)
217        }
218        MSGPACK_EXT8 => {
219            let mut len = [0u8; 1];
220            let mut typ = [0u8; 1];
221            rd.read_exact(&mut len)?;
222            rd.read_exact(&mut typ)?;
223            (len[0] as usize, typ[0] as i8)
224        }
225        MSGPACK_EXT16 => {
226            let mut len = [0u8; 2];
227            let mut typ = [0u8; 1];
228            rd.read_exact(&mut len)?;
229            rd.read_exact(&mut typ)?;
230            (u16::from_be_bytes(len) as usize, typ[0] as i8)
231        }
232        MSGPACK_EXT32 => {
233            let mut len = [0u8; 4];
234            let mut typ = [0u8; 1];
235            rd.read_exact(&mut len)?;
236            rd.read_exact(&mut typ)?;
237            (u32::from_be_bytes(len) as usize, typ[0] as i8)
238        }
239        other => return Err(Error::other(format!("unsupported msgpack time marker: 0x{other:02x}"))),
240    };
241
242    let payload = read_exact_vec(rd, len)?;
243    decode_msgp_time_payload(ext_type, &payload)
244}
245
246fn parse_legacy_uuid_bytes(bytes: &[u8], field: &str) -> Result<Option<Uuid>> {
247    if bytes.is_empty() {
248        return Ok(None);
249    }
250
251    if bytes.len() != 16 {
252        return Err(Error::other(format!("legacy {field} must be 16 bytes, got {}", bytes.len())));
253    }
254
255    let id = Uuid::from_slice(bytes).map_err(Error::from)?;
256    Ok((!id.is_nil()).then_some(id))
257}
258
259/// Decode a stored transitioned-version-id from a version's `meta_sys`.
260///
261/// Legacy RustFS writes used 16 raw UUID bytes. New writes and MinIO-migrated
262/// records use the provider's exact UTF-8 version text. Empty, nil UUID, and
263/// malformed bytes are not usable remote versions.
264fn transition_version_state_from_bytes(value: Option<&[u8]>) -> Result<TransitionVersionState> {
265    let Some(value) = value else {
266        return Ok(TransitionVersionState::Unknown);
267    };
268    match value {
269        b"known-disabled" => Ok(TransitionVersionState::KnownDisabled),
270        b"suspended-null" => Ok(TransitionVersionState::SuspendedNull),
271        b"exact" => Ok(TransitionVersionState::Exact),
272        b"unknown" => Ok(TransitionVersionState::Unknown),
273        _ => Err(Error::FileCorrupt),
274    }
275}
276
277fn transitioned_version_from_bytes(value: Option<&[u8]>, state: TransitionVersionState) -> Option<String> {
278    let value = value?;
279    if value.is_empty() {
280        return None;
281    }
282    if state == TransitionVersionState::Unknown
283        && let Ok(id) = Uuid::from_slice(value)
284    {
285        return (!id.is_nil()).then(|| id.to_string());
286    }
287    let Ok(value) = std::str::from_utf8(value) else {
288        return None;
289    };
290    if value.is_empty()
291        || value.len() > MAX_TRANSITION_VERSION_LEN
292        || value.chars().any(char::is_control)
293        || Uuid::parse_str(value).is_ok_and(|id| id.is_nil())
294    {
295        None
296    } else {
297        Some(value.to_string())
298    }
299}
300
301fn transition_version_metadata_value(raw: &[u8], decoded: Option<&str>) -> String {
302    decoded.map(str::to_owned).unwrap_or_else(|| {
303        if raw.is_empty() {
304            String::new()
305        } else {
306            String::from_utf8_lossy(raw).into_owned()
307        }
308    })
309}
310
311fn is_transition_version_metadata_key(key: &str) -> bool {
312    strip_internal_prefix_preserving_case(key).is_some_and(|suffix| suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_ID))
313}
314
315fn validate_transition_version_state(state: TransitionVersionState, version: Option<&str>) -> Result<()> {
316    let valid = match state {
317        TransitionVersionState::Unknown | TransitionVersionState::KnownDisabled => version.is_none(),
318        TransitionVersionState::SuspendedNull => version == Some("null"),
319        TransitionVersionState::Exact => version.is_some_and(|value| value != "null"),
320    };
321    valid.then_some(()).ok_or(Error::FileCorrupt)
322}
323
324#[derive(Default)]
325struct DerivedInternalMetadata<'a> {
326    checksum: Option<&'a [u8]>,
327    part_checksums: Option<&'a [u8]>,
328    transition_status: Option<&'a [u8]>,
329    transitioned_object: Option<&'a [u8]>,
330    transitioned_version: Option<&'a [u8]>,
331    transitioned_version_state: Option<&'a [u8]>,
332    transition_tier: Option<&'a [u8]>,
333}
334
335impl<'a> DerivedInternalMetadata<'a> {
336    fn from_meta_sys(meta_sys: &'a HashMap<String, Vec<u8>>) -> Result<Self> {
337        let mut canonical = Self::default();
338        let mut legacy = Self::default();
339        for (key, value) in meta_sys {
340            let Some(suffix) = rustfs_utils::http::strip_internal_prefix_preserving_case(key) else {
341                continue;
342            };
343            let (canonical_slot, legacy_slot, expected_suffix) = if suffix.eq_ignore_ascii_case(SUFFIX_CRC) {
344                (&mut canonical.checksum, &mut legacy.checksum, SUFFIX_CRC)
345            } else if suffix.eq_ignore_ascii_case(SUFFIX_PART_CHECKSUMS) {
346                (&mut canonical.part_checksums, &mut legacy.part_checksums, SUFFIX_PART_CHECKSUMS)
347            } else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITION_STATUS) {
348                (&mut canonical.transition_status, &mut legacy.transition_status, SUFFIX_TRANSITION_STATUS)
349            } else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_OBJECTNAME) {
350                (
351                    &mut canonical.transitioned_object,
352                    &mut legacy.transitioned_object,
353                    SUFFIX_TRANSITIONED_OBJECTNAME,
354                )
355            } else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_ID) {
356                (
357                    &mut canonical.transitioned_version,
358                    &mut legacy.transitioned_version,
359                    SUFFIX_TRANSITIONED_VERSION_ID,
360                )
361            } else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_STATE) {
362                (
363                    &mut canonical.transitioned_version_state,
364                    &mut legacy.transitioned_version_state,
365                    SUFFIX_TRANSITIONED_VERSION_STATE,
366                )
367            } else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITION_TIER) {
368                (&mut canonical.transition_tier, &mut legacy.transition_tier, SUFFIX_TRANSITION_TIER)
369            } else {
370                continue;
371            };
372            let slot = if suffix == expected_suffix
373                && (key.starts_with(RUSTFS_INTERNAL_PREFIX) || key.starts_with(rustfs_utils::http::MINIO_INTERNAL_PREFIX))
374            {
375                canonical_slot
376            } else {
377                legacy_slot
378            };
379            if slot.is_some_and(|current| current != value.as_slice()) {
380                return Err(Error::FileCorrupt);
381            }
382            *slot = Some(value.as_slice());
383        }
384        fn merge_consistent<'a>(canonical: Option<&'a [u8]>, legacy: Option<&'a [u8]>) -> Result<Option<&'a [u8]>> {
385            if let (Some(canonical), Some(legacy)) = (canonical, legacy)
386                && canonical != legacy
387            {
388                return Err(Error::FileCorrupt);
389            }
390            Ok(canonical.or(legacy))
391        }
392
393        Ok(Self {
394            checksum: canonical.checksum.or(legacy.checksum),
395            part_checksums: canonical.part_checksums.or(legacy.part_checksums),
396            transition_status: merge_consistent(canonical.transition_status, legacy.transition_status)?,
397            transitioned_object: merge_consistent(canonical.transitioned_object, legacy.transitioned_object)?,
398            transitioned_version: merge_consistent(canonical.transitioned_version, legacy.transitioned_version)?,
399            transitioned_version_state: merge_consistent(
400                canonical.transitioned_version_state,
401                legacy.transitioned_version_state,
402            )?,
403            transition_tier: merge_consistent(canonical.transition_tier, legacy.transition_tier)?,
404        })
405    }
406}
407
408struct UniquePartChecksums(HashMap<String, String>);
409
410impl<'de> serde::Deserialize<'de> for UniquePartChecksums {
411    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
412    where
413        D: serde::Deserializer<'de>,
414    {
415        struct UniquePartChecksumsVisitor;
416
417        impl<'de> serde::de::Visitor<'de> for UniquePartChecksumsVisitor {
418            type Value = UniquePartChecksums;
419
420            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421                formatter.write_str("an array of unique checksum name and value pairs")
422            }
423
424            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
425            where
426                A: serde::de::SeqAccess<'de>,
427            {
428                let mut checksums = HashMap::with_capacity(seq.size_hint().unwrap_or_default());
429                while let Some((key, value)) = seq.next_element::<(String, String)>()? {
430                    if checksums.insert(key, value).is_some() {
431                        return Err(serde::de::Error::custom("duplicate part checksum name"));
432                    }
433                }
434                Ok(UniquePartChecksums(checksums))
435            }
436        }
437
438        deserializer.deserialize_seq(UniquePartChecksumsVisitor)
439    }
440}
441
442impl FileInfo {
443    pub fn hydrate_data_movement_part_checksums(&mut self) -> Result<()> {
444        let present = self
445            .metadata
446            .keys()
447            .any(|key| has_internal_suffix(key, SUFFIX_PART_CHECKSUMS));
448        if !present {
449            return Ok(());
450        }
451        let encoded = rustfs_utils::http::get_consistent_str(&self.metadata, SUFFIX_PART_CHECKSUMS).ok_or(Error::FileCorrupt)?;
452        let persisted = serde_json::from_str::<Vec<(usize, UniquePartChecksums)>>(encoded).map_err(|_| Error::FileCorrupt)?;
453        let mut part_indices = HashMap::with_capacity(self.parts.len());
454        for (index, part) in self.parts.iter().enumerate() {
455            if part_indices.insert(part.number, index).is_some() {
456                return Err(Error::FileCorrupt);
457            }
458        }
459        for (part_number, UniquePartChecksums(checksums)) in persisted {
460            let index = part_indices.remove(&part_number).ok_or(Error::FileCorrupt)?;
461            let part = self.parts.get_mut(index).ok_or(Error::FileCorrupt)?;
462            part.checksums = Some(checksums);
463        }
464        Ok(())
465    }
466}
467
468fn set_transition_version_state(
469    meta_sys: &mut HashMap<String, Vec<u8>>,
470    state: TransitionVersionState,
471    source_metadata: &HashMap<String, String>,
472) {
473    if state == TransitionVersionState::Unknown
474        && !rustfs_utils::http::metadata_compat::contains_key_str(source_metadata, SUFFIX_TRANSITIONED_VERSION_STATE)
475    {
476        remove_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE);
477    } else {
478        insert_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE, state.as_str().as_bytes().to_vec());
479    }
480}
481
482fn transitioned_version_bytes(fi: &FileInfo) -> Option<Vec<u8>> {
483    fi.transition_version
484        .as_ref()
485        .map(|version| version.as_bytes().to_vec())
486        .or_else(|| fi.transition_version_id.map(|version_id| version_id.as_bytes().to_vec()))
487}
488
489fn parse_legacy_erasure_algo(value: &str) -> ErasureAlgo {
490    match value {
491        "ReedSolomon" => ErasureAlgo::ReedSolomon,
492        _ => ErasureAlgo::Invalid,
493    }
494}
495
496fn parse_legacy_checksum_algo(value: &str) -> ChecksumAlgo {
497    match value {
498        "HighwayHash" => ChecksumAlgo::HighwayHash,
499        _ => ChecksumAlgo::Invalid,
500    }
501}
502
503#[derive(Debug, Deserialize)]
504enum LegacyMetaV2VersionType {
505    #[serde(rename = "Object")]
506    Object,
507    #[serde(rename = "Delete")]
508    Delete,
509    #[serde(rename = "DeleteMarker")]
510    DeleteMarker,
511}
512
513#[derive(Debug, Deserialize)]
514struct LegacyMetaV2Version {
515    version_type: LegacyMetaV2VersionType,
516    object: Option<LegacyMetaV2Object>,
517    delete_marker: Option<LegacyMetaV2DeleteMarker>,
518    write_version: u64,
519}
520
521#[derive(Debug, Deserialize)]
522struct LegacyMetaV2Object {
523    #[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")]
524    version_id: Vec<u8>,
525    #[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")]
526    data_dir: Vec<u8>,
527    erasure_algorithm: String,
528    erasure_m: usize,
529    erasure_n: usize,
530    erasure_block_size: usize,
531    erasure_index: usize,
532    erasure_dist: Vec<u8>,
533    bitrot_checksum_algo: String,
534    part_numbers: Vec<usize>,
535    part_etags: Vec<String>,
536    part_sizes: Vec<usize>,
537    part_actual_sizes: Vec<i64>,
538    part_indices: Vec<Vec<u8>>,
539    size: i64,
540    mod_time: Option<OffsetDateTime>,
541    meta_sys: HashMap<String, Vec<u8>>,
542    meta_user: HashMap<String, String>,
543}
544
545#[derive(Debug, Deserialize)]
546struct LegacyMetaV2DeleteMarker {
547    #[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")]
548    version_id: Vec<u8>,
549    mod_time: Option<OffsetDateTime>,
550    meta_sys: HashMap<String, Vec<u8>>,
551}
552
553#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, Eq, PartialOrd, Ord)]
554pub struct FileMetaShallowVersion {
555    pub header: FileMetaVersionHeader,
556    pub meta: Vec<u8>, // FileMetaVersion.marshal_msg
557}
558
559fn write_quorum_from_erasure(data_blocks: usize, parity_blocks: usize) -> Option<usize> {
560    if data_blocks == 0 {
561        return None;
562    }
563
564    Some(if data_blocks == parity_blocks {
565        data_blocks.saturating_add(1)
566    } else {
567        data_blocks
568    })
569}
570
571impl FileMetaShallowVersion {
572    /// Parse version meta with legacy format compatibility.
573    /// Use this instead of `FileMetaVersion::default()` + `unmarshal_msg()` to handle old-version xl.meta.
574    pub fn parse_version_meta(&self) -> Result<FileMetaVersion> {
575        FileMetaVersion::try_from(self.meta.as_slice())
576    }
577
578    pub fn write_quorum(&self, fallback_quorum: usize) -> usize {
579        let header_quorum = self.header.write_quorum(fallback_quorum);
580        if self.header.has_ec() || !matches!(self.header.version_type, VersionType::Object | VersionType::Legacy) {
581            return header_quorum;
582        }
583
584        self.parse_version_meta()
585            .ok()
586            .and_then(|version| version.erasure_write_quorum())
587            .unwrap_or(header_quorum)
588    }
589
590    pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
591        self.parse_version_meta()?.into_fileinfo(volume, path, all_parts)
592    }
593
594    pub(super) fn to_fileinfo_with_part_checksums(
595        &self,
596        volume: &str,
597        path: &str,
598        all_parts: bool,
599        include_part_checksums: bool,
600    ) -> Result<FileInfo> {
601        self.parse_version_meta()?
602            .to_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)
603    }
604}
605
606impl TryFrom<FileMetaVersion> for FileMetaShallowVersion {
607    type Error = Error;
608
609    fn try_from(value: FileMetaVersion) -> std::result::Result<Self, Self::Error> {
610        let header = value.header();
611        let meta = value.marshal_msg()?;
612        Ok(Self { meta, header })
613    }
614}
615
616#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
617pub struct FileMetaVersion {
618    #[serde(rename = "Type")]
619    pub version_type: VersionType,
620    #[serde(rename = "V1Obj")]
621    pub legacy_object: Option<MetaObjectV1>,
622    #[serde(rename = "V2Obj")]
623    pub object: Option<MetaObject>,
624    #[serde(rename = "DelObj")]
625    pub delete_marker: Option<MetaDeleteMarker>,
626    #[serde(rename = "v")]
627    pub write_version: u64, // rustfs version
628    /// True when parsed via rmp_serde fallback (legacy format). Used for checksum algorithm selection.
629    #[serde(skip)]
630    pub uses_legacy_checksum: bool,
631}
632
633impl FileMetaVersion {
634    fn erasure_write_quorum(&self) -> Option<usize> {
635        let (data_blocks, parity_blocks) = match self.version_type {
636            VersionType::Object | VersionType::Legacy => {
637                if let Some(object) = &self.object {
638                    (object.erasure_m, object.erasure_n)
639                } else if let Some(object) = &self.legacy_object {
640                    (object.erasure.data_blocks, object.erasure.parity_blocks)
641                } else {
642                    return None;
643                }
644            }
645            _ => return None,
646        };
647
648        write_quorum_from_erasure(data_blocks, parity_blocks)
649    }
650
651    fn decode_data_dir_from_v2_object(buf: &[u8]) -> Result<Option<Uuid>> {
652        let mut cur = std::io::Cursor::new(buf);
653        let mut fields = rmp::decode::read_map_len(&mut cur)?;
654        let mut version_type = VersionType::Invalid;
655
656        while fields > 0 {
657            fields -= 1;
658
659            let key_len = rmp::decode::read_str_len(&mut cur)? as usize;
660            let key_buf = read_exact_vec(&mut cur, key_len)?;
661            let key = String::from_utf8(key_buf)?;
662
663            match key.as_str() {
664                "Type" => {
665                    let v: i64 = rmp::decode::read_int(&mut cur)?;
666                    version_type = VersionType::from_u8(v as u8);
667                }
668                "V2Obj" => {
669                    if version_type != VersionType::Object {
670                        skip_msgp_value(&mut cur)?;
671                        continue;
672                    }
673
674                    let mut first = [0u8; 1];
675                    cur.read_exact(&mut first)?;
676                    if first[0] == 0xc0 {
677                        return Ok(None);
678                    }
679
680                    let mut prepend = PrependByteReader {
681                        byte: Some(first[0]),
682                        inner: &mut cur,
683                    };
684                    let mut obj_fields = rmp::decode::read_map_len(&mut prepend)?;
685                    let mut data_dir: Option<Uuid> = None;
686
687                    while obj_fields > 0 {
688                        obj_fields -= 1;
689
690                        let obj_key_len = rmp::decode::read_str_len(&mut prepend)? as usize;
691                        let obj_key_buf = read_exact_vec(&mut prepend, obj_key_len)?;
692                        let obj_key = String::from_utf8(obj_key_buf)?;
693
694                        if obj_key == "DDir" {
695                            let bin_len = rmp::decode::read_bin_len(&mut prepend)? as usize;
696                            if bin_len != 16 {
697                                return Err(Error::other(format!("DDir must be 16 bytes, got {bin_len}")));
698                            }
699                            let mut raw = [0u8; 16];
700                            prepend.read_exact(&mut raw)?;
701                            let id = Uuid::from_bytes(raw);
702                            data_dir = if id.is_nil() { None } else { Some(id) };
703                            break;
704                        }
705
706                        skip_msgp_value(&mut prepend)?;
707                    }
708
709                    return Ok(data_dir);
710                }
711                _ => {
712                    skip_msgp_value(&mut cur)?;
713                }
714            }
715        }
716
717        Ok(None)
718    }
719
720    pub fn valid(&self) -> bool {
721        if !self.version_type.valid() {
722            return false;
723        }
724
725        match self.version_type {
726            VersionType::Object => self
727                .object
728                .as_ref()
729                .map(|v| v.erasure_algorithm.valid() && v.bitrot_checksum_algo.valid() && v.mod_time.is_some())
730                .unwrap_or_default(),
731            VersionType::Legacy => self.legacy_object.as_ref().map(MetaObjectV1::valid).unwrap_or_default(),
732            VersionType::Delete => self
733                .delete_marker
734                .as_ref()
735                .map(|v| v.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH) > OffsetDateTime::UNIX_EPOCH)
736                .unwrap_or_default(),
737            _ => false,
738        }
739    }
740
741    pub fn get_data_dir(&self) -> Option<Uuid> {
742        if self.valid() {
743            {
744                if self.version_type == VersionType::Object {
745                    self.object.as_ref().map(|v| v.data_dir).unwrap_or_default()
746                } else {
747                    None
748                }
749            }
750        } else {
751            Default::default()
752        }
753    }
754
755    pub fn get_version_id(&self) -> Option<Uuid> {
756        match self.version_type {
757            VersionType::Object => self.object.as_ref().map(|v| v.version_id).unwrap_or_default(),
758            VersionType::Delete => self.delete_marker.as_ref().map(|v| v.version_id).unwrap_or_default(),
759            VersionType::Legacy => self.legacy_object.as_ref().and_then(MetaObjectV1::version_id),
760            VersionType::Invalid => None,
761        }
762    }
763
764    pub fn get_mod_time(&self) -> Option<OffsetDateTime> {
765        match self.version_type {
766            VersionType::Object => self.object.as_ref().map(|v| v.mod_time).unwrap_or_default(),
767            VersionType::Delete => self.delete_marker.as_ref().map(|v| v.mod_time).unwrap_or_default(),
768            VersionType::Legacy => self.legacy_object.as_ref().and_then(|v| v.stat.mod_time),
769            VersionType::Invalid => None,
770        }
771    }
772
773    // decode_data_dir_from_meta reads data_dir from meta TODO: directly parse only data_dir from meta buf, msg.skip
774    pub fn decode_data_dir_from_meta(buf: &[u8]) -> Result<Option<Uuid>> {
775        if let Ok(data_dir) = Self::decode_data_dir_from_v2_object(buf) {
776            return Ok(data_dir);
777        }
778        Ok(Self::try_from(buf)?.get_data_dir())
779    }
780
781    pub fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
782        let mut fields = rmp::decode::read_map_len(rd)?;
783        *self = FileMetaVersion::default();
784
785        while fields > 0 {
786            fields -= 1;
787
788            let key_len = rmp::decode::read_str_len(rd)?;
789            let key_buf = read_exact_vec(rd, key_len as usize)?;
790            let key = String::from_utf8(key_buf)?;
791
792            match key.as_str() {
793                "Type" => {
794                    let v: i64 = rmp::decode::read_int(rd)?;
795                    self.version_type = VersionType::from_u8(v as u8);
796                }
797                "V1Obj" => {
798                    let mut buf = [0u8; 1];
799                    rd.read_exact(&mut buf).map_err(Error::from)?;
800                    if buf[0] == 0xc0 {
801                        self.legacy_object = None;
802                    } else {
803                        let mut prepend = PrependByteReader {
804                            byte: Some(buf[0]),
805                            inner: rd,
806                        };
807                        let mut obj = MetaObjectV1::default();
808                        obj.decode_from(&mut prepend)?;
809                        self.legacy_object = Some(obj);
810                    }
811                }
812                "V2Obj" => {
813                    let mut buf = [0u8; 1];
814                    rd.read_exact(&mut buf).map_err(Error::from)?;
815                    if buf[0] == 0xc0 {
816                        self.object = None;
817                    } else {
818                        let mut prepend = PrependByteReader {
819                            byte: Some(buf[0]),
820                            inner: rd,
821                        };
822                        let mut obj = MetaObject::default();
823                        obj.decode_from(&mut prepend)?;
824                        self.object = Some(obj);
825                    }
826                }
827                "DelObj" => {
828                    let mut buf = [0u8; 1];
829                    rd.read_exact(&mut buf).map_err(Error::from)?;
830                    if buf[0] == 0xc0 {
831                        self.delete_marker = None;
832                    } else {
833                        let mut prepend = PrependByteReader {
834                            byte: Some(buf[0]),
835                            inner: rd,
836                        };
837                        let mut dm = MetaDeleteMarker::default();
838                        dm.decode_from(&mut prepend)?;
839                        self.delete_marker = Some(dm);
840                    }
841                }
842                "v" => {
843                    let v: i64 = rmp::decode::read_int(rd)?;
844                    if v < 0 {
845                        return Err(Error::other("negative write_version not supported"));
846                    }
847                    self.write_version = v as u64;
848                }
849                other => {
850                    tracing::debug!(field = %other, "decode_from: skipping unknown field");
851                    skip_msgp_value(rd)?;
852                }
853            }
854        }
855
856        Ok(())
857    }
858
859    pub fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
860        // Variable map size: omit V1Obj/V2Obj/DelObj when None
861        let mut map_len: u32 = 2; // "Type" + "v"
862        if self.legacy_object.is_some() {
863            map_len += 1;
864        }
865        if self.object.is_some() {
866            map_len += 1;
867        }
868        if self.delete_marker.is_some() {
869            map_len += 1;
870        }
871
872        rmp::encode::write_map_len(wr, map_len)?;
873
874        // Type
875        rmp::encode::write_str(wr, "Type")?;
876        rmp::encode::write_uint(wr, self.version_type.to_u8() as u64)?;
877
878        // V1Obj — legacy body must round-trip; dropping it silently corrupted
879        // Legacy versions on re-encode (backlog#799 B18).
880        if let Some(ref legacy) = self.legacy_object {
881            rmp::encode::write_str(wr, "V1Obj")?;
882            legacy.encode_to(wr)?;
883        }
884
885        // V2Obj
886        if let Some(ref obj) = self.object {
887            rmp::encode::write_str(wr, "V2Obj")?;
888            obj.encode_to(wr)?;
889        }
890
891        // DelObj
892        if let Some(ref dm) = self.delete_marker {
893            rmp::encode::write_str(wr, "DelObj")?;
894            dm.encode_to(wr)?;
895        }
896
897        // v
898        rmp::encode::write_str(wr, "v")?;
899        rmp::encode::write_uint(wr, self.write_version)?;
900
901        Ok(())
902    }
903
904    pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
905        let mut cur = std::io::Cursor::new(buf);
906        self.decode_from(&mut cur)?;
907        Ok(cur.position())
908    }
909
910    pub fn marshal_msg(&self) -> Result<Vec<u8>> {
911        let mut wr = Vec::new();
912        self.encode_to(&mut wr)?;
913        Ok(wr)
914    }
915
916    pub fn free_version(&self) -> bool {
917        self.version_type == VersionType::Delete && self.delete_marker.as_ref().map(|m| m.free_version()).unwrap_or_default()
918    }
919
920    pub fn header(&self) -> FileMetaVersionHeader {
921        FileMetaVersionHeader::from(self.clone())
922    }
923
924    pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
925        self.to_fileinfo_with_part_checksums(volume, path, all_parts, true)
926    }
927
928    pub(super) fn to_fileinfo_with_part_checksums(
929        &self,
930        volume: &str,
931        path: &str,
932        all_parts: bool,
933        include_part_checksums: bool,
934    ) -> Result<FileInfo> {
935        let mut fi = match self.version_type {
936            VersionType::Invalid | VersionType::Legacy => {
937                if let Some(ref legacy) = self.legacy_object {
938                    legacy.to_fileinfo(volume, path)
939                } else {
940                    FileInfo {
941                        name: path.to_string(),
942                        volume: volume.to_string(),
943                        ..Default::default()
944                    }
945                }
946            }
947            VersionType::Object => {
948                let default_object = MetaObject::default();
949                self.object
950                    .as_ref()
951                    .unwrap_or(&default_object)
952                    .to_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)?
953            }
954            VersionType::Delete => {
955                let default_marker = MetaDeleteMarker::default();
956                self.delete_marker
957                    .as_ref()
958                    .unwrap_or(&default_marker)
959                    .into_fileinfo(volume, path, all_parts)?
960            }
961        };
962        fi.uses_legacy_checksum = self.uses_legacy_checksum;
963        Ok(fi)
964    }
965
966    /// Support for Legacy version type
967    pub fn is_legacy(&self) -> bool {
968        self.version_type == VersionType::Legacy
969    }
970
971    /// Compute the header signature for this version.
972    ///
973    /// The signature must be identical across all disks that hold the same
974    /// logical version, yet differ whenever any body field differs (tags,
975    /// user/system metadata, parts, size, ...). Otherwise a partial-write
976    /// divergence (e.g. `PutObjectTagging` reaching only some disks) shares the
977    /// same `version_id`+`mod_time` and becomes undetectable / unhealable — the
978    /// bug tracked in backlog#861 (B12), where this was hardcoded to `[0;4]`.
979    ///
980    /// Per-disk-varying fields (`erasure_index`) are excluded so identical
981    /// content never falsely diverges. Semantics follow MinIO's
982    /// `xlMetaV2Version.getSignature`.
983    pub fn get_signature(&self) -> [u8; 4] {
984        match self.version_type {
985            VersionType::Object => self.object.as_ref().map(MetaObject::get_signature).unwrap_or(SIGNATURE_ERR),
986            VersionType::Delete => self
987                .delete_marker
988                .as_ref()
989                .map(MetaDeleteMarker::get_signature)
990                .unwrap_or(SIGNATURE_ERR),
991            VersionType::Legacy => self
992                .legacy_object
993                .as_ref()
994                .map(MetaObjectV1::get_signature)
995                .unwrap_or(SIGNATURE_ERR),
996            _ => SIGNATURE_ERR,
997        }
998    }
999
1000    /// Check if this version uses data directory
1001    pub fn uses_data_dir(&self) -> bool {
1002        match self.version_type {
1003            VersionType::Object => self.object.as_ref().map(|obj| obj.uses_data_dir()).unwrap_or(false),
1004            VersionType::Legacy => false,
1005            _ => false,
1006        }
1007    }
1008
1009    /// Check if this version uses inline data
1010    pub fn uses_inline_data(&self) -> bool {
1011        match self.version_type {
1012            VersionType::Object => self.object.as_ref().map(|obj| obj.inlinedata()).unwrap_or(false),
1013            VersionType::Legacy => false,
1014            _ => false,
1015        }
1016    }
1017}
1018
1019impl TryFrom<&[u8]> for FileMetaVersion {
1020    type Error = Error;
1021
1022    fn try_from(value: &[u8]) -> std::result::Result<Self, Self::Error> {
1023        let mut ver = FileMetaVersion::default();
1024        if ver.unmarshal_msg(value).is_ok() && ver.valid() {
1025            ver.uses_legacy_checksum = false;
1026            return Ok(ver);
1027        }
1028
1029        if let Ok(legacy_ver) = rmp_serde::from_slice::<LegacyMetaV2Version>(value) {
1030            let mut ver = FileMetaVersion::try_from(legacy_ver)?;
1031            ver.uses_legacy_checksum = true;
1032            return Ok(ver);
1033        }
1034
1035        // Fallback for legacy ver_meta: rmp_serde format
1036        let mut ver: Self = rmp_serde::from_slice(value).map_err(Error::other)?;
1037        ver.uses_legacy_checksum = true;
1038        Ok(ver)
1039    }
1040}
1041
1042impl TryFrom<LegacyMetaV2Version> for FileMetaVersion {
1043    type Error = Error;
1044
1045    fn try_from(value: LegacyMetaV2Version) -> std::result::Result<Self, Self::Error> {
1046        let (version_type, object, delete_marker) = match value.version_type {
1047            LegacyMetaV2VersionType::Object => (VersionType::Object, value.object.map(TryInto::try_into).transpose()?, None),
1048            LegacyMetaV2VersionType::Delete | LegacyMetaV2VersionType::DeleteMarker => {
1049                (VersionType::Delete, None, value.delete_marker.map(TryInto::try_into).transpose()?)
1050            }
1051        };
1052
1053        Ok(Self {
1054            version_type,
1055            legacy_object: None,
1056            object,
1057            delete_marker,
1058            write_version: value.write_version,
1059            uses_legacy_checksum: true,
1060        })
1061    }
1062}
1063
1064impl From<FileInfo> for FileMetaVersion {
1065    fn from(value: FileInfo) -> Self {
1066        if value.is_storage_delete_marker() {
1067            FileMetaVersion {
1068                version_type: VersionType::Delete,
1069                legacy_object: None,
1070                delete_marker: Some(MetaDeleteMarker::from(value)),
1071                object: None,
1072                write_version: 0,
1073                uses_legacy_checksum: false,
1074            }
1075        } else {
1076            // A `deleted` FileInfo that is not a canonical delete marker is only
1077            // legitimate as a purge-pending payload, which carries real erasure
1078            // geometry and is intentionally serialized as an Object. A `deleted`
1079            // FileInfo with neither a canonical-marker shape nor valid erasure
1080            // geometry would silently serialize as a zero-geometry MetaObject that
1081            // later fails `validate_for_metadata_read`. Write paths validate first
1082            // (`validate_for_erasure_write` / `validate_for_metadata_read`), so this
1083            // is a caller bug — surface it rather than writing malformed metadata
1084            // silently. (`From` is infallible, so this cannot return an error.)
1085            if value.deleted && !value.has_valid_erasure_geometry() {
1086                tracing::warn!(
1087                    event = "filemeta_non_canonical_deleted_fileinfo_as_object",
1088                    component = "filemeta",
1089                    "serializing a deleted FileInfo that is neither a canonical delete marker nor a valid erasure payload as an Object version; upstream validation should have rejected it"
1090                );
1091            }
1092            FileMetaVersion {
1093                version_type: VersionType::Object,
1094                legacy_object: None,
1095                delete_marker: None,
1096                object: Some(MetaObject::from(value)),
1097                write_version: 0,
1098                uses_legacy_checksum: false,
1099            }
1100        }
1101    }
1102}
1103
1104impl TryFrom<FileMetaShallowVersion> for FileMetaVersion {
1105    type Error = Error;
1106
1107    fn try_from(value: FileMetaShallowVersion) -> std::result::Result<Self, Self::Error> {
1108        FileMetaVersion::try_from(value.meta.as_slice())
1109    }
1110}
1111
1112#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone, Eq, Hash)]
1113pub struct FileMetaVersionHeader {
1114    pub version_id: Option<Uuid>,
1115    pub mod_time: Option<OffsetDateTime>,
1116    pub signature: [u8; 4],
1117    pub version_type: VersionType,
1118    pub flags: u8,
1119    pub ec_n: u8,
1120    pub ec_m: u8,
1121}
1122
1123impl FileMetaVersionHeader {
1124    fn reset_for_unmarshal(&mut self) {
1125        self.version_id = None;
1126        self.mod_time = None;
1127        self.signature = [0; 4];
1128        self.version_type = VersionType::Invalid;
1129        self.flags = 0;
1130        self.ec_n = 0;
1131        self.ec_m = 0;
1132    }
1133
1134    pub fn has_ec(&self) -> bool {
1135        self.ec_m > 0 && self.ec_n > 0
1136    }
1137
1138    pub fn write_quorum(&self, fallback_quorum: usize) -> usize {
1139        if self.version_type != VersionType::Object || !self.has_ec() {
1140            return fallback_quorum;
1141        }
1142
1143        write_quorum_from_erasure(usize::from(self.ec_m), usize::from(self.ec_n)).unwrap_or(fallback_quorum)
1144    }
1145
1146    pub fn matches_not_strict(&self, o: &FileMetaVersionHeader) -> bool {
1147        let mut ok = self.version_id == o.version_id && self.version_type == o.version_type && self.matches_ec(o);
1148        // Disk-loaded headers keep the null version as Some(nil), not None: all null
1149        // versions share one id, so mod_time is the only thing distinguishing an
1150        // interrupted overwrite from the committed version.
1151        if self.version_id.is_none() || self.version_id == Some(Uuid::nil()) {
1152            ok = ok && self.mod_time == o.mod_time;
1153        }
1154
1155        ok
1156    }
1157
1158    pub fn matches_ec(&self, o: &FileMetaVersionHeader) -> bool {
1159        if self.has_ec() && o.has_ec() {
1160            return self.ec_n == o.ec_n && self.ec_m == o.ec_m;
1161        }
1162
1163        true
1164    }
1165
1166    pub fn free_version(&self) -> bool {
1167        self.flags & XL_FLAG_FREE_VERSION != 0
1168    }
1169
1170    pub fn sorts_before(&self, o: &FileMetaVersionHeader) -> bool {
1171        if self == o {
1172            return false;
1173        }
1174
1175        // Prefer newest modtime.
1176        if self.mod_time != o.mod_time {
1177            return self.mod_time > o.mod_time;
1178        }
1179
1180        // The following doesn't make too much sense, but we want sort to be consistent nonetheless.
1181        // Prefer lower types
1182        if self.version_type != o.version_type {
1183            return self.version_type < o.version_type;
1184        }
1185        // Consistent sort on signature
1186        match self.signature.cmp(&o.signature) {
1187            Ordering::Greater => {
1188                return true;
1189            }
1190            Ordering::Less => {
1191                return false;
1192            }
1193            _ => {}
1194        }
1195        // Consistent sort on version_id
1196        match self.version_id.cmp(&o.version_id) {
1197            Ordering::Greater => {
1198                return true;
1199            }
1200            Ordering::Less => {
1201                return false;
1202            }
1203            _ => {}
1204        }
1205
1206        if self.flags != o.flags {
1207            return self.flags > o.flags;
1208        }
1209
1210        false
1211    }
1212
1213    pub fn uses_data_dir(&self) -> bool {
1214        self.flags & Flags::UsesDataDir as u8 != 0
1215    }
1216
1217    pub fn marshal_msg(&self) -> Result<Vec<u8>> {
1218        let mut wr = Vec::new();
1219
1220        // array len 7
1221        rmp::encode::write_array_len(&mut wr, 7)?;
1222
1223        // version_id
1224        rmp::encode::write_bin(&mut wr, self.version_id.unwrap_or_default().as_bytes())?;
1225        // mod_time
1226        rmp::encode::write_i64(&mut wr, self.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp_nanos() as i64)?;
1227        // signature
1228        rmp::encode::write_bin(&mut wr, self.signature.as_slice())?;
1229        // version_type
1230        rmp::encode::write_uint8(&mut wr, self.version_type.to_u8())?;
1231        // flags
1232        rmp::encode::write_uint8(&mut wr, self.flags)?;
1233        // ec_n
1234        rmp::encode::write_uint8(&mut wr, self.ec_n)?;
1235        // ec_m
1236        rmp::encode::write_uint8(&mut wr, self.ec_m)?;
1237
1238        Ok(wr)
1239    }
1240
1241    pub fn unmarshal_v(&mut self, version: u8, buf: &[u8]) -> Result<u64> {
1242        match version {
1243            1 => self.unmarshal_v1(buf),
1244            2 => self.unmarshal_v2(buf),
1245            3 => self.unmarshal_msg(buf),
1246            _ => Err(Error::other(format!("unknown xl header version: {version}"))),
1247        }
1248    }
1249
1250    pub fn unmarshal_v1(&mut self, buf: &[u8]) -> Result<u64> {
1251        self.reset_for_unmarshal();
1252
1253        let mut cur = Cursor::new(buf);
1254        let alen = rmp::decode::read_array_len(&mut cur)?;
1255        if alen != 4 {
1256            return Err(Error::other(format!("version header array len err need 4 got {alen}")));
1257        }
1258
1259        rmp::decode::read_bin_len(&mut cur)?;
1260        let mut version_id = [0u8; 16];
1261        cur.read_exact(&mut version_id)?;
1262        self.version_id = Some(Uuid::from_bytes(version_id));
1263
1264        let unix: i128 = rmp::decode::read_int(&mut cur)?;
1265        let time = OffsetDateTime::from_unix_timestamp_nanos(unix)?;
1266        if time != OffsetDateTime::UNIX_EPOCH {
1267            self.mod_time = Some(time);
1268        }
1269
1270        let typ: u8 = rmp::decode::read_int(&mut cur)?;
1271        self.version_type = VersionType::from_u8(typ);
1272        self.flags = rmp::decode::read_int(&mut cur)?;
1273
1274        Ok(cur.position())
1275    }
1276
1277    pub fn unmarshal_v2(&mut self, buf: &[u8]) -> Result<u64> {
1278        self.reset_for_unmarshal();
1279
1280        let mut cur = Cursor::new(buf);
1281        let alen = rmp::decode::read_array_len(&mut cur)?;
1282        if alen != 5 {
1283            return Err(Error::other(format!("version header array len err need 5 got {alen}")));
1284        }
1285
1286        rmp::decode::read_bin_len(&mut cur)?;
1287        let mut version_id = [0u8; 16];
1288        cur.read_exact(&mut version_id)?;
1289        self.version_id = Some(Uuid::from_bytes(version_id));
1290
1291        let unix: i128 = rmp::decode::read_int(&mut cur)?;
1292        let time = OffsetDateTime::from_unix_timestamp_nanos(unix)?;
1293        if time != OffsetDateTime::UNIX_EPOCH {
1294            self.mod_time = Some(time);
1295        }
1296
1297        rmp::decode::read_bin_len(&mut cur)?;
1298        cur.read_exact(&mut self.signature)?;
1299
1300        let typ: u8 = rmp::decode::read_int(&mut cur)?;
1301        self.version_type = VersionType::from_u8(typ);
1302        self.flags = rmp::decode::read_int(&mut cur)?;
1303
1304        Ok(cur.position())
1305    }
1306
1307    pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
1308        self.reset_for_unmarshal();
1309
1310        let mut cur = Cursor::new(buf);
1311        let alen = rmp::decode::read_array_len(&mut cur)?;
1312        if alen != 7 {
1313            return Err(Error::other(format!("version header array len err need 7 got {alen}")));
1314        }
1315
1316        // version_id
1317        rmp::decode::read_bin_len(&mut cur)?;
1318        let mut buf = [0u8; 16];
1319        cur.read_exact(&mut buf)?;
1320        self.version_id = {
1321            let id = Uuid::from_bytes(buf);
1322            // if id.is_nil() { None } else { Some(id) }
1323            Some(id)
1324        };
1325
1326        // mod_time
1327        let unix: i128 = rmp::decode::read_int(&mut cur)?;
1328
1329        let time = OffsetDateTime::from_unix_timestamp_nanos(unix)?;
1330        if time == OffsetDateTime::UNIX_EPOCH {
1331            self.mod_time = None;
1332        } else {
1333            self.mod_time = Some(time);
1334        }
1335
1336        // signature
1337        rmp::decode::read_bin_len(&mut cur)?;
1338        cur.read_exact(&mut self.signature)?;
1339
1340        // version_type
1341        let typ: u8 = rmp::decode::read_int(&mut cur)?;
1342        self.version_type = VersionType::from_u8(typ);
1343
1344        // flags
1345        self.flags = rmp::decode::read_int(&mut cur)?;
1346        // ec_n
1347        self.ec_n = rmp::decode::read_int(&mut cur)?;
1348        // ec_m
1349        self.ec_m = rmp::decode::read_int(&mut cur)?;
1350
1351        Ok(cur.position())
1352    }
1353
1354    /// Get signature for header
1355    pub fn get_signature(&self) -> [u8; 4] {
1356        self.signature
1357    }
1358
1359    /// Check if this header represents inline data
1360    pub fn inline_data(&self) -> bool {
1361        self.flags & Flags::InlineData as u8 != 0
1362    }
1363
1364    /// Update signature based on version content
1365    pub fn update_signature(&mut self, version: &FileMetaVersion) {
1366        self.signature = version.get_signature();
1367    }
1368}
1369
1370impl PartialOrd for FileMetaVersionHeader {
1371    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1372        Some(self.cmp(other))
1373    }
1374}
1375
1376impl Ord for FileMetaVersionHeader {
1377    fn cmp(&self, other: &Self) -> Ordering {
1378        match self.mod_time.cmp(&other.mod_time) {
1379            core::cmp::Ordering::Equal => {}
1380            ord => return ord,
1381        }
1382
1383        match self.version_type.cmp(&other.version_type) {
1384            core::cmp::Ordering::Equal => {}
1385            ord => return ord,
1386        }
1387        match self.signature.cmp(&other.signature) {
1388            core::cmp::Ordering::Equal => {}
1389            ord => return ord,
1390        }
1391        match self.version_id.cmp(&other.version_id) {
1392            core::cmp::Ordering::Equal => {}
1393            ord => return ord,
1394        }
1395        self.flags.cmp(&other.flags)
1396    }
1397}
1398
1399impl From<FileMetaVersion> for FileMetaVersionHeader {
1400    fn from(value: FileMetaVersion) -> Self {
1401        let flags = {
1402            let mut f: u8 = 0;
1403            if value.free_version() {
1404                f |= Flags::FreeVersion as u8;
1405            }
1406
1407            if value.version_type == VersionType::Object && value.object.as_ref().map(|v| v.uses_data_dir()).unwrap_or_default() {
1408                f |= Flags::UsesDataDir as u8;
1409            }
1410
1411            if value.version_type == VersionType::Object && value.object.as_ref().map(|v| v.inlinedata()).unwrap_or_default() {
1412                f |= Flags::InlineData as u8;
1413            }
1414
1415            f
1416        };
1417
1418        let (ec_n, ec_m) = match (value.version_type == VersionType::Object, value.object.as_ref()) {
1419            (true, Some(obj)) => (obj.erasure_n as u8, obj.erasure_m as u8),
1420            _ => (0, 0),
1421        };
1422
1423        Self {
1424            version_id: value.get_version_id(),
1425            mod_time: value.get_mod_time(),
1426            signature: value.get_signature(),
1427            version_type: value.version_type,
1428            flags,
1429            ec_n,
1430            ec_m,
1431        }
1432    }
1433}
1434
1435#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
1436// Because of custom message_pack, field order must be guaranteed
1437pub struct MetaObject {
1438    #[serde(rename = "ID")]
1439    pub version_id: Option<Uuid>, // Version ID
1440    #[serde(rename = "DDir")]
1441    pub data_dir: Option<Uuid>, // Data dir ID
1442    #[serde(rename = "EcAlgo")]
1443    pub erasure_algorithm: ErasureAlgo, // Erasure coding algorithm
1444    #[serde(rename = "EcM")]
1445    pub erasure_m: usize, // Erasure data blocks
1446    #[serde(rename = "EcN")]
1447    pub erasure_n: usize, // Erasure parity blocks
1448    #[serde(rename = "EcBSize")]
1449    pub erasure_block_size: usize, // Erasure block size
1450    #[serde(rename = "EcIndex")]
1451    pub erasure_index: usize, // Erasure disk index
1452    #[serde(rename = "EcDist")]
1453    pub erasure_dist: Vec<u8>, // Erasure distribution
1454    #[serde(rename = "CSumAlgo")]
1455    pub bitrot_checksum_algo: ChecksumAlgo, // Bitrot checksum algo
1456    #[serde(rename = "PartNums")]
1457    pub part_numbers: Vec<usize>, // Part Numbers
1458    #[serde(rename = "PartETags")]
1459    pub part_etags: Vec<String>, // Part ETags
1460    #[serde(rename = "PartSizes")]
1461    pub part_sizes: Vec<usize>, // Part Sizes
1462    #[serde(rename = "PartASizes")]
1463    pub part_actual_sizes: Vec<i64>, // Part ActualSizes (compression)
1464    #[serde(rename = "PartIdx")]
1465    pub part_indices: Vec<Bytes>, // Part Indexes (compression)
1466    #[serde(rename = "Size")]
1467    pub size: i64, // Object version size
1468    #[serde(rename = "MTime")]
1469    pub mod_time: Option<OffsetDateTime>, // Object version modified time
1470    #[serde(rename = "MetaSys")]
1471    pub meta_sys: HashMap<String, Vec<u8>>, // Object version internal metadata
1472    #[serde(rename = "MetaUsr")]
1473    pub meta_user: HashMap<String, String>, // Object version metadata set by user
1474}
1475
1476impl TryFrom<LegacyMetaV2Object> for MetaObject {
1477    type Error = Error;
1478
1479    fn try_from(value: LegacyMetaV2Object) -> std::result::Result<Self, Self::Error> {
1480        Ok(Self {
1481            version_id: parse_legacy_uuid_bytes(&value.version_id, "version_id")?,
1482            data_dir: parse_legacy_uuid_bytes(&value.data_dir, "data_dir")?,
1483            erasure_algorithm: parse_legacy_erasure_algo(&value.erasure_algorithm),
1484            erasure_m: value.erasure_m,
1485            erasure_n: value.erasure_n,
1486            erasure_block_size: value.erasure_block_size,
1487            erasure_index: value.erasure_index,
1488            erasure_dist: value.erasure_dist,
1489            bitrot_checksum_algo: parse_legacy_checksum_algo(&value.bitrot_checksum_algo),
1490            part_numbers: value.part_numbers,
1491            part_etags: value.part_etags,
1492            part_sizes: value.part_sizes,
1493            part_actual_sizes: value.part_actual_sizes,
1494            part_indices: value.part_indices.into_iter().map(Bytes::from).collect(),
1495            size: value.size,
1496            mod_time: value.mod_time,
1497            meta_sys: value.meta_sys,
1498            meta_user: value.meta_user,
1499        })
1500    }
1501}
1502
1503#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
1504pub struct MetaObjectV1 {
1505    #[serde(rename = "Version")]
1506    pub version: String,
1507    #[serde(rename = "Format")]
1508    pub format: String,
1509    #[serde(rename = "Stat")]
1510    pub stat: MetaObjectV1Stat,
1511    #[serde(rename = "Erasure")]
1512    pub erasure: MetaObjectV1Erasure,
1513    #[serde(rename = "Meta")]
1514    pub meta: HashMap<String, String>,
1515    #[serde(rename = "Parts")]
1516    pub parts: Vec<MetaObjectV1Part>,
1517    #[serde(rename = "VersionID")]
1518    pub version_id: String,
1519    #[serde(rename = "DataDir")]
1520    pub data_dir: String,
1521}
1522
1523#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
1524pub struct MetaObjectV1Stat {
1525    #[serde(rename = "Size")]
1526    pub size: i64,
1527    #[serde(rename = "ModTime")]
1528    pub mod_time: Option<OffsetDateTime>,
1529    #[serde(rename = "Name")]
1530    pub name: String,
1531    #[serde(rename = "Dir")]
1532    pub dir: bool,
1533    #[serde(rename = "Mode")]
1534    pub mode: u32,
1535}
1536
1537#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
1538pub struct MetaObjectV1ChecksumInfo {
1539    #[serde(rename = "PartNumber")]
1540    pub part_number: usize,
1541    #[serde(rename = "Algorithm")]
1542    pub algorithm: String,
1543    #[serde(rename = "Hash")]
1544    pub hash: Vec<u8>,
1545}
1546
1547#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
1548pub struct MetaObjectV1Erasure {
1549    #[serde(rename = "Algorithm")]
1550    pub algorithm: String,
1551    #[serde(rename = "DataBlocks")]
1552    pub data_blocks: usize,
1553    #[serde(rename = "ParityBlocks")]
1554    pub parity_blocks: usize,
1555    #[serde(rename = "BlockSize")]
1556    pub block_size: usize,
1557    #[serde(rename = "Index")]
1558    pub index: usize,
1559    #[serde(rename = "Distribution")]
1560    pub distribution: Vec<usize>,
1561    #[serde(rename = "Checksums")]
1562    pub checksums: Vec<MetaObjectV1ChecksumInfo>,
1563}
1564
1565#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
1566pub struct MetaObjectV1Part {
1567    #[serde(rename = "e")]
1568    pub etag: String,
1569    #[serde(rename = "n")]
1570    pub number: usize,
1571    #[serde(rename = "s")]
1572    pub size: usize,
1573    #[serde(rename = "as")]
1574    pub actual_size: i64,
1575    #[serde(rename = "mt")]
1576    pub mod_time: Option<OffsetDateTime>,
1577    #[serde(rename = "i")]
1578    pub index: Option<Bytes>,
1579    #[serde(rename = "crc")]
1580    pub checksums: Option<HashMap<String, String>>,
1581    #[serde(rename = "err")]
1582    pub error: Option<String>,
1583}
1584
1585impl MetaObjectV1 {
1586    fn version_id(&self) -> Option<Uuid> {
1587        if self.version_id.is_empty() {
1588            None
1589        } else {
1590            Uuid::parse_str(&self.version_id).ok().filter(|id| !id.is_nil())
1591        }
1592    }
1593
1594    fn valid(&self) -> bool {
1595        if self.format != "xl" || self.stat.mod_time.is_none() {
1596            return false;
1597        }
1598
1599        let data_blocks = self.erasure.data_blocks;
1600        let parity_blocks = self.erasure.parity_blocks;
1601        data_blocks > 0
1602            && data_blocks >= parity_blocks
1603            && self.erasure.index > 0
1604            && self.erasure.index <= data_blocks + parity_blocks
1605            && crate::fileinfo::is_valid_distribution(&self.erasure.distribution, data_blocks + parity_blocks)
1606    }
1607
1608    fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
1609        let mut fields = rmp::decode::read_map_len(rd)?;
1610        *self = Self::default();
1611
1612        while fields > 0 {
1613            fields -= 1;
1614            let key = read_msgp_string(rd)?;
1615            match key.as_str() {
1616                "Version" => self.version = read_msgp_string(rd)?,
1617                "Format" => self.format = read_msgp_string(rd)?,
1618                "Stat" => self.stat.decode_from(rd)?,
1619                "Erasure" => self.erasure.decode_from(rd)?,
1620                "Meta" => {
1621                    let len = rmp::decode::read_map_len(rd)? as usize;
1622                    self.meta.clear();
1623                    for _ in 0..len {
1624                        self.meta.insert(read_msgp_string(rd)?, read_msgp_string(rd)?);
1625                    }
1626                }
1627                "Parts" => {
1628                    let len = rmp::decode::read_array_len(rd)? as usize;
1629                    self.parts.clear();
1630                    self.parts.reserve(prealloc_hint(len));
1631                    for _ in 0..len {
1632                        let mut part = MetaObjectV1Part::default();
1633                        part.decode_from(rd)?;
1634                        self.parts.push(part);
1635                    }
1636                }
1637                "VersionID" => self.version_id = read_msgp_string(rd)?,
1638                "DataDir" => self.data_dir = read_msgp_string(rd)?,
1639                _ => skip_msgp_value(rd)?,
1640            }
1641        }
1642
1643        Ok(())
1644    }
1645
1646    /// Symmetric encoder for the V1 (Legacy) object body. Kept field-for-field
1647    /// consistent with `decode_from` so a Legacy version round-trips through
1648    /// `FileMetaVersion::encode_to` without losing its body (backlog#799 B18).
1649    fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
1650        rmp::encode::write_map_len(wr, 8)?;
1651
1652        rmp::encode::write_str(wr, "Version")?;
1653        rmp::encode::write_str(wr, &self.version)?;
1654
1655        rmp::encode::write_str(wr, "Format")?;
1656        rmp::encode::write_str(wr, &self.format)?;
1657
1658        rmp::encode::write_str(wr, "Stat")?;
1659        self.stat.encode_to(wr)?;
1660
1661        rmp::encode::write_str(wr, "Erasure")?;
1662        self.erasure.encode_to(wr)?;
1663
1664        rmp::encode::write_str(wr, "Meta")?;
1665        rmp::encode::write_map_len(wr, self.meta.len() as u32)?;
1666        for (k, v) in &self.meta {
1667            rmp::encode::write_str(wr, k)?;
1668            rmp::encode::write_str(wr, v)?;
1669        }
1670
1671        rmp::encode::write_str(wr, "Parts")?;
1672        rmp::encode::write_array_len(wr, self.parts.len() as u32)?;
1673        for part in &self.parts {
1674            part.encode_to(wr)?;
1675        }
1676
1677        rmp::encode::write_str(wr, "VersionID")?;
1678        rmp::encode::write_str(wr, &self.version_id)?;
1679
1680        rmp::encode::write_str(wr, "DataDir")?;
1681        rmp::encode::write_str(wr, &self.data_dir)?;
1682
1683        Ok(())
1684    }
1685
1686    fn get_signature(&self) -> [u8; 4] {
1687        let mut hasher = xxhash_rust::xxh64::Xxh64::new(XXHASH_SEED);
1688        hasher.update(self.version.as_bytes());
1689        hasher.update(self.format.as_bytes());
1690        hasher.update(&self.stat.size.to_le_bytes());
1691        hasher.update(&self.stat.mode.to_le_bytes());
1692        if let Some(mod_time) = self.stat.mod_time {
1693            hasher.update(&mod_time.unix_timestamp_nanos().to_le_bytes());
1694        }
1695        hasher.update(self.erasure.algorithm.as_bytes());
1696        hasher.update(&(self.erasure.data_blocks as u64).to_le_bytes());
1697        hasher.update(&(self.erasure.parity_blocks as u64).to_le_bytes());
1698        hasher.update(&(self.erasure.block_size as u64).to_le_bytes());
1699        for v in &self.erasure.distribution {
1700            hasher.update(&(*v as u64).to_le_bytes());
1701        }
1702        for checksum in &self.erasure.checksums {
1703            hasher.update(&(checksum.part_number as u64).to_le_bytes());
1704            hasher.update(checksum.algorithm.as_bytes());
1705            hasher.update(&checksum.hash);
1706        }
1707        let mut meta_keys: Vec<_> = self.meta.iter().collect();
1708        meta_keys.sort_by(|a, b| a.0.cmp(b.0));
1709        for (k, v) in meta_keys {
1710            hasher.update(k.as_bytes());
1711            hasher.update(v.as_bytes());
1712        }
1713        for part in &self.parts {
1714            hasher.update(&(part.number as u64).to_le_bytes());
1715            hasher.update(&(part.size as u64).to_le_bytes());
1716            hasher.update(&part.actual_size.to_le_bytes());
1717            hasher.update(part.etag.as_bytes());
1718            if let Some(mod_time) = part.mod_time {
1719                hasher.update(&mod_time.unix_timestamp_nanos().to_le_bytes());
1720            }
1721            if let Some(index) = &part.index {
1722                hasher.update(index);
1723            }
1724        }
1725        let hash = hasher.finish();
1726        let bytes = hash.to_le_bytes();
1727        [bytes[0], bytes[1], bytes[2], bytes[3]]
1728    }
1729
1730    fn to_fileinfo(&self, volume: &str, path: &str) -> FileInfo {
1731        FileInfo {
1732            volume: volume.to_string(),
1733            name: path.to_string(),
1734            version_id: self.version_id(),
1735            mod_time: self.stat.mod_time,
1736            size: self.stat.size,
1737            mode: Some(self.stat.mode),
1738            metadata: self.meta.clone(),
1739            parts: self.parts.iter().cloned().map(Into::into).collect(),
1740            erasure: self.erasure.clone().into(),
1741            num_versions: 1,
1742            data_dir: Uuid::parse_str(&self.data_dir).ok().filter(|id| !id.is_nil()),
1743            ..Default::default()
1744        }
1745    }
1746}
1747
1748impl MetaObjectV1Stat {
1749    fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
1750        let mut fields = rmp::decode::read_map_len(rd)?;
1751        *self = Self::default();
1752
1753        while fields > 0 {
1754            fields -= 1;
1755            let key = read_msgp_string(rd)?;
1756            match key.as_str() {
1757                "Size" => self.size = rmp::decode::read_int(rd)?,
1758                "ModTime" => self.mod_time = Some(read_msgp_time(rd)?),
1759                "Name" => self.name = read_msgp_string(rd)?,
1760                "Dir" => self.dir = rmp::decode::read_bool(rd)?,
1761                "Mode" => self.mode = rmp::decode::read_u32(rd)?,
1762                _ => skip_msgp_value(rd)?,
1763            }
1764        }
1765
1766        Ok(())
1767    }
1768
1769    fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
1770        // `ModTime` is only written when present, mirroring `decode_from` (which
1771        // sets it only when the field exists) so a `None` mod_time stays absent
1772        // rather than round-tripping to `Some(UNIX_EPOCH)`.
1773        let map_len: u32 = if self.mod_time.is_some() { 5 } else { 4 };
1774        rmp::encode::write_map_len(wr, map_len)?;
1775
1776        rmp::encode::write_str(wr, "Size")?;
1777        rmp::encode::write_sint(wr, self.size)?;
1778
1779        if let Some(mod_time) = self.mod_time {
1780            rmp::encode::write_str(wr, "ModTime")?;
1781            write_msgp_time(wr, mod_time)?;
1782        }
1783
1784        rmp::encode::write_str(wr, "Name")?;
1785        rmp::encode::write_str(wr, &self.name)?;
1786
1787        rmp::encode::write_str(wr, "Dir")?;
1788        rmp::encode::write_bool(wr, self.dir)?;
1789
1790        rmp::encode::write_str(wr, "Mode")?;
1791        // `Mode` decodes with the strict `read_u32`, which only accepts the U32
1792        // marker — a narrower `write_uint` marker would fail to round-trip.
1793        rmp::encode::write_u32(wr, self.mode)?;
1794
1795        Ok(())
1796    }
1797}
1798
1799impl MetaObjectV1Erasure {
1800    fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
1801        let mut fields = rmp::decode::read_map_len(rd)?;
1802        *self = Self::default();
1803
1804        while fields > 0 {
1805            fields -= 1;
1806            let key = read_msgp_string(rd)?;
1807            match key.as_str() {
1808                "Algorithm" => self.algorithm = read_msgp_string(rd)?,
1809                "DataBlocks" => self.data_blocks = rmp::decode::read_int::<i64, _>(rd)? as usize,
1810                "ParityBlocks" => self.parity_blocks = rmp::decode::read_int::<i64, _>(rd)? as usize,
1811                "BlockSize" => self.block_size = rmp::decode::read_int::<i64, _>(rd)? as usize,
1812                "Index" => self.index = rmp::decode::read_int::<i64, _>(rd)? as usize,
1813                "Distribution" => {
1814                    let len = rmp::decode::read_array_len(rd)? as usize;
1815                    self.distribution.clear();
1816                    self.distribution.reserve(prealloc_hint(len));
1817                    for _ in 0..len {
1818                        self.distribution.push(rmp::decode::read_int::<i64, _>(rd)? as usize);
1819                    }
1820                }
1821                "Checksums" => {
1822                    let len = rmp::decode::read_array_len(rd)? as usize;
1823                    self.checksums.clear();
1824                    self.checksums.reserve(prealloc_hint(len));
1825                    for _ in 0..len {
1826                        let mut checksum = MetaObjectV1ChecksumInfo::default();
1827                        checksum.decode_from(rd)?;
1828                        self.checksums.push(checksum);
1829                    }
1830                }
1831                _ => skip_msgp_value(rd)?,
1832            }
1833        }
1834
1835        Ok(())
1836    }
1837
1838    fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
1839        rmp::encode::write_map_len(wr, 7)?;
1840
1841        rmp::encode::write_str(wr, "Algorithm")?;
1842        rmp::encode::write_str(wr, &self.algorithm)?;
1843
1844        rmp::encode::write_str(wr, "DataBlocks")?;
1845        rmp::encode::write_sint(wr, self.data_blocks as i64)?;
1846
1847        rmp::encode::write_str(wr, "ParityBlocks")?;
1848        rmp::encode::write_sint(wr, self.parity_blocks as i64)?;
1849
1850        rmp::encode::write_str(wr, "BlockSize")?;
1851        rmp::encode::write_sint(wr, self.block_size as i64)?;
1852
1853        rmp::encode::write_str(wr, "Index")?;
1854        rmp::encode::write_sint(wr, self.index as i64)?;
1855
1856        rmp::encode::write_str(wr, "Distribution")?;
1857        rmp::encode::write_array_len(wr, self.distribution.len() as u32)?;
1858        for v in &self.distribution {
1859            rmp::encode::write_sint(wr, *v as i64)?;
1860        }
1861
1862        rmp::encode::write_str(wr, "Checksums")?;
1863        rmp::encode::write_array_len(wr, self.checksums.len() as u32)?;
1864        for checksum in &self.checksums {
1865            checksum.encode_to(wr)?;
1866        }
1867
1868        Ok(())
1869    }
1870}
1871
1872impl MetaObjectV1ChecksumInfo {
1873    fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
1874        let mut fields = rmp::decode::read_map_len(rd)?;
1875        *self = Self::default();
1876
1877        while fields > 0 {
1878            fields -= 1;
1879            let key = read_msgp_string(rd)?;
1880            match key.as_str() {
1881                "PartNumber" => self.part_number = rmp::decode::read_int::<i64, _>(rd)? as usize,
1882                "Algorithm" => self.algorithm = read_msgp_string(rd)?,
1883                "Hash" => self.hash = read_msgp_bin(rd)?,
1884                _ => skip_msgp_value(rd)?,
1885            }
1886        }
1887
1888        Ok(())
1889    }
1890
1891    fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
1892        rmp::encode::write_map_len(wr, 3)?;
1893
1894        rmp::encode::write_str(wr, "PartNumber")?;
1895        rmp::encode::write_sint(wr, self.part_number as i64)?;
1896
1897        rmp::encode::write_str(wr, "Algorithm")?;
1898        rmp::encode::write_str(wr, &self.algorithm)?;
1899
1900        rmp::encode::write_str(wr, "Hash")?;
1901        rmp::encode::write_bin(wr, &self.hash)?;
1902
1903        Ok(())
1904    }
1905}
1906
1907impl MetaObjectV1Part {
1908    fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
1909        let mut fields = rmp::decode::read_map_len(rd)?;
1910        *self = Self::default();
1911
1912        while fields > 0 {
1913            fields -= 1;
1914            let key = read_msgp_string(rd)?;
1915            match key.as_str() {
1916                "e" => self.etag = read_msgp_string(rd)?,
1917                "n" => self.number = rmp::decode::read_int::<i64, _>(rd)? as usize,
1918                "s" => self.size = rmp::decode::read_int::<i64, _>(rd)? as usize,
1919                "as" => self.actual_size = rmp::decode::read_int(rd)?,
1920                "mt" => self.mod_time = Some(read_msgp_time(rd)?),
1921                "i" => self.index = Some(Bytes::from(read_msgp_bin(rd)?)),
1922                "crc" => {
1923                    let len = rmp::decode::read_map_len(rd)? as usize;
1924                    let mut checksums = HashMap::with_capacity(prealloc_hint(len));
1925                    for _ in 0..len {
1926                        checksums.insert(read_msgp_string(rd)?, read_msgp_string(rd)?);
1927                    }
1928                    self.checksums = Some(checksums);
1929                }
1930                "err" => self.error = Some(read_msgp_string(rd)?),
1931                _ => skip_msgp_value(rd)?,
1932            }
1933        }
1934
1935        Ok(())
1936    }
1937
1938    fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
1939        // Non-optional scalars are always written; optional fields only when
1940        // present, so a decoded part round-trips (absent stays absent).
1941        let mut map_len: u32 = 4; // e, n, s, as
1942        if self.mod_time.is_some() {
1943            map_len += 1;
1944        }
1945        if self.index.is_some() {
1946            map_len += 1;
1947        }
1948        if self.checksums.is_some() {
1949            map_len += 1;
1950        }
1951        if self.error.is_some() {
1952            map_len += 1;
1953        }
1954        rmp::encode::write_map_len(wr, map_len)?;
1955
1956        rmp::encode::write_str(wr, "e")?;
1957        rmp::encode::write_str(wr, &self.etag)?;
1958
1959        rmp::encode::write_str(wr, "n")?;
1960        rmp::encode::write_sint(wr, self.number as i64)?;
1961
1962        rmp::encode::write_str(wr, "s")?;
1963        rmp::encode::write_sint(wr, self.size as i64)?;
1964
1965        rmp::encode::write_str(wr, "as")?;
1966        rmp::encode::write_sint(wr, self.actual_size)?;
1967
1968        if let Some(mt) = self.mod_time {
1969            rmp::encode::write_str(wr, "mt")?;
1970            write_msgp_time(wr, mt)?;
1971        }
1972
1973        if let Some(ref index) = self.index {
1974            rmp::encode::write_str(wr, "i")?;
1975            rmp::encode::write_bin(wr, index)?;
1976        }
1977
1978        if let Some(ref checksums) = self.checksums {
1979            rmp::encode::write_str(wr, "crc")?;
1980            rmp::encode::write_map_len(wr, checksums.len() as u32)?;
1981            for (k, v) in checksums {
1982                rmp::encode::write_str(wr, k)?;
1983                rmp::encode::write_str(wr, v)?;
1984            }
1985        }
1986
1987        if let Some(ref err) = self.error {
1988            rmp::encode::write_str(wr, "err")?;
1989            rmp::encode::write_str(wr, err)?;
1990        }
1991
1992        Ok(())
1993    }
1994}
1995
1996impl From<MetaObjectV1Erasure> for ErasureInfo {
1997    fn from(value: MetaObjectV1Erasure) -> Self {
1998        ErasureInfo {
1999            algorithm: value.algorithm,
2000            data_blocks: value.data_blocks,
2001            parity_blocks: value.parity_blocks,
2002            block_size: value.block_size,
2003            index: value.index,
2004            distribution: value.distribution,
2005            checksums: value.checksums.into_iter().map(Into::into).collect(),
2006        }
2007    }
2008}
2009
2010impl From<MetaObjectV1ChecksumInfo> for ChecksumInfo {
2011    fn from(value: MetaObjectV1ChecksumInfo) -> Self {
2012        ChecksumInfo {
2013            part_number: value.part_number,
2014            algorithm: match value.algorithm.as_str() {
2015                "sha256" => HashAlgorithm::SHA256,
2016                "highwayhash256" => HashAlgorithm::HighwayHash256,
2017                "highwayhash256S" => HashAlgorithm::HighwayHash256S,
2018                "blake2b" | "blake2b512" => HashAlgorithm::BLAKE2b512,
2019                "md5" => HashAlgorithm::Md5,
2020                _ => HashAlgorithm::HighwayHash256S,
2021            },
2022            hash: Bytes::from(value.hash),
2023        }
2024    }
2025}
2026
2027impl From<MetaObjectV1Part> for ObjectPartInfo {
2028    fn from(value: MetaObjectV1Part) -> Self {
2029        ObjectPartInfo {
2030            etag: value.etag,
2031            number: value.number,
2032            size: value.size,
2033            actual_size: value.actual_size,
2034            mod_time: value.mod_time,
2035            index: value.index,
2036            checksums: value.checksums,
2037            error: value.error,
2038            integrity: None,
2039        }
2040    }
2041}
2042
2043impl MetaObject {
2044    pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
2045        let mut cur = std::io::Cursor::new(buf);
2046        self.decode_from(&mut cur)?;
2047        Ok(cur.position())
2048    }
2049
2050    // marshal_msg custom messagepack naming consistent with go
2051    pub fn marshal_msg(&self) -> Result<Vec<u8>> {
2052        let mut wr = Vec::new();
2053        self.encode_to(&mut wr)?;
2054        Ok(wr)
2055    }
2056
2057    /// Compute this object version's header signature, mirroring MinIO's
2058    /// `xlMetaV2Object.Signature`. See [`FileMetaVersion::get_signature`] for
2059    /// why divergence detection requires covering every body field.
2060    pub fn get_signature(&self) -> [u8; 4] {
2061        let mut c = self.clone();
2062
2063        // Zero fields that legitimately vary per disk within an erasure set.
2064        c.erasure_index = 0;
2065
2066        // Treat an all-empty PartETags vector the same as an absent one, so two
2067        // disks that encode `[]` vs `["", ""]` do not falsely diverge.
2068        if c.part_etags.iter().all(String::is_empty) {
2069            c.part_etags.clear();
2070        }
2071
2072        // Fold maps in with an order-independent hash: msgpack map order is not
2073        // stable across disks, so they must not be part of the marshaled body.
2074        let mut crc = hash_deterministic_string(&c.meta_user);
2075        crc ^= hash_deterministic_bytes(&c.meta_sys);
2076        c.meta_sys.clear();
2077        c.meta_user.clear();
2078
2079        if let Ok(bytes) = c.marshal_msg() {
2080            crc ^= xxhash_rust::xxh64::xxh64(&bytes, XXHASH_SEED);
2081        }
2082
2083        fold_signature(crc)
2084    }
2085
2086    pub fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
2087        // Variable map size: omit PartIdx when empty
2088        let mut map_len = 18u32;
2089        if self.part_indices.is_empty() {
2090            map_len -= 1;
2091        }
2092        rmp::encode::write_map_len(wr, map_len)?;
2093
2094        // ID
2095        rmp::encode::write_str(wr, "ID")?;
2096        rmp::encode::write_bin(wr, self.version_id.unwrap_or_default().as_bytes())?;
2097
2098        // DDir
2099        rmp::encode::write_str(wr, "DDir")?;
2100        rmp::encode::write_bin(wr, self.data_dir.unwrap_or_default().as_bytes())?;
2101
2102        // EcAlgo
2103        rmp::encode::write_str(wr, "EcAlgo")?;
2104        rmp::encode::write_uint(wr, self.erasure_algorithm.to_u8() as u64)?;
2105
2106        // EcM
2107        rmp::encode::write_str(wr, "EcM")?;
2108        rmp::encode::write_sint(wr, self.erasure_m as i64)?;
2109
2110        // EcN
2111        rmp::encode::write_str(wr, "EcN")?;
2112        rmp::encode::write_sint(wr, self.erasure_n as i64)?;
2113
2114        // EcBSize
2115        rmp::encode::write_str(wr, "EcBSize")?;
2116        rmp::encode::write_sint(wr, self.erasure_block_size as i64)?;
2117
2118        // EcIndex
2119        rmp::encode::write_str(wr, "EcIndex")?;
2120        rmp::encode::write_sint(wr, self.erasure_index as i64)?;
2121
2122        // EcDist
2123        rmp::encode::write_str(wr, "EcDist")?;
2124        rmp::encode::write_array_len(wr, self.erasure_dist.len() as u32)?;
2125        for v in &self.erasure_dist {
2126            rmp::encode::write_uint(wr, *v as u64)?;
2127        }
2128
2129        // CSumAlgo
2130        rmp::encode::write_str(wr, "CSumAlgo")?;
2131        rmp::encode::write_uint(wr, self.bitrot_checksum_algo.to_u8() as u64)?;
2132
2133        // PartNums
2134        rmp::encode::write_str(wr, "PartNums")?;
2135        rmp::encode::write_array_len(wr, self.part_numbers.len() as u32)?;
2136        for n in &self.part_numbers {
2137            rmp::encode::write_sint(wr, *n as i64)?;
2138        }
2139
2140        // PartETags (write nil when empty)
2141        rmp::encode::write_str(wr, "PartETags")?;
2142        if self.part_etags.is_empty() {
2143            rmp::encode::write_nil(wr)?;
2144        } else {
2145            rmp::encode::write_array_len(wr, self.part_etags.len() as u32)?;
2146            for et in &self.part_etags {
2147                rmp::encode::write_str(wr, et)?;
2148            }
2149        }
2150
2151        // PartSizes
2152        rmp::encode::write_str(wr, "PartSizes")?;
2153        rmp::encode::write_array_len(wr, self.part_sizes.len() as u32)?;
2154        for s in &self.part_sizes {
2155            rmp::encode::write_sint(wr, *s as i64)?;
2156        }
2157
2158        // PartASizes (write nil when empty)
2159        rmp::encode::write_str(wr, "PartASizes")?;
2160        if self.part_actual_sizes.is_empty() {
2161            rmp::encode::write_nil(wr)?;
2162        } else {
2163            rmp::encode::write_array_len(wr, self.part_actual_sizes.len() as u32)?;
2164            for s in &self.part_actual_sizes {
2165                rmp::encode::write_sint(wr, *s)?;
2166            }
2167        }
2168
2169        // PartIdx (omit when empty)
2170        if !self.part_indices.is_empty() {
2171            rmp::encode::write_str(wr, "PartIdx")?;
2172            rmp::encode::write_array_len(wr, self.part_indices.len() as u32)?;
2173            for idx in &self.part_indices {
2174                rmp::encode::write_bin(wr, idx)?;
2175            }
2176        }
2177
2178        // Size
2179        rmp::encode::write_str(wr, "Size")?;
2180        rmp::encode::write_sint(wr, self.size)?;
2181
2182        // MTime Unix timestamp nanos
2183        rmp::encode::write_str(wr, "MTime")?;
2184        let nanos = self.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp_nanos();
2185        rmp::encode::write_sint(wr, nanos as i64)?;
2186
2187        // MetaSys (write nil when empty)
2188        rmp::encode::write_str(wr, "MetaSys")?;
2189        if self.meta_sys.is_empty() {
2190            rmp::encode::write_nil(wr)?;
2191        } else {
2192            rmp::encode::write_map_len(wr, self.meta_sys.len() as u32)?;
2193            for (k, v) in &self.meta_sys {
2194                rmp::encode::write_str(wr, k)?;
2195                rmp::encode::write_bin(wr, v)?;
2196            }
2197        }
2198
2199        // MetaUsr (write nil when empty)
2200        rmp::encode::write_str(wr, "MetaUsr")?;
2201        if self.meta_user.is_empty() {
2202            rmp::encode::write_nil(wr)?;
2203        } else {
2204            rmp::encode::write_map_len(wr, self.meta_user.len() as u32)?;
2205            for (k, v) in &self.meta_user {
2206                rmp::encode::write_str(wr, k)?;
2207                rmp::encode::write_str(wr, v)?;
2208            }
2209        }
2210
2211        Ok(())
2212    }
2213
2214    pub fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
2215        let mut fields = rmp::decode::read_map_len(rd).map_err(|e| {
2216            tracing::error!(error = %e, "decode_from: read_map_len failed");
2217            e
2218        })?;
2219        *self = MetaObject::default();
2220
2221        while fields > 0 {
2222            fields -= 1;
2223
2224            let key_len = rmp::decode::read_str_len(rd).map_err(|e| {
2225                tracing::error!(error = %e, "decode_from: read_str_len key failed");
2226                e
2227            })?;
2228            let key_len = usize::try_from(key_len).map_err(|e| {
2229                tracing::error!(error = %e, "decode_from: key length conversion failed");
2230                Error::other(e)
2231            })?;
2232
2233            let mut inline_key = [0u8; MSGP_OBJECT_KEY_STACK_CAP];
2234            let heap_key;
2235            let key_buf = if key_len <= MSGP_OBJECT_KEY_STACK_CAP {
2236                rd.read_exact(&mut inline_key[..key_len]).map_err(|e| {
2237                    tracing::error!(error = %e, "decode_from: read key_buf failed");
2238                    Error::from(e)
2239                })?;
2240                &inline_key[..key_len]
2241            } else {
2242                heap_key = read_exact_vec(rd, key_len).map_err(|e| {
2243                    tracing::error!(error = %e, "decode_from: read key_buf failed");
2244                    e
2245                })?;
2246                heap_key.as_slice()
2247            };
2248
2249            let key = std::str::from_utf8(key_buf).map_err(|e| {
2250                tracing::error!(error = %e, "decode_from: from_utf8 key failed");
2251                Error::FromUtf8(e.to_string())
2252            })?;
2253
2254            match key {
2255                "ID" => {
2256                    let _ = rmp::decode::read_bin_len(rd).map_err(|e| {
2257                        tracing::error!(error = %e, "decode_from: read_bin_len ID failed");
2258                        e
2259                    })?;
2260                    let mut buf = [0u8; 16];
2261                    rd.read_exact(&mut buf).map_err(|e| {
2262                        tracing::error!(error = %e, "decode_from: read_exact ID buf failed");
2263                        e
2264                    })?;
2265                    let id = Uuid::from_bytes(buf);
2266                    self.version_id = if id.is_nil() { None } else { Some(id) };
2267                }
2268                "DDir" => {
2269                    let _ = rmp::decode::read_bin_len(rd).map_err(|e| {
2270                        tracing::error!(error = %e, "decode_from: read_bin_len DDir failed");
2271                        e
2272                    })?;
2273                    let mut buf = [0u8; 16];
2274                    rd.read_exact(&mut buf).map_err(|e| {
2275                        tracing::error!(error = %e, "decode_from: read_exact DDir buf failed");
2276                        e
2277                    })?;
2278                    let id = Uuid::from_bytes(buf);
2279                    self.data_dir = if id.is_nil() { None } else { Some(id) };
2280                }
2281                "EcAlgo" => {
2282                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2283                        tracing::error!(error = %e, "decode_from: read_int EcAlgo failed");
2284                        e
2285                    })?;
2286                    self.erasure_algorithm = ErasureAlgo::from_u8(v as u8);
2287                }
2288                "EcM" => {
2289                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2290                        tracing::error!(error = %e, "decode_from: read_int EcM failed");
2291                        e
2292                    })?;
2293                    self.erasure_m = v as usize;
2294                }
2295                "EcN" => {
2296                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2297                        tracing::error!(error = %e, "decode_from: read_int EcN failed");
2298                        e
2299                    })?;
2300                    self.erasure_n = v as usize;
2301                }
2302                "EcBSize" => {
2303                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2304                        tracing::error!(error = %e, "decode_from: read_int EcBSize failed");
2305                        e
2306                    })?;
2307                    self.erasure_block_size = v as usize;
2308                }
2309                "EcIndex" => {
2310                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2311                        tracing::error!(error = %e, "decode_from: read_int EcIndex failed");
2312                        e
2313                    })?;
2314                    self.erasure_index = v as usize;
2315                }
2316                "EcDist" => {
2317                    let len = rmp::decode::read_array_len(rd).map_err(|e| {
2318                        tracing::error!(error = %e, "decode_from: read_array_len EcDist failed");
2319                        e
2320                    })? as usize;
2321                    self.erasure_dist.clear();
2322                    self.erasure_dist.reserve(prealloc_hint(len));
2323                    for _ in 0..len {
2324                        let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2325                            tracing::error!(error = %e, "decode_from: read_int EcDist item failed");
2326                            e
2327                        })?;
2328                        self.erasure_dist.push(v as u8);
2329                    }
2330                }
2331                "CSumAlgo" => {
2332                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2333                        tracing::error!(error = %e, "decode_from: read_int CSumAlgo failed");
2334                        e
2335                    })?;
2336                    self.bitrot_checksum_algo = ChecksumAlgo::from_u8(v as u8);
2337                }
2338                "PartNums" => {
2339                    let len = rmp::decode::read_array_len(rd).map_err(|e| {
2340                        tracing::error!(error = %e, "decode_from: read_array_len PartNums failed");
2341                        e
2342                    })? as usize;
2343                    self.part_numbers.clear();
2344                    self.part_numbers.reserve(prealloc_hint(len));
2345                    for _ in 0..len {
2346                        let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2347                            tracing::error!(error = %e, "decode_from: read_int PartNums item failed");
2348                            e
2349                        })?;
2350                        self.part_numbers.push(v as usize);
2351                    }
2352                }
2353                "PartETags" => {
2354                    let len = match read_nil_or_array_len(rd).map_err(|e| {
2355                        tracing::error!(error = %e, "decode_from: read PartETags failed");
2356                        e
2357                    })? {
2358                        None => {
2359                            self.part_etags.clear();
2360                            continue;
2361                        }
2362                        Some(n) => n,
2363                    };
2364                    self.part_etags.clear();
2365                    self.part_etags.reserve(prealloc_hint(len));
2366                    for _ in 0..len {
2367                        let s_len = rmp::decode::read_str_len(rd).map_err(|e| {
2368                            tracing::error!(error = %e, "decode_from: read_str_len PartETags item failed");
2369                            e
2370                        })?;
2371                        let sbuf = read_exact_vec(rd, s_len as usize).map_err(|e| {
2372                            tracing::error!(error = %e, "decode_from: read PartETags sbuf failed");
2373                            e
2374                        })?;
2375                        let s = String::from_utf8(sbuf).map_err(|e| {
2376                            tracing::error!(error = %e, "decode_from: from_utf8 PartETags item failed");
2377                            e
2378                        })?;
2379                        self.part_etags.push(s);
2380                    }
2381                }
2382                "PartSizes" => {
2383                    let len = rmp::decode::read_array_len(rd).map_err(|e| {
2384                        tracing::error!(error = %e, "decode_from: read_array_len PartSizes failed");
2385                        e
2386                    })? as usize;
2387                    self.part_sizes.clear();
2388                    self.part_sizes.reserve(prealloc_hint(len));
2389                    for _ in 0..len {
2390                        let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2391                            tracing::error!(error = %e, "decode_from: read_int PartSizes item failed");
2392                            e
2393                        })?;
2394                        self.part_sizes.push(v as usize);
2395                    }
2396                }
2397                "PartASizes" => {
2398                    let len = match read_nil_or_array_len(rd).map_err(|e| {
2399                        tracing::error!(error = %e, "decode_from: read PartASizes failed");
2400                        e
2401                    })? {
2402                        None => {
2403                            self.part_actual_sizes.clear();
2404                            continue;
2405                        }
2406                        Some(n) => n,
2407                    };
2408                    self.part_actual_sizes.clear();
2409                    self.part_actual_sizes.reserve(prealloc_hint(len));
2410                    for _ in 0..len {
2411                        let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2412                            tracing::error!(error = %e, "decode_from: read_int PartASizes item failed");
2413                            e
2414                        })?;
2415                        self.part_actual_sizes.push(v);
2416                    }
2417                }
2418                "PartIdx" => {
2419                    let len = rmp::decode::read_array_len(rd).map_err(|e| {
2420                        tracing::error!(error = %e, "decode_from: read_array_len PartIdx failed");
2421                        e
2422                    })? as usize;
2423                    self.part_indices.clear();
2424                    self.part_indices.reserve(prealloc_hint(len));
2425                    for _ in 0..len {
2426                        let blen = rmp::decode::read_bin_len(rd).map_err(|e| {
2427                            tracing::error!(error = %e, "decode_from: read_bin_len PartIdx item failed");
2428                            e
2429                        })? as usize;
2430                        let buf = read_exact_vec(rd, blen).map_err(|e| {
2431                            tracing::error!(error = %e, "decode_from: read PartIdx buf failed");
2432                            e
2433                        })?;
2434                        self.part_indices.push(Bytes::from(buf));
2435                    }
2436                }
2437                "Size" => {
2438                    let v: i64 = rmp::decode::read_int(rd).map_err(|e| {
2439                        tracing::error!(error = %e, "decode_from: read_int Size failed");
2440                        e
2441                    })?;
2442                    self.size = v;
2443                }
2444                "MTime" => {
2445                    let nanos: i64 = rmp::decode::read_int(rd).map_err(|e| {
2446                        tracing::error!(error = %e, "decode_from: read_int MTime failed");
2447                        e
2448                    })?;
2449                    let time = OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).inspect_err(|&e| {
2450                        tracing::error!(error = %e, "decode_from: from_unix_timestamp_nanos MTime failed");
2451                    })?;
2452                    self.mod_time = if time == OffsetDateTime::UNIX_EPOCH {
2453                        None
2454                    } else {
2455                        Some(time)
2456                    };
2457                }
2458                "MetaSys" => {
2459                    let len = match read_nil_or_map_len(rd).map_err(|e| {
2460                        tracing::error!(error = %e, "decode_from: read MetaSys failed");
2461                        e
2462                    })? {
2463                        None => {
2464                            self.meta_sys.clear();
2465                            continue;
2466                        }
2467                        Some(n) => n,
2468                    };
2469                    self.meta_sys.clear();
2470                    self.meta_sys.reserve(prealloc_hint(len));
2471                    for _ in 0..len {
2472                        let k_len = rmp::decode::read_str_len(rd).map_err(|e| {
2473                            tracing::error!(error = %e, "decode_from: read_str_len MetaSys key failed");
2474                            e
2475                        })?;
2476                        let kbuf = read_exact_vec(rd, k_len as usize).map_err(|e| {
2477                            tracing::error!(error = %e, "decode_from: read MetaSys kbuf failed");
2478                            e
2479                        })?;
2480                        let k = String::from_utf8(kbuf).map_err(|e| {
2481                            tracing::error!(error = %e, "decode_from: from_utf8 MetaSys key failed");
2482                            e
2483                        })?;
2484
2485                        let blen = rmp::decode::read_bin_len(rd).map_err(|e| {
2486                            tracing::error!(error = %e, "decode_from: read_bin_len MetaSys value failed");
2487                            e
2488                        })? as usize;
2489                        let v = read_exact_vec(rd, blen).map_err(|e| {
2490                            tracing::error!(error = %e, "decode_from: read MetaSys value failed");
2491                            e
2492                        })?;
2493
2494                        self.meta_sys.insert(k, v);
2495                    }
2496                }
2497                "MetaUsr" => {
2498                    let len = match read_nil_or_map_len(rd).map_err(|e| {
2499                        tracing::error!(error = %e, "decode_from: read MetaUsr failed");
2500                        e
2501                    })? {
2502                        None => {
2503                            self.meta_user.clear();
2504                            continue;
2505                        }
2506                        Some(n) => n,
2507                    };
2508                    self.meta_user.clear();
2509                    self.meta_user.reserve(prealloc_hint(len));
2510                    for _ in 0..len {
2511                        let k_len = rmp::decode::read_str_len(rd).map_err(|e| {
2512                            tracing::error!(error = %e, "decode_from: read_str_len MetaUsr key failed");
2513                            e
2514                        })?;
2515                        let kbuf = read_exact_vec(rd, k_len as usize).map_err(|e| {
2516                            tracing::error!(error = %e, "decode_from: read MetaUsr kbuf failed");
2517                            e
2518                        })?;
2519                        let k = String::from_utf8(kbuf).map_err(|e| {
2520                            tracing::error!(error = %e, "decode_from: from_utf8 MetaUsr key failed");
2521                            e
2522                        })?;
2523
2524                        let v_len = rmp::decode::read_str_len(rd).map_err(|e| {
2525                            tracing::error!(error = %e, "decode_from: read_str_len MetaUsr value failed");
2526                            e
2527                        })?;
2528                        let vbuf = read_exact_vec(rd, v_len as usize).map_err(|e| {
2529                            tracing::error!(error = %e, "decode_from: read MetaUsr vbuf failed");
2530                            e
2531                        })?;
2532                        let v = String::from_utf8(vbuf).map_err(|e| {
2533                            tracing::error!(error = %e, "decode_from: from_utf8 MetaUsr value failed");
2534                            e
2535                        })?;
2536
2537                        self.meta_user.insert(k, v);
2538                    }
2539                }
2540                other => {
2541                    // Skip unknown fields for forward compatibility with Go (dc.Skip())
2542                    tracing::debug!(field = %other, "decode_from: skipping unknown field");
2543                    skip_msgp_value(rd).map_err(|e| {
2544                        tracing::error!(error = %e, "decode_from: skip unknown field failed");
2545                        e
2546                    })?;
2547                }
2548            }
2549        }
2550
2551        Ok(())
2552    }
2553
2554    pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
2555        self.to_fileinfo_with_part_checksums(volume, path, all_parts, true)
2556    }
2557
2558    fn to_fileinfo_with_part_checksums(
2559        &self,
2560        volume: &str,
2561        path: &str,
2562        all_parts: bool,
2563        include_part_checksums: bool,
2564    ) -> Result<FileInfo> {
2565        let version_id = self.version_id.filter(|&vid| !vid.is_nil());
2566        let derived_metadata = DerivedInternalMetadata::from_meta_sys(&self.meta_sys)?;
2567
2568        let parts = if all_parts {
2569            let n = self.part_numbers.len();
2570            // Required fields: `part_sizes`/`part_actual_sizes` must match `part_numbers`
2571            // exactly. These arrays are decoded from independent msgpack length prefixes
2572            // (`decode_from`), so a truncated/half-written/bitrot xl.meta can leave them
2573            // shorter (or longer) than `part_numbers`. Indexing without this guard would
2574            // panic; silently defaulting to 0 would miscompute Content-Length / Range /
2575            // multipart boundaries and hand corrupt data to the client (worse than an
2576            // error). `n == 0` preserves the legitimate "no-part object / legacy / empty
2577            // array" wire (MinIO parity: empty PartNumbers is valid).
2578            if n != 0 && (self.part_sizes.len() != n || self.part_actual_sizes.len() != n) {
2579                return Err(Error::FileCorrupt);
2580            }
2581            let mut parts = vec![ObjectPartInfo::default(); n];
2582
2583            for (i, part) in parts.iter_mut().enumerate() {
2584                part.number = self.part_numbers[i];
2585                part.size = self.part_sizes[i];
2586                part.actual_size = self.part_actual_sizes[i];
2587
2588                // etag/index stay soft-guarded: they are recomputable / optional, an empty
2589                // PartETags is legitimate MinIO interop, and a length mismatch there does
2590                // not corrupt returned data.
2591                if self.part_etags.len() == n {
2592                    part.etag = self.part_etags[i].clone();
2593                }
2594
2595                if self.part_indices.len() == n {
2596                    part.index = if self.part_indices[i].is_empty() {
2597                        None
2598                    } else {
2599                        Some(self.part_indices[i].clone())
2600                    };
2601                }
2602            }
2603            parts
2604        } else {
2605            Vec::new()
2606        };
2607
2608        let mut metadata = HashMap::with_capacity(self.meta_user.len() + self.meta_sys.len());
2609        for (k, v) in &self.meta_user {
2610            if k == AMZ_META_UNENCRYPTED_CONTENT_LENGTH || k == AMZ_META_UNENCRYPTED_CONTENT_MD5 {
2611                continue;
2612            }
2613
2614            if k == metadata_keys::STORAGE_CLASS && v == "STANDARD" {
2615                continue;
2616            }
2617
2618            metadata.insert(k.to_owned(), v.to_owned());
2619        }
2620
2621        for (k, v) in &self.meta_sys {
2622            if has_internal_suffix(k, SUFFIX_TIER_FV_ID) || has_internal_suffix(k, SUFFIX_TIER_FV_MARKER) {
2623                continue;
2624            }
2625
2626            if k.eq_ignore_ascii_case(metadata_keys::STORAGE_CLASS) && v == b"STANDARD" {
2627                continue;
2628            }
2629
2630            if is_internal_key(k) {
2631                metadata.insert(k.to_owned(), String::from_utf8(v.to_owned()).unwrap_or_default());
2632            }
2633        }
2634
2635        let replication_state_internal = get_internal_replication_state(&metadata);
2636
2637        let mut deleted = false;
2638
2639        if let Some(v) = replication_state_internal.as_ref() {
2640            if !v.composite_version_purge_status().is_empty() {
2641                deleted = true;
2642            }
2643
2644            let st = v.composite_replication_status();
2645            if !st.is_empty() {
2646                metadata.insert(metadata_keys::REPLICATION_STATUS.to_string(), st.to_string());
2647            }
2648        }
2649
2650        let checksum = derived_metadata
2651            .checksum
2652            .filter(|checksum| !checksum.is_empty())
2653            .map(Bytes::copy_from_slice);
2654
2655        let erasure = ErasureInfo {
2656            algorithm: self.erasure_algorithm.to_string(),
2657            data_blocks: self.erasure_m,
2658            parity_blocks: self.erasure_n,
2659            block_size: self.erasure_block_size,
2660            index: self.erasure_index,
2661            distribution: self.erasure_dist.iter().map(|&v| v as usize).collect(),
2662            ..Default::default()
2663        };
2664
2665        let transition_status = derived_metadata
2666            .transition_status
2667            .filter(|value| !value.is_empty())
2668            .map(|v| String::from_utf8_lossy(v).to_string())
2669            .unwrap_or_default();
2670        let transitioned_objname = derived_metadata
2671            .transitioned_object
2672            .filter(|value| !value.is_empty())
2673            .map(|v| String::from_utf8_lossy(v).to_string())
2674            .unwrap_or_default();
2675        let transition_version_state = transition_version_state_from_bytes(derived_metadata.transitioned_version_state)?;
2676        let transition_version = transitioned_version_from_bytes(derived_metadata.transitioned_version, transition_version_state);
2677        if derived_metadata.transitioned_version_state.is_some() {
2678            validate_transition_version_state(transition_version_state, transition_version.as_deref())?;
2679        }
2680        for (key, value) in &self.meta_sys {
2681            if is_transition_version_metadata_key(key) {
2682                metadata.insert(key.to_owned(), transition_version_metadata_value(value, transition_version.as_deref()));
2683            }
2684        }
2685        let transition_version_id = transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
2686        let transition_tier = derived_metadata
2687            .transition_tier
2688            .filter(|value| !value.is_empty())
2689            .map(|v| String::from_utf8_lossy(v).to_string())
2690            .unwrap_or_default();
2691
2692        let mut file_info = FileInfo {
2693            version_id,
2694            erasure,
2695            data_dir: self.data_dir,
2696            mod_time: self.mod_time,
2697            size: self.size,
2698            name: path.to_string(),
2699            volume: volume.to_string(),
2700            parts,
2701            metadata,
2702            replication_state_internal,
2703            deleted,
2704            checksum,
2705            transition_status,
2706            transitioned_objname,
2707            transition_version_id,
2708            transition_version,
2709            transition_version_state,
2710            transition_tier,
2711            ..Default::default()
2712        };
2713        if all_parts && include_part_checksums {
2714            file_info.hydrate_data_movement_part_checksums()?;
2715        }
2716        if !self.part_numbers.is_empty()
2717            && rustfs_utils::http::contains_key_str(&file_info.metadata, crate::shard_integrity::SUFFIX_UPLOAD_INTEGRITY)
2718        {
2719            return Err(Error::FileCorrupt);
2720        }
2721        if let Some(commitments) = crate::shard_integrity::descriptor_from_metadata(&file_info.metadata)? {
2722            let layout = crate::shard_integrity::IntegrityLayout::new(
2723                self.erasure_m,
2724                self.erasure_n,
2725                self.erasure_block_size,
2726                file_info.uses_legacy_checksum,
2727            )?;
2728            if commitments.len() != self.part_numbers.len() || commitments.len() != self.part_sizes.len() {
2729                return Err(Error::FileCorrupt);
2730            }
2731            for (i, commitment) in commitments.into_iter().enumerate() {
2732                if commitment.layout != layout
2733                    || usize::try_from(commitment.number).map_err(|_| Error::FileCorrupt)? != self.part_numbers[i]
2734                    || usize::try_from(commitment.size).map_err(|_| Error::FileCorrupt)? != self.part_sizes[i]
2735                {
2736                    return Err(Error::FileCorrupt);
2737                }
2738                if all_parts {
2739                    file_info.parts[i].integrity = Some(commitment);
2740                }
2741            }
2742        }
2743        Ok(file_info)
2744    }
2745
2746    pub fn set_transition(&mut self, fi: &FileInfo) {
2747        insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_STATUS, fi.transition_status.as_bytes().to_vec());
2748        insert_bytes(
2749            &mut self.meta_sys,
2750            SUFFIX_TRANSITIONED_OBJECTNAME,
2751            fi.transitioned_objname.as_bytes().to_vec(),
2752        );
2753        if let Some(transition_version) = transitioned_version_bytes(fi) {
2754            insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version);
2755        } else {
2756            remove_bytes(&mut self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID);
2757        }
2758        set_transition_version_state(&mut self.meta_sys, fi.transition_version_state, &fi.metadata);
2759        insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER, fi.transition_tier.as_bytes().to_vec());
2760        if let Some(destination_id) = get_str(&fi.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID) {
2761            insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, destination_id.into_bytes());
2762        }
2763    }
2764
2765    pub fn remove_restore_hdrs(&mut self) {
2766        self.meta_user.remove(metadata_keys::RESTORE);
2767        self.meta_user.remove(metadata_keys::RESTORE_EXPIRY_DAYS);
2768        self.meta_user.remove(metadata_keys::RESTORE_REQUEST_DATE);
2769        remove_bytes(&mut self.meta_sys, SUFFIX_RESTORE_OPERATION_ID);
2770        remove_bytes(&mut self.meta_sys, SUFFIX_RESTORE_WORKER_LOCK);
2771    }
2772
2773    pub fn uses_data_dir(&self) -> bool {
2774        if let Some(status) = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_STATUS)
2775            && status == TRANSITION_COMPLETE.as_bytes().to_vec()
2776        {
2777            return false;
2778        }
2779
2780        is_restored_object_on_disk(&self.meta_user)
2781    }
2782
2783    pub fn inlinedata(&self) -> bool {
2784        contains_key_bytes(&self.meta_sys, SUFFIX_INLINE_DATA)
2785    }
2786
2787    pub fn reset_inline_data(&mut self) {
2788        remove_bytes(&mut self.meta_sys, SUFFIX_INLINE_DATA);
2789    }
2790
2791    /// Remove restore headers
2792    pub fn remove_restore_headers(&mut self) {
2793        // Remove any restore-related metadata
2794        self.meta_sys.retain(|k, _| !k.starts_with("X-Amz-Restore"));
2795    }
2796
2797    /// Builds the free-version cleanup record appended when a transitioned
2798    /// version is removed from xl.meta. The record keeps the remote tier
2799    /// identity so the lifecycle worker can issue the idempotent remote delete
2800    /// and only then remove the record; until then the recovery scan and the
2801    /// usage scanner keep re-enqueueing it. S3 and lifecycle deletes also
2802    /// persist a committed tier-journal entry for the same remote delete. The
2803    /// decommission path copies this record unchanged before source cleanup,
2804    /// including when the transition state is unknown — see
2805    /// docs/architecture/decommission-compatibility.md.
2806    pub fn init_free_version(&self, fi: &FileInfo) -> Result<(FileMetaVersion, bool)> {
2807        if fi.skip_tier_free_version() {
2808            return Ok((FileMetaVersion::default(), false));
2809        }
2810        if let Some(status) = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_STATUS)
2811            && status == TRANSITION_COMPLETE.as_bytes().to_vec()
2812        {
2813            let vid = Uuid::parse_str(&fi.tier_free_version_id())?;
2814            let mut free_entry = FileMetaVersion {
2815                version_type: VersionType::Delete,
2816                write_version: 0,
2817                ..Default::default()
2818            };
2819            free_entry.delete_marker = Some(MetaDeleteMarker {
2820                version_id: Some(vid),
2821                mod_time: self.mod_time,
2822                meta_sys: HashMap::<String, Vec<u8>>::new(),
2823            });
2824
2825            let delete_marker = free_entry.delete_marker.as_mut().unwrap();
2826
2827            insert_bytes(&mut delete_marker.meta_sys, SUFFIX_FREE_VERSION, vec![]);
2828
2829            for suffix in [
2830                SUFFIX_TRANSITION_TIER,
2831                SUFFIX_TRANSITIONED_OBJECTNAME,
2832                SUFFIX_TRANSITIONED_VERSION_ID,
2833                SUFFIX_TRANSITIONED_VERSION_STATE,
2834            ] {
2835                if let Some(v) = get_bytes(&self.meta_sys, suffix) {
2836                    insert_bytes(&mut delete_marker.meta_sys, suffix, v);
2837                }
2838            }
2839            if contains_key_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID) {
2840                let destination_id = get_consistent_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
2841                    .filter(|value| value.len() == 64 && value.iter().all(u8::is_ascii_hexdigit))
2842                    .ok_or(Error::FileCorrupt)?;
2843                insert_bytes(
2844                    &mut delete_marker.meta_sys,
2845                    SUFFIX_TRANSITION_TIER_DESTINATION_ID,
2846                    destination_id.to_vec(),
2847                );
2848            }
2849            return Ok((free_entry, true));
2850        }
2851        Ok((FileMetaVersion::default(), false))
2852    }
2853}
2854
2855impl From<FileInfo> for MetaObject {
2856    fn from(value: FileInfo) -> Self {
2857        let part_etags = if !value.parts.is_empty() {
2858            value.parts.iter().map(|v| v.etag.clone()).collect()
2859        } else {
2860            vec![]
2861        };
2862
2863        let part_indices = if !value.parts.is_empty() {
2864            value.parts.iter().map(|v| v.index.clone().unwrap_or_default()).collect()
2865        } else {
2866            vec![]
2867        };
2868
2869        let mut meta_sys = HashMap::new();
2870        let mut meta_user = HashMap::new();
2871        for (k, v) in value.metadata.iter() {
2872            if is_internal_key(k) {
2873                if is_skip_meta_key(k) {
2874                    continue;
2875                }
2876
2877                meta_sys.insert(k.to_owned(), v.as_bytes().to_vec());
2878            } else {
2879                meta_user.insert(k.to_owned(), v.to_owned());
2880            }
2881        }
2882
2883        if !value.transition_status.is_empty() {
2884            insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_STATUS, value.transition_status.as_bytes().to_vec());
2885        }
2886
2887        if !value.transitioned_objname.is_empty() {
2888            insert_bytes(
2889                &mut meta_sys,
2890                SUFFIX_TRANSITIONED_OBJECTNAME,
2891                value.transitioned_objname.as_bytes().to_vec(),
2892            );
2893        }
2894
2895        if let Some(transition_version) = transitioned_version_bytes(&value) {
2896            insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version);
2897        }
2898        if !value.transition_status.is_empty() {
2899            set_transition_version_state(&mut meta_sys, value.transition_version_state, &value.metadata);
2900        }
2901
2902        if !value.transition_tier.is_empty() {
2903            insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec());
2904        }
2905
2906        if let Some(content_hash) = value.checksum {
2907            insert_bytes(&mut meta_sys, SUFFIX_CRC, content_hash.to_vec());
2908        }
2909
2910        Self {
2911            version_id: value.version_id,
2912            data_dir: value.data_dir,
2913            size: value.size,
2914            mod_time: value.mod_time,
2915            erasure_algorithm: ErasureAlgo::ReedSolomon,
2916            erasure_m: value.erasure.data_blocks,
2917            erasure_n: value.erasure.parity_blocks,
2918            erasure_block_size: value.erasure.block_size,
2919            erasure_index: value.erasure.index,
2920            erasure_dist: value.erasure.distribution.iter().map(|x| *x as u8).collect(),
2921            bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
2922            part_numbers: value.parts.iter().map(|v| v.number).collect(),
2923            part_etags,
2924            part_sizes: value.parts.iter().map(|v| v.size).collect(),
2925            part_actual_sizes: value.parts.iter().map(|v| v.actual_size).collect(),
2926            part_indices,
2927            meta_sys,
2928            meta_user,
2929        }
2930    }
2931}
2932
2933/// Rebuild the structured replication state from its durable internal metadata.
2934///
2935/// Mutation paths that update internal replication keys on an existing
2936/// [`FileInfo`] must use this parser before serializing xl.meta so the metadata
2937/// map and the structured state cannot diverge.
2938pub fn get_internal_replication_state(metadata: &HashMap<String, String>) -> Option<ReplicationState> {
2939    let mut rs = ReplicationState::default();
2940    let mut has = false;
2941
2942    for (k, v) in metadata.iter() {
2943        if has_internal_suffix(k, SUFFIX_PURGESTATUS) {
2944            rs.version_purge_status_internal = Some(v.clone());
2945            rs.purge_targets = version_purge_statuses_map(v.as_str());
2946            has = true;
2947            continue;
2948        }
2949
2950        if let Some(sub_key) = strip_internal_prefix_preserving_case(k) {
2951            if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICA_TIMESTAMP) {
2952                has = true;
2953                rs.replica_timestamp = Some(parse_replication_timestamp(v).unwrap_or(OffsetDateTime::UNIX_EPOCH));
2954            } else if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICA_STATUS) {
2955                has = true;
2956                rs.replica_status = ReplicationStatusType::from(v.as_str());
2957            } else if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICATION_TIMESTAMP) {
2958                has = true;
2959                rs.replication_timestamp = Some(parse_replication_timestamp(v).unwrap_or(OffsetDateTime::UNIX_EPOCH))
2960            } else if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICATION_STATUS) {
2961                has = true;
2962                rs.replication_status_internal = Some(v.clone());
2963                rs.targets = replication_statuses_map(v.as_str());
2964            } else if let Some(arn) = rustfs_utils::http::internal_key_strip_suffix_prefix(k, SUFFIX_REPLICATION_RESET_ARN_PREFIX)
2965            {
2966                has = true;
2967                // Store the canonical full-header key so the map matches
2968                // the key `target_reset_header()` produces on the
2969                // write/lookup side. Storing the bare ARN keyed the map
2970                // inconsistently (bare on read, full on write), which
2971                // could drop reset state across merge/reflatten cycles
2972                // (backlog#799 B16).
2973                rs.reset_statuses_map
2974                    .insert(crate::replication::target_reset_header(&arn), v.clone());
2975            }
2976        }
2977    }
2978
2979    // Re-derive the per-target delete-marker versions from the durable keys.
2980    // `ReplicationState` skips this map on the wire, so the metadata is the only
2981    // authority. `corrupt` means the dual internal prefixes disagreed: surface it
2982    // rather than guessing, so callers fail closed instead of purging the wrong
2983    // target version.
2984    (rs.target_delete_marker_version_ids, rs.target_delete_marker_version_ids_corrupt) = target_delete_marker_versions(metadata);
2985    has |= !rs.target_delete_marker_version_ids.is_empty();
2986    has |= rs.target_delete_marker_version_ids_corrupt;
2987
2988    if has { Some(rs) } else { None }
2989}
2990
2991#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
2992pub struct MetaDeleteMarker {
2993    #[serde(rename = "ID")]
2994    pub version_id: Option<Uuid>, // Version ID for delete marker
2995    #[serde(rename = "MTime")]
2996    pub mod_time: Option<OffsetDateTime>, // Object delete marker modified time
2997    #[serde(rename = "MetaSys")]
2998    pub meta_sys: HashMap<String, Vec<u8>>, // Delete marker internal metadata
2999}
3000
3001impl TryFrom<LegacyMetaV2DeleteMarker> for MetaDeleteMarker {
3002    type Error = Error;
3003
3004    fn try_from(value: LegacyMetaV2DeleteMarker) -> std::result::Result<Self, Self::Error> {
3005        Ok(Self {
3006            version_id: parse_legacy_uuid_bytes(&value.version_id, "version_id")?,
3007            mod_time: value.mod_time,
3008            meta_sys: value.meta_sys,
3009        })
3010    }
3011}
3012
3013impl MetaDeleteMarker {
3014    /// Return a deterministic identity for an exact delete-marker body.
3015    /// MessagePack map order is intentionally excluded from the identity.
3016    pub fn stable_identity(&self) -> [u8; 32] {
3017        const DOMAIN: &[u8] = b"rustfs-delete-marker-identity-v1\0";
3018
3019        let mut hasher = Sha256::new();
3020        hasher.update(DOMAIN);
3021        match self.version_id {
3022            Some(version_id) => {
3023                hasher.update([1]);
3024                hasher.update(version_id.as_bytes());
3025            }
3026            None => hasher.update([0]),
3027        }
3028        match self.mod_time {
3029            Some(mod_time) => {
3030                hasher.update([1]);
3031                hasher.update(mod_time.unix_timestamp_nanos().to_be_bytes());
3032            }
3033            None => hasher.update([0]),
3034        }
3035
3036        let mut metadata = self.meta_sys.iter().collect::<Vec<_>>();
3037        metadata.sort_unstable_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes()));
3038        hasher.update(u64::try_from(metadata.len()).unwrap_or(u64::MAX).to_be_bytes());
3039        for (key, value) in metadata {
3040            hasher.update(u64::try_from(key.len()).unwrap_or(u64::MAX).to_be_bytes());
3041            hasher.update(key.as_bytes());
3042            hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
3043            hasher.update(value);
3044        }
3045        hasher.finalize().into()
3046    }
3047
3048    pub fn free_version(&self) -> bool {
3049        contains_key_bytes(&self.meta_sys, SUFFIX_FREE_VERSION)
3050    }
3051
3052    pub fn into_fileinfo(&self, volume: &str, path: &str, _all_parts: bool) -> Result<FileInfo> {
3053        let metadata = self
3054            .meta_sys
3055            .clone()
3056            .into_iter()
3057            .map(|(k, v)| (k, String::from_utf8_lossy(&v).to_string()))
3058            .collect();
3059        let replication_state_internal = get_internal_replication_state(&metadata);
3060
3061        let mut fi = FileInfo {
3062            version_id: self.version_id.filter(|&vid| !vid.is_nil()),
3063            name: path.to_string(),
3064            volume: volume.to_string(),
3065            deleted: true,
3066            mod_time: self.mod_time,
3067            metadata,
3068            replication_state_internal,
3069            ..Default::default()
3070        };
3071
3072        if self.free_version() {
3073            fi.set_tier_free_version();
3074            let derived_metadata = DerivedInternalMetadata::from_meta_sys(&self.meta_sys)?;
3075            fi.transition_tier = derived_metadata
3076                .transition_tier
3077                .filter(|value| !value.is_empty())
3078                .map(|value| String::from_utf8_lossy(value).to_string())
3079                .unwrap_or_default();
3080            fi.transitioned_objname = derived_metadata
3081                .transitioned_object
3082                .filter(|value| !value.is_empty())
3083                .map(|value| String::from_utf8_lossy(value).to_string())
3084                .unwrap_or_default();
3085            fi.transition_version_state = transition_version_state_from_bytes(derived_metadata.transitioned_version_state)?;
3086            fi.transition_version =
3087                transitioned_version_from_bytes(derived_metadata.transitioned_version, fi.transition_version_state);
3088            for (key, value) in &self.meta_sys {
3089                if is_transition_version_metadata_key(key) {
3090                    fi.metadata
3091                        .insert(key.to_owned(), transition_version_metadata_value(value, fi.transition_version.as_deref()));
3092                }
3093            }
3094            fi.transition_version_id = fi.transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
3095            if derived_metadata.transitioned_version_state.is_some() {
3096                validate_transition_version_state(fi.transition_version_state, fi.transition_version.as_deref())?;
3097            }
3098        }
3099
3100        Ok(fi)
3101    }
3102
3103    pub fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
3104        rmp::encode::write_map_len(wr, 3)?;
3105
3106        // ID
3107        rmp::encode::write_str(wr, "ID")?;
3108        rmp::encode::write_bin(wr, self.version_id.unwrap_or_default().as_bytes())?;
3109
3110        // MTime Unix timestamp nanos
3111        rmp::encode::write_str(wr, "MTime")?;
3112        let nanos = self.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp_nanos();
3113        rmp::encode::write_sint(wr, nanos as i64)?;
3114
3115        // MetaSys
3116        rmp::encode::write_str(wr, "MetaSys")?;
3117        rmp::encode::write_map_len(wr, self.meta_sys.len() as u32)?;
3118        for (k, v) in &self.meta_sys {
3119            rmp::encode::write_str(wr, k)?;
3120            rmp::encode::write_bin(wr, v)?;
3121        }
3122
3123        Ok(())
3124    }
3125
3126    pub fn decode_from<R: std::io::Read>(&mut self, rd: &mut R) -> Result<()> {
3127        let mut fields = rmp::decode::read_map_len(rd)?;
3128        *self = MetaDeleteMarker::default();
3129
3130        while fields > 0 {
3131            fields -= 1;
3132
3133            let key_len = rmp::decode::read_str_len(rd)?;
3134            let key_buf = read_exact_vec(rd, key_len as usize)?;
3135            let key = String::from_utf8(key_buf)?;
3136
3137            match key.as_str() {
3138                "ID" => {
3139                    let _ = rmp::decode::read_bin_len(rd)?;
3140                    let mut buf = [0u8; 16];
3141                    rd.read_exact(&mut buf)?;
3142                    let id = Uuid::from_bytes(buf);
3143                    self.version_id = if id.is_nil() { None } else { Some(id) };
3144                }
3145                "MTime" => {
3146                    let nanos: i64 = rmp::decode::read_int(rd)?;
3147                    let time = OffsetDateTime::from_unix_timestamp_nanos(nanos as i128)?;
3148                    self.mod_time = if time == OffsetDateTime::UNIX_EPOCH {
3149                        None
3150                    } else {
3151                        Some(time)
3152                    };
3153                }
3154                "MetaSys" => {
3155                    let len = rmp::decode::read_map_len(rd)? as usize;
3156                    self.meta_sys.clear();
3157                    for _ in 0..len {
3158                        let k_len = rmp::decode::read_str_len(rd)?;
3159                        let kbuf = read_exact_vec(rd, k_len as usize)?;
3160                        let k = String::from_utf8(kbuf)?;
3161
3162                        let blen = rmp::decode::read_bin_len(rd)? as usize;
3163                        let v = read_exact_vec(rd, blen)?;
3164
3165                        self.meta_sys.insert(k, v);
3166                    }
3167                }
3168                other => {
3169                    // Skip unknown fields for forward compatibility, matching
3170                    // MetaObject::decode_from. A newer writer's extra keys must not
3171                    // break decoding of a delete marker (backlog#799 B17).
3172                    tracing::debug!(field = %other, "MetaDeleteMarker::decode_from: skipping unknown field");
3173                    skip_msgp_value(rd)?;
3174                }
3175            }
3176        }
3177
3178        Ok(())
3179    }
3180
3181    pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
3182        let mut cur = std::io::Cursor::new(buf);
3183        self.decode_from(&mut cur)?;
3184        Ok(cur.position())
3185    }
3186
3187    pub fn marshal_msg(&self) -> Result<Vec<u8>> {
3188        let mut wr = Vec::new();
3189        self.encode_to(&mut wr)?;
3190        Ok(wr)
3191    }
3192
3193    /// Compute this delete marker's header signature, mirroring MinIO's
3194    /// `xlMetaV2DeleteMarker.Signature`. See [`FileMetaVersion::get_signature`].
3195    pub fn get_signature(&self) -> [u8; 4] {
3196        let mut c = self.clone();
3197
3198        // MetaSys is order-unstable across disks; fold it in separately.
3199        let mut crc = hash_deterministic_bytes(&c.meta_sys);
3200        c.meta_sys.clear();
3201
3202        if let Ok(bytes) = c.marshal_msg() {
3203            crc ^= xxhash_rust::xxh64::xxh64(&bytes, XXHASH_SEED);
3204        }
3205
3206        fold_signature(crc)
3207    }
3208}
3209
3210impl From<FileInfo> for MetaDeleteMarker {
3211    fn from(value: FileInfo) -> Self {
3212        let mut meta_sys = HashMap::new();
3213        let mut durable_metadata: HashMap<String, (&str, bool)> = HashMap::new();
3214        for (key, metadata_value) in &value.metadata {
3215            if !is_internal_key(key) || is_skip_meta_key(key) {
3216                continue;
3217            }
3218            if rustfs_utils::http::internal_key_strip_suffix_prefix(key, SUFFIX_REPLICATION_RESET_ARN_PREFIX).is_some()
3219                || rustfs_utils::http::internal_key_strip_suffix_prefix(key, SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX)
3220                    .is_some()
3221            {
3222                meta_sys.insert(key.clone(), metadata_value.as_bytes().to_vec());
3223                continue;
3224            }
3225            let Some(suffix) = strip_internal_prefix(key) else {
3226                continue;
3227            };
3228            let rustfs_preferred = key
3229                .get(..RUSTFS_INTERNAL_PREFIX.len())
3230                .is_some_and(|prefix| prefix.eq_ignore_ascii_case(RUSTFS_INTERNAL_PREFIX));
3231            match durable_metadata.entry(suffix) {
3232                std::collections::hash_map::Entry::Vacant(entry) => {
3233                    entry.insert((metadata_value, rustfs_preferred));
3234                }
3235                std::collections::hash_map::Entry::Occupied(mut entry) if rustfs_preferred && !entry.get().1 => {
3236                    entry.insert((metadata_value, true));
3237                }
3238                std::collections::hash_map::Entry::Occupied(_) => {}
3239            }
3240        }
3241        for (suffix, (metadata_value, _)) in durable_metadata {
3242            insert_bytes(&mut meta_sys, &suffix, metadata_value.as_bytes().to_vec());
3243        }
3244        if value.tier_free_version() {
3245            insert_bytes(&mut meta_sys, SUFFIX_FREE_VERSION, vec![]);
3246        }
3247        if !value.transition_status.is_empty() {
3248            insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_STATUS, value.transition_status.as_bytes().to_vec());
3249        }
3250        if !value.transitioned_objname.is_empty() {
3251            insert_bytes(
3252                &mut meta_sys,
3253                SUFFIX_TRANSITIONED_OBJECTNAME,
3254                value.transitioned_objname.as_bytes().to_vec(),
3255            );
3256        }
3257        if let Some(transition_version) = transitioned_version_bytes(&value) {
3258            insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version);
3259        }
3260        if !value.transition_status.is_empty() || value.tier_free_version() {
3261            set_transition_version_state(&mut meta_sys, value.transition_version_state, &value.metadata);
3262        }
3263        if !value.transition_tier.is_empty() {
3264            insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec());
3265        }
3266        if let Some(state) = value.replication_state_internal.as_ref() {
3267            persist_delete_marker_replication_state(&mut meta_sys, state);
3268            persist_reset_statuses(&mut meta_sys, &state.reset_statuses_map);
3269            persist_target_delete_marker_versions(&mut meta_sys, &state.target_delete_marker_version_ids, &value.metadata);
3270        }
3271        Self {
3272            version_id: value.version_id,
3273            mod_time: value.mod_time,
3274            meta_sys,
3275        }
3276    }
3277}
3278
3279#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Default, Clone, PartialOrd, Ord, Hash)]
3280pub enum VersionType {
3281    #[default]
3282    Invalid = 0,
3283    Object = 1,
3284    Delete = 2,
3285    Legacy = 3,
3286}
3287
3288impl VersionType {
3289    pub fn valid(&self) -> bool {
3290        matches!(*self, VersionType::Object | VersionType::Delete | VersionType::Legacy)
3291    }
3292
3293    pub fn to_u8(&self) -> u8 {
3294        match self {
3295            VersionType::Invalid => 0,
3296            VersionType::Object => 1,
3297            VersionType::Delete => 2,
3298            VersionType::Legacy => 3,
3299        }
3300    }
3301
3302    pub fn from_u8(n: u8) -> Self {
3303        match n {
3304            1 => VersionType::Object,
3305            2 => VersionType::Delete,
3306            3 => VersionType::Legacy,
3307            _ => VersionType::Invalid,
3308        }
3309    }
3310}
3311
3312#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Default, Clone)]
3313pub enum ChecksumAlgo {
3314    #[default]
3315    Invalid = 0,
3316    HighwayHash = 1,
3317}
3318
3319impl ChecksumAlgo {
3320    pub fn valid(&self) -> bool {
3321        *self > ChecksumAlgo::Invalid
3322    }
3323    pub fn to_u8(&self) -> u8 {
3324        match self {
3325            ChecksumAlgo::Invalid => 0,
3326            ChecksumAlgo::HighwayHash => 1,
3327        }
3328    }
3329    pub fn from_u8(u: u8) -> Self {
3330        match u {
3331            1 => ChecksumAlgo::HighwayHash,
3332            _ => ChecksumAlgo::Invalid,
3333        }
3334    }
3335}
3336
3337#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Default, Clone)]
3338pub enum Flags {
3339    #[default]
3340    FreeVersion = 1 << 0,
3341    UsesDataDir = 1 << 1,
3342    InlineData = 1 << 2,
3343}
3344
3345// mergeXLV2Versions
3346pub fn merge_file_meta_versions(
3347    quorum: usize,
3348    strict: bool,
3349    requested_versions: usize,
3350    versions: &[Vec<FileMetaShallowVersion>],
3351) -> Vec<FileMetaShallowVersion> {
3352    merge_file_meta_versions_inner(quorum, strict, requested_versions, false, 0, versions)
3353}
3354
3355pub(crate) fn merge_file_meta_versions_with_write_quorum(
3356    quorum: usize,
3357    strict: bool,
3358    requested_versions: usize,
3359    write_quorum_slack: usize,
3360    versions: &[Vec<FileMetaShallowVersion>],
3361) -> Vec<FileMetaShallowVersion> {
3362    merge_file_meta_versions_inner(quorum, strict, requested_versions, true, write_quorum_slack, versions)
3363}
3364
3365fn merge_file_meta_versions_inner(
3366    mut quorum: usize,
3367    mut strict: bool,
3368    requested_versions: usize,
3369    enforce_write_quorum: bool,
3370    write_quorum_slack: usize,
3371    versions: &[Vec<FileMetaShallowVersion>],
3372) -> Vec<FileMetaShallowVersion> {
3373    if quorum == 0 {
3374        quorum = 1;
3375    }
3376
3377    if versions.len() < quorum || versions.is_empty() {
3378        return Vec::new();
3379    }
3380
3381    if versions.len() == 1 {
3382        if !enforce_write_quorum {
3383            return versions[0].clone();
3384        }
3385
3386        let required_quorum = versions[0]
3387            .first()
3388            .map(|version| version.write_quorum(quorum).saturating_sub(write_quorum_slack).max(quorum))
3389            .unwrap_or(quorum);
3390        if versions.len() >= required_quorum {
3391            return versions[0].clone();
3392        }
3393        return Vec::new();
3394    }
3395
3396    if quorum == 1 {
3397        strict = true;
3398    }
3399
3400    let required_quorum = |version: &FileMetaShallowVersion| {
3401        if enforce_write_quorum {
3402            version.write_quorum(quorum).saturating_sub(write_quorum_slack).max(quorum)
3403        } else {
3404            quorum
3405        }
3406    };
3407
3408    let mut versions = versions.to_owned();
3409
3410    let mut n_versions = 0;
3411
3412    let mut merged = Vec::new();
3413    loop {
3414        let mut tops = Vec::new();
3415        let mut top_sig = FileMetaVersionHeader::default();
3416        let mut consistent = true;
3417        for vers in versions.iter() {
3418            if vers.is_empty() {
3419                consistent = false;
3420                continue;
3421            }
3422            if tops.is_empty() {
3423                consistent = true;
3424                top_sig = vers[0].header.clone();
3425            } else {
3426                consistent = consistent && vers[0].header == top_sig;
3427            }
3428            tops.push(vers[0].clone());
3429        }
3430
3431        // check if done...
3432        if tops.len() < quorum {
3433            break;
3434        }
3435
3436        let mut latest = FileMetaShallowVersion::default();
3437        if consistent {
3438            latest = tops[0].clone();
3439            if tops.len() >= required_quorum(&latest) {
3440                merged.push(latest.clone());
3441                if !latest.header.free_version() {
3442                    n_versions += 1;
3443                }
3444            }
3445        } else {
3446            let mut latest_count = 0;
3447            for (i, ver) in tops.iter().enumerate() {
3448                if ver.header == latest.header {
3449                    latest_count += 1;
3450                    continue;
3451                }
3452
3453                if i == 0 || ver.header.sorts_before(&latest.header) {
3454                    if i == 0 || latest_count == 0 {
3455                        latest_count = 1;
3456                    } else if !strict && ver.header.matches_not_strict(&latest.header) {
3457                        latest_count += 1;
3458                    } else {
3459                        latest_count = 1;
3460                    }
3461                    latest = ver.clone();
3462                    continue;
3463                }
3464
3465                // Mismatch, but older.
3466                if latest_count > 0 && !strict && ver.header.matches_not_strict(&latest.header) {
3467                    latest_count += 1;
3468                    continue;
3469                }
3470
3471                if latest_count > 0 && ver.header.version_id == latest.header.version_id {
3472                    let mut x: HashMap<FileMetaVersionHeader, usize> = HashMap::new();
3473                    for a in tops.iter() {
3474                        if a.header.version_id != ver.header.version_id {
3475                            continue;
3476                        }
3477                        let mut a_clone = a.clone();
3478                        if !strict {
3479                            a_clone.header.signature = [0; 4];
3480                        }
3481                        *x.entry(a_clone.header).or_insert(0) += 1;
3482                    }
3483                    latest_count = 0;
3484                    for (k, v) in x.iter() {
3485                        if *v < latest_count {
3486                            continue;
3487                        }
3488                        if *v == latest_count && latest.header.sorts_before(k) {
3489                            continue;
3490                        }
3491                        tops.iter().for_each(|a| {
3492                            let mut hdr = a.header.clone();
3493                            if !strict {
3494                                hdr.signature = [0; 4];
3495                            }
3496                            if hdr == *k {
3497                                latest = a.clone();
3498                            }
3499                        });
3500
3501                        latest_count = *v;
3502                    }
3503                    break;
3504                }
3505            }
3506            if latest_count >= required_quorum(&latest) {
3507                if !latest.header.free_version() {
3508                    n_versions += 1;
3509                }
3510                merged.push(latest.clone());
3511            }
3512        }
3513
3514        // Remove from all streams up until latest modtime or if selected.
3515        versions.iter_mut().for_each(|vers| {
3516            // // Keep top entry (and remaining)...
3517            let mut bre = false;
3518            vers.retain(|ver| {
3519                if bre {
3520                    return true;
3521                }
3522                if let Ordering::Greater = ver.header.mod_time.cmp(&latest.header.mod_time) {
3523                    bre = true;
3524                    return false;
3525                }
3526                if ver.header == latest.header {
3527                    bre = true;
3528                    return false;
3529                }
3530                if let Ordering::Equal = latest.header.version_id.cmp(&ver.header.version_id) {
3531                    bre = true;
3532                    return false;
3533                }
3534                for merged_v in merged.iter() {
3535                    if let Ordering::Equal = ver.header.version_id.cmp(&merged_v.header.version_id) {
3536                        bre = true;
3537                        return false;
3538                    }
3539                }
3540                true
3541            });
3542        });
3543        if requested_versions > 0 && requested_versions == n_versions {
3544            merged.append(&mut versions[0]);
3545            break;
3546        }
3547    }
3548
3549    // Sanity check. Enable if duplicates show up.
3550    // todo
3551    merged
3552}
3553
3554pub fn file_info_from_raw(
3555    ri: RawFileInfo,
3556    bucket: &str,
3557    object: &str,
3558    read_data: bool,
3559    include_free_versions: bool,
3560) -> Result<FileInfo> {
3561    get_file_info(
3562        &ri.buf,
3563        bucket,
3564        object,
3565        "",
3566        FileInfoOpts {
3567            data: read_data,
3568            include_free_versions,
3569            include_part_checksums: true,
3570        },
3571    )
3572}
3573
3574pub struct FileInfoOpts {
3575    pub data: bool,
3576    pub include_free_versions: bool,
3577    pub include_part_checksums: bool,
3578}
3579
3580pub fn get_file_info(buf: &[u8], volume: &str, path: &str, version_id: &str, opts: FileInfoOpts) -> Result<FileInfo> {
3581    let vid = {
3582        if version_id.is_empty() {
3583            None
3584        } else {
3585            Some(Uuid::parse_str(version_id)?)
3586        }
3587    };
3588
3589    let meta = FileMeta::load(buf)?;
3590    if meta.versions.is_empty() {
3591        return Ok(FileInfo {
3592            volume: volume.to_owned(),
3593            name: path.to_owned(),
3594            version_id: vid,
3595            is_latest: true,
3596            deleted: true,
3597            mod_time: Some(OffsetDateTime::from_unix_timestamp(1)?),
3598            ..Default::default()
3599        });
3600    }
3601
3602    let fi = if opts.include_part_checksums {
3603        meta.into_fileinfo(volume, path, version_id, opts.data, opts.include_free_versions, true)?
3604    } else {
3605        meta.into_fileinfo_without_part_checksums(volume, path, version_id, opts.data, opts.include_free_versions)?
3606    };
3607    Ok(fi)
3608}
3609
3610async fn read_more<R: AsyncRead + Unpin>(
3611    reader: &mut R,
3612    buf: &mut Vec<u8>,
3613    total_size: usize,
3614    read_size: usize,
3615    has_full: bool,
3616) -> Result<()> {
3617    use tokio::io::AsyncReadExt;
3618    let has = buf.len();
3619
3620    if has >= read_size {
3621        return Ok(());
3622    }
3623
3624    if has_full || read_size > total_size {
3625        return Err(Error::other(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Unexpected EOF")));
3626    }
3627
3628    let extra = read_size - has;
3629    if buf.capacity() >= read_size {
3630        // Extend the buffer if we have enough space.
3631        buf.resize(read_size, 0);
3632    } else {
3633        buf.extend(vec![0u8; extra]);
3634    }
3635
3636    reader.read_exact(&mut buf[has..]).await?;
3637    Ok(())
3638}
3639
3640pub async fn read_xl_meta_no_data<R: AsyncRead + Unpin>(reader: &mut R, size: usize) -> Result<Vec<u8>> {
3641    use tokio::io::AsyncReadExt;
3642
3643    let mut initial = size;
3644    let mut has_full = true;
3645
3646    if initial > META_DATA_READ_DEFAULT {
3647        initial = META_DATA_READ_DEFAULT;
3648        has_full = false;
3649    }
3650
3651    let mut buf = vec![0u8; initial];
3652    reader.read_exact(&mut buf).await?;
3653
3654    let (tmp_buf, major, minor) = FileMeta::check_xl2_v1(&buf)?;
3655
3656    match major {
3657        1 => match minor {
3658            0 => {
3659                read_more(reader, &mut buf, size, size, has_full).await?;
3660                Ok(buf)
3661            }
3662            1..=3 => {
3663                let (sz, tmp_buf) = FileMeta::read_bytes_header(tmp_buf)?;
3664                let mut want = sz as usize + (buf.len() - tmp_buf.len());
3665
3666                if minor < 2 {
3667                    read_more(reader, &mut buf, size, want, has_full).await?;
3668                    buf.truncate(want);
3669                    return Ok(buf);
3670                }
3671
3672                let want_max = usize::min(want + MSGP_UINT32_SIZE, size);
3673                read_more(reader, &mut buf, size, want_max, has_full).await?;
3674
3675                if buf.len() < want {
3676                    return Err(Error::FileCorrupt);
3677                }
3678
3679                // The metadata block is followed by a 5-byte msgp uint32 CRC trailer;
3680                // a file truncated inside the trailer is corrupt, not a shorter meta.
3681                let crc_size = 5;
3682                if buf.len() - want < crc_size {
3683                    return Err(Error::FileCorrupt);
3684                }
3685
3686                want += crc_size;
3687
3688                buf.truncate(want);
3689                Ok(buf)
3690            }
3691            _ => Err(Error::other(std::io::Error::new(
3692                std::io::ErrorKind::InvalidData,
3693                "Unknown minor metadata version",
3694            ))),
3695        },
3696        _ => Err(Error::other(std::io::Error::new(
3697            std::io::ErrorKind::InvalidData,
3698            "Unknown major metadata version",
3699        ))),
3700    }
3701}
3702
3703/// Synchronous twin of [`read_more`].
3704///
3705/// Line-for-line mirror of the async version; the only difference is that
3706/// `reader.read_exact(&mut buf[has..]).await?` becomes the blocking
3707/// `std::io::Read::read_exact`. std's `read_exact` reports the same
3708/// `ErrorKind::UnexpectedEof` on a short read as tokio's, so the `?`
3709/// conversion into [`Error`] is identical. Kept in lockstep with `read_more`
3710/// so the two paths stay byte-for-byte equivalent (see equivalence tests).
3711fn read_more_sync<R: std::io::Read>(
3712    reader: &mut R,
3713    buf: &mut Vec<u8>,
3714    total_size: usize,
3715    read_size: usize,
3716    has_full: bool,
3717) -> Result<()> {
3718    let has = buf.len();
3719
3720    if has >= read_size {
3721        return Ok(());
3722    }
3723
3724    if has_full || read_size > total_size {
3725        return Err(Error::other(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Unexpected EOF")));
3726    }
3727
3728    let extra = read_size - has;
3729    if buf.capacity() >= read_size {
3730        // Extend the buffer if we have enough space.
3731        buf.resize(read_size, 0);
3732    } else {
3733        buf.extend(vec![0u8; extra]);
3734    }
3735
3736    reader.read_exact(&mut buf[has..])?;
3737    Ok(())
3738}
3739
3740/// Synchronous twin of [`read_xl_meta_no_data`].
3741///
3742/// Byte-for-byte equivalent to the async version for any input: the parsing
3743/// logic (`check_xl2_v1` / `read_bytes_header` / major-minor branches / `want`
3744/// computation / 5-byte CRC trailer handling / `truncate` / `FileCorrupt` /
3745/// `InvalidData` / `UnexpectedEof`) is copied verbatim; only the reads switch
3746/// from async `read_exact(...).await` to blocking `std::io::Read::read_exact`.
3747/// This lets a caller fold open+fstat+read into a single `spawn_blocking`
3748/// closure without changing any observable result.
3749pub fn read_xl_meta_no_data_sync<R: std::io::Read>(reader: &mut R, size: usize) -> Result<Vec<u8>> {
3750    let mut initial = size;
3751    let mut has_full = true;
3752
3753    if initial > META_DATA_READ_DEFAULT {
3754        initial = META_DATA_READ_DEFAULT;
3755        has_full = false;
3756    }
3757
3758    let mut buf = vec![0u8; initial];
3759    reader.read_exact(&mut buf)?;
3760
3761    let (tmp_buf, major, minor) = FileMeta::check_xl2_v1(&buf)?;
3762
3763    match major {
3764        1 => match minor {
3765            0 => {
3766                read_more_sync(reader, &mut buf, size, size, has_full)?;
3767                Ok(buf)
3768            }
3769            1..=3 => {
3770                let (sz, tmp_buf) = FileMeta::read_bytes_header(tmp_buf)?;
3771                let mut want = sz as usize + (buf.len() - tmp_buf.len());
3772
3773                if minor < 2 {
3774                    read_more_sync(reader, &mut buf, size, want, has_full)?;
3775                    buf.truncate(want);
3776                    return Ok(buf);
3777                }
3778
3779                let want_max = usize::min(want + MSGP_UINT32_SIZE, size);
3780                read_more_sync(reader, &mut buf, size, want_max, has_full)?;
3781
3782                if buf.len() < want {
3783                    return Err(Error::FileCorrupt);
3784                }
3785
3786                // The metadata block is followed by a 5-byte msgp uint32 CRC trailer;
3787                // a file truncated inside the trailer is corrupt, not a shorter meta.
3788                let crc_size = 5;
3789                if buf.len() - want < crc_size {
3790                    return Err(Error::FileCorrupt);
3791                }
3792
3793                want += crc_size;
3794
3795                buf.truncate(want);
3796                Ok(buf)
3797            }
3798            _ => Err(Error::other(std::io::Error::new(
3799                std::io::ErrorKind::InvalidData,
3800                "Unknown minor metadata version",
3801            ))),
3802        },
3803        _ => Err(Error::other(std::io::Error::new(
3804            std::io::ErrorKind::InvalidData,
3805            "Unknown major metadata version",
3806        ))),
3807    }
3808}
3809
3810#[cfg(test)]
3811mod tests {
3812    use super::*;
3813    use serde::Serialize;
3814
3815    #[test]
3816    fn delete_marker_conversion_preserves_only_durable_internal_metadata() {
3817        let mut marker = FileInfo::default();
3818        marker
3819            .metadata
3820            .insert("x-minio-internal-purgestatus".to_string(), "pending".to_string());
3821        marker
3822            .metadata
3823            .insert("x-rustfs-internal-healing".to_string(), "true".to_string());
3824        marker.metadata.insert("content-type".to_string(), "text/plain".to_string());
3825        let remote_version_id = Uuid::new_v4();
3826        marker.transition_version = Some(remote_version_id.to_string());
3827
3828        let converted = MetaDeleteMarker::from(marker);
3829
3830        assert_eq!(converted.meta_sys.get("x-rustfs-internal-purgestatus"), Some(&b"pending".to_vec()));
3831        assert_eq!(converted.meta_sys.get("x-minio-internal-purgestatus"), Some(&b"pending".to_vec()));
3832        assert_eq!(
3833            get_bytes(&converted.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID),
3834            Some(remote_version_id.to_string().into_bytes())
3835        );
3836        assert_eq!(
3837            converted
3838                .meta_sys
3839                .get(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}")),
3840            Some(&remote_version_id.to_string().into_bytes())
3841        );
3842        assert_eq!(
3843            converted
3844                .meta_sys
3845                .get(&format!("{}{SUFFIX_TRANSITIONED_VERSION_ID}", rustfs_utils::http::MINIO_INTERNAL_PREFIX)),
3846            Some(&remote_version_id.to_string().into_bytes())
3847        );
3848        assert!(!converted.meta_sys.contains_key("x-rustfs-internal-healing"));
3849        assert!(!converted.meta_sys.contains_key("content-type"));
3850    }
3851
3852    #[test]
3853    fn delete_marker_conversion_does_not_lowercase_dynamic_replication_targets() {
3854        let arn = "arn:rustfs:replication:us-east-1:TenantA:bucket";
3855        let reset_suffix = format!("{SUFFIX_REPLICATION_RESET_ARN_PREFIX}{arn}");
3856        let version_suffix = format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}{arn}");
3857        let mut marker = FileInfo::default();
3858        marker.metadata.insert(
3859            format!("{}{reset_suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
3860            "2026-08-12T00:00:00Z;COMPLETED".to_string(),
3861        );
3862        marker.metadata.insert(
3863            format!("{}{version_suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
3864            "remote-version".to_string(),
3865        );
3866        marker.replication_state_internal = get_internal_replication_state(&marker.metadata);
3867
3868        let converted = MetaDeleteMarker::from(marker);
3869
3870        assert!(converted.meta_sys.keys().any(|key| key.ends_with(&reset_suffix)));
3871        assert!(converted.meta_sys.keys().any(|key| key.ends_with(&version_suffix)));
3872        assert!(!converted.meta_sys.keys().any(|key| key.contains("tenanta")));
3873
3874        let mut conflicting = FileInfo::default();
3875        conflicting
3876            .metadata
3877            .insert(format!("{RUSTFS_INTERNAL_PREFIX}{version_suffix}"), "remote-version-a".to_string());
3878        conflicting.metadata.insert(
3879            format!("{}{version_suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
3880            "remote-version-b".to_string(),
3881        );
3882        conflicting.replication_state_internal = get_internal_replication_state(&conflicting.metadata);
3883        assert!(
3884            conflicting
3885                .replication_state_internal
3886                .as_ref()
3887                .is_some_and(|state| state.target_delete_marker_version_ids_corrupt)
3888        );
3889
3890        let roundtrip = MetaDeleteMarker::from(conflicting)
3891            .into_fileinfo("bucket", "object", false)
3892            .expect("dynamic replication aliases should remain decodable");
3893        assert!(
3894            roundtrip
3895                .replication_state_internal
3896                .as_ref()
3897                .is_some_and(|state| state.target_delete_marker_version_ids_corrupt),
3898            "conflicting dynamic aliases must remain corrupt across persistence"
3899        );
3900    }
3901
3902    #[derive(Serialize)]
3903    enum LegacyDeleteVersionTypeFixture {
3904        #[serde(rename = "DeleteMarker")]
3905        DeleteMarker,
3906    }
3907
3908    #[derive(Serialize)]
3909    struct LegacyDeleteMarkerFixture {
3910        version_id: Vec<u8>,
3911        mod_time: Option<OffsetDateTime>,
3912        meta_sys: HashMap<String, Vec<u8>>,
3913    }
3914
3915    #[derive(Serialize)]
3916    struct LegacyDeleteVersionFixture {
3917        version_type: LegacyDeleteVersionTypeFixture,
3918        object: Option<()>,
3919        delete_marker: Option<LegacyDeleteMarkerFixture>,
3920        write_version: u64,
3921    }
3922
3923    #[derive(Serialize)]
3924    struct LegacyDeleteMarkerNilFixture {
3925        version_id: Option<Vec<u8>>,
3926        mod_time: Option<OffsetDateTime>,
3927        meta_sys: HashMap<String, Vec<u8>>,
3928    }
3929
3930    #[derive(Serialize)]
3931    struct LegacyDeleteVersionNilFixture {
3932        version_type: LegacyDeleteVersionTypeFixture,
3933        object: Option<()>,
3934        delete_marker: Option<LegacyDeleteMarkerNilFixture>,
3935        write_version: u64,
3936    }
3937
3938    #[derive(Serialize)]
3939    enum LegacyObjectVersionTypeFixture {
3940        #[serde(rename = "Object")]
3941        Object,
3942    }
3943
3944    #[derive(Serialize)]
3945    struct LegacyObjectFixture {
3946        version_id: Option<Vec<u8>>,
3947        data_dir: Option<Vec<u8>>,
3948        erasure_algorithm: String,
3949        erasure_m: usize,
3950        erasure_n: usize,
3951        erasure_block_size: usize,
3952        erasure_index: usize,
3953        erasure_dist: Vec<u8>,
3954        bitrot_checksum_algo: String,
3955        part_numbers: Vec<usize>,
3956        part_etags: Vec<String>,
3957        part_sizes: Vec<usize>,
3958        part_actual_sizes: Vec<i64>,
3959        part_indices: Vec<Vec<u8>>,
3960        size: i64,
3961        mod_time: Option<OffsetDateTime>,
3962        meta_sys: HashMap<String, Vec<u8>>,
3963        meta_user: HashMap<String, String>,
3964    }
3965
3966    #[derive(Serialize)]
3967    struct LegacyObjectVersionFixture {
3968        version_type: LegacyObjectVersionTypeFixture,
3969        object: Option<LegacyObjectFixture>,
3970        delete_marker: Option<()>,
3971        write_version: u64,
3972    }
3973
3974    fn sample_version_id() -> Uuid {
3975        Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef").unwrap()
3976    }
3977
3978    fn sample_mod_time() -> OffsetDateTime {
3979        OffsetDateTime::from_unix_timestamp_nanos(1_705_312_200_123_456_789).unwrap()
3980    }
3981
3982    // ----------------------------------------------------------------------
3983    // backlog#900: MetaObject::into_fileinfo part-array length guard.
3984    // ----------------------------------------------------------------------
3985
3986    fn object_with_parts(part_numbers: Vec<usize>, part_sizes: Vec<usize>, part_actual_sizes: Vec<i64>) -> MetaObject {
3987        MetaObject {
3988            version_id: Some(sample_version_id()),
3989            erasure_algorithm: ErasureAlgo::ReedSolomon,
3990            erasure_m: 2,
3991            erasure_n: 2,
3992            erasure_block_size: 1_048_576,
3993            bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
3994            part_numbers,
3995            part_sizes,
3996            part_actual_sizes,
3997            mod_time: Some(sample_mod_time()),
3998            ..Default::default()
3999        }
4000    }
4001
4002    #[test]
4003    fn into_fileinfo_rejects_short_part_sizes() {
4004        // part_numbers=2 but part_sizes=1 -> old code indexed self.part_sizes[1] and panicked.
4005        let obj = object_with_parts(vec![1, 2], vec![10], vec![10, 20]);
4006        let res = obj.into_fileinfo("bucket", "key", true);
4007        assert!(matches!(res, Err(Error::FileCorrupt)), "short part_sizes must map to FileCorrupt");
4008    }
4009
4010    #[test]
4011    fn into_fileinfo_rejects_conflicting_derived_internal_aliases() {
4012        for suffix in [
4013            SUFFIX_CRC,
4014            SUFFIX_TRANSITION_STATUS,
4015            SUFFIX_TRANSITIONED_OBJECTNAME,
4016            SUFFIX_TRANSITIONED_VERSION_ID,
4017            SUFFIX_TRANSITIONED_VERSION_STATE,
4018            SUFFIX_TRANSITION_TIER,
4019        ] {
4020            let mut meta_sys = HashMap::from([(format!("{RUSTFS_INTERNAL_PREFIX}{suffix}"), vec![0xff, 1])]);
4021            meta_sys.insert(format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX), vec![0xfe, 2]);
4022            let object = MetaObject {
4023                meta_sys,
4024                ..Default::default()
4025            };
4026
4027            assert_eq!(
4028                object
4029                    .into_fileinfo("bucket", "key", false)
4030                    .expect_err("conflicting aliases must fail closed"),
4031                Error::FileCorrupt,
4032                "suffix {suffix}"
4033            );
4034        }
4035    }
4036
4037    #[test]
4038    fn into_fileinfo_recovers_noncanonical_binary_checksum_alias() {
4039        let checksum = vec![0xff, 0x00, 0x80, 0x01];
4040        let object = MetaObject {
4041            meta_sys: HashMap::from([("X-Minio-Internal-crc".to_string(), checksum.clone())]),
4042            ..Default::default()
4043        };
4044
4045        let file_info = object
4046            .into_fileinfo("bucket", "key", false)
4047            .expect("a single legacy checksum alias should remain readable");
4048
4049        assert_eq!(file_info.checksum.as_deref(), Some(checksum.as_slice()));
4050    }
4051
4052    #[test]
4053    fn into_fileinfo_prefers_canonical_rewrite_over_stale_mixed_case_alias() {
4054        let checksum = vec![0xff, 0x00, 0x80, 0x01];
4055        let mut object = MetaObject {
4056            meta_sys: HashMap::from([("X-Minio-Internal-crc".to_string(), b"stale".to_vec())]),
4057            ..Default::default()
4058        };
4059        insert_bytes(&mut object.meta_sys, SUFFIX_CRC, checksum.clone());
4060
4061        let file_info = object
4062            .into_fileinfo("bucket", "key", false)
4063            .expect("canonical rewrites should supersede legacy mixed-case aliases");
4064
4065        assert_eq!(file_info.checksum.as_deref(), Some(checksum.as_slice()));
4066    }
4067
4068    #[test]
4069    fn into_fileinfo_recovers_data_movement_part_checksums() {
4070        let mut object = object_with_parts(vec![1], vec![16], vec![16]);
4071        insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, br#"[[1,[["CRC32C","AAAAAA=="]]]]"#.to_vec());
4072
4073        let file_info = object
4074            .into_fileinfo("bucket", "key", true)
4075            .expect("data movement part checksums should decode");
4076
4077        assert_eq!(
4078            file_info.parts[0]
4079                .checksums
4080                .as_ref()
4081                .and_then(|checksums| checksums.get("CRC32C"))
4082                .map(String::as_str),
4083            Some("AAAAAA==")
4084        );
4085
4086        let mut deferred = object
4087            .to_fileinfo_with_part_checksums("bucket", "key", true, false)
4088            .expect("quorum candidates should retain raw checksum metadata");
4089        assert!(deferred.parts[0].checksums.is_none());
4090        deferred
4091            .hydrate_data_movement_part_checksums()
4092            .expect("the selected candidate should hydrate checksums once");
4093        assert_eq!(deferred.parts[0].checksums, file_info.parts[0].checksums);
4094
4095        insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, b"not-json".to_vec());
4096        assert_eq!(
4097            object
4098                .into_fileinfo("bucket", "key", true)
4099                .expect_err("malformed data movement part checksums must fail closed"),
4100            Error::FileCorrupt
4101        );
4102
4103        for encoded in [
4104            br#"[[1,[["CRC32C","AAAAAA=="]]],[1,[["CRC32C","BBBBBB=="]]]]"#.as_slice(),
4105            br#"[[1,[["CRC32C","AAAAAA=="],["CRC32C","BBBBBB=="]]]]"#.as_slice(),
4106        ] {
4107            insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, encoded.to_vec());
4108            assert_eq!(
4109                object
4110                    .into_fileinfo("bucket", "key", true)
4111                    .expect_err("duplicate part checksum keys must fail closed"),
4112                Error::FileCorrupt
4113            );
4114        }
4115
4116        insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, br#"[[2,[["CRC32C","AAAAAA=="]]]]"#.to_vec());
4117        assert_eq!(
4118            object
4119                .into_fileinfo("bucket", "key", true)
4120                .expect_err("a sidecar for an unknown part must fail closed"),
4121            Error::FileCorrupt
4122        );
4123
4124        object.meta_sys = HashMap::from([
4125            (
4126                format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_PART_CHECKSUMS}"),
4127                br#"[[1,[["CRC32C","AAAAAA=="]]]]"#.to_vec(),
4128            ),
4129            (
4130                format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, SUFFIX_PART_CHECKSUMS),
4131                br#"[[1,[["CRC32C","BBBBBB=="]]]]"#.to_vec(),
4132            ),
4133        ]);
4134        assert_eq!(
4135            object
4136                .into_fileinfo("bucket", "key", true)
4137                .expect_err("conflicting sidecar aliases must fail closed"),
4138            Error::FileCorrupt
4139        );
4140    }
4141
4142    #[test]
4143    fn into_fileinfo_rejects_short_part_actual_sizes_including_empty() {
4144        let obj = object_with_parts(vec![1, 2], vec![10, 20], vec![]);
4145        let res = obj.into_fileinfo("bucket", "key", true);
4146        assert!(matches!(res, Err(Error::FileCorrupt)), "empty part_actual_sizes must map to FileCorrupt");
4147    }
4148
4149    #[test]
4150    fn into_fileinfo_rejects_over_long_part_sizes() {
4151        // Strict equality: an over-long array is corrupt too (MinIO would panic on any mismatch).
4152        let obj = object_with_parts(vec![1], vec![10, 20], vec![10]);
4153        let res = obj.into_fileinfo("bucket", "key", true);
4154        assert!(matches!(res, Err(Error::FileCorrupt)), "over-long part_sizes must map to FileCorrupt");
4155    }
4156
4157    #[test]
4158    fn into_fileinfo_all_parts_false_skips_validation() {
4159        // all_parts=false never enters the parts loop -> Ok, empty parts. Regression guard.
4160        let obj = object_with_parts(vec![1, 2], vec![10], vec![10, 20]);
4161        let fi = obj
4162            .into_fileinfo("bucket", "key", false)
4163            .expect("all_parts=false must not validate parts");
4164        assert!(fi.parts.is_empty());
4165    }
4166
4167    #[test]
4168    fn into_fileinfo_healthy_parts_decode_field_by_field() {
4169        let mut obj = object_with_parts(vec![1, 2], vec![100, 200], vec![111, 222]);
4170        obj.part_etags = vec!["etag-1".to_string(), "etag-2".to_string()];
4171        obj.part_indices = vec![Bytes::from_static(b"idx1"), Bytes::new()];
4172
4173        let fi = obj.into_fileinfo("b", "k", true).expect("healthy parts must decode");
4174        assert_eq!(fi.parts.len(), 2);
4175        assert_eq!(fi.parts[0].number, 1);
4176        assert_eq!(fi.parts[0].size, 100);
4177        assert_eq!(fi.parts[0].actual_size, 111);
4178        assert_eq!(fi.parts[0].etag, "etag-1");
4179        assert_eq!(fi.parts[0].index.as_deref(), Some(&b"idx1"[..]));
4180        assert_eq!(fi.parts[1].number, 2);
4181        assert_eq!(fi.parts[1].size, 200);
4182        assert_eq!(fi.parts[1].actual_size, 222);
4183        assert_eq!(fi.parts[1].etag, "etag-2");
4184        assert_eq!(fi.parts[1].index, None); // empty bytes -> None
4185    }
4186
4187    #[test]
4188    fn into_fileinfo_empty_part_numbers_is_valid() {
4189        // No-part object (empty part_numbers) is legitimate: n==0 -> no guard -> Ok, no parts.
4190        let obj = object_with_parts(vec![], vec![], vec![]);
4191        let fi = obj.into_fileinfo("b", "k", true).expect("no-part object must be valid");
4192        assert!(fi.parts.is_empty());
4193    }
4194
4195    #[test]
4196    fn into_fileinfo_etag_soft_guard_not_regressed() {
4197        // size/actual match (pass hard guard) but etag length differs -> soft guard: empty etag, not corrupt.
4198        let mut obj = object_with_parts(vec![1, 2], vec![10, 20], vec![10, 20]);
4199        obj.part_etags = vec!["only-one".to_string()];
4200        let fi = obj.into_fileinfo("b", "k", true).expect("etag soft guard must not fail");
4201        assert_eq!(fi.parts.len(), 2);
4202        assert_eq!(fi.parts[0].etag, ""); // soft guard: len mismatch -> default empty
4203    }
4204
4205    fn sample_header() -> FileMetaVersionHeader {
4206        FileMetaVersionHeader {
4207            version_id: Some(sample_version_id()),
4208            mod_time: Some(sample_mod_time()),
4209            signature: [0x96, 0x33, 0x4c, 0x78],
4210            version_type: VersionType::Object,
4211            flags: 0x06,
4212            ec_n: 4,
4213            ec_m: 2,
4214        }
4215    }
4216
4217    fn encode_v1_header(header: &FileMetaVersionHeader) -> Vec<u8> {
4218        let mut wr = Vec::new();
4219        rmp::encode::write_array_len(&mut wr, 4).unwrap();
4220        rmp::encode::write_bin(&mut wr, header.version_id.unwrap().as_bytes()).unwrap();
4221        rmp::encode::write_i64(&mut wr, header.mod_time.unwrap().unix_timestamp_nanos() as i64).unwrap();
4222        rmp::encode::write_uint8(&mut wr, header.version_type.to_u8()).unwrap();
4223        rmp::encode::write_uint8(&mut wr, header.flags).unwrap();
4224        wr
4225    }
4226
4227    fn encode_v2_header(header: &FileMetaVersionHeader) -> Vec<u8> {
4228        let mut wr = Vec::new();
4229        rmp::encode::write_array_len(&mut wr, 5).unwrap();
4230        rmp::encode::write_bin(&mut wr, header.version_id.unwrap().as_bytes()).unwrap();
4231        rmp::encode::write_i64(&mut wr, header.mod_time.unwrap().unix_timestamp_nanos() as i64).unwrap();
4232        rmp::encode::write_bin(&mut wr, header.signature.as_slice()).unwrap();
4233        rmp::encode::write_uint8(&mut wr, header.version_type.to_u8()).unwrap();
4234        rmp::encode::write_uint8(&mut wr, header.flags).unwrap();
4235        wr
4236    }
4237
4238    fn write_legacy_time(wr: &mut Vec<u8>, ts: OffsetDateTime) {
4239        wr.push(MSGPACK_EXT8);
4240        wr.push(12);
4241        wr.push(MSGPACK_TIME_EXT_LEGACY as u8);
4242        wr.extend_from_slice(&ts.unix_timestamp().to_be_bytes());
4243        wr.extend_from_slice(&ts.nanosecond().to_be_bytes());
4244    }
4245
4246    fn encode_legacy_v1_body() -> Vec<u8> {
4247        let mut wr = Vec::new();
4248        let mod_time = sample_mod_time();
4249
4250        rmp::encode::write_map_len(&mut wr, 3).unwrap();
4251
4252        rmp::encode::write_str(&mut wr, "Type").unwrap();
4253        rmp::encode::write_uint8(&mut wr, VersionType::Legacy.to_u8()).unwrap();
4254
4255        rmp::encode::write_str(&mut wr, "V1Obj").unwrap();
4256        rmp::encode::write_map_len(&mut wr, 8).unwrap();
4257
4258        rmp::encode::write_str(&mut wr, "Version").unwrap();
4259        rmp::encode::write_str(&mut wr, "1.0.1").unwrap();
4260        rmp::encode::write_str(&mut wr, "Format").unwrap();
4261        rmp::encode::write_str(&mut wr, "xl").unwrap();
4262
4263        rmp::encode::write_str(&mut wr, "Stat").unwrap();
4264        rmp::encode::write_map_len(&mut wr, 5).unwrap();
4265        rmp::encode::write_str(&mut wr, "Size").unwrap();
4266        rmp::encode::write_sint(&mut wr, 11).unwrap();
4267        rmp::encode::write_str(&mut wr, "ModTime").unwrap();
4268        write_legacy_time(&mut wr, mod_time);
4269        rmp::encode::write_str(&mut wr, "Name").unwrap();
4270        rmp::encode::write_str(&mut wr, "hello.txt").unwrap();
4271        rmp::encode::write_str(&mut wr, "Dir").unwrap();
4272        rmp::encode::write_bool(&mut wr, false).unwrap();
4273        rmp::encode::write_str(&mut wr, "Mode").unwrap();
4274        rmp::encode::write_u32(&mut wr, 0o644).unwrap();
4275
4276        rmp::encode::write_str(&mut wr, "Erasure").unwrap();
4277        rmp::encode::write_map_len(&mut wr, 7).unwrap();
4278        rmp::encode::write_str(&mut wr, "Algorithm").unwrap();
4279        rmp::encode::write_str(&mut wr, "ReedSolomon").unwrap();
4280        rmp::encode::write_str(&mut wr, "DataBlocks").unwrap();
4281        rmp::encode::write_sint(&mut wr, 4).unwrap();
4282        rmp::encode::write_str(&mut wr, "ParityBlocks").unwrap();
4283        rmp::encode::write_sint(&mut wr, 2).unwrap();
4284        rmp::encode::write_str(&mut wr, "BlockSize").unwrap();
4285        rmp::encode::write_sint(&mut wr, 1_048_576).unwrap();
4286        rmp::encode::write_str(&mut wr, "Index").unwrap();
4287        rmp::encode::write_sint(&mut wr, 1).unwrap();
4288        rmp::encode::write_str(&mut wr, "Distribution").unwrap();
4289        rmp::encode::write_array_len(&mut wr, 6).unwrap();
4290        for value in 1..=6 {
4291            rmp::encode::write_sint(&mut wr, value).unwrap();
4292        }
4293        rmp::encode::write_str(&mut wr, "Checksums").unwrap();
4294        rmp::encode::write_array_len(&mut wr, 0).unwrap();
4295
4296        rmp::encode::write_str(&mut wr, "Meta").unwrap();
4297        rmp::encode::write_map_len(&mut wr, 1).unwrap();
4298        rmp::encode::write_str(&mut wr, "content-type").unwrap();
4299        rmp::encode::write_str(&mut wr, "text/plain").unwrap();
4300
4301        rmp::encode::write_str(&mut wr, "Parts").unwrap();
4302        rmp::encode::write_array_len(&mut wr, 1).unwrap();
4303        rmp::encode::write_map_len(&mut wr, 5).unwrap();
4304        rmp::encode::write_str(&mut wr, "e").unwrap();
4305        rmp::encode::write_str(&mut wr, "etag-1").unwrap();
4306        rmp::encode::write_str(&mut wr, "n").unwrap();
4307        rmp::encode::write_sint(&mut wr, 1).unwrap();
4308        rmp::encode::write_str(&mut wr, "s").unwrap();
4309        rmp::encode::write_sint(&mut wr, 11).unwrap();
4310        rmp::encode::write_str(&mut wr, "as").unwrap();
4311        rmp::encode::write_sint(&mut wr, 11).unwrap();
4312        rmp::encode::write_str(&mut wr, "mt").unwrap();
4313        write_legacy_time(&mut wr, mod_time);
4314
4315        rmp::encode::write_str(&mut wr, "VersionID").unwrap();
4316        rmp::encode::write_str(&mut wr, "").unwrap();
4317        rmp::encode::write_str(&mut wr, "DataDir").unwrap();
4318        rmp::encode::write_str(&mut wr, "legacy").unwrap();
4319
4320        rmp::encode::write_str(&mut wr, "v").unwrap();
4321        rmp::encode::write_uint(&mut wr, 1).unwrap();
4322
4323        wr
4324    }
4325
4326    fn encode_legacy_v2_object_body(data_blocks: usize, parity_blocks: usize) -> Vec<u8> {
4327        let drive_count = data_blocks + parity_blocks;
4328        let payload = LegacyObjectVersionFixture {
4329            version_type: LegacyObjectVersionTypeFixture::Object,
4330            object: Some(LegacyObjectFixture {
4331                version_id: Some(sample_version_id().as_bytes().to_vec()),
4332                data_dir: Some(Uuid::from_u128(42).as_bytes().to_vec()),
4333                erasure_algorithm: "ReedSolomon".to_string(),
4334                erasure_m: data_blocks,
4335                erasure_n: parity_blocks,
4336                erasure_block_size: 1_048_576,
4337                erasure_index: 1,
4338                erasure_dist: (1..=drive_count)
4339                    .map(|idx| u8::try_from(idx).expect("test drive index should fit u8"))
4340                    .collect(),
4341                bitrot_checksum_algo: "HighwayHash".to_string(),
4342                part_numbers: vec![1],
4343                part_etags: vec!["etag-1".to_string()],
4344                part_sizes: vec![11],
4345                part_actual_sizes: vec![11],
4346                part_indices: vec![Vec::new()],
4347                size: 11,
4348                mod_time: Some(sample_mod_time()),
4349                meta_sys: HashMap::new(),
4350                meta_user: HashMap::from([("content-type".to_string(), "text/plain".to_string())]),
4351            }),
4352            delete_marker: None,
4353            write_version: 3,
4354        };
4355
4356        rmp_serde::to_vec_named(&payload).expect("legacy object payload should marshal")
4357    }
4358
4359    #[test]
4360    fn version_header_unmarshal_v1_uses_legacy_layout_defaults() {
4361        let expected = sample_header();
4362        let encoded = encode_v1_header(&expected);
4363
4364        let mut decoded = FileMetaVersionHeader::default();
4365        decoded.unmarshal_v(1, &encoded).unwrap();
4366
4367        assert_eq!(decoded.version_id, expected.version_id);
4368        assert_eq!(decoded.mod_time, expected.mod_time);
4369        assert_eq!(decoded.version_type, expected.version_type);
4370        assert_eq!(decoded.flags, expected.flags);
4371        assert_eq!(decoded.signature, [0; 4]);
4372        assert_eq!(decoded.ec_n, 0);
4373        assert_eq!(decoded.ec_m, 0);
4374    }
4375
4376    #[test]
4377    fn version_header_unmarshal_v2_keeps_signature_and_zeroes_ec() {
4378        let expected = sample_header();
4379        let encoded = encode_v2_header(&expected);
4380
4381        let mut decoded = FileMetaVersionHeader::default();
4382        decoded.unmarshal_v(2, &encoded).unwrap();
4383
4384        assert_eq!(decoded.version_id, expected.version_id);
4385        assert_eq!(decoded.mod_time, expected.mod_time);
4386        assert_eq!(decoded.signature, expected.signature);
4387        assert_eq!(decoded.version_type, expected.version_type);
4388        assert_eq!(decoded.flags, expected.flags);
4389        assert_eq!(decoded.ec_n, 0);
4390        assert_eq!(decoded.ec_m, 0);
4391    }
4392
4393    #[test]
4394    fn shallow_version_write_quorum_uses_legacy_object_payload_when_header_lacks_ec() {
4395        let expected = sample_header();
4396        let encoded = encode_v2_header(&expected);
4397        let mut header = FileMetaVersionHeader::default();
4398        header.unmarshal_v(2, &encoded).expect("legacy v2 header should decode");
4399
4400        let version = FileMetaShallowVersion {
4401            header,
4402            meta: encode_legacy_v2_object_body(7, 1),
4403        };
4404
4405        assert_eq!(version.header.write_quorum(5), 5);
4406        assert_eq!(version.write_quorum(5), 7);
4407    }
4408
4409    #[test]
4410    fn shallow_version_write_quorum_uses_zero_parity_object_payload() {
4411        let expected = sample_header();
4412        let encoded = encode_v2_header(&expected);
4413        let mut header = FileMetaVersionHeader::default();
4414        header.unmarshal_v(2, &encoded).expect("legacy v2 header should decode");
4415
4416        let version = FileMetaShallowVersion {
4417            header,
4418            meta: encode_legacy_v2_object_body(4, 0),
4419        };
4420
4421        assert_eq!(version.header.write_quorum(2), 2);
4422        assert_eq!(version.write_quorum(2), 4);
4423    }
4424
4425    #[test]
4426    fn version_header_unmarshal_v3_round_trips_current_layout() {
4427        let expected = sample_header();
4428        let encoded = expected.marshal_msg().unwrap();
4429
4430        let mut decoded = FileMetaVersionHeader::default();
4431        decoded.unmarshal_v(3, &encoded).unwrap();
4432
4433        assert_eq!(decoded, expected);
4434    }
4435
4436    #[test]
4437    fn legacy_v1_object_body_decodes_into_fileinfo() {
4438        let encoded = encode_legacy_v1_body();
4439        let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap();
4440
4441        assert_eq!(decoded.version_type, VersionType::Legacy);
4442        assert!(decoded.valid());
4443        assert!(decoded.legacy_object.is_some());
4444
4445        let fi = decoded.into_fileinfo("bucket", "hello.txt", true).expect("into_fileinfo");
4446        assert_eq!(fi.volume, "bucket");
4447        assert_eq!(fi.name, "hello.txt");
4448        assert_eq!(fi.size, 11);
4449        assert_eq!(fi.mod_time, Some(sample_mod_time()));
4450        assert_eq!(fi.mode, Some(0o644));
4451        assert_eq!(fi.parts.len(), 1);
4452        assert_eq!(fi.parts[0].etag, "etag-1");
4453        assert_eq!(fi.parts[0].size, 11);
4454        assert_eq!(fi.erasure.data_blocks, 4);
4455        assert_eq!(fi.erasure.parity_blocks, 2);
4456        assert_eq!(fi.metadata.get("content-type").map(String::as_str), Some("text/plain"));
4457    }
4458
4459    #[test]
4460    fn legacy_meta_v2_delete_marker_decodes_into_delete_fileinfo() {
4461        let payload = LegacyDeleteVersionFixture {
4462            version_type: LegacyDeleteVersionTypeFixture::DeleteMarker,
4463            object: None,
4464            delete_marker: Some(LegacyDeleteMarkerFixture {
4465                version_id: sample_version_id().as_bytes().to_vec(),
4466                mod_time: Some(sample_mod_time()),
4467                meta_sys: HashMap::from([("x-rustfs-test".to_string(), b"gone".to_vec())]),
4468            }),
4469            write_version: 9,
4470        };
4471        let encoded = rmp_serde::to_vec_named(&payload).unwrap();
4472
4473        let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap();
4474
4475        assert_eq!(decoded.version_type, VersionType::Delete);
4476        assert!(decoded.object.is_none());
4477        assert!(decoded.delete_marker.is_some());
4478        assert!(decoded.uses_legacy_checksum);
4479
4480        let fi = decoded.into_fileinfo("bucket", "gone.txt", true).expect("into_fileinfo");
4481        assert!(fi.deleted);
4482        assert_eq!(fi.volume, "bucket");
4483        assert_eq!(fi.name, "gone.txt");
4484        assert_eq!(fi.version_id, Some(sample_version_id()));
4485        assert_eq!(fi.mod_time, Some(sample_mod_time()));
4486        assert_eq!(fi.metadata.get("x-rustfs-test").map(String::as_str), Some("gone"));
4487        assert!(fi.uses_legacy_checksum);
4488    }
4489
4490    #[test]
4491    fn legacy_meta_v2_delete_marker_decodes_into_delete_fileinfo_via_struct() {
4492        let version_id = sample_version_id();
4493        let mod_time = sample_mod_time();
4494        let version = LegacyMetaV2Version {
4495            version_type: LegacyMetaV2VersionType::DeleteMarker,
4496            object: None,
4497            delete_marker: Some(LegacyMetaV2DeleteMarker {
4498                version_id: version_id.as_bytes().to_vec(),
4499                mod_time: Some(mod_time),
4500                meta_sys: HashMap::from([("x-minio-internal".to_string(), b"present".to_vec())]),
4501            }),
4502            write_version: 7,
4503        };
4504
4505        let decoded = FileMetaVersion::try_from(version).unwrap();
4506
4507        assert_eq!(decoded.version_type, VersionType::Delete);
4508        assert!(decoded.uses_legacy_checksum);
4509        assert!(decoded.object.is_none());
4510
4511        let delete_marker = decoded.delete_marker.as_ref().expect("delete marker should be decoded");
4512        assert_eq!(delete_marker.version_id, Some(version_id));
4513        assert_eq!(delete_marker.mod_time, Some(mod_time));
4514
4515        let fi = decoded.into_fileinfo("bucket", "deleted.txt", true).expect("into_fileinfo");
4516        assert!(fi.deleted);
4517        assert_eq!(fi.version_id, Some(version_id));
4518        assert_eq!(fi.mod_time, Some(mod_time));
4519        assert_eq!(fi.metadata.get("x-minio-internal").map(String::as_str), Some("present"));
4520    }
4521
4522    #[test]
4523    fn legacy_meta_v2_delete_marker_rejects_invalid_uuid_bytes() {
4524        let payload = LegacyDeleteVersionFixture {
4525            version_type: LegacyDeleteVersionTypeFixture::DeleteMarker,
4526            object: None,
4527            delete_marker: Some(LegacyDeleteMarkerFixture {
4528                version_id: vec![7; 15],
4529                mod_time: Some(sample_mod_time()),
4530                meta_sys: HashMap::new(),
4531            }),
4532            write_version: 10,
4533        };
4534        let encoded = rmp_serde::to_vec_named(&payload).unwrap();
4535
4536        let err = FileMetaVersion::try_from(encoded.as_slice()).expect_err("invalid legacy delete marker UUID must fail");
4537        assert!(err.to_string().contains("legacy version_id must be 16 bytes"));
4538    }
4539
4540    #[test]
4541    fn legacy_meta_v2_delete_marker_rejects_invalid_uuid_bytes_via_struct() {
4542        let err = MetaDeleteMarker::try_from(LegacyMetaV2DeleteMarker {
4543            version_id: vec![1, 2, 3],
4544            mod_time: Some(sample_mod_time()),
4545            meta_sys: HashMap::new(),
4546        })
4547        .expect_err("invalid legacy delete-marker version ids should be rejected");
4548
4549        assert!(err.to_string().contains("legacy version_id must be 16 bytes"));
4550    }
4551
4552    #[test]
4553    fn legacy_meta_v2_object_accepts_nil_uuid_fields() {
4554        let payload = LegacyObjectVersionFixture {
4555            version_type: LegacyObjectVersionTypeFixture::Object,
4556            object: Some(LegacyObjectFixture {
4557                version_id: None,
4558                data_dir: None,
4559                erasure_algorithm: "ReedSolomon".to_string(),
4560                erasure_m: 2,
4561                erasure_n: 4,
4562                erasure_block_size: 1_048_576,
4563                erasure_index: 1,
4564                erasure_dist: vec![1, 2, 3, 4, 5, 6],
4565                bitrot_checksum_algo: "HighwayHash".to_string(),
4566                part_numbers: vec![1],
4567                part_etags: vec!["etag-1".to_string()],
4568                part_sizes: vec![11],
4569                part_actual_sizes: vec![11],
4570                part_indices: vec![Vec::new()],
4571                size: 11,
4572                mod_time: Some(sample_mod_time()),
4573                meta_sys: HashMap::new(),
4574                meta_user: HashMap::from([("content-type".to_string(), "text/plain".to_string())]),
4575            }),
4576            delete_marker: None,
4577            write_version: 3,
4578        };
4579        let encoded = rmp_serde::to_vec_named(&payload).unwrap();
4580
4581        let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap();
4582        let object = decoded.object.as_ref().expect("object should be decoded");
4583
4584        assert_eq!(decoded.version_type, VersionType::Object);
4585        assert!(decoded.uses_legacy_checksum);
4586        assert_eq!(object.version_id, None);
4587        assert_eq!(object.data_dir, None);
4588
4589        let fi = decoded
4590            .into_fileinfo("bucket", "legacy-nil.txt", true)
4591            .expect("into_fileinfo");
4592        assert_eq!(fi.version_id, None);
4593        assert_eq!(fi.data_dir, None);
4594        assert_eq!(fi.metadata.get("content-type").map(String::as_str), Some("text/plain"));
4595    }
4596
4597    #[test]
4598    fn legacy_meta_v2_delete_marker_accepts_nil_version_id() {
4599        let payload = LegacyDeleteVersionNilFixture {
4600            version_type: LegacyDeleteVersionTypeFixture::DeleteMarker,
4601            object: None,
4602            delete_marker: Some(LegacyDeleteMarkerNilFixture {
4603                version_id: None,
4604                mod_time: Some(sample_mod_time()),
4605                meta_sys: HashMap::from([("x-rustfs-test".to_string(), b"gone".to_vec())]),
4606            }),
4607            write_version: 11,
4608        };
4609        let encoded = rmp_serde::to_vec_named(&payload).unwrap();
4610
4611        let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap();
4612        let delete_marker = decoded.delete_marker.as_ref().expect("delete marker should be decoded");
4613
4614        assert_eq!(decoded.version_type, VersionType::Delete);
4615        assert!(decoded.uses_legacy_checksum);
4616        assert_eq!(delete_marker.version_id, None);
4617        assert_eq!(delete_marker.mod_time, Some(sample_mod_time()));
4618
4619        let fi = decoded.into_fileinfo("bucket", "deleted.txt", true).expect("into_fileinfo");
4620        assert!(fi.deleted);
4621        assert_eq!(fi.version_id, None);
4622        assert_eq!(fi.mod_time, Some(sample_mod_time()));
4623        assert_eq!(fi.metadata.get("x-rustfs-test").map(String::as_str), Some("gone"));
4624    }
4625
4626    #[test]
4627    fn decode_data_dir_from_meta_extracts_v2_object_fast_path() {
4628        let data_dir = Uuid::new_v4();
4629        let version = FileMetaVersion {
4630            version_type: VersionType::Object,
4631            object: Some(MetaObject {
4632                version_id: Some(Uuid::new_v4()),
4633                data_dir: Some(data_dir),
4634                erasure_algorithm: ErasureAlgo::ReedSolomon,
4635                erasure_m: 2,
4636                erasure_n: 4,
4637                erasure_block_size: 1024 * 1024,
4638                erasure_index: 1,
4639                erasure_dist: vec![1, 2, 3, 4, 5, 6],
4640                bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
4641                size: 64 * 1024,
4642                mod_time: Some(OffsetDateTime::now_utc()),
4643                ..Default::default()
4644            }),
4645            ..Default::default()
4646        };
4647
4648        let encoded = version.marshal_msg().expect("marshal");
4649        let decoded = FileMetaVersion::decode_data_dir_from_meta(&encoded).expect("decode data_dir");
4650        assert_eq!(decoded, Some(data_dir));
4651    }
4652
4653    fn make_meta_object_with_sys(meta_sys: HashMap<String, Vec<u8>>) -> MetaObject {
4654        MetaObject {
4655            erasure_algorithm: ErasureAlgo::ReedSolomon,
4656            erasure_m: 2,
4657            erasure_n: 4,
4658            erasure_block_size: 1_048_576,
4659            erasure_index: 1,
4660            erasure_dist: vec![1, 2, 3, 4, 5, 6],
4661            bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
4662            meta_sys,
4663            ..Default::default()
4664        }
4665    }
4666
4667    #[test]
4668    fn meta_object_transition_version_id_absent_yields_none() {
4669        let fi = make_meta_object_with_sys(HashMap::new())
4670            .into_fileinfo("b", "k", false)
4671            .expect("into_fileinfo");
4672        assert_eq!(fi.transition_version_id, None);
4673    }
4674
4675    #[test]
4676    fn meta_object_transition_version_id_empty_bytes_yields_none() {
4677        let mut sys = HashMap::new();
4678        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, vec![]);
4679        let fi = make_meta_object_with_sys(sys)
4680            .into_fileinfo("b", "k", false)
4681            .expect("into_fileinfo");
4682        assert_eq!(fi.transition_version_id, None);
4683        assert_eq!(get_str(&fi.metadata, SUFFIX_TRANSITIONED_VERSION_ID), Some(String::new()));
4684    }
4685
4686    #[test]
4687    fn meta_object_transition_version_id_nil_uuid_yields_none() {
4688        // Regression: old code used unwrap_or_default() which turned nil bytes into Some(Uuid::nil())
4689        let mut sys = HashMap::new();
4690        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, Uuid::nil().as_bytes().to_vec());
4691        let fi = make_meta_object_with_sys(sys)
4692            .into_fileinfo("b", "k", false)
4693            .expect("into_fileinfo");
4694        assert_eq!(fi.transition_version_id, None);
4695        assert!(
4696            get_str(&fi.metadata, SUFFIX_TRANSITIONED_VERSION_ID).is_some_and(|value| !value.is_empty()),
4697            "nil UUID bytes must remain distinguishable from an empty MinIO version"
4698        );
4699    }
4700
4701    #[test]
4702    fn meta_object_transition_version_id_valid_uuid_round_trips() {
4703        let id = sample_version_id();
4704        let mut sys = HashMap::new();
4705        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.as_bytes().to_vec());
4706        let fi = make_meta_object_with_sys(sys)
4707            .into_fileinfo("b", "k", false)
4708            .expect("into_fileinfo");
4709        assert_eq!(fi.transition_version_id, Some(id));
4710        assert_eq!(fi.transition_version, Some(id.to_string()));
4711        assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown);
4712        assert_eq!(get_str(&fi.metadata, SUFFIX_TRANSITIONED_VERSION_ID), Some(id.to_string()));
4713    }
4714
4715    #[test]
4716    fn meta_object_transition_exact_rejects_legacy_raw_uuid_encoding() {
4717        let mut sys = HashMap::new();
4718        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, sample_version_id().as_bytes().to_vec());
4719        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_STATE, b"exact".to_vec());
4720
4721        let err = make_meta_object_with_sys(sys)
4722            .into_fileinfo("b", "k", false)
4723            .expect_err("exact remote versions must use their UTF-8 provider representation");
4724
4725        assert_eq!(err, Error::FileCorrupt);
4726    }
4727
4728    #[test]
4729    fn meta_object_transition_version_id_mixed_case_alias_is_recovered() {
4730        let id = sample_version_id();
4731        let sys = HashMap::from([("X-Minio-Internal-transitioned-versionID".to_string(), id.as_bytes().to_vec())]);
4732        let fi = make_meta_object_with_sys(sys)
4733            .into_fileinfo("b", "k", false)
4734            .expect("a legacy mixed-case transition version alias should decode");
4735
4736        assert_eq!(fi.transition_version_id, Some(id));
4737        assert_eq!(fi.transition_version, Some(id.to_string()));
4738    }
4739
4740    #[test]
4741    fn meta_object_transition_version_id_opaque_text_is_preserved() {
4742        let mut sys = HashMap::new();
4743        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"opaque-generation-42".to_vec());
4744        let fi = make_meta_object_with_sys(sys)
4745            .into_fileinfo("b", "k", false)
4746            .expect("opaque transition version id must decode");
4747        assert_eq!(fi.transition_version_id, None);
4748        assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42"));
4749        assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown);
4750    }
4751
4752    #[test]
4753    fn meta_object_transition_version_state_explicit_unknown_is_not_legacy_missing() {
4754        let mut metadata = HashMap::new();
4755        rustfs_utils::http::metadata_compat::insert_str(
4756            &mut metadata,
4757            SUFFIX_TRANSITIONED_VERSION_STATE,
4758            TransitionVersionState::Unknown.as_str().to_string(),
4759        );
4760        let fi = FileInfo {
4761            transition_status: "complete".to_string(),
4762            transition_version_state: TransitionVersionState::Unknown,
4763            metadata,
4764            ..Default::default()
4765        };
4766
4767        let object = MetaObject::from(fi);
4768        assert_eq!(
4769            get_consistent_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE),
4770            Some(b"unknown".as_slice())
4771        );
4772        let decoded = object
4773            .into_fileinfo("b", "k", false)
4774            .expect("explicit unknown state should decode");
4775        assert_eq!(decoded.transition_version_state, TransitionVersionState::Unknown);
4776        assert_eq!(
4777            rustfs_utils::http::metadata_compat::get_consistent_str(&decoded.metadata, SUFFIX_TRANSITIONED_VERSION_STATE,),
4778            Some("unknown")
4779        );
4780    }
4781
4782    #[test]
4783    fn meta_object_transition_version_state_exact_round_trips_dual_keys() {
4784        let id = sample_version_id();
4785        let expected_version = id.to_string();
4786        let fi = FileInfo {
4787            transition_status: "complete".to_string(),
4788            transition_version: Some(expected_version.clone()),
4789            transition_version_state: TransitionVersionState::Exact,
4790            ..Default::default()
4791        };
4792
4793        let object = MetaObject::from(fi);
4794        assert_eq!(
4795            object
4796                .meta_sys
4797                .get(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}"))
4798                .map(Vec::as_slice),
4799            Some(b"exact".as_slice())
4800        );
4801        assert_eq!(
4802            object
4803                .meta_sys
4804                .get(&format!(
4805                    "{}{SUFFIX_TRANSITIONED_VERSION_STATE}",
4806                    rustfs_utils::http::MINIO_INTERNAL_PREFIX
4807                ))
4808                .map(Vec::as_slice),
4809            Some(b"exact".as_slice())
4810        );
4811        let persisted_version = get_consistent_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID);
4812        assert_eq!(
4813            transitioned_version_from_bytes(persisted_version, TransitionVersionState::Unknown)
4814                .and_then(|value| Uuid::parse_str(&value).ok()),
4815            Some(id),
4816            "UUID exact writes must remain readable by the legacy UUID consumer"
4817        );
4818        let decoded = object.into_fileinfo("b", "k", false).expect("exact state should round trip");
4819        assert_eq!(decoded.transition_version_state, TransitionVersionState::Exact);
4820        assert_eq!(decoded.transition_version.as_deref(), Some(expected_version.as_str()));
4821    }
4822
4823    #[test]
4824    fn meta_object_transition_version_state_exact_preserves_sixteen_byte_opaque_text() {
4825        let expected_version = "opaque.wasabi_01";
4826        assert_eq!(expected_version.len(), 16);
4827        let fi = FileInfo {
4828            transition_status: "complete".to_string(),
4829            transition_version: Some(expected_version.to_string()),
4830            transition_version_state: TransitionVersionState::Exact,
4831            ..Default::default()
4832        };
4833
4834        let decoded = MetaObject::from(fi)
4835            .into_fileinfo("b", "k", false)
4836            .expect("exact opaque transition version should round trip");
4837
4838        assert_eq!(decoded.transition_version.as_deref(), Some(expected_version));
4839        assert_eq!(decoded.transition_version_id, None);
4840        assert_eq!(decoded.transition_version_state, TransitionVersionState::Exact);
4841    }
4842
4843    #[test]
4844    fn set_transition_known_disabled_removes_stale_version_dual_keys() {
4845        let mut meta_sys = HashMap::new();
4846        insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, b"stale-legacy-version".to_vec());
4847        let mut object = make_meta_object_with_sys(meta_sys);
4848        object.set_transition(&FileInfo {
4849            transition_status: TRANSITION_COMPLETE.to_string(),
4850            transitioned_objname: "remote/object".to_string(),
4851            transition_version_state: TransitionVersionState::KnownDisabled,
4852            transition_tier: "WARM".to_string(),
4853            ..Default::default()
4854        });
4855
4856        assert_eq!(get_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID), None);
4857        assert!(
4858            !object
4859                .meta_sys
4860                .contains_key(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}"))
4861        );
4862        assert!(
4863            !object
4864                .meta_sys
4865                .contains_key(&format!("{}{SUFFIX_TRANSITIONED_VERSION_ID}", rustfs_utils::http::MINIO_INTERNAL_PREFIX))
4866        );
4867        let decoded = object
4868            .into_fileinfo("b", "k", false)
4869            .expect("known-disabled transition must remain readable after replacing stale metadata");
4870        assert_eq!(decoded.transition_version, None);
4871        assert_eq!(decoded.transition_version_state, TransitionVersionState::KnownDisabled);
4872    }
4873
4874    #[test]
4875    fn meta_object_transition_version_state_conflict_fails_closed() {
4876        let mut sys = HashMap::new();
4877        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, sample_version_id().as_bytes().to_vec());
4878        sys.insert(format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}"), b"exact".to_vec());
4879        sys.insert(
4880            format!("{}{SUFFIX_TRANSITIONED_VERSION_STATE}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
4881            b"known-disabled".to_vec(),
4882        );
4883
4884        make_meta_object_with_sys(sys)
4885            .into_fileinfo("b", "k", false)
4886            .expect_err("conflicting state keys must fail closed");
4887    }
4888
4889    #[test]
4890    fn meta_object_transition_version_id_invalid_utf8_yields_none() {
4891        let mut sys = HashMap::new();
4892        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, vec![0xff]);
4893        let fi = make_meta_object_with_sys(sys)
4894            .into_fileinfo("b", "k", false)
4895            .expect("invalid transition version bytes must not fail the object read");
4896        assert_eq!(fi.transition_version_id, None);
4897        assert_eq!(fi.transition_version, None);
4898        assert!(
4899            get_str(&fi.metadata, SUFFIX_TRANSITIONED_VERSION_ID).is_some_and(|value| !value.is_empty()),
4900            "invalid raw bytes must remain distinguishable from an empty MinIO version"
4901        );
4902    }
4903
4904    #[test]
4905    fn meta_object_transition_version_id_unsafe_text_yields_none() {
4906        for value in [b"opaque\0version".to_vec(), vec![b'x'; MAX_TRANSITION_VERSION_LEN + 1]] {
4907            let mut sys = HashMap::new();
4908            insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, value);
4909            let fi = make_meta_object_with_sys(sys)
4910                .into_fileinfo("b", "k", false)
4911                .expect("unsafe transition version text must not fail the object read");
4912            assert_eq!(fi.transition_version_id, None);
4913            assert_eq!(fi.transition_version, None);
4914        }
4915    }
4916
4917    #[test]
4918    fn meta_object_transition_version_id_minio_string_form_is_recovered() {
4919        // MinIO-migrated tiered objects store the remote tier's version id as a
4920        // UUID string, not 16 raw bytes; recover it instead of dropping it.
4921        let id = sample_version_id();
4922        let mut sys = HashMap::new();
4923        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.to_string().into_bytes());
4924        let fi = make_meta_object_with_sys(sys)
4925            .into_fileinfo("b", "k", false)
4926            .expect("string-form transition version id must decode");
4927        assert_eq!(fi.transition_version_id, Some(id));
4928        assert_eq!(fi.transition_version, Some(id.to_string()));
4929    }
4930
4931    #[test]
4932    fn delete_marker_free_version_transition_version_id_nil_uuid_yields_none() {
4933        let mut sys = HashMap::new();
4934        insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
4935        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, Uuid::nil().as_bytes().to_vec());
4936        let fi = MetaDeleteMarker {
4937            version_id: None,
4938            mod_time: None,
4939            meta_sys: sys,
4940        }
4941        .into_fileinfo("b", "k", false)
4942        .expect("nil tier version should remain an absent remote version");
4943        assert_eq!(fi.transition_version_id, None);
4944        assert!(
4945            get_str(&fi.metadata, SUFFIX_TRANSITIONED_VERSION_ID).is_some_and(|value| !value.is_empty()),
4946            "nil UUID bytes must remain distinguishable from an empty MinIO version"
4947        );
4948    }
4949
4950    #[test]
4951    fn delete_marker_free_version_transition_version_id_valid_uuid_round_trips() {
4952        let id = sample_version_id();
4953        let mut sys = HashMap::new();
4954        insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
4955        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.as_bytes().to_vec());
4956        let fi = MetaDeleteMarker {
4957            version_id: None,
4958            mod_time: None,
4959            meta_sys: sys,
4960        }
4961        .into_fileinfo("b", "k", false)
4962        .expect("legacy binary UUID tier version should decode");
4963        assert_eq!(fi.transition_version_id, Some(id));
4964        assert_eq!(fi.transition_version, Some(id.to_string()));
4965        assert_eq!(get_str(&fi.metadata, SUFFIX_TRANSITIONED_VERSION_ID), Some(id.to_string()));
4966    }
4967
4968    #[test]
4969    fn delete_marker_free_version_transition_version_id_opaque_text_is_preserved() {
4970        let mut sys = HashMap::new();
4971        insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
4972        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"opaque-generation-42".to_vec());
4973        insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER, b"WARM".to_vec());
4974        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".to_vec());
4975        let fi = MetaDeleteMarker {
4976            version_id: Some(sample_version_id()),
4977            mod_time: Some(sample_mod_time()),
4978            meta_sys: sys,
4979        }
4980        .into_fileinfo("b", "k", false)
4981        .expect("opaque tier version should remain readable");
4982
4983        assert_eq!(fi.transition_version_id, None);
4984        assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42"));
4985        fi.validate_for_metadata_read()
4986            .expect("free-version record with an opaque tier id must remain readable");
4987    }
4988
4989    #[test]
4990    fn delete_marker_free_version_transition_version_id_minio_string_form_is_recovered() {
4991        // MinIO stores the tier version id as a UUID string; recover it.
4992        let id = sample_version_id();
4993        let mut sys = HashMap::new();
4994        insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
4995        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.to_string().into_bytes());
4996        insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER, b"WARM".to_vec());
4997        insert_bytes(&mut sys, SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".to_vec());
4998        let fi = MetaDeleteMarker {
4999            version_id: Some(sample_version_id()),
5000            mod_time: Some(sample_mod_time()),
5001            meta_sys: sys,
5002        }
5003        .into_fileinfo("b", "k", false)
5004        .expect("mixed-case tier aliases should decode");
5005
5006        assert_eq!(fi.transition_version_id, Some(id));
5007        assert_eq!(fi.transition_version, Some(id.to_string()));
5008    }
5009
5010    #[test]
5011    fn delete_marker_free_version_recovers_mixed_case_transition_aliases() {
5012        let id = sample_version_id();
5013        let id_text = id.to_string();
5014        let mut sys = HashMap::new();
5015        insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
5016        for (suffix, value) in [
5017            (SUFFIX_TRANSITIONED_VERSION_ID, id_text.as_bytes()),
5018            (SUFFIX_TRANSITIONED_VERSION_STATE, b"exact".as_slice()),
5019            (SUFFIX_TRANSITION_TIER, b"WARM".as_slice()),
5020            (SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".as_slice()),
5021        ] {
5022            sys.insert(format!("X-Minio-Internal-{suffix}"), value.to_vec());
5023        }
5024
5025        let fi = MetaDeleteMarker {
5026            version_id: Some(sample_version_id()),
5027            mod_time: Some(sample_mod_time()),
5028            meta_sys: sys,
5029        }
5030        .into_fileinfo("b", "k", false)
5031        .expect("mixed-case tier aliases should decode");
5032
5033        assert_eq!(fi.transition_version_id, Some(id));
5034        assert_eq!(fi.transition_version, Some(id.to_string()));
5035        assert_eq!(fi.transition_version_state, TransitionVersionState::Exact);
5036        assert_eq!(fi.transition_tier, "WARM");
5037        assert_eq!(fi.transitioned_objname, "remote-object");
5038    }
5039
5040    #[test]
5041    fn delete_marker_free_version_rejects_conflicting_transition_aliases() {
5042        let mut sys = HashMap::new();
5043        insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
5044        sys.insert(
5045            format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}"),
5046            b"source-version".to_vec(),
5047        );
5048        sys.insert(
5049            format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, SUFFIX_TRANSITIONED_VERSION_ID),
5050            b"target-version".to_vec(),
5051        );
5052
5053        let err = MetaDeleteMarker {
5054            version_id: Some(sample_version_id()),
5055            mod_time: Some(sample_mod_time()),
5056            meta_sys: sys,
5057        }
5058        .into_fileinfo("b", "k", false)
5059        .expect_err("conflicting transition aliases must fail closed");
5060
5061        assert_eq!(err, Error::FileCorrupt);
5062    }
5063
5064    #[test]
5065    fn meta_object_transition_version_state_mixed_case_alias_conflict_fails_closed() {
5066        let sys = HashMap::from([
5067            (
5068                format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}"),
5069                b"unknown".to_vec(),
5070            ),
5071            ("X-Minio-Internal-transitioned-version-state".to_string(), b"exact".to_vec()),
5072        ]);
5073
5074        let err = make_meta_object_with_sys(sys)
5075            .into_fileinfo("b", "k", false)
5076            .expect_err("mixed-case transition state aliases must agree");
5077
5078        assert_eq!(err, Error::FileCorrupt);
5079    }
5080
5081    #[test]
5082    fn version_header_sorts_before_prefers_object_over_delete_marker_on_equal_mod_time() {
5083        let object = FileMetaVersionHeader {
5084            version_id: Some(Uuid::from_u128(1)),
5085            mod_time: Some(sample_mod_time()),
5086            version_type: VersionType::Object,
5087            ..Default::default()
5088        };
5089        let delete_marker = FileMetaVersionHeader {
5090            version_id: Some(Uuid::from_u128(2)),
5091            version_type: VersionType::Delete,
5092            ..object
5093        };
5094
5095        // With equal mod_time the tie is broken by "prefer lower types":
5096        // Object (1) sorts before Delete (2), regardless of version_id.
5097        assert!(object.sorts_before(&delete_marker));
5098        assert!(!delete_marker.sorts_before(&object));
5099    }
5100
5101    #[test]
5102    fn version_header_sorts_before_equal_mod_time_is_deterministic_and_antisymmetric() {
5103        let a = FileMetaVersionHeader {
5104            version_id: Some(Uuid::from_u128(1)),
5105            mod_time: Some(sample_mod_time()),
5106            version_type: VersionType::Object,
5107            ..Default::default()
5108        };
5109        let b = FileMetaVersionHeader {
5110            version_id: Some(Uuid::from_u128(2)),
5111            ..a.clone()
5112        };
5113
5114        // Equal mod_time and version_type: the higher version_id sorts first,
5115        // and exactly one of the two orderings holds so merge sees a stable
5116        // total order for distinct headers.
5117        assert!(b.sorts_before(&a));
5118        assert!(!a.sorts_before(&b));
5119        assert_ne!(a.sorts_before(&b), b.sorts_before(&a));
5120
5121        // A header never sorts before an identical header.
5122        assert!(!a.sorts_before(&a.clone()));
5123    }
5124
5125    #[test]
5126    fn version_header_sorts_before_breaks_signature_tie_before_version_id() {
5127        // Two same-type Object headers with equal mod_time but differing
5128        // signature and version_id. Matching MinIO's sortsBefore, the higher
5129        // signature wins the tie ahead of the version_id comparison, even when
5130        // version_id would order the two the other way.
5131        let lower_sig_higher_id = FileMetaVersionHeader {
5132            version_id: Some(Uuid::from_u128(2)),
5133            mod_time: Some(sample_mod_time()),
5134            version_type: VersionType::Object,
5135            signature: [0x00, 0x00, 0x00, 0x01],
5136            ..Default::default()
5137        };
5138        let higher_sig_lower_id = FileMetaVersionHeader {
5139            version_id: Some(Uuid::from_u128(1)),
5140            signature: [0x00, 0x00, 0x00, 0x02],
5141            ..lower_sig_higher_id.clone()
5142        };
5143
5144        assert!(higher_sig_lower_id.sorts_before(&lower_sig_higher_id));
5145        assert!(!lower_sig_higher_id.sorts_before(&higher_sig_lower_id));
5146    }
5147
5148    #[test]
5149    fn version_header_sorts_before_version_id_only_breaks_signature_tie() {
5150        // Equal mod_time, version_type, and signature: only then does version_id
5151        // decide, and the higher version_id sorts first.
5152        let a = FileMetaVersionHeader {
5153            version_id: Some(Uuid::from_u128(1)),
5154            mod_time: Some(sample_mod_time()),
5155            version_type: VersionType::Object,
5156            signature: [0x0a, 0x0b, 0x0c, 0x0d],
5157            ..Default::default()
5158        };
5159        let b = FileMetaVersionHeader {
5160            version_id: Some(Uuid::from_u128(2)),
5161            ..a.clone()
5162        };
5163
5164        assert!(b.sorts_before(&a));
5165        assert!(!a.sorts_before(&b));
5166    }
5167
5168    #[test]
5169    fn merge_file_meta_versions_survives_garbage_header_stream() {
5170        let valid = FileMetaShallowVersion {
5171            header: sample_header(),
5172            meta: Vec::new(),
5173        };
5174        let garbage = FileMetaShallowVersion {
5175            header: FileMetaVersionHeader {
5176                version_id: None,
5177                mod_time: None,
5178                signature: [0xde, 0xad, 0xbe, 0xef],
5179                version_type: VersionType::Invalid,
5180                flags: 0xff,
5181                ec_n: 0,
5182                ec_m: 0,
5183            },
5184            meta: b"not-a-valid-msgpack-version".to_vec(),
5185        };
5186
5187        // One disk returns a garbage header alongside two healthy streams:
5188        // merge must not panic and must still return the quorum version.
5189        let merged = merge_file_meta_versions(2, true, 0, &[vec![valid.clone()], vec![valid.clone()], vec![garbage]]);
5190
5191        assert_eq!(merged, vec![valid]);
5192    }
5193
5194    /// Build a single distinct Object version header for the union tests.
5195    fn union_version(version_u128: u128, mod_unix: i64) -> FileMetaShallowVersion {
5196        FileMetaShallowVersion {
5197            header: FileMetaVersionHeader {
5198                version_id: Some(Uuid::from_u128(version_u128)),
5199                mod_time: Some(OffsetDateTime::from_unix_timestamp(mod_unix).expect("valid timestamp")),
5200                signature: [0x11, 0x22, 0x33, 0x44],
5201                version_type: VersionType::Object,
5202                flags: 0,
5203                ec_n: 4,
5204                ec_m: 2,
5205            },
5206            meta: Vec::new(),
5207        }
5208    }
5209
5210    /// backlog#920: quorum==1 (union) must surface EVERY version present on ANY
5211    /// per-disk stream, even when the per-disk version sets are fully DISJOINT
5212    /// (a version living on a single disk). This is the enumeration guarantee the
5213    /// sub-quorum disk-walk depends on.
5214    #[test]
5215    fn merge_union_quorum1_yields_full_union_over_disjoint_disks() {
5216        let a = union_version(0xA, 1_705_312_300);
5217        let b = union_version(0xB, 1_705_312_200);
5218        let c = union_version(0xC, 1_705_312_100);
5219
5220        // Three disks, each holding exactly ONE distinct version (disjoint sets).
5221        let disks = [vec![a], vec![b], vec![c]];
5222        let merged = merge_file_meta_versions(1, true, 0, &disks);
5223
5224        let ids: std::collections::HashSet<Option<Uuid>> = merged.iter().map(|v| v.header.version_id).collect();
5225        assert_eq!(merged.len(), 3, "union must retain all three disjoint versions: {merged:?}");
5226        assert!(ids.contains(&Some(Uuid::from_u128(0xA))));
5227        assert!(ids.contains(&Some(Uuid::from_u128(0xB))));
5228        assert!(ids.contains(&Some(Uuid::from_u128(0xC))));
5229
5230        // Contrast: read-quorum (2) over the same disjoint streams surfaces NONE,
5231        // because no version reaches two agreeing disks. This is the exact gap.
5232        let read_quorum = merge_file_meta_versions(2, true, 0, &disks);
5233        assert!(
5234            read_quorum.is_empty(),
5235            "read-quorum merge must drop every sub-quorum version, got {read_quorum:?}"
5236        );
5237    }
5238
5239    /// The equal-modTime / distinct-versionId retain-and-advance branch: two
5240    /// versions sharing a mod_time but with different ids must BOTH survive the
5241    /// union merge (they are not collapsed as duplicates).
5242    #[test]
5243    fn merge_union_quorum1_retains_equal_modtime_distinct_version_ids() {
5244        let same_time = 1_705_312_300;
5245        let a = union_version(0xAA, same_time);
5246        let b = union_version(0xBB, same_time);
5247
5248        let disks = [vec![a], vec![b]];
5249        let merged = merge_file_meta_versions(1, true, 0, &disks);
5250
5251        let ids: std::collections::HashSet<Option<Uuid>> = merged.iter().map(|v| v.header.version_id).collect();
5252        assert_eq!(merged.len(), 2, "equal-modTime distinct-id versions must both survive: {merged:?}");
5253        assert!(ids.contains(&Some(Uuid::from_u128(0xAA))));
5254        assert!(ids.contains(&Some(Uuid::from_u128(0xBB))));
5255    }
5256
5257    #[test]
5258    fn meta_object_init_free_version_rejects_invalid_tier_free_version_id() {
5259        let mut sys = HashMap::new();
5260        insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec());
5261
5262        let obj = make_meta_object_with_sys(sys);
5263        let mut fi = FileInfo::new("object", 2, 2);
5264        fi.set_tier_free_version_id("not-a-uuid");
5265
5266        let err = obj
5267            .init_free_version(&fi)
5268            .expect_err("invalid free-version UUID should return an error instead of panicking");
5269
5270        assert!(matches!(err, Error::UuidParse(_)));
5271    }
5272
5273    #[test]
5274    fn meta_object_init_free_version_preserves_transition_destination_identity() {
5275        let mut sys = HashMap::new();
5276        insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec());
5277        let identity = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_vec();
5278        insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, identity.clone());
5279        let obj = make_meta_object_with_sys(sys);
5280        let mut fi = FileInfo::new("object", 2, 2);
5281        fi.set_tier_free_version_id(&Uuid::new_v4().to_string());
5282
5283        let (free_version, created) = obj
5284            .init_free_version(&fi)
5285            .expect("free-version initialization should succeed");
5286        let meta_sys = &free_version
5287            .delete_marker
5288            .expect("free-version should be a delete marker")
5289            .meta_sys;
5290
5291        assert!(created);
5292        assert_eq!(get_bytes(meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID), Some(identity));
5293        assert!(meta_sys.contains_key(&format!(
5294            "{}{}",
5295            rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX,
5296            SUFFIX_TRANSITION_TIER_DESTINATION_ID
5297        )));
5298        assert!(meta_sys.contains_key(&format!(
5299            "{}{}",
5300            rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX,
5301            SUFFIX_TRANSITION_TIER_DESTINATION_ID
5302        )));
5303    }
5304
5305    #[test]
5306    fn meta_object_init_free_version_accepts_single_prefix_transition_destination_identity() {
5307        let mut sys = HashMap::new();
5308        insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec());
5309        let identity = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_vec();
5310        sys.insert(
5311            format!(
5312                "{}{}",
5313                rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX,
5314                SUFFIX_TRANSITION_TIER_DESTINATION_ID
5315            ),
5316            identity.clone(),
5317        );
5318        let obj = make_meta_object_with_sys(sys);
5319        let mut fi = FileInfo::new("object", 2, 2);
5320        fi.set_tier_free_version_id(&Uuid::new_v4().to_string());
5321
5322        let (free_version, created) = obj
5323            .init_free_version(&fi)
5324            .expect("single-prefix legacy destination identity should remain compatible");
5325        let meta_sys = &free_version
5326            .delete_marker
5327            .expect("free-version should be a delete marker")
5328            .meta_sys;
5329
5330        assert!(created);
5331        assert_eq!(
5332            get_consistent_bytes(meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
5333            Some(identity.as_slice())
5334        );
5335    }
5336
5337    #[test]
5338    fn meta_object_init_free_version_rejects_conflicting_or_invalid_transition_destination_identity() {
5339        let valid_identity = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_vec();
5340        for (rustfs_identity, minio_identity) in [
5341            (
5342                valid_identity,
5343                b"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_vec(),
5344            ),
5345            (b"not-hex".to_vec(), b"not-hex".to_vec()),
5346            (Vec::new(), Vec::new()),
5347        ] {
5348            let mut sys = HashMap::new();
5349            insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec());
5350            sys.insert(
5351                format!(
5352                    "{}{}",
5353                    rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX,
5354                    SUFFIX_TRANSITION_TIER_DESTINATION_ID
5355                ),
5356                rustfs_identity,
5357            );
5358            sys.insert(
5359                format!(
5360                    "{}{}",
5361                    rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX,
5362                    SUFFIX_TRANSITION_TIER_DESTINATION_ID
5363                ),
5364                minio_identity,
5365            );
5366            let obj = make_meta_object_with_sys(sys);
5367            let mut fi = FileInfo::new("object", 2, 2);
5368            fi.set_tier_free_version_id(&Uuid::new_v4().to_string());
5369
5370            assert_eq!(
5371                obj.init_free_version(&fi)
5372                    .expect_err("unsafe destination identity must fail closed"),
5373                Error::FileCorrupt
5374            );
5375        }
5376    }
5377
5378    #[test]
5379    fn delete_marker_decode_skips_unknown_fields_for_forward_compat() {
5380        // A newer writer emits the three known fields plus an extra one. Decoding
5381        // must skip the unknown field instead of erroring (backlog#799 B17).
5382        let vid = Uuid::from_u128(0x1234);
5383        let mut buf = Vec::new();
5384        rmp::encode::write_map_len(&mut buf, 4).unwrap();
5385        rmp::encode::write_str(&mut buf, "ID").unwrap();
5386        rmp::encode::write_bin(&mut buf, vid.as_bytes()).unwrap();
5387        rmp::encode::write_str(&mut buf, "MTime").unwrap();
5388        rmp::encode::write_sint(&mut buf, 1_700_000_000_000_000_000i64).unwrap();
5389        rmp::encode::write_str(&mut buf, "MetaSys").unwrap();
5390        rmp::encode::write_map_len(&mut buf, 0).unwrap();
5391        // Unknown field a future version added.
5392        rmp::encode::write_str(&mut buf, "FutureField").unwrap();
5393        rmp::encode::write_str(&mut buf, "ignored").unwrap();
5394
5395        let mut dm = MetaDeleteMarker::default();
5396        dm.decode_from(&mut std::io::Cursor::new(buf))
5397            .expect("unknown fields must be skipped, not rejected");
5398        assert_eq!(dm.version_id, Some(vid));
5399        assert!(dm.mod_time.is_some());
5400    }
5401
5402    #[test]
5403    fn target_delete_marker_version_metadata_is_forward_and_backward_compatible() {
5404        let arn = "arn:rustfs:replication:us-east-1:TenantA:bucket";
5405        let suffix = format!("{}{arn}", rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX);
5406        let mut metadata = HashMap::from([(format!("{RUSTFS_INTERNAL_PREFIX}replication-status"), format!("{arn}=COMPLETED;"))]);
5407
5408        let old = get_internal_replication_state(&metadata).expect("legacy replication metadata should parse");
5409        assert!(
5410            old.target_delete_marker_version_ids.is_empty(),
5411            "a new reader must treat the missing legacy field as empty"
5412        );
5413
5414        metadata.insert(format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX), "target-id".to_string());
5415        metadata.insert(format!("{RUSTFS_INTERNAL_PREFIX}{suffix}"), "target-id".to_string());
5416        let current = get_internal_replication_state(&metadata).expect("new replication metadata should parse");
5417        assert_eq!(
5418            current.target_delete_marker_version_ids.get(arn).map(String::as_str),
5419            Some("target-id"),
5420            "matching dual-prefix values must remain readable"
5421        );
5422        assert_eq!(
5423            current.targets.get(arn),
5424            Some(&ReplicationStatusType::Completed),
5425            "the added metadata key must not disturb fields understood by old nodes"
5426        );
5427
5428        metadata.insert(
5429            format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
5430            "conflicting-id".to_string(),
5431        );
5432        let conflicted = get_internal_replication_state(&metadata).expect("replication status should still parse");
5433        assert!(
5434            conflicted.target_delete_marker_version_ids.is_empty(),
5435            "a destructive version ID must fail closed when the dual prefixes disagree"
5436        );
5437        assert!(
5438            conflicted.target_delete_marker_version_ids_corrupt,
5439            "a dual-prefix conflict must remain distinguishable from missing legacy metadata"
5440        );
5441    }
5442
5443    #[test]
5444    fn get_internal_replication_state_keeps_canonical_reset_key() {
5445        // The reset status is stored on disk under the full internal key; parsing
5446        // must keep it keyed by `target_reset_header(arn)` (not the bare ARN) so
5447        // `ReplicationState::target_state` finds it after a round trip
5448        // (backlog#799 B16).
5449        let arn = "arn:rustfs:replication:us-east-1:TenantA:bucket";
5450        let ts = "2026-06-30T00:00:00Z;reset-1".to_string();
5451        let key = crate::replication::target_reset_header(arn);
5452        let mut metadata = HashMap::new();
5453        metadata.insert(key.clone(), ts.clone());
5454
5455        let rs = get_internal_replication_state(&metadata).expect("reset state should parse");
5456        assert_eq!(
5457            rs.reset_statuses_map.get(&key),
5458            Some(&ts),
5459            "map must be keyed by target_reset_header, not the bare ARN"
5460        );
5461        assert_eq!(
5462            rs.target_state(arn).resync_timestamp,
5463            ts,
5464            "lookup must find the round-tripped reset status"
5465        );
5466    }
5467
5468    #[test]
5469    fn get_internal_replication_state_accepts_rfc3339_and_rustfs_display_timestamps() {
5470        let replica_timestamp = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
5471        let replication_timestamp = replica_timestamp + time::Duration::SECOND;
5472        let metadata = HashMap::from([
5473            (format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICA_STATUS}"), "REPLICA".to_string()),
5474            (
5475                format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICA_TIMESTAMP}"),
5476                replica_timestamp.to_string(),
5477            ),
5478            (
5479                format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICATION_STATUS}"),
5480                "arn:rustfs:replication:us-east-1:TenantA:bucket=COMPLETED;".to_string(),
5481            ),
5482            (
5483                format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICATION_TIMESTAMP}"),
5484                replication_timestamp
5485                    .format(&Rfc3339)
5486                    .expect("RFC3339 timestamp should format"),
5487            ),
5488        ]);
5489
5490        let state = get_internal_replication_state(&metadata).expect("replication metadata should parse");
5491        assert_eq!(state.replica_timestamp, Some(replica_timestamp));
5492        assert_eq!(state.replication_timestamp, Some(replication_timestamp));
5493    }
5494
5495    // ---- Header signature (backlog#861 / B12) ----
5496
5497    fn signed_object() -> MetaObject {
5498        MetaObject {
5499            version_id: Some(sample_version_id()),
5500            data_dir: Some(sample_version_id()),
5501            erasure_algorithm: ErasureAlgo::ReedSolomon,
5502            erasure_m: 2,
5503            erasure_n: 4,
5504            erasure_block_size: 1_048_576,
5505            erasure_index: 3,
5506            erasure_dist: vec![1, 2, 3, 4, 5, 6],
5507            bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
5508            part_numbers: vec![1],
5509            part_etags: vec!["etag-1".to_string()],
5510            part_sizes: vec![11],
5511            part_actual_sizes: vec![11],
5512            size: 11,
5513            mod_time: Some(sample_mod_time()),
5514            meta_user: HashMap::from([("content-type".to_string(), "text/plain".to_string())]),
5515            ..Default::default()
5516        }
5517    }
5518
5519    #[test]
5520    fn meta_object_decode_round_trips_stack_sized_keys() {
5521        let object = signed_object();
5522        let encoded = object.marshal_msg().expect("object marshal should succeed");
5523
5524        let mut decoded = MetaObject::default();
5525        decoded
5526            .decode_from(&mut std::io::Cursor::new(&encoded))
5527            .expect("object decode should succeed");
5528
5529        assert_eq!(decoded, object);
5530    }
5531
5532    #[test]
5533    fn meta_object_decode_skips_unknown_valid_utf8_field() {
5534        let mut encoded = Vec::new();
5535        rmp::encode::write_map_len(&mut encoded, 1).expect("map header should encode");
5536        rmp::encode::write_str(&mut encoded, "UnknownFutureField").expect("field key should encode");
5537        rmp::encode::write_nil(&mut encoded).expect("nil payload should encode");
5538
5539        let mut decoded = MetaObject::default();
5540        decoded
5541            .decode_from(&mut std::io::Cursor::new(&encoded))
5542            .expect("unknown valid UTF-8 field should be skipped");
5543
5544        assert_eq!(decoded, MetaObject::default());
5545    }
5546
5547    #[test]
5548    fn meta_object_decode_rejects_invalid_utf8_field_name() {
5549        let encoded = [0x81, 0xa1, 0xff, 0xc0];
5550        let mut decoded = MetaObject::default();
5551
5552        let err = decoded
5553            .decode_from(&mut std::io::Cursor::new(encoded))
5554            .expect_err("invalid UTF-8 field name should fail");
5555
5556        assert!(matches!(err, Error::FromUtf8(_)), "unexpected error: {err}");
5557    }
5558
5559    #[test]
5560    fn signature_is_no_longer_hardcoded_zero() {
5561        // Regression for B12: the write-path header must carry a real signature.
5562        let version = FileMetaVersion {
5563            version_type: VersionType::Object,
5564            object: Some(signed_object()),
5565            ..Default::default()
5566        };
5567        let header = version.header();
5568        assert_ne!(header.signature, [0, 0, 0, 0], "object header signature must be computed, not zeroed");
5569        assert_eq!(header.signature, version.get_signature(), "header must use the computed signature");
5570    }
5571
5572    #[test]
5573    fn signature_ignores_per_disk_erasure_index() {
5574        // Two disks in the same set hold the same version but differ only in the
5575        // per-disk EcIndex; their signatures must match so heal does not see a
5576        // false divergence.
5577        let mut disk_a = signed_object();
5578        disk_a.erasure_index = 1;
5579        let mut disk_b = signed_object();
5580        disk_b.erasure_index = 5;
5581        assert_eq!(disk_a.get_signature(), disk_b.get_signature());
5582    }
5583
5584    #[test]
5585    fn signature_detects_tag_and_metadata_divergence() {
5586        // The exact bug: same version_id + same mod_time, but a PutObjectTagging
5587        // that only reached some disks. Signatures must differ so the divergence
5588        // is detectable / healable.
5589        let base = signed_object();
5590
5591        let mut tagged = base.clone();
5592        tagged
5593            .meta_sys
5594            .insert("x-rustfs-internal-tags".to_string(), b"env=prod".to_vec());
5595        assert_ne!(base.get_signature(), tagged.get_signature(), "meta_sys change must move the signature");
5596
5597        let mut user_changed = base.clone();
5598        user_changed
5599            .meta_user
5600            .insert("x-amz-meta-owner".to_string(), "alice".to_string());
5601        assert_ne!(
5602            base.get_signature(),
5603            user_changed.get_signature(),
5604            "meta_user change must move the signature"
5605        );
5606
5607        let mut resized = base.clone();
5608        resized.size += 1;
5609        resized.part_sizes = vec![12];
5610        assert_ne!(base.get_signature(), resized.get_signature(), "body change must move the signature");
5611    }
5612
5613    #[test]
5614    fn signature_is_map_order_independent() {
5615        // HashMap iteration order is not stable across disks; folding maps in
5616        // order-independently must produce the same signature regardless.
5617        let mut a = signed_object();
5618        let mut b = signed_object();
5619        for obj in [&mut a, &mut b] {
5620            obj.meta_user.clear();
5621        }
5622        a.meta_user.insert("k1".into(), "v1".into());
5623        a.meta_user.insert("k2".into(), "v2".into());
5624        a.meta_user.insert("k3".into(), "v3".into());
5625        // Insert in a different order into b.
5626        b.meta_user.insert("k3".into(), "v3".into());
5627        b.meta_user.insert("k1".into(), "v1".into());
5628        b.meta_user.insert("k2".into(), "v2".into());
5629        assert_eq!(a.get_signature(), b.get_signature());
5630
5631        // But a different value for the same key must still diverge.
5632        let mut c = a.clone();
5633        c.meta_user.insert("k2".into(), "changed".into());
5634        assert_ne!(a.get_signature(), c.get_signature());
5635    }
5636
5637    #[test]
5638    fn signature_treats_empty_and_all_empty_part_etags_alike() {
5639        let mut none = signed_object();
5640        none.part_etags.clear();
5641        let mut all_empty = signed_object();
5642        all_empty.part_etags = vec![String::new(), String::new()];
5643        assert_eq!(none.get_signature(), all_empty.get_signature());
5644    }
5645
5646    #[test]
5647    fn delete_marker_signature_detects_meta_sys_divergence() {
5648        let base = MetaDeleteMarker {
5649            version_id: Some(sample_version_id()),
5650            mod_time: Some(sample_mod_time()),
5651            meta_sys: HashMap::new(),
5652        };
5653        let mut diverged = base.clone();
5654        diverged
5655            .meta_sys
5656            .insert("x-rustfs-internal-purgestatus".to_string(), b"pending".to_vec());
5657        assert_ne!(base.get_signature(), diverged.get_signature());
5658
5659        // Same content is stable across recomputation.
5660        assert_eq!(base.get_signature(), base.get_signature());
5661    }
5662
5663    #[test]
5664    fn delete_marker_stable_identity_is_order_independent_and_exact() {
5665        let version_id = sample_version_id();
5666        let mod_time = sample_mod_time();
5667        let mut first = MetaDeleteMarker {
5668            version_id: Some(version_id),
5669            mod_time: Some(mod_time),
5670            meta_sys: HashMap::new(),
5671        };
5672        first.meta_sys.insert("alpha".to_string(), vec![1, 2]);
5673        first.meta_sys.insert("beta".to_string(), vec![3, 4]);
5674
5675        let mut reordered = MetaDeleteMarker {
5676            version_id: Some(version_id),
5677            mod_time: Some(mod_time),
5678            meta_sys: HashMap::new(),
5679        };
5680        reordered.meta_sys.insert("beta".to_string(), vec![3, 4]);
5681        reordered.meta_sys.insert("alpha".to_string(), vec![1, 2]);
5682        assert_eq!(first.stable_identity(), reordered.stable_identity());
5683
5684        let mut changed = first.clone();
5685        changed.meta_sys.insert("beta".to_string(), vec![3, 5]);
5686        assert_ne!(first.stable_identity(), changed.stable_identity());
5687
5688        changed = first.clone();
5689        changed.version_id = Some(Uuid::new_v4());
5690        assert_ne!(first.stable_identity(), changed.stable_identity());
5691
5692        changed = first.clone();
5693        changed.mod_time = changed.mod_time.map(|value| value + time::Duration::NANOSECOND);
5694        assert_ne!(first.stable_identity(), changed.stable_identity());
5695    }
5696
5697    #[test]
5698    fn invalid_version_uses_error_sentinel_not_zero() {
5699        // A version whose inner body is missing must not masquerade as an
5700        // all-zero legacy signature.
5701        let version = FileMetaVersion {
5702            version_type: VersionType::Object,
5703            object: None,
5704            ..Default::default()
5705        };
5706        assert_eq!(version.get_signature(), SIGNATURE_ERR);
5707        assert_ne!(version.get_signature(), [0, 0, 0, 0]);
5708    }
5709
5710    /// Regression for backlog#799 B18: a Legacy (V1Obj) version must survive an
5711    /// encode/decode round trip. `encode_to` used to omit the `V1Obj` field
5712    /// entirely, silently dropping the whole legacy body on re-marshal.
5713    #[test]
5714    fn legacy_version_body_round_trips_through_encode() {
5715        let mut meta = HashMap::new();
5716        meta.insert("content-type".to_string(), "application/octet-stream".to_string());
5717
5718        let mut crc = HashMap::new();
5719        crc.insert("crc32c".to_string(), "deadbeef".to_string());
5720
5721        let legacy = MetaObjectV1 {
5722            version: "1.0.1".to_string(),
5723            format: "xl".to_string(),
5724            stat: MetaObjectV1Stat {
5725                size: 4096,
5726                mod_time: Some(
5727                    OffsetDateTime::from_unix_timestamp(1_700_000_123)
5728                        .unwrap()
5729                        .replace_nanosecond(456)
5730                        .unwrap(),
5731                ),
5732                name: "obj".to_string(),
5733                dir: false,
5734                mode: 0o644,
5735            },
5736            erasure: MetaObjectV1Erasure {
5737                algorithm: "klauspost/reedsolomon/vandermonde".to_string(),
5738                data_blocks: 4,
5739                parity_blocks: 2,
5740                block_size: 10 << 20,
5741                index: 1,
5742                distribution: vec![1, 2, 3, 4, 5, 6],
5743                checksums: vec![MetaObjectV1ChecksumInfo {
5744                    part_number: 1,
5745                    algorithm: "highwayhash256S".to_string(),
5746                    hash: vec![0xaa, 0xbb, 0xcc, 0xdd],
5747                }],
5748            },
5749            meta,
5750            parts: vec![MetaObjectV1Part {
5751                etag: "etag-1".to_string(),
5752                number: 1,
5753                size: 4096,
5754                actual_size: 4096,
5755                mod_time: Some(OffsetDateTime::from_unix_timestamp(1_700_000_100).unwrap()),
5756                index: Some(Bytes::from_static(&[1, 2, 3])),
5757                checksums: Some(crc),
5758                error: None,
5759            }],
5760            version_id: "00000000-0000-0000-0000-000000000001".to_string(),
5761            data_dir: "11111111-1111-1111-1111-111111111111".to_string(),
5762        };
5763
5764        let version = FileMetaVersion {
5765            version_type: VersionType::Legacy,
5766            legacy_object: Some(legacy.clone()),
5767            ..Default::default()
5768        };
5769
5770        let buf = version.marshal_msg().expect("marshal must succeed");
5771        let mut decoded = FileMetaVersion::default();
5772        decoded.unmarshal_msg(&buf).expect("unmarshal must succeed");
5773
5774        assert_eq!(decoded.version_type, VersionType::Legacy);
5775        let decoded_legacy = decoded.legacy_object.expect("legacy body must survive round trip");
5776        assert_eq!(decoded_legacy, legacy, "legacy body must be byte-for-byte preserved");
5777    }
5778
5779    /// A `None` `Stat.ModTime` must stay `None` across encode/decode — the
5780    /// encoder writes the field only when present, mirroring the decoder, so it
5781    /// never silently becomes `Some(UNIX_EPOCH)` (backlog#799 B18 hardening).
5782    #[test]
5783    fn legacy_stat_none_mod_time_round_trips_as_none() {
5784        let stat = MetaObjectV1Stat {
5785            size: 10,
5786            mod_time: None,
5787            name: "n".to_string(),
5788            dir: true,
5789            mode: 0o755,
5790        };
5791
5792        let mut buf = Vec::new();
5793        stat.encode_to(&mut buf).expect("encode must succeed");
5794        let mut decoded = MetaObjectV1Stat::default();
5795        decoded
5796            .decode_from(&mut std::io::Cursor::new(&buf))
5797            .expect("decode must succeed");
5798
5799        assert_eq!(decoded, stat);
5800        assert!(decoded.mod_time.is_none(), "absent mod_time must not become Some(UNIX_EPOCH)");
5801    }
5802}
5803
5804/// Equivalence tests proving `read_xl_meta_no_data_sync` returns byte-for-byte
5805/// identical `Result`s to the async `read_xl_meta_no_data` for every code path.
5806///
5807/// This is the critical guard for HP-12 item 1: the ecstore metadata read path
5808/// folds open+fstat+read into one `spawn_blocking` closure that calls the sync
5809/// twin, and it MUST NOT drift from the async version in either the Ok bytes or
5810/// the Err variant.
5811#[cfg(test)]
5812mod read_xl_meta_sync_equivalence_tests {
5813    use super::*;
5814    use std::io::Cursor as StdCursor;
5815    // tokio implements `AsyncRead` for `std::io::Cursor`, so the same type
5816    // drives both the async and the sync readers.
5817    use std::io::Cursor as TokioCursor;
5818
5819    /// 8-byte xl.meta header: "XL2 " magic + LE major + LE minor.
5820    fn header(major: u16, minor: u16) -> Vec<u8> {
5821        let mut v = Vec::with_capacity(8);
5822        v.extend_from_slice(&XL_FILE_HEADER);
5823        v.extend_from_slice(&major.to_le_bytes());
5824        v.extend_from_slice(&minor.to_le_bytes());
5825        v
5826    }
5827
5828    /// 5-byte msgpack `bin32` length prefix (marker 0xc6 + 4-byte BE length),
5829    /// matching what `read_bytes_header` decodes via `read_bin_len`.
5830    fn bin32_prefix(len: u32) -> [u8; 5] {
5831        let mut p = [0u8; 5];
5832        p[0] = 0xc6;
5833        p[1..].copy_from_slice(&len.to_be_bytes());
5834        p
5835    }
5836
5837    /// Build a v1.x xl.meta buffer: header + bin32(meta_len) + meta payload +
5838    /// `crc_bytes` trailer bytes + `inline` trailing inline-data bytes. The meta
5839    /// payload content is irrelevant: `read_xl_meta_no_data` never parses it.
5840    fn build_v1x(minor: u16, meta_len: usize, crc_bytes: usize, inline: usize) -> Vec<u8> {
5841        let mut v = header(1, minor);
5842        v.extend_from_slice(&bin32_prefix(meta_len as u32));
5843        v.extend(std::iter::repeat_n(0xABu8, meta_len));
5844        v.extend(std::iter::repeat_n(0xC1u8, crc_bytes));
5845        v.extend(std::iter::repeat_n(0xCDu8, inline));
5846        v
5847    }
5848
5849    async fn run_async(buf: &[u8], size: usize) -> Result<Vec<u8>> {
5850        let mut r = TokioCursor::new(buf.to_vec());
5851        read_xl_meta_no_data(&mut r, size).await
5852    }
5853
5854    fn run_sync(buf: &[u8], size: usize) -> Result<Vec<u8>> {
5855        let mut r = StdCursor::new(buf.to_vec());
5856        read_xl_meta_no_data_sync(&mut r, size)
5857    }
5858
5859    /// Drive both implementations with the same input and assert the results
5860    /// are equivalent (Ok bytes equal, or Err variant equal per `Error`'s
5861    /// `PartialEq`, which compares io kind + message for `Error::Io`).
5862    async fn assert_equivalent(label: &str, buf: &[u8], size: usize) -> Result<Vec<u8>> {
5863        let a = run_async(buf, size).await;
5864        let s = run_sync(buf, size);
5865        match (&a, &s) {
5866            (Ok(ab), Ok(sb)) => assert_eq!(ab, sb, "[{label}] Ok bytes must match"),
5867            (Err(ae), Err(se)) => assert_eq!(ae, se, "[{label}] Err variant must match"),
5868            _ => panic!("[{label}] async/sync disagree on Ok vs Err: async={a:?} sync={s:?}"),
5869        }
5870        a
5871    }
5872
5873    #[tokio::test]
5874    async fn equivalence_across_all_paths() {
5875        // (a) v1.0: whole-file read path.
5876        {
5877            let mut buf = header(1, 0);
5878            buf.extend(std::iter::repeat_n(0x11u8, 24));
5879            let len = buf.len();
5880            let out = assert_equivalent("v1.0", &buf, len).await.unwrap();
5881            assert_eq!(out, buf, "v1.0 returns the whole file");
5882        }
5883
5884        // (b) v1.1 / v1.2 / v1.3, each a clean well-formed buffer.
5885        // v1.1: minor < 2, no CRC trailer; returns header+prefix+meta.
5886        {
5887            let buf = build_v1x(1, 10, 0, 0);
5888            let len = buf.len();
5889            let out = assert_equivalent("v1.1", &buf, len).await.unwrap();
5890            assert_eq!(out.len(), 8 + 5 + 10, "v1.1 -> header+prefix+meta");
5891        }
5892        // v1.2: minor >= 2, 5-byte CRC trailer retained.
5893        {
5894            let buf = build_v1x(2, 15, 5, 0);
5895            let len = buf.len();
5896            let out = assert_equivalent("v1.2", &buf, len).await.unwrap();
5897            assert_eq!(out.len(), 8 + 5 + 15 + 5, "v1.2 -> header+prefix+meta+crc");
5898        }
5899        // v1.3: minor >= 2, 5-byte CRC trailer retained.
5900        {
5901            let buf = build_v1x(3, 20, 5, 0);
5902            let len = buf.len();
5903            let out = assert_equivalent("v1.3", &buf, len).await.unwrap();
5904            assert_eq!(out.len(), 8 + 5 + 20 + 5, "v1.3 -> header+prefix+meta+crc");
5905        }
5906
5907        // (c) size > META_DATA_READ_DEFAULT: initial 4KiB read then read_more.
5908        {
5909            let meta_len = 5000; // total = 8 + 5 + 5000 + 5 = 5018 > 4096
5910            let buf = build_v1x(3, meta_len, 5, 0);
5911            assert!(buf.len() > META_DATA_READ_DEFAULT);
5912            let len = buf.len();
5913            let out = assert_equivalent("v1.3-large", &buf, len).await.unwrap();
5914            assert_eq!(out.len(), 8 + 5 + meta_len + 5);
5915        }
5916
5917        // (d) truncated inside the header -> UnexpectedEof (initial read_exact
5918        // fails because the reader holds fewer bytes than the claimed size).
5919        {
5920            let buf = XL_FILE_HEADER.to_vec(); // only 4 bytes present
5921            assert_equivalent("truncated-header", &buf, 8).await.unwrap_err();
5922        }
5923
5924        // (e) truncated inside the CRC trailer -> FileCorrupt (v1.3 with only
5925        // 2 of the 5 trailer bytes present; size matches the buffer).
5926        {
5927            let buf = build_v1x(3, 10, 2, 0); // want=23, buf.len()=25, 25-23=2 < 5
5928            let len = buf.len();
5929            let e = assert_equivalent("truncated-crc", &buf, len).await.unwrap_err();
5930            assert_eq!(e, Error::FileCorrupt);
5931        }
5932
5933        // (f) unknown major / unknown minor -> InvalidData.
5934        // Unknown major: major=0 passes check_xl2_v1 (0 <= MAJOR) but misses the
5935        // `1 =>` arm.
5936        {
5937            let buf = header(0, 0);
5938            let e = assert_equivalent("unknown-major", &buf, buf.len()).await.unwrap_err();
5939            assert_eq!(
5940                e,
5941                Error::other(std::io::Error::new(std::io::ErrorKind::InvalidData, "Unknown major metadata version"))
5942            );
5943        }
5944        // Unknown minor: major=1, minor=4 misses the `0` and `1..=3` arms.
5945        {
5946            let buf = header(1, 4);
5947            let e = assert_equivalent("unknown-minor", &buf, buf.len()).await.unwrap_err();
5948            assert_eq!(
5949                e,
5950                Error::other(std::io::Error::new(std::io::ErrorKind::InvalidData, "Unknown minor metadata version"))
5951            );
5952        }
5953
5954        // (g) want boundaries.
5955        // Exact fit: size == want + crc, no inline data.
5956        {
5957            let buf = build_v1x(3, 20, 5, 0); // size == want+5 exactly
5958            let len = buf.len();
5959            let out = assert_equivalent("want-exact-fit", &buf, len).await.unwrap();
5960            assert_eq!(out, buf);
5961        }
5962        // Inline data past the trailer: truncate drops it, want_max clamps below size.
5963        {
5964            let buf = build_v1x(3, 20, 5, 100); // size = want+5+100
5965            let len = buf.len();
5966            let out = assert_equivalent("want-with-inline", &buf, len).await.unwrap();
5967            assert_eq!(out.len(), 8 + 5 + 20 + 5, "inline data is dropped by truncate");
5968        }
5969
5970        // (h) read_more's own read_exact hits EOF (has_full=false, read_size
5971        // within claimed size but the reader is physically shorter).
5972        {
5973            // prefix claims a 5000-byte meta so size=5018, but only 4100 bytes exist.
5974            let mut buf = header(1, 3);
5975            buf.extend_from_slice(&bin32_prefix(5000));
5976            buf.extend(std::iter::repeat_n(0xABu8, 4100 - buf.len()));
5977            assert_eq!(buf.len(), 4100);
5978            let e = assert_equivalent("read_more-eof", &buf, 5018).await.unwrap_err();
5979            assert_eq!(e, Error::Unexpected, "short read surfaces as Unexpected");
5980        }
5981    }
5982}