// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Version meta parsing with legacy compatibility. //! //! **Rule**: To parse version meta bytes (from `FileMetaShallowVersion.meta` or raw `&[u8]`), //! always use one of: //! - `FileMetaShallowVersion::parse_version_meta()` or `into_fileinfo()` //! - `FileMetaVersion::try_from(buf)` //! //! Do NOT use `FileMetaVersion::default()` + `unmarshal_msg()` directly, as that fails on //! legacy (rmp_serde) format. `try_from` falls back to rmp_serde when hand-written decode fails. use super::msgp_decode::{ PrependByteReader, prealloc_hint, read_exact_vec, read_nil_or_array_len, read_nil_or_map_len, skip_msgp_value, }; use super::*; use crate::{ChecksumInfo, TransitionVersionState}; use rustfs_utils::HashAlgorithm; use rustfs_utils::http::{ RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_FREE_VERSION, SUFFIX_INLINE_DATA, SUFFIX_PURGESTATUS, SUFFIX_TIER_FV_ID, SUFFIX_TIER_FV_MARKER, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID, SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE, contains_key_bytes, get_bytes, get_consistent_bytes, get_str, has_internal_suffix, insert_bytes, is_internal_key, remove_bytes, strip_internal_prefix, }; const MSGPACK_EXT8: u8 = 0xc7; const MSGPACK_EXT16: u8 = 0xc8; const MSGPACK_EXT32: u8 = 0xc9; const MSGPACK_FIXEXT4: u8 = 0xd6; const MSGPACK_FIXEXT8: u8 = 0xd7; const MSGPACK_TIME_EXT_LEGACY: i8 = 5; const MSGPACK_TIME_EXT_OFFICIAL: i8 = -1; const MSGPACK_TIME_LEN: u8 = 12; const MAX_TRANSITION_VERSION_LEN: usize = 1024; /// Sentinel signature returned when a version has no computable body (invalid / /// missing inner object). Mirrors MinIO's `signatureErr` so such versions never /// collide with a real all-zero legacy signature. const SIGNATURE_ERR: [u8; 4] = [b'e', b'r', b'r', 0]; /// Order-independent hash of a `String -> String` map, mirroring MinIO's /// `hashDeterministicString`. Msgpack map serialization order is not stable /// across disks, so map fields must be folded in with XOR (order-independent) /// rather than left in the marshaled body. Uses xxh64 (RustFS-internal only; /// signatures are recomputed on write and never compared against MinIO's). fn hash_deterministic_string(m: &HashMap) -> u64 { let mut crc: u64 = 0xc2b4_0bba_c11a_7295; for (k, v) in m { crc ^= (xxhash_rust::xxh64::xxh64(k.as_bytes(), 0) ^ 0x4ee3_bbaf_7ab2_506b) .wrapping_add(xxhash_rust::xxh64::xxh64(v.as_bytes(), 0) ^ 0x8da4_c8da_6619_4257); } crc } /// Order-independent hash of a `String -> Vec` map, mirroring MinIO's /// `hashDeterministicBytes`. See [`hash_deterministic_string`]. fn hash_deterministic_bytes(m: &HashMap>) -> u64 { let mut crc: u64 = 0x1bbc_7e1d_de65_4743; for (k, v) in m { crc ^= (xxhash_rust::xxh64::xxh64(k.as_bytes(), 0) ^ 0x4ee3_bbaf_7ab2_506b) .wrapping_add(xxhash_rust::xxh64::xxh64(v, 0) ^ 0x8da4_c8da_6619_4257); } crc } /// Fold a 64-bit crc into the 4-byte header signature, matching MinIO's /// `binary.LittleEndian.PutUint32(tmp, uint32(crc ^ (crc>>32)))`. fn fold_signature(crc: u64) -> [u8; 4] { ((crc ^ (crc >> 32)) as u32).to_le_bytes() } fn read_msgp_string(rd: &mut R) -> Result { let len = rmp::decode::read_str_len(rd)? as usize; let buf = read_exact_vec(rd, len)?; Ok(String::from_utf8(buf)?) } fn read_msgp_bin(rd: &mut R) -> Result> { let len = rmp::decode::read_bin_len(rd)? as usize; read_exact_vec(rd, len) } /// Writes an `OffsetDateTime` as the ext8 / legacy (type 5, 12-byte /// seconds+nanos) msgpack time encoding used by the V1 (Legacy) object body. /// `read_msgp_time` decodes exactly this shape via `MSGPACK_TIME_EXT_LEGACY`. fn write_msgp_time(wr: &mut W, t: OffsetDateTime) -> Result<()> { wr.write_all(&[MSGPACK_EXT8, MSGPACK_TIME_LEN, MSGPACK_TIME_EXT_LEGACY as u8])?; let mut buf = [0u8; MSGPACK_TIME_LEN as usize]; buf[0..8].copy_from_slice(&t.unix_timestamp().to_be_bytes()); buf[8..12].copy_from_slice(&t.nanosecond().to_be_bytes()); wr.write_all(&buf)?; Ok(()) } fn deserialize_legacy_uuid_bytes<'de, D>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, { struct LegacyUuidBytesVisitor; impl<'de> serde::de::Visitor<'de> for LegacyUuidBytesVisitor { type Value = Vec; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str("nil or binary UUID bytes") } fn visit_none(self) -> std::result::Result where E: serde::de::Error, { Ok(Vec::new()) } fn visit_unit(self) -> std::result::Result where E: serde::de::Error, { Ok(Vec::new()) } fn visit_bytes(self, value: &[u8]) -> std::result::Result where E: serde::de::Error, { Ok(value.to_vec()) } fn visit_byte_buf(self, value: Vec) -> std::result::Result where E: serde::de::Error, { Ok(value) } fn visit_seq(self, mut seq: A) -> std::result::Result where A: serde::de::SeqAccess<'de>, { let mut value = Vec::new(); while let Some(byte) = seq.next_element()? { value.push(byte); } Ok(value) } } deserializer.deserialize_any(LegacyUuidBytesVisitor) } fn decode_msgp_time_payload(ext_type: i8, payload: &[u8]) -> Result { let (secs, nanos) = match (ext_type, payload.len()) { (MSGPACK_TIME_EXT_LEGACY, 12) => { let secs = i64::from_be_bytes(payload[..8].try_into().unwrap()); let nanos = u32::from_be_bytes(payload[8..12].try_into().unwrap()); (secs, nanos) } (MSGPACK_TIME_EXT_OFFICIAL, 4) => (u32::from_be_bytes(payload.try_into().unwrap()) as i64, 0), (MSGPACK_TIME_EXT_OFFICIAL, 8) => { let v = u64::from_be_bytes(payload.try_into().unwrap()); let nanos = (v >> 34) as u32; let secs = (v & ((1 << 34) - 1)) as i64; (secs, nanos) } (MSGPACK_TIME_EXT_OFFICIAL, 12) => { let nanos = u32::from_be_bytes(payload[..4].try_into().unwrap()); let secs = i64::from_be_bytes(payload[4..12].try_into().unwrap()); (secs, nanos) } _ => { return Err(Error::other(format!( "unsupported msgpack time ext type {ext_type} len {}", payload.len() ))); } }; if nanos > 999_999_999 { return Err(Error::other(format!("invalid msgpack time nanos: {nanos}"))); } OffsetDateTime::from_unix_timestamp_nanos(secs as i128 * 1_000_000_000 + nanos as i128).map_err(Error::from) } fn read_msgp_time(rd: &mut R) -> Result { let mut tag = [0u8; 1]; rd.read_exact(&mut tag)?; let (len, ext_type) = match tag[0] { MSGPACK_FIXEXT4 => { let mut typ = [0u8; 1]; rd.read_exact(&mut typ)?; (4usize, typ[0] as i8) } MSGPACK_FIXEXT8 => { let mut typ = [0u8; 1]; rd.read_exact(&mut typ)?; (8usize, typ[0] as i8) } MSGPACK_EXT8 => { let mut len = [0u8; 1]; let mut typ = [0u8; 1]; rd.read_exact(&mut len)?; rd.read_exact(&mut typ)?; (len[0] as usize, typ[0] as i8) } MSGPACK_EXT16 => { let mut len = [0u8; 2]; let mut typ = [0u8; 1]; rd.read_exact(&mut len)?; rd.read_exact(&mut typ)?; (u16::from_be_bytes(len) as usize, typ[0] as i8) } MSGPACK_EXT32 => { let mut len = [0u8; 4]; let mut typ = [0u8; 1]; rd.read_exact(&mut len)?; rd.read_exact(&mut typ)?; (u32::from_be_bytes(len) as usize, typ[0] as i8) } other => return Err(Error::other(format!("unsupported msgpack time marker: 0x{other:02x}"))), }; let payload = read_exact_vec(rd, len)?; decode_msgp_time_payload(ext_type, &payload) } fn parse_legacy_uuid_bytes(bytes: &[u8], field: &str) -> Result> { if bytes.is_empty() { return Ok(None); } if bytes.len() != 16 { return Err(Error::other(format!("legacy {field} must be 16 bytes, got {}", bytes.len()))); } let id = Uuid::from_slice(bytes).map_err(Error::from)?; Ok((!id.is_nil()).then_some(id)) } /// Decode a stored transitioned-version-id from a version's `meta_sys`. /// /// Legacy RustFS writes used 16 raw UUID bytes. New writes and MinIO-migrated /// records use the provider's exact UTF-8 version text. Empty, nil UUID, and /// malformed bytes are not usable remote versions. fn transitioned_version_from_meta_sys(meta_sys: &HashMap>) -> Result> { if !contains_key_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID) { return Ok(None); } let Some(value) = get_consistent_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID) else { return Ok(None); }; let value = value.to_vec(); if value.is_empty() { return Ok(None); } if let Ok(id) = Uuid::from_slice(&value) { return Ok((!id.is_nil()).then(|| id.to_string())); } let Ok(value) = String::from_utf8(value) else { return Ok(None); }; if value.is_empty() || value.len() > MAX_TRANSITION_VERSION_LEN || value.chars().any(char::is_control) || Uuid::parse_str(&value).is_ok_and(|id| id.is_nil()) { Ok(None) } else { Ok(Some(value)) } } fn transition_version_state_from_meta_sys( meta_sys: &HashMap>, version: Option<&str>, ) -> Result { if !contains_key_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE) { return Ok(TransitionVersionState::Unknown); } let value = get_consistent_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE).ok_or(Error::FileCorrupt)?; let state = match value { b"known-disabled" => TransitionVersionState::KnownDisabled, b"suspended-null" => TransitionVersionState::SuspendedNull, b"exact" => TransitionVersionState::Exact, b"unknown" => TransitionVersionState::Unknown, _ => return Err(Error::FileCorrupt), }; let valid = match state { TransitionVersionState::Unknown | TransitionVersionState::KnownDisabled => version.is_none(), TransitionVersionState::SuspendedNull => version == Some("null"), TransitionVersionState::Exact => version.is_some_and(|value| value != "null"), }; valid.then_some(state).ok_or(Error::FileCorrupt) } fn transition_version_state_bytes(state: TransitionVersionState) -> &'static [u8] { match state { TransitionVersionState::Unknown => b"unknown", TransitionVersionState::KnownDisabled => b"known-disabled", TransitionVersionState::SuspendedNull => b"suspended-null", TransitionVersionState::Exact => b"exact", } } fn set_transition_version_state(meta_sys: &mut HashMap>, state: TransitionVersionState) { if state == TransitionVersionState::Unknown { remove_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE); } else { insert_bytes( meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE, transition_version_state_bytes(state).to_vec(), ); } } fn legacy_transitioned_version_id_from_meta_sys(meta_sys: &HashMap>) -> Option { transitioned_version_from_meta_sys(meta_sys) .ok() .flatten() .and_then(|value| Uuid::parse_str(&value).ok()) } fn transitioned_version_bytes(fi: &FileInfo) -> Option> { fi.transition_version .as_ref() .map(|version| version.as_bytes().to_vec()) .or_else(|| fi.transition_version_id.map(|version_id| version_id.as_bytes().to_vec())) } fn parse_legacy_erasure_algo(value: &str) -> ErasureAlgo { match value { "ReedSolomon" => ErasureAlgo::ReedSolomon, _ => ErasureAlgo::Invalid, } } fn parse_legacy_checksum_algo(value: &str) -> ChecksumAlgo { match value { "HighwayHash" => ChecksumAlgo::HighwayHash, _ => ChecksumAlgo::Invalid, } } #[derive(Debug, Deserialize)] enum LegacyMetaV2VersionType { #[serde(rename = "Object")] Object, #[serde(rename = "Delete")] Delete, #[serde(rename = "DeleteMarker")] DeleteMarker, } #[derive(Debug, Deserialize)] struct LegacyMetaV2Version { version_type: LegacyMetaV2VersionType, object: Option, delete_marker: Option, write_version: u64, } #[derive(Debug, Deserialize)] struct LegacyMetaV2Object { #[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")] version_id: Vec, #[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")] data_dir: Vec, erasure_algorithm: String, erasure_m: usize, erasure_n: usize, erasure_block_size: usize, erasure_index: usize, erasure_dist: Vec, bitrot_checksum_algo: String, part_numbers: Vec, part_etags: Vec, part_sizes: Vec, part_actual_sizes: Vec, part_indices: Vec>, size: i64, mod_time: Option, meta_sys: HashMap>, meta_user: HashMap, } #[derive(Debug, Deserialize)] struct LegacyMetaV2DeleteMarker { #[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")] version_id: Vec, mod_time: Option, meta_sys: HashMap>, } #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, Eq, PartialOrd, Ord)] pub struct FileMetaShallowVersion { pub header: FileMetaVersionHeader, pub meta: Vec, // FileMetaVersion.marshal_msg } fn write_quorum_from_erasure(data_blocks: usize, parity_blocks: usize) -> Option { if data_blocks == 0 { return None; } Some(if data_blocks == parity_blocks { data_blocks.saturating_add(1) } else { data_blocks }) } impl FileMetaShallowVersion { /// Parse version meta with legacy format compatibility. /// Use this instead of `FileMetaVersion::default()` + `unmarshal_msg()` to handle old-version xl.meta. pub fn parse_version_meta(&self) -> Result { FileMetaVersion::try_from(self.meta.as_slice()) } pub fn write_quorum(&self, fallback_quorum: usize) -> usize { let header_quorum = self.header.write_quorum(fallback_quorum); if self.header.has_ec() || !matches!(self.header.version_type, VersionType::Object | VersionType::Legacy) { return header_quorum; } self.parse_version_meta() .ok() .and_then(|version| version.erasure_write_quorum()) .unwrap_or(header_quorum) } pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result { self.parse_version_meta()?.into_fileinfo(volume, path, all_parts) } } impl TryFrom for FileMetaShallowVersion { type Error = Error; fn try_from(value: FileMetaVersion) -> std::result::Result { let header = value.header(); let meta = value.marshal_msg()?; Ok(Self { meta, header }) } } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct FileMetaVersion { #[serde(rename = "Type")] pub version_type: VersionType, #[serde(rename = "V1Obj")] pub legacy_object: Option, #[serde(rename = "V2Obj")] pub object: Option, #[serde(rename = "DelObj")] pub delete_marker: Option, #[serde(rename = "v")] pub write_version: u64, // rustfs version /// True when parsed via rmp_serde fallback (legacy format). Used for checksum algorithm selection. #[serde(skip)] pub uses_legacy_checksum: bool, } impl FileMetaVersion { fn erasure_write_quorum(&self) -> Option { let (data_blocks, parity_blocks) = match self.version_type { VersionType::Object | VersionType::Legacy => { if let Some(object) = &self.object { (object.erasure_m, object.erasure_n) } else if let Some(object) = &self.legacy_object { (object.erasure.data_blocks, object.erasure.parity_blocks) } else { return None; } } _ => return None, }; write_quorum_from_erasure(data_blocks, parity_blocks) } fn decode_data_dir_from_v2_object(buf: &[u8]) -> Result> { let mut cur = std::io::Cursor::new(buf); let mut fields = rmp::decode::read_map_len(&mut cur)?; let mut version_type = VersionType::Invalid; while fields > 0 { fields -= 1; let key_len = rmp::decode::read_str_len(&mut cur)? as usize; let key_buf = read_exact_vec(&mut cur, key_len)?; let key = String::from_utf8(key_buf)?; match key.as_str() { "Type" => { let v: i64 = rmp::decode::read_int(&mut cur)?; version_type = VersionType::from_u8(v as u8); } "V2Obj" => { if version_type != VersionType::Object { skip_msgp_value(&mut cur)?; continue; } let mut first = [0u8; 1]; cur.read_exact(&mut first)?; if first[0] == 0xc0 { return Ok(None); } let mut prepend = PrependByteReader { byte: Some(first[0]), inner: &mut cur, }; let mut obj_fields = rmp::decode::read_map_len(&mut prepend)?; let mut data_dir: Option = None; while obj_fields > 0 { obj_fields -= 1; let obj_key_len = rmp::decode::read_str_len(&mut prepend)? as usize; let obj_key_buf = read_exact_vec(&mut prepend, obj_key_len)?; let obj_key = String::from_utf8(obj_key_buf)?; if obj_key == "DDir" { let bin_len = rmp::decode::read_bin_len(&mut prepend)? as usize; if bin_len != 16 { return Err(Error::other(format!("DDir must be 16 bytes, got {bin_len}"))); } let mut raw = [0u8; 16]; prepend.read_exact(&mut raw)?; let id = Uuid::from_bytes(raw); data_dir = if id.is_nil() { None } else { Some(id) }; break; } skip_msgp_value(&mut prepend)?; } return Ok(data_dir); } _ => { skip_msgp_value(&mut cur)?; } } } Ok(None) } pub fn valid(&self) -> bool { if !self.version_type.valid() { return false; } match self.version_type { VersionType::Object => self .object .as_ref() .map(|v| v.erasure_algorithm.valid() && v.bitrot_checksum_algo.valid() && v.mod_time.is_some()) .unwrap_or_default(), VersionType::Legacy => self.legacy_object.as_ref().map(MetaObjectV1::valid).unwrap_or_default(), VersionType::Delete => self .delete_marker .as_ref() .map(|v| v.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH) > OffsetDateTime::UNIX_EPOCH) .unwrap_or_default(), _ => false, } } pub fn get_data_dir(&self) -> Option { if self.valid() { { if self.version_type == VersionType::Object { self.object.as_ref().map(|v| v.data_dir).unwrap_or_default() } else { None } } } else { Default::default() } } pub fn get_version_id(&self) -> Option { match self.version_type { VersionType::Object => self.object.as_ref().map(|v| v.version_id).unwrap_or_default(), VersionType::Delete => self.delete_marker.as_ref().map(|v| v.version_id).unwrap_or_default(), VersionType::Legacy => self.legacy_object.as_ref().and_then(MetaObjectV1::version_id), VersionType::Invalid => None, } } pub fn get_mod_time(&self) -> Option { match self.version_type { VersionType::Object => self.object.as_ref().map(|v| v.mod_time).unwrap_or_default(), VersionType::Delete => self.delete_marker.as_ref().map(|v| v.mod_time).unwrap_or_default(), VersionType::Legacy => self.legacy_object.as_ref().and_then(|v| v.stat.mod_time), VersionType::Invalid => None, } } // decode_data_dir_from_meta reads data_dir from meta TODO: directly parse only data_dir from meta buf, msg.skip pub fn decode_data_dir_from_meta(buf: &[u8]) -> Result> { if let Ok(data_dir) = Self::decode_data_dir_from_v2_object(buf) { return Ok(data_dir); } Ok(Self::try_from(buf)?.get_data_dir()) } pub fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = FileMetaVersion::default(); while fields > 0 { fields -= 1; let key_len = rmp::decode::read_str_len(rd)?; let key_buf = read_exact_vec(rd, key_len as usize)?; let key = String::from_utf8(key_buf)?; match key.as_str() { "Type" => { let v: i64 = rmp::decode::read_int(rd)?; self.version_type = VersionType::from_u8(v as u8); } "V1Obj" => { let mut buf = [0u8; 1]; rd.read_exact(&mut buf).map_err(Error::from)?; if buf[0] == 0xc0 { self.legacy_object = None; } else { let mut prepend = PrependByteReader { byte: Some(buf[0]), inner: rd, }; let mut obj = MetaObjectV1::default(); obj.decode_from(&mut prepend)?; self.legacy_object = Some(obj); } } "V2Obj" => { let mut buf = [0u8; 1]; rd.read_exact(&mut buf).map_err(Error::from)?; if buf[0] == 0xc0 { self.object = None; } else { let mut prepend = PrependByteReader { byte: Some(buf[0]), inner: rd, }; let mut obj = MetaObject::default(); obj.decode_from(&mut prepend)?; self.object = Some(obj); } } "DelObj" => { let mut buf = [0u8; 1]; rd.read_exact(&mut buf).map_err(Error::from)?; if buf[0] == 0xc0 { self.delete_marker = None; } else { let mut prepend = PrependByteReader { byte: Some(buf[0]), inner: rd, }; let mut dm = MetaDeleteMarker::default(); dm.decode_from(&mut prepend)?; self.delete_marker = Some(dm); } } "v" => { let v: i64 = rmp::decode::read_int(rd)?; if v < 0 { return Err(Error::other("negative write_version not supported")); } self.write_version = v as u64; } other => { tracing::debug!(field = %other, "decode_from: skipping unknown field"); skip_msgp_value(rd)?; } } } Ok(()) } pub fn encode_to(&self, wr: &mut W) -> Result<()> { // Variable map size: omit V1Obj/V2Obj/DelObj when None let mut map_len: u32 = 2; // "Type" + "v" if self.legacy_object.is_some() { map_len += 1; } if self.object.is_some() { map_len += 1; } if self.delete_marker.is_some() { map_len += 1; } rmp::encode::write_map_len(wr, map_len)?; // Type rmp::encode::write_str(wr, "Type")?; rmp::encode::write_uint(wr, self.version_type.to_u8() as u64)?; // V1Obj — legacy body must round-trip; dropping it silently corrupted // Legacy versions on re-encode (backlog#799 B18). if let Some(ref legacy) = self.legacy_object { rmp::encode::write_str(wr, "V1Obj")?; legacy.encode_to(wr)?; } // V2Obj if let Some(ref obj) = self.object { rmp::encode::write_str(wr, "V2Obj")?; obj.encode_to(wr)?; } // DelObj if let Some(ref dm) = self.delete_marker { rmp::encode::write_str(wr, "DelObj")?; dm.encode_to(wr)?; } // v rmp::encode::write_str(wr, "v")?; rmp::encode::write_uint(wr, self.write_version)?; Ok(()) } pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result { let mut cur = std::io::Cursor::new(buf); self.decode_from(&mut cur)?; Ok(cur.position()) } pub fn marshal_msg(&self) -> Result> { let mut wr = Vec::new(); self.encode_to(&mut wr)?; Ok(wr) } pub fn free_version(&self) -> bool { self.version_type == VersionType::Delete && self.delete_marker.as_ref().map(|m| m.free_version()).unwrap_or_default() } pub fn header(&self) -> FileMetaVersionHeader { FileMetaVersionHeader::from(self.clone()) } pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result { // Only the Object arm carries part arrays and can fail the length guard; the // Legacy and Delete arms have no part arrays and stay infallible. let mut fi = match self.version_type { VersionType::Invalid | VersionType::Legacy => { if let Some(ref legacy) = self.legacy_object { legacy.to_fileinfo(volume, path) } else { FileInfo { name: path.to_string(), volume: volume.to_string(), ..Default::default() } } } VersionType::Object => { let default_object = MetaObject::default(); self.object .as_ref() .unwrap_or(&default_object) .into_fileinfo(volume, path, all_parts)? } VersionType::Delete => { let default_marker = MetaDeleteMarker::default(); self.delete_marker .as_ref() .unwrap_or(&default_marker) .into_fileinfo(volume, path, all_parts) } }; fi.uses_legacy_checksum = self.uses_legacy_checksum; Ok(fi) } /// Support for Legacy version type pub fn is_legacy(&self) -> bool { self.version_type == VersionType::Legacy } /// Compute the header signature for this version. /// /// The signature must be identical across all disks that hold the same /// logical version, yet differ whenever any body field differs (tags, /// user/system metadata, parts, size, ...). Otherwise a partial-write /// divergence (e.g. `PutObjectTagging` reaching only some disks) shares the /// same `version_id`+`mod_time` and becomes undetectable / unhealable — the /// bug tracked in backlog#861 (B12), where this was hardcoded to `[0;4]`. /// /// Per-disk-varying fields (`erasure_index`) are excluded so identical /// content never falsely diverges. Semantics follow MinIO's /// `xlMetaV2Version.getSignature`. pub fn get_signature(&self) -> [u8; 4] { match self.version_type { VersionType::Object => self.object.as_ref().map(MetaObject::get_signature).unwrap_or(SIGNATURE_ERR), VersionType::Delete => self .delete_marker .as_ref() .map(MetaDeleteMarker::get_signature) .unwrap_or(SIGNATURE_ERR), VersionType::Legacy => self .legacy_object .as_ref() .map(MetaObjectV1::get_signature) .unwrap_or(SIGNATURE_ERR), _ => SIGNATURE_ERR, } } /// Check if this version uses data directory pub fn uses_data_dir(&self) -> bool { match self.version_type { VersionType::Object => self.object.as_ref().map(|obj| obj.uses_data_dir()).unwrap_or(false), VersionType::Legacy => false, _ => false, } } /// Check if this version uses inline data pub fn uses_inline_data(&self) -> bool { match self.version_type { VersionType::Object => self.object.as_ref().map(|obj| obj.inlinedata()).unwrap_or(false), VersionType::Legacy => false, _ => false, } } } impl TryFrom<&[u8]> for FileMetaVersion { type Error = Error; fn try_from(value: &[u8]) -> std::result::Result { let mut ver = FileMetaVersion::default(); if ver.unmarshal_msg(value).is_ok() && ver.valid() { ver.uses_legacy_checksum = false; return Ok(ver); } if let Ok(legacy_ver) = rmp_serde::from_slice::(value) { let mut ver = FileMetaVersion::try_from(legacy_ver)?; ver.uses_legacy_checksum = true; return Ok(ver); } // Fallback for legacy ver_meta: rmp_serde format let mut ver: Self = rmp_serde::from_slice(value).map_err(Error::other)?; ver.uses_legacy_checksum = true; Ok(ver) } } impl TryFrom for FileMetaVersion { type Error = Error; fn try_from(value: LegacyMetaV2Version) -> std::result::Result { let (version_type, object, delete_marker) = match value.version_type { LegacyMetaV2VersionType::Object => (VersionType::Object, value.object.map(TryInto::try_into).transpose()?, None), LegacyMetaV2VersionType::Delete | LegacyMetaV2VersionType::DeleteMarker => { (VersionType::Delete, None, value.delete_marker.map(TryInto::try_into).transpose()?) } }; Ok(Self { version_type, legacy_object: None, object, delete_marker, write_version: value.write_version, uses_legacy_checksum: true, }) } } impl From for FileMetaVersion { fn from(value: FileInfo) -> Self { if value.is_storage_delete_marker() { FileMetaVersion { version_type: VersionType::Delete, legacy_object: None, delete_marker: Some(MetaDeleteMarker::from(value)), object: None, write_version: 0, uses_legacy_checksum: false, } } else { // A `deleted` FileInfo that is not a canonical delete marker is only // legitimate as a purge-pending payload, which carries real erasure // geometry and is intentionally serialized as an Object. A `deleted` // FileInfo with neither a canonical-marker shape nor valid erasure // geometry would silently serialize as a zero-geometry MetaObject that // later fails `validate_for_metadata_read`. Write paths validate first // (`validate_for_erasure_write` / `validate_for_metadata_read`), so this // is a caller bug — surface it rather than writing malformed metadata // silently. (`From` is infallible, so this cannot return an error.) if value.deleted && !value.has_valid_erasure_geometry() { tracing::warn!( event = "filemeta_non_canonical_deleted_fileinfo_as_object", component = "filemeta", "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" ); } FileMetaVersion { version_type: VersionType::Object, legacy_object: None, delete_marker: None, object: Some(MetaObject::from(value)), write_version: 0, uses_legacy_checksum: false, } } } } impl TryFrom for FileMetaVersion { type Error = Error; fn try_from(value: FileMetaShallowVersion) -> std::result::Result { FileMetaVersion::try_from(value.meta.as_slice()) } } #[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone, Eq, Hash)] pub struct FileMetaVersionHeader { pub version_id: Option, pub mod_time: Option, pub signature: [u8; 4], pub version_type: VersionType, pub flags: u8, pub ec_n: u8, pub ec_m: u8, } impl FileMetaVersionHeader { fn reset_for_unmarshal(&mut self) { self.version_id = None; self.mod_time = None; self.signature = [0; 4]; self.version_type = VersionType::Invalid; self.flags = 0; self.ec_n = 0; self.ec_m = 0; } pub fn has_ec(&self) -> bool { self.ec_m > 0 && self.ec_n > 0 } pub fn write_quorum(&self, fallback_quorum: usize) -> usize { if self.version_type != VersionType::Object || !self.has_ec() { return fallback_quorum; } write_quorum_from_erasure(usize::from(self.ec_m), usize::from(self.ec_n)).unwrap_or(fallback_quorum) } pub fn matches_not_strict(&self, o: &FileMetaVersionHeader) -> bool { let mut ok = self.version_id == o.version_id && self.version_type == o.version_type && self.matches_ec(o); // Disk-loaded headers keep the null version as Some(nil), not None: all null // versions share one id, so mod_time is the only thing distinguishing an // interrupted overwrite from the committed version. if self.version_id.is_none() || self.version_id == Some(Uuid::nil()) { ok = ok && self.mod_time == o.mod_time; } ok } pub fn matches_ec(&self, o: &FileMetaVersionHeader) -> bool { if self.has_ec() && o.has_ec() { return self.ec_n == o.ec_n && self.ec_m == o.ec_m; } true } pub fn free_version(&self) -> bool { self.flags & XL_FLAG_FREE_VERSION != 0 } pub fn sorts_before(&self, o: &FileMetaVersionHeader) -> bool { if self == o { return false; } // Prefer newest modtime. if self.mod_time != o.mod_time { return self.mod_time > o.mod_time; } // The following doesn't make too much sense, but we want sort to be consistent nonetheless. // Prefer lower types if self.version_type != o.version_type { return self.version_type < o.version_type; } // Consistent sort on signature match self.signature.cmp(&o.signature) { Ordering::Greater => { return true; } Ordering::Less => { return false; } _ => {} } // Consistent sort on version_id match self.version_id.cmp(&o.version_id) { Ordering::Greater => { return true; } Ordering::Less => { return false; } _ => {} } if self.flags != o.flags { return self.flags > o.flags; } false } pub fn uses_data_dir(&self) -> bool { self.flags & Flags::UsesDataDir as u8 != 0 } pub fn marshal_msg(&self) -> Result> { let mut wr = Vec::new(); // array len 7 rmp::encode::write_array_len(&mut wr, 7)?; // version_id rmp::encode::write_bin(&mut wr, self.version_id.unwrap_or_default().as_bytes())?; // mod_time rmp::encode::write_i64(&mut wr, self.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp_nanos() as i64)?; // signature rmp::encode::write_bin(&mut wr, self.signature.as_slice())?; // version_type rmp::encode::write_uint8(&mut wr, self.version_type.to_u8())?; // flags rmp::encode::write_uint8(&mut wr, self.flags)?; // ec_n rmp::encode::write_uint8(&mut wr, self.ec_n)?; // ec_m rmp::encode::write_uint8(&mut wr, self.ec_m)?; Ok(wr) } pub fn unmarshal_v(&mut self, version: u8, buf: &[u8]) -> Result { match version { 1 => self.unmarshal_v1(buf), 2 => self.unmarshal_v2(buf), 3 => self.unmarshal_msg(buf), _ => Err(Error::other(format!("unknown xl header version: {version}"))), } } pub fn unmarshal_v1(&mut self, buf: &[u8]) -> Result { self.reset_for_unmarshal(); let mut cur = Cursor::new(buf); let alen = rmp::decode::read_array_len(&mut cur)?; if alen != 4 { return Err(Error::other(format!("version header array len err need 4 got {alen}"))); } rmp::decode::read_bin_len(&mut cur)?; let mut version_id = [0u8; 16]; cur.read_exact(&mut version_id)?; self.version_id = Some(Uuid::from_bytes(version_id)); let unix: i128 = rmp::decode::read_int(&mut cur)?; let time = OffsetDateTime::from_unix_timestamp_nanos(unix)?; if time != OffsetDateTime::UNIX_EPOCH { self.mod_time = Some(time); } let typ: u8 = rmp::decode::read_int(&mut cur)?; self.version_type = VersionType::from_u8(typ); self.flags = rmp::decode::read_int(&mut cur)?; Ok(cur.position()) } pub fn unmarshal_v2(&mut self, buf: &[u8]) -> Result { self.reset_for_unmarshal(); let mut cur = Cursor::new(buf); let alen = rmp::decode::read_array_len(&mut cur)?; if alen != 5 { return Err(Error::other(format!("version header array len err need 5 got {alen}"))); } rmp::decode::read_bin_len(&mut cur)?; let mut version_id = [0u8; 16]; cur.read_exact(&mut version_id)?; self.version_id = Some(Uuid::from_bytes(version_id)); let unix: i128 = rmp::decode::read_int(&mut cur)?; let time = OffsetDateTime::from_unix_timestamp_nanos(unix)?; if time != OffsetDateTime::UNIX_EPOCH { self.mod_time = Some(time); } rmp::decode::read_bin_len(&mut cur)?; cur.read_exact(&mut self.signature)?; let typ: u8 = rmp::decode::read_int(&mut cur)?; self.version_type = VersionType::from_u8(typ); self.flags = rmp::decode::read_int(&mut cur)?; Ok(cur.position()) } pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result { self.reset_for_unmarshal(); let mut cur = Cursor::new(buf); let alen = rmp::decode::read_array_len(&mut cur)?; if alen != 7 { return Err(Error::other(format!("version header array len err need 7 got {alen}"))); } // version_id rmp::decode::read_bin_len(&mut cur)?; let mut buf = [0u8; 16]; cur.read_exact(&mut buf)?; self.version_id = { let id = Uuid::from_bytes(buf); // if id.is_nil() { None } else { Some(id) } Some(id) }; // mod_time let unix: i128 = rmp::decode::read_int(&mut cur)?; let time = OffsetDateTime::from_unix_timestamp_nanos(unix)?; if time == OffsetDateTime::UNIX_EPOCH { self.mod_time = None; } else { self.mod_time = Some(time); } // signature rmp::decode::read_bin_len(&mut cur)?; cur.read_exact(&mut self.signature)?; // version_type let typ: u8 = rmp::decode::read_int(&mut cur)?; self.version_type = VersionType::from_u8(typ); // flags self.flags = rmp::decode::read_int(&mut cur)?; // ec_n self.ec_n = rmp::decode::read_int(&mut cur)?; // ec_m self.ec_m = rmp::decode::read_int(&mut cur)?; Ok(cur.position()) } /// Get signature for header pub fn get_signature(&self) -> [u8; 4] { self.signature } /// Check if this header represents inline data pub fn inline_data(&self) -> bool { self.flags & Flags::InlineData as u8 != 0 } /// Update signature based on version content pub fn update_signature(&mut self, version: &FileMetaVersion) { self.signature = version.get_signature(); } } impl PartialOrd for FileMetaVersionHeader { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for FileMetaVersionHeader { fn cmp(&self, other: &Self) -> Ordering { match self.mod_time.cmp(&other.mod_time) { core::cmp::Ordering::Equal => {} ord => return ord, } match self.version_type.cmp(&other.version_type) { core::cmp::Ordering::Equal => {} ord => return ord, } match self.signature.cmp(&other.signature) { core::cmp::Ordering::Equal => {} ord => return ord, } match self.version_id.cmp(&other.version_id) { core::cmp::Ordering::Equal => {} ord => return ord, } self.flags.cmp(&other.flags) } } impl From for FileMetaVersionHeader { fn from(value: FileMetaVersion) -> Self { let flags = { let mut f: u8 = 0; if value.free_version() { f |= Flags::FreeVersion as u8; } if value.version_type == VersionType::Object && value.object.as_ref().map(|v| v.uses_data_dir()).unwrap_or_default() { f |= Flags::UsesDataDir as u8; } if value.version_type == VersionType::Object && value.object.as_ref().map(|v| v.inlinedata()).unwrap_or_default() { f |= Flags::InlineData as u8; } f }; let (ec_n, ec_m) = match (value.version_type == VersionType::Object, value.object.as_ref()) { (true, Some(obj)) => (obj.erasure_n as u8, obj.erasure_m as u8), _ => (0, 0), }; Self { version_id: value.get_version_id(), mod_time: value.get_mod_time(), signature: value.get_signature(), version_type: value.version_type, flags, ec_n, ec_m, } } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] // Because of custom message_pack, field order must be guaranteed pub struct MetaObject { #[serde(rename = "ID")] pub version_id: Option, // Version ID #[serde(rename = "DDir")] pub data_dir: Option, // Data dir ID #[serde(rename = "EcAlgo")] pub erasure_algorithm: ErasureAlgo, // Erasure coding algorithm #[serde(rename = "EcM")] pub erasure_m: usize, // Erasure data blocks #[serde(rename = "EcN")] pub erasure_n: usize, // Erasure parity blocks #[serde(rename = "EcBSize")] pub erasure_block_size: usize, // Erasure block size #[serde(rename = "EcIndex")] pub erasure_index: usize, // Erasure disk index #[serde(rename = "EcDist")] pub erasure_dist: Vec, // Erasure distribution #[serde(rename = "CSumAlgo")] pub bitrot_checksum_algo: ChecksumAlgo, // Bitrot checksum algo #[serde(rename = "PartNums")] pub part_numbers: Vec, // Part Numbers #[serde(rename = "PartETags")] pub part_etags: Vec, // Part ETags #[serde(rename = "PartSizes")] pub part_sizes: Vec, // Part Sizes #[serde(rename = "PartASizes")] pub part_actual_sizes: Vec, // Part ActualSizes (compression) #[serde(rename = "PartIdx")] pub part_indices: Vec, // Part Indexes (compression) #[serde(rename = "Size")] pub size: i64, // Object version size #[serde(rename = "MTime")] pub mod_time: Option, // Object version modified time #[serde(rename = "MetaSys")] pub meta_sys: HashMap>, // Object version internal metadata #[serde(rename = "MetaUsr")] pub meta_user: HashMap, // Object version metadata set by user } impl TryFrom for MetaObject { type Error = Error; fn try_from(value: LegacyMetaV2Object) -> std::result::Result { Ok(Self { version_id: parse_legacy_uuid_bytes(&value.version_id, "version_id")?, data_dir: parse_legacy_uuid_bytes(&value.data_dir, "data_dir")?, erasure_algorithm: parse_legacy_erasure_algo(&value.erasure_algorithm), erasure_m: value.erasure_m, erasure_n: value.erasure_n, erasure_block_size: value.erasure_block_size, erasure_index: value.erasure_index, erasure_dist: value.erasure_dist, bitrot_checksum_algo: parse_legacy_checksum_algo(&value.bitrot_checksum_algo), part_numbers: value.part_numbers, part_etags: value.part_etags, part_sizes: value.part_sizes, part_actual_sizes: value.part_actual_sizes, part_indices: value.part_indices.into_iter().map(Bytes::from).collect(), size: value.size, mod_time: value.mod_time, meta_sys: value.meta_sys, meta_user: value.meta_user, }) } } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct MetaObjectV1 { #[serde(rename = "Version")] pub version: String, #[serde(rename = "Format")] pub format: String, #[serde(rename = "Stat")] pub stat: MetaObjectV1Stat, #[serde(rename = "Erasure")] pub erasure: MetaObjectV1Erasure, #[serde(rename = "Meta")] pub meta: HashMap, #[serde(rename = "Parts")] pub parts: Vec, #[serde(rename = "VersionID")] pub version_id: String, #[serde(rename = "DataDir")] pub data_dir: String, } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct MetaObjectV1Stat { #[serde(rename = "Size")] pub size: i64, #[serde(rename = "ModTime")] pub mod_time: Option, #[serde(rename = "Name")] pub name: String, #[serde(rename = "Dir")] pub dir: bool, #[serde(rename = "Mode")] pub mode: u32, } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct MetaObjectV1ChecksumInfo { #[serde(rename = "PartNumber")] pub part_number: usize, #[serde(rename = "Algorithm")] pub algorithm: String, #[serde(rename = "Hash")] pub hash: Vec, } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct MetaObjectV1Erasure { #[serde(rename = "Algorithm")] pub algorithm: String, #[serde(rename = "DataBlocks")] pub data_blocks: usize, #[serde(rename = "ParityBlocks")] pub parity_blocks: usize, #[serde(rename = "BlockSize")] pub block_size: usize, #[serde(rename = "Index")] pub index: usize, #[serde(rename = "Distribution")] pub distribution: Vec, #[serde(rename = "Checksums")] pub checksums: Vec, } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct MetaObjectV1Part { #[serde(rename = "e")] pub etag: String, #[serde(rename = "n")] pub number: usize, #[serde(rename = "s")] pub size: usize, #[serde(rename = "as")] pub actual_size: i64, #[serde(rename = "mt")] pub mod_time: Option, #[serde(rename = "i")] pub index: Option, #[serde(rename = "crc")] pub checksums: Option>, #[serde(rename = "err")] pub error: Option, } impl MetaObjectV1 { fn version_id(&self) -> Option { if self.version_id.is_empty() { None } else { Uuid::parse_str(&self.version_id).ok().filter(|id| !id.is_nil()) } } fn valid(&self) -> bool { if self.format != "xl" || self.stat.mod_time.is_none() { return false; } let data_blocks = self.erasure.data_blocks; let parity_blocks = self.erasure.parity_blocks; data_blocks > 0 && data_blocks >= parity_blocks && self.erasure.index > 0 && self.erasure.index <= data_blocks + parity_blocks && crate::fileinfo::is_valid_distribution(&self.erasure.distribution, data_blocks + parity_blocks) } fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = Self::default(); while fields > 0 { fields -= 1; let key = read_msgp_string(rd)?; match key.as_str() { "Version" => self.version = read_msgp_string(rd)?, "Format" => self.format = read_msgp_string(rd)?, "Stat" => self.stat.decode_from(rd)?, "Erasure" => self.erasure.decode_from(rd)?, "Meta" => { let len = rmp::decode::read_map_len(rd)? as usize; self.meta.clear(); for _ in 0..len { self.meta.insert(read_msgp_string(rd)?, read_msgp_string(rd)?); } } "Parts" => { let len = rmp::decode::read_array_len(rd)? as usize; self.parts.clear(); self.parts.reserve(prealloc_hint(len)); for _ in 0..len { let mut part = MetaObjectV1Part::default(); part.decode_from(rd)?; self.parts.push(part); } } "VersionID" => self.version_id = read_msgp_string(rd)?, "DataDir" => self.data_dir = read_msgp_string(rd)?, _ => skip_msgp_value(rd)?, } } Ok(()) } /// Symmetric encoder for the V1 (Legacy) object body. Kept field-for-field /// consistent with `decode_from` so a Legacy version round-trips through /// `FileMetaVersion::encode_to` without losing its body (backlog#799 B18). fn encode_to(&self, wr: &mut W) -> Result<()> { rmp::encode::write_map_len(wr, 8)?; rmp::encode::write_str(wr, "Version")?; rmp::encode::write_str(wr, &self.version)?; rmp::encode::write_str(wr, "Format")?; rmp::encode::write_str(wr, &self.format)?; rmp::encode::write_str(wr, "Stat")?; self.stat.encode_to(wr)?; rmp::encode::write_str(wr, "Erasure")?; self.erasure.encode_to(wr)?; rmp::encode::write_str(wr, "Meta")?; rmp::encode::write_map_len(wr, self.meta.len() as u32)?; for (k, v) in &self.meta { rmp::encode::write_str(wr, k)?; rmp::encode::write_str(wr, v)?; } rmp::encode::write_str(wr, "Parts")?; rmp::encode::write_array_len(wr, self.parts.len() as u32)?; for part in &self.parts { part.encode_to(wr)?; } rmp::encode::write_str(wr, "VersionID")?; rmp::encode::write_str(wr, &self.version_id)?; rmp::encode::write_str(wr, "DataDir")?; rmp::encode::write_str(wr, &self.data_dir)?; Ok(()) } fn get_signature(&self) -> [u8; 4] { let mut hasher = xxhash_rust::xxh64::Xxh64::new(XXHASH_SEED); hasher.update(self.version.as_bytes()); hasher.update(self.format.as_bytes()); hasher.update(&self.stat.size.to_le_bytes()); hasher.update(&self.stat.mode.to_le_bytes()); if let Some(mod_time) = self.stat.mod_time { hasher.update(&mod_time.unix_timestamp_nanos().to_le_bytes()); } hasher.update(self.erasure.algorithm.as_bytes()); hasher.update(&(self.erasure.data_blocks as u64).to_le_bytes()); hasher.update(&(self.erasure.parity_blocks as u64).to_le_bytes()); hasher.update(&(self.erasure.block_size as u64).to_le_bytes()); for v in &self.erasure.distribution { hasher.update(&(*v as u64).to_le_bytes()); } for checksum in &self.erasure.checksums { hasher.update(&(checksum.part_number as u64).to_le_bytes()); hasher.update(checksum.algorithm.as_bytes()); hasher.update(&checksum.hash); } let mut meta_keys: Vec<_> = self.meta.iter().collect(); meta_keys.sort_by(|a, b| a.0.cmp(b.0)); for (k, v) in meta_keys { hasher.update(k.as_bytes()); hasher.update(v.as_bytes()); } for part in &self.parts { hasher.update(&(part.number as u64).to_le_bytes()); hasher.update(&(part.size as u64).to_le_bytes()); hasher.update(&part.actual_size.to_le_bytes()); hasher.update(part.etag.as_bytes()); if let Some(mod_time) = part.mod_time { hasher.update(&mod_time.unix_timestamp_nanos().to_le_bytes()); } if let Some(index) = &part.index { hasher.update(index); } } let hash = hasher.finish(); let bytes = hash.to_le_bytes(); [bytes[0], bytes[1], bytes[2], bytes[3]] } fn to_fileinfo(&self, volume: &str, path: &str) -> FileInfo { FileInfo { volume: volume.to_string(), name: path.to_string(), version_id: self.version_id(), mod_time: self.stat.mod_time, size: self.stat.size, mode: Some(self.stat.mode), metadata: self.meta.clone(), parts: self.parts.iter().cloned().map(Into::into).collect(), erasure: self.erasure.clone().into(), num_versions: 1, data_dir: Uuid::parse_str(&self.data_dir).ok().filter(|id| !id.is_nil()), ..Default::default() } } } impl MetaObjectV1Stat { fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = Self::default(); while fields > 0 { fields -= 1; let key = read_msgp_string(rd)?; match key.as_str() { "Size" => self.size = rmp::decode::read_int(rd)?, "ModTime" => self.mod_time = Some(read_msgp_time(rd)?), "Name" => self.name = read_msgp_string(rd)?, "Dir" => self.dir = rmp::decode::read_bool(rd)?, "Mode" => self.mode = rmp::decode::read_u32(rd)?, _ => skip_msgp_value(rd)?, } } Ok(()) } fn encode_to(&self, wr: &mut W) -> Result<()> { // `ModTime` is only written when present, mirroring `decode_from` (which // sets it only when the field exists) so a `None` mod_time stays absent // rather than round-tripping to `Some(UNIX_EPOCH)`. let map_len: u32 = if self.mod_time.is_some() { 5 } else { 4 }; rmp::encode::write_map_len(wr, map_len)?; rmp::encode::write_str(wr, "Size")?; rmp::encode::write_sint(wr, self.size)?; if let Some(mod_time) = self.mod_time { rmp::encode::write_str(wr, "ModTime")?; write_msgp_time(wr, mod_time)?; } rmp::encode::write_str(wr, "Name")?; rmp::encode::write_str(wr, &self.name)?; rmp::encode::write_str(wr, "Dir")?; rmp::encode::write_bool(wr, self.dir)?; rmp::encode::write_str(wr, "Mode")?; // `Mode` decodes with the strict `read_u32`, which only accepts the U32 // marker — a narrower `write_uint` marker would fail to round-trip. rmp::encode::write_u32(wr, self.mode)?; Ok(()) } } impl MetaObjectV1Erasure { fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = Self::default(); while fields > 0 { fields -= 1; let key = read_msgp_string(rd)?; match key.as_str() { "Algorithm" => self.algorithm = read_msgp_string(rd)?, "DataBlocks" => self.data_blocks = rmp::decode::read_int::(rd)? as usize, "ParityBlocks" => self.parity_blocks = rmp::decode::read_int::(rd)? as usize, "BlockSize" => self.block_size = rmp::decode::read_int::(rd)? as usize, "Index" => self.index = rmp::decode::read_int::(rd)? as usize, "Distribution" => { let len = rmp::decode::read_array_len(rd)? as usize; self.distribution.clear(); self.distribution.reserve(prealloc_hint(len)); for _ in 0..len { self.distribution.push(rmp::decode::read_int::(rd)? as usize); } } "Checksums" => { let len = rmp::decode::read_array_len(rd)? as usize; self.checksums.clear(); self.checksums.reserve(prealloc_hint(len)); for _ in 0..len { let mut checksum = MetaObjectV1ChecksumInfo::default(); checksum.decode_from(rd)?; self.checksums.push(checksum); } } _ => skip_msgp_value(rd)?, } } Ok(()) } fn encode_to(&self, wr: &mut W) -> Result<()> { rmp::encode::write_map_len(wr, 7)?; rmp::encode::write_str(wr, "Algorithm")?; rmp::encode::write_str(wr, &self.algorithm)?; rmp::encode::write_str(wr, "DataBlocks")?; rmp::encode::write_sint(wr, self.data_blocks as i64)?; rmp::encode::write_str(wr, "ParityBlocks")?; rmp::encode::write_sint(wr, self.parity_blocks as i64)?; rmp::encode::write_str(wr, "BlockSize")?; rmp::encode::write_sint(wr, self.block_size as i64)?; rmp::encode::write_str(wr, "Index")?; rmp::encode::write_sint(wr, self.index as i64)?; rmp::encode::write_str(wr, "Distribution")?; rmp::encode::write_array_len(wr, self.distribution.len() as u32)?; for v in &self.distribution { rmp::encode::write_sint(wr, *v as i64)?; } rmp::encode::write_str(wr, "Checksums")?; rmp::encode::write_array_len(wr, self.checksums.len() as u32)?; for checksum in &self.checksums { checksum.encode_to(wr)?; } Ok(()) } } impl MetaObjectV1ChecksumInfo { fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = Self::default(); while fields > 0 { fields -= 1; let key = read_msgp_string(rd)?; match key.as_str() { "PartNumber" => self.part_number = rmp::decode::read_int::(rd)? as usize, "Algorithm" => self.algorithm = read_msgp_string(rd)?, "Hash" => self.hash = read_msgp_bin(rd)?, _ => skip_msgp_value(rd)?, } } Ok(()) } fn encode_to(&self, wr: &mut W) -> Result<()> { rmp::encode::write_map_len(wr, 3)?; rmp::encode::write_str(wr, "PartNumber")?; rmp::encode::write_sint(wr, self.part_number as i64)?; rmp::encode::write_str(wr, "Algorithm")?; rmp::encode::write_str(wr, &self.algorithm)?; rmp::encode::write_str(wr, "Hash")?; rmp::encode::write_bin(wr, &self.hash)?; Ok(()) } } impl MetaObjectV1Part { fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = Self::default(); while fields > 0 { fields -= 1; let key = read_msgp_string(rd)?; match key.as_str() { "e" => self.etag = read_msgp_string(rd)?, "n" => self.number = rmp::decode::read_int::(rd)? as usize, "s" => self.size = rmp::decode::read_int::(rd)? as usize, "as" => self.actual_size = rmp::decode::read_int(rd)?, "mt" => self.mod_time = Some(read_msgp_time(rd)?), "i" => self.index = Some(Bytes::from(read_msgp_bin(rd)?)), "crc" => { let len = rmp::decode::read_map_len(rd)? as usize; let mut checksums = HashMap::with_capacity(prealloc_hint(len)); for _ in 0..len { checksums.insert(read_msgp_string(rd)?, read_msgp_string(rd)?); } self.checksums = Some(checksums); } "err" => self.error = Some(read_msgp_string(rd)?), _ => skip_msgp_value(rd)?, } } Ok(()) } fn encode_to(&self, wr: &mut W) -> Result<()> { // Non-optional scalars are always written; optional fields only when // present, so a decoded part round-trips (absent stays absent). let mut map_len: u32 = 4; // e, n, s, as if self.mod_time.is_some() { map_len += 1; } if self.index.is_some() { map_len += 1; } if self.checksums.is_some() { map_len += 1; } if self.error.is_some() { map_len += 1; } rmp::encode::write_map_len(wr, map_len)?; rmp::encode::write_str(wr, "e")?; rmp::encode::write_str(wr, &self.etag)?; rmp::encode::write_str(wr, "n")?; rmp::encode::write_sint(wr, self.number as i64)?; rmp::encode::write_str(wr, "s")?; rmp::encode::write_sint(wr, self.size as i64)?; rmp::encode::write_str(wr, "as")?; rmp::encode::write_sint(wr, self.actual_size)?; if let Some(mt) = self.mod_time { rmp::encode::write_str(wr, "mt")?; write_msgp_time(wr, mt)?; } if let Some(ref index) = self.index { rmp::encode::write_str(wr, "i")?; rmp::encode::write_bin(wr, index)?; } if let Some(ref checksums) = self.checksums { rmp::encode::write_str(wr, "crc")?; rmp::encode::write_map_len(wr, checksums.len() as u32)?; for (k, v) in checksums { rmp::encode::write_str(wr, k)?; rmp::encode::write_str(wr, v)?; } } if let Some(ref err) = self.error { rmp::encode::write_str(wr, "err")?; rmp::encode::write_str(wr, err)?; } Ok(()) } } impl From for ErasureInfo { fn from(value: MetaObjectV1Erasure) -> Self { ErasureInfo { algorithm: value.algorithm, data_blocks: value.data_blocks, parity_blocks: value.parity_blocks, block_size: value.block_size, index: value.index, distribution: value.distribution, checksums: value.checksums.into_iter().map(Into::into).collect(), } } } impl From for ChecksumInfo { fn from(value: MetaObjectV1ChecksumInfo) -> Self { ChecksumInfo { part_number: value.part_number, algorithm: match value.algorithm.as_str() { "sha256" => HashAlgorithm::SHA256, "highwayhash256" => HashAlgorithm::HighwayHash256, "highwayhash256S" => HashAlgorithm::HighwayHash256S, "blake2b" | "blake2b512" => HashAlgorithm::BLAKE2b512, _ => HashAlgorithm::HighwayHash256S, }, hash: Bytes::from(value.hash), } } } impl From for ObjectPartInfo { fn from(value: MetaObjectV1Part) -> Self { ObjectPartInfo { etag: value.etag, number: value.number, size: value.size, actual_size: value.actual_size, mod_time: value.mod_time, index: value.index, checksums: value.checksums, error: value.error, } } } impl MetaObject { pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result { let mut cur = std::io::Cursor::new(buf); self.decode_from(&mut cur)?; Ok(cur.position()) } // marshal_msg custom messagepack naming consistent with go pub fn marshal_msg(&self) -> Result> { let mut wr = Vec::new(); self.encode_to(&mut wr)?; Ok(wr) } /// Compute this object version's header signature, mirroring MinIO's /// `xlMetaV2Object.Signature`. See [`FileMetaVersion::get_signature`] for /// why divergence detection requires covering every body field. pub fn get_signature(&self) -> [u8; 4] { let mut c = self.clone(); // Zero fields that legitimately vary per disk within an erasure set. c.erasure_index = 0; // Treat an all-empty PartETags vector the same as an absent one, so two // disks that encode `[]` vs `["", ""]` do not falsely diverge. if c.part_etags.iter().all(String::is_empty) { c.part_etags.clear(); } // Fold maps in with an order-independent hash: msgpack map order is not // stable across disks, so they must not be part of the marshaled body. let mut crc = hash_deterministic_string(&c.meta_user); crc ^= hash_deterministic_bytes(&c.meta_sys); c.meta_sys.clear(); c.meta_user.clear(); if let Ok(bytes) = c.marshal_msg() { crc ^= xxhash_rust::xxh64::xxh64(&bytes, XXHASH_SEED); } fold_signature(crc) } pub fn encode_to(&self, wr: &mut W) -> Result<()> { // Variable map size: omit PartIdx when empty let mut map_len = 18u32; if self.part_indices.is_empty() { map_len -= 1; } rmp::encode::write_map_len(wr, map_len)?; // ID rmp::encode::write_str(wr, "ID")?; rmp::encode::write_bin(wr, self.version_id.unwrap_or_default().as_bytes())?; // DDir rmp::encode::write_str(wr, "DDir")?; rmp::encode::write_bin(wr, self.data_dir.unwrap_or_default().as_bytes())?; // EcAlgo rmp::encode::write_str(wr, "EcAlgo")?; rmp::encode::write_uint(wr, self.erasure_algorithm.to_u8() as u64)?; // EcM rmp::encode::write_str(wr, "EcM")?; rmp::encode::write_sint(wr, self.erasure_m as i64)?; // EcN rmp::encode::write_str(wr, "EcN")?; rmp::encode::write_sint(wr, self.erasure_n as i64)?; // EcBSize rmp::encode::write_str(wr, "EcBSize")?; rmp::encode::write_sint(wr, self.erasure_block_size as i64)?; // EcIndex rmp::encode::write_str(wr, "EcIndex")?; rmp::encode::write_sint(wr, self.erasure_index as i64)?; // EcDist rmp::encode::write_str(wr, "EcDist")?; rmp::encode::write_array_len(wr, self.erasure_dist.len() as u32)?; for v in &self.erasure_dist { rmp::encode::write_uint(wr, *v as u64)?; } // CSumAlgo rmp::encode::write_str(wr, "CSumAlgo")?; rmp::encode::write_uint(wr, self.bitrot_checksum_algo.to_u8() as u64)?; // PartNums rmp::encode::write_str(wr, "PartNums")?; rmp::encode::write_array_len(wr, self.part_numbers.len() as u32)?; for n in &self.part_numbers { rmp::encode::write_sint(wr, *n as i64)?; } // PartETags (write nil when empty) rmp::encode::write_str(wr, "PartETags")?; if self.part_etags.is_empty() { rmp::encode::write_nil(wr)?; } else { rmp::encode::write_array_len(wr, self.part_etags.len() as u32)?; for et in &self.part_etags { rmp::encode::write_str(wr, et)?; } } // PartSizes rmp::encode::write_str(wr, "PartSizes")?; rmp::encode::write_array_len(wr, self.part_sizes.len() as u32)?; for s in &self.part_sizes { rmp::encode::write_sint(wr, *s as i64)?; } // PartASizes (write nil when empty) rmp::encode::write_str(wr, "PartASizes")?; if self.part_actual_sizes.is_empty() { rmp::encode::write_nil(wr)?; } else { rmp::encode::write_array_len(wr, self.part_actual_sizes.len() as u32)?; for s in &self.part_actual_sizes { rmp::encode::write_sint(wr, *s)?; } } // PartIdx (omit when empty) if !self.part_indices.is_empty() { rmp::encode::write_str(wr, "PartIdx")?; rmp::encode::write_array_len(wr, self.part_indices.len() as u32)?; for idx in &self.part_indices { rmp::encode::write_bin(wr, idx)?; } } // Size rmp::encode::write_str(wr, "Size")?; rmp::encode::write_sint(wr, self.size)?; // MTime Unix timestamp nanos rmp::encode::write_str(wr, "MTime")?; let nanos = self.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp_nanos(); rmp::encode::write_sint(wr, nanos as i64)?; // MetaSys (write nil when empty) rmp::encode::write_str(wr, "MetaSys")?; if self.meta_sys.is_empty() { rmp::encode::write_nil(wr)?; } else { rmp::encode::write_map_len(wr, self.meta_sys.len() as u32)?; for (k, v) in &self.meta_sys { rmp::encode::write_str(wr, k)?; rmp::encode::write_bin(wr, v)?; } } // MetaUsr (write nil when empty) rmp::encode::write_str(wr, "MetaUsr")?; if self.meta_user.is_empty() { rmp::encode::write_nil(wr)?; } else { rmp::encode::write_map_len(wr, self.meta_user.len() as u32)?; for (k, v) in &self.meta_user { rmp::encode::write_str(wr, k)?; rmp::encode::write_str(wr, v)?; } } Ok(()) } pub fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_map_len failed"); e })?; *self = MetaObject::default(); while fields > 0 { fields -= 1; let key_len = rmp::decode::read_str_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_str_len key failed"); e })?; let key_buf = read_exact_vec(rd, key_len as usize).map_err(|e| { tracing::error!(error = %e, "decode_from: read key_buf failed"); e })?; let key = String::from_utf8(key_buf).map_err(|e| { tracing::error!(error = %e, "decode_from: from_utf8 key failed"); e })?; match key.as_str() { "ID" => { let _ = rmp::decode::read_bin_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_bin_len ID failed"); e })?; let mut buf = [0u8; 16]; rd.read_exact(&mut buf).map_err(|e| { tracing::error!(error = %e, "decode_from: read_exact ID buf failed"); e })?; let id = Uuid::from_bytes(buf); self.version_id = if id.is_nil() { None } else { Some(id) }; } "DDir" => { let _ = rmp::decode::read_bin_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_bin_len DDir failed"); e })?; let mut buf = [0u8; 16]; rd.read_exact(&mut buf).map_err(|e| { tracing::error!(error = %e, "decode_from: read_exact DDir buf failed"); e })?; let id = Uuid::from_bytes(buf); self.data_dir = if id.is_nil() { None } else { Some(id) }; } "EcAlgo" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int EcAlgo failed"); e })?; self.erasure_algorithm = ErasureAlgo::from_u8(v as u8); } "EcM" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int EcM failed"); e })?; self.erasure_m = v as usize; } "EcN" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int EcN failed"); e })?; self.erasure_n = v as usize; } "EcBSize" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int EcBSize failed"); e })?; self.erasure_block_size = v as usize; } "EcIndex" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int EcIndex failed"); e })?; self.erasure_index = v as usize; } "EcDist" => { let len = rmp::decode::read_array_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_array_len EcDist failed"); e })? as usize; self.erasure_dist.clear(); self.erasure_dist.reserve(prealloc_hint(len)); for _ in 0..len { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int EcDist item failed"); e })?; self.erasure_dist.push(v as u8); } } "CSumAlgo" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int CSumAlgo failed"); e })?; self.bitrot_checksum_algo = ChecksumAlgo::from_u8(v as u8); } "PartNums" => { let len = rmp::decode::read_array_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_array_len PartNums failed"); e })? as usize; self.part_numbers.clear(); self.part_numbers.reserve(prealloc_hint(len)); for _ in 0..len { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int PartNums item failed"); e })?; self.part_numbers.push(v as usize); } } "PartETags" => { let len = match read_nil_or_array_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read PartETags failed"); e })? { None => { self.part_etags.clear(); continue; } Some(n) => n, }; self.part_etags.clear(); self.part_etags.reserve(prealloc_hint(len)); for _ in 0..len { let s_len = rmp::decode::read_str_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_str_len PartETags item failed"); e })?; let sbuf = read_exact_vec(rd, s_len as usize).map_err(|e| { tracing::error!(error = %e, "decode_from: read PartETags sbuf failed"); e })?; let s = String::from_utf8(sbuf).map_err(|e| { tracing::error!(error = %e, "decode_from: from_utf8 PartETags item failed"); e })?; self.part_etags.push(s); } } "PartSizes" => { let len = rmp::decode::read_array_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_array_len PartSizes failed"); e })? as usize; self.part_sizes.clear(); self.part_sizes.reserve(prealloc_hint(len)); for _ in 0..len { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int PartSizes item failed"); e })?; self.part_sizes.push(v as usize); } } "PartASizes" => { let len = match read_nil_or_array_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read PartASizes failed"); e })? { None => { self.part_actual_sizes.clear(); continue; } Some(n) => n, }; self.part_actual_sizes.clear(); self.part_actual_sizes.reserve(prealloc_hint(len)); for _ in 0..len { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int PartASizes item failed"); e })?; self.part_actual_sizes.push(v); } } "PartIdx" => { let len = rmp::decode::read_array_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_array_len PartIdx failed"); e })? as usize; self.part_indices.clear(); self.part_indices.reserve(prealloc_hint(len)); for _ in 0..len { let blen = rmp::decode::read_bin_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_bin_len PartIdx item failed"); e })? as usize; let buf = read_exact_vec(rd, blen).map_err(|e| { tracing::error!(error = %e, "decode_from: read PartIdx buf failed"); e })?; self.part_indices.push(Bytes::from(buf)); } } "Size" => { let v: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int Size failed"); e })?; self.size = v; } "MTime" => { let nanos: i64 = rmp::decode::read_int(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_int MTime failed"); e })?; let time = OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).inspect_err(|&e| { tracing::error!(error = %e, "decode_from: from_unix_timestamp_nanos MTime failed"); })?; self.mod_time = if time == OffsetDateTime::UNIX_EPOCH { None } else { Some(time) }; } "MetaSys" => { let len = match read_nil_or_map_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read MetaSys failed"); e })? { None => { self.meta_sys.clear(); continue; } Some(n) => n, }; self.meta_sys.clear(); for _ in 0..len { let k_len = rmp::decode::read_str_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_str_len MetaSys key failed"); e })?; let kbuf = read_exact_vec(rd, k_len as usize).map_err(|e| { tracing::error!(error = %e, "decode_from: read MetaSys kbuf failed"); e })?; let k = String::from_utf8(kbuf).map_err(|e| { tracing::error!(error = %e, "decode_from: from_utf8 MetaSys key failed"); e })?; let blen = rmp::decode::read_bin_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_bin_len MetaSys value failed"); e })? as usize; let v = read_exact_vec(rd, blen).map_err(|e| { tracing::error!(error = %e, "decode_from: read MetaSys value failed"); e })?; self.meta_sys.insert(k, v); } } "MetaUsr" => { let len = match read_nil_or_map_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read MetaUsr failed"); e })? { None => { self.meta_user.clear(); continue; } Some(n) => n, }; self.meta_user.clear(); for _ in 0..len { let k_len = rmp::decode::read_str_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_str_len MetaUsr key failed"); e })?; let kbuf = read_exact_vec(rd, k_len as usize).map_err(|e| { tracing::error!(error = %e, "decode_from: read MetaUsr kbuf failed"); e })?; let k = String::from_utf8(kbuf).map_err(|e| { tracing::error!(error = %e, "decode_from: from_utf8 MetaUsr key failed"); e })?; let v_len = rmp::decode::read_str_len(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: read_str_len MetaUsr value failed"); e })?; let vbuf = read_exact_vec(rd, v_len as usize).map_err(|e| { tracing::error!(error = %e, "decode_from: read MetaUsr vbuf failed"); e })?; let v = String::from_utf8(vbuf).map_err(|e| { tracing::error!(error = %e, "decode_from: from_utf8 MetaUsr value failed"); e })?; self.meta_user.insert(k, v); } } other => { // Skip unknown fields for forward compatibility with Go (dc.Skip()) tracing::debug!(field = %other, "decode_from: skipping unknown field"); skip_msgp_value(rd).map_err(|e| { tracing::error!(error = %e, "decode_from: skip unknown field failed"); e })?; } } } Ok(()) } pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result { let version_id = self.version_id.filter(|&vid| !vid.is_nil()); let parts = if all_parts { let n = self.part_numbers.len(); // Required fields: `part_sizes`/`part_actual_sizes` must match `part_numbers` // exactly. These arrays are decoded from independent msgpack length prefixes // (`decode_from`), so a truncated/half-written/bitrot xl.meta can leave them // shorter (or longer) than `part_numbers`. Indexing without this guard would // panic; silently defaulting to 0 would miscompute Content-Length / Range / // multipart boundaries and hand corrupt data to the client (worse than an // error). `n == 0` preserves the legitimate "no-part object / legacy / empty // array" wire (MinIO parity: empty PartNumbers is valid). if n != 0 && (self.part_sizes.len() != n || self.part_actual_sizes.len() != n) { return Err(Error::FileCorrupt); } let mut parts = vec![ObjectPartInfo::default(); n]; for (i, part) in parts.iter_mut().enumerate() { part.number = self.part_numbers[i]; part.size = self.part_sizes[i]; part.actual_size = self.part_actual_sizes[i]; // etag/index stay soft-guarded: they are recomputable / optional, an empty // PartETags is legitimate MinIO interop, and a length mismatch there does // not corrupt returned data. if self.part_etags.len() == n { part.etag = self.part_etags[i].clone(); } if self.part_indices.len() == n { part.index = if self.part_indices[i].is_empty() { None } else { Some(self.part_indices[i].clone()) }; } } parts } else { Vec::new() }; let mut metadata = HashMap::with_capacity(self.meta_user.len() + self.meta_sys.len()); for (k, v) in &self.meta_user { if k == AMZ_META_UNENCRYPTED_CONTENT_LENGTH || k == AMZ_META_UNENCRYPTED_CONTENT_MD5 { continue; } if k == AMZ_STORAGE_CLASS && v == "STANDARD" { continue; } metadata.insert(k.to_owned(), v.to_owned()); } for (k, v) in &self.meta_sys { if has_internal_suffix(k, SUFFIX_TIER_FV_ID) || has_internal_suffix(k, SUFFIX_TIER_FV_MARKER) { continue; } if k.eq_ignore_ascii_case(AMZ_STORAGE_CLASS) && v == b"STANDARD" { continue; } if is_internal_key(k) { metadata.insert(k.to_owned(), String::from_utf8(v.to_owned()).unwrap_or_default()); } } let replication_state_internal = get_internal_replication_state(&metadata); let mut deleted = false; if let Some(v) = replication_state_internal.as_ref() { if !v.composite_version_purge_status().is_empty() { deleted = true; } let st = v.composite_replication_status(); if !st.is_empty() { metadata.insert(AMZ_BUCKET_REPLICATION_STATUS.to_string(), st.to_string()); } } let checksum = get_bytes(&self.meta_sys, SUFFIX_CRC).map(Bytes::from); let erasure = ErasureInfo { algorithm: self.erasure_algorithm.to_string(), data_blocks: self.erasure_m, parity_blocks: self.erasure_n, block_size: self.erasure_block_size, index: self.erasure_index, distribution: self.erasure_dist.iter().map(|&v| v as usize).collect(), ..Default::default() }; let transition_status = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_STATUS) .map(|v| String::from_utf8_lossy(&v).to_string()) .unwrap_or_default(); let transitioned_objname = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME) .map(|v| String::from_utf8_lossy(&v).to_string()) .unwrap_or_default(); let transition_version = transitioned_version_from_meta_sys(&self.meta_sys)?; let transition_version_state = transition_version_state_from_meta_sys(&self.meta_sys, transition_version.as_deref())?; let transition_version_id = transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok()); let transition_tier = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER) .map(|v| String::from_utf8_lossy(&v).to_string()) .unwrap_or_default(); Ok(FileInfo { version_id, erasure, data_dir: self.data_dir, mod_time: self.mod_time, size: self.size, name: path.to_string(), volume: volume.to_string(), parts, metadata, replication_state_internal, deleted, checksum, transition_status, transitioned_objname, transition_version_id, transition_version, transition_version_state, transition_tier, ..Default::default() }) } pub fn set_transition(&mut self, fi: &FileInfo) { insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_STATUS, fi.transition_status.as_bytes().to_vec()); insert_bytes( &mut self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME, fi.transitioned_objname.as_bytes().to_vec(), ); if let Some(transition_version) = transitioned_version_bytes(fi) { insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version); } else { remove_bytes(&mut self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID); } set_transition_version_state(&mut self.meta_sys, fi.transition_version_state); insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER, fi.transition_tier.as_bytes().to_vec()); if let Some(destination_id) = get_str(&fi.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID) { insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, destination_id.into_bytes()); } } pub fn remove_restore_hdrs(&mut self) { self.meta_user.remove(X_AMZ_RESTORE.as_str()); self.meta_user.remove(AMZ_RESTORE_EXPIRY_DAYS); self.meta_user.remove(AMZ_RESTORE_REQUEST_DATE); } pub fn uses_data_dir(&self) -> bool { if let Some(status) = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_STATUS) && status == TRANSITION_COMPLETE.as_bytes().to_vec() { return false; } is_restored_object_on_disk(&self.meta_user) } pub fn inlinedata(&self) -> bool { contains_key_bytes(&self.meta_sys, SUFFIX_INLINE_DATA) } pub fn reset_inline_data(&mut self) { remove_bytes(&mut self.meta_sys, SUFFIX_INLINE_DATA); } /// Remove restore headers pub fn remove_restore_headers(&mut self) { // Remove any restore-related metadata self.meta_sys.retain(|k, _| !k.starts_with("X-Amz-Restore")); } pub fn init_free_version(&self, fi: &FileInfo) -> Result<(FileMetaVersion, bool)> { if fi.skip_tier_free_version() { return Ok((FileMetaVersion::default(), false)); } if let Some(status) = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_STATUS) && status == TRANSITION_COMPLETE.as_bytes().to_vec() { let vid = Uuid::parse_str(&fi.tier_free_version_id())?; let mut free_entry = FileMetaVersion { version_type: VersionType::Delete, write_version: 0, ..Default::default() }; free_entry.delete_marker = Some(MetaDeleteMarker { version_id: Some(vid), mod_time: self.mod_time, meta_sys: HashMap::>::new(), }); let delete_marker = free_entry.delete_marker.as_mut().unwrap(); insert_bytes(&mut delete_marker.meta_sys, SUFFIX_FREE_VERSION, vec![]); for suffix in [ SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE, ] { if let Some(v) = get_bytes(&self.meta_sys, suffix) { insert_bytes(&mut delete_marker.meta_sys, suffix, v); } } if contains_key_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID) { let destination_id = get_consistent_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID) .filter(|value| value.len() == 64 && value.iter().all(u8::is_ascii_hexdigit)) .ok_or(Error::FileCorrupt)?; insert_bytes( &mut delete_marker.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, destination_id.to_vec(), ); } return Ok((free_entry, true)); } Ok((FileMetaVersion::default(), false)) } } impl From for MetaObject { fn from(value: FileInfo) -> Self { let part_etags = if !value.parts.is_empty() { value.parts.iter().map(|v| v.etag.clone()).collect() } else { vec![] }; let part_indices = if !value.parts.is_empty() { value.parts.iter().map(|v| v.index.clone().unwrap_or_default()).collect() } else { vec![] }; let mut meta_sys = HashMap::new(); let mut meta_user = HashMap::new(); for (k, v) in value.metadata.iter() { if is_internal_key(k) { if is_skip_meta_key(k) { continue; } meta_sys.insert(k.to_owned(), v.as_bytes().to_vec()); } else { meta_user.insert(k.to_owned(), v.to_owned()); } } if !value.transition_status.is_empty() { insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_STATUS, value.transition_status.as_bytes().to_vec()); } if !value.transitioned_objname.is_empty() { insert_bytes( &mut meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME, value.transitioned_objname.as_bytes().to_vec(), ); } if let Some(transition_version) = transitioned_version_bytes(&value) { insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version); } if !value.transition_status.is_empty() { set_transition_version_state(&mut meta_sys, value.transition_version_state); } if !value.transition_tier.is_empty() { insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec()); } if let Some(content_hash) = value.checksum { insert_bytes(&mut meta_sys, SUFFIX_CRC, content_hash.to_vec()); } Self { version_id: value.version_id, data_dir: value.data_dir, size: value.size, mod_time: value.mod_time, erasure_algorithm: ErasureAlgo::ReedSolomon, erasure_m: value.erasure.data_blocks, erasure_n: value.erasure.parity_blocks, erasure_block_size: value.erasure.block_size, erasure_index: value.erasure.index, erasure_dist: value.erasure.distribution.iter().map(|x| *x as u8).collect(), bitrot_checksum_algo: ChecksumAlgo::HighwayHash, part_numbers: value.parts.iter().map(|v| v.number).collect(), part_etags, part_sizes: value.parts.iter().map(|v| v.size).collect(), part_actual_sizes: value.parts.iter().map(|v| v.actual_size).collect(), part_indices, meta_sys, meta_user, } } } fn get_internal_replication_state(metadata: &HashMap) -> Option { let mut rs = ReplicationState::default(); let mut has = false; for (k, v) in metadata.iter() { if has_internal_suffix(k, SUFFIX_PURGESTATUS) { rs.version_purge_status_internal = Some(v.clone()); rs.purge_targets = version_purge_statuses_map(v.as_str()); has = true; continue; } let sub_key_opt = strip_internal_prefix(k); if let Some(ref sub_key) = sub_key_opt { match sub_key.as_str() { "replica-timestamp" => { has = true; rs.replica_timestamp = Some(OffsetDateTime::parse(v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH)); } "replica-status" => { has = true; rs.replica_status = ReplicationStatusType::from(v.as_str()); } "replication-timestamp" => { has = true; rs.replication_timestamp = Some(OffsetDateTime::parse(v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH)) } "replication-status" => { has = true; rs.replication_status_internal = Some(v.clone()); rs.targets = replication_statuses_map(v.as_str()); } _ => { if let Some(arn) = sub_key.strip_prefix("replication-reset-") { has = true; // Store the canonical full-header key so the map matches // the key `target_reset_header()` produces on the // write/lookup side. Storing the bare ARN keyed the map // inconsistently (bare on read, full on write), which // could drop reset state across merge/reflatten cycles // (backlog#799 B16). rs.reset_statuses_map .insert(crate::replication::target_reset_header(arn), v.clone()); } } } } } if has { Some(rs) } else { None } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct MetaDeleteMarker { #[serde(rename = "ID")] pub version_id: Option, // Version ID for delete marker #[serde(rename = "MTime")] pub mod_time: Option, // Object delete marker modified time #[serde(rename = "MetaSys")] pub meta_sys: HashMap>, // Delete marker internal metadata } impl TryFrom for MetaDeleteMarker { type Error = Error; fn try_from(value: LegacyMetaV2DeleteMarker) -> std::result::Result { Ok(Self { version_id: parse_legacy_uuid_bytes(&value.version_id, "version_id")?, mod_time: value.mod_time, meta_sys: value.meta_sys, }) } } impl MetaDeleteMarker { pub fn free_version(&self) -> bool { contains_key_bytes(&self.meta_sys, SUFFIX_FREE_VERSION) } pub fn into_fileinfo(&self, volume: &str, path: &str, _all_parts: bool) -> FileInfo { let metadata = self .meta_sys .clone() .into_iter() .map(|(k, v)| (k, String::from_utf8_lossy(&v).to_string())) .collect(); let replication_state_internal = get_internal_replication_state(&metadata); let mut fi = FileInfo { version_id: self.version_id.filter(|&vid| !vid.is_nil()), name: path.to_string(), volume: volume.to_string(), deleted: true, mod_time: self.mod_time, metadata, replication_state_internal, ..Default::default() }; if self.free_version() { fi.set_tier_free_version(); fi.transition_tier = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER) .map(|v| String::from_utf8_lossy(&v).to_string()) .unwrap_or_default(); fi.transitioned_objname = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME) .map(|v| String::from_utf8_lossy(&v).to_string()) .unwrap_or_default(); fi.transition_version = transitioned_version_from_meta_sys(&self.meta_sys).ok().flatten(); fi.transition_version_id = legacy_transitioned_version_id_from_meta_sys(&self.meta_sys); fi.transition_version_state = transition_version_state_from_meta_sys(&self.meta_sys, fi.transition_version.as_deref()) .unwrap_or(TransitionVersionState::Unknown); } fi } pub fn encode_to(&self, wr: &mut W) -> Result<()> { rmp::encode::write_map_len(wr, 3)?; // ID rmp::encode::write_str(wr, "ID")?; rmp::encode::write_bin(wr, self.version_id.unwrap_or_default().as_bytes())?; // MTime Unix timestamp nanos rmp::encode::write_str(wr, "MTime")?; let nanos = self.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp_nanos(); rmp::encode::write_sint(wr, nanos as i64)?; // MetaSys rmp::encode::write_str(wr, "MetaSys")?; rmp::encode::write_map_len(wr, self.meta_sys.len() as u32)?; for (k, v) in &self.meta_sys { rmp::encode::write_str(wr, k)?; rmp::encode::write_bin(wr, v)?; } Ok(()) } pub fn decode_from(&mut self, rd: &mut R) -> Result<()> { let mut fields = rmp::decode::read_map_len(rd)?; *self = MetaDeleteMarker::default(); while fields > 0 { fields -= 1; let key_len = rmp::decode::read_str_len(rd)?; let key_buf = read_exact_vec(rd, key_len as usize)?; let key = String::from_utf8(key_buf)?; match key.as_str() { "ID" => { let _ = rmp::decode::read_bin_len(rd)?; let mut buf = [0u8; 16]; rd.read_exact(&mut buf)?; let id = Uuid::from_bytes(buf); self.version_id = if id.is_nil() { None } else { Some(id) }; } "MTime" => { let nanos: i64 = rmp::decode::read_int(rd)?; let time = OffsetDateTime::from_unix_timestamp_nanos(nanos as i128)?; self.mod_time = if time == OffsetDateTime::UNIX_EPOCH { None } else { Some(time) }; } "MetaSys" => { let len = rmp::decode::read_map_len(rd)? as usize; self.meta_sys.clear(); for _ in 0..len { let k_len = rmp::decode::read_str_len(rd)?; let kbuf = read_exact_vec(rd, k_len as usize)?; let k = String::from_utf8(kbuf)?; let blen = rmp::decode::read_bin_len(rd)? as usize; let v = read_exact_vec(rd, blen)?; self.meta_sys.insert(k, v); } } other => { // Skip unknown fields for forward compatibility, matching // MetaObject::decode_from. A newer writer's extra keys must not // break decoding of a delete marker (backlog#799 B17). tracing::debug!(field = %other, "MetaDeleteMarker::decode_from: skipping unknown field"); skip_msgp_value(rd)?; } } } Ok(()) } pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result { let mut cur = std::io::Cursor::new(buf); self.decode_from(&mut cur)?; Ok(cur.position()) } pub fn marshal_msg(&self) -> Result> { let mut wr = Vec::new(); self.encode_to(&mut wr)?; Ok(wr) } /// Compute this delete marker's header signature, mirroring MinIO's /// `xlMetaV2DeleteMarker.Signature`. See [`FileMetaVersion::get_signature`]. pub fn get_signature(&self) -> [u8; 4] { let mut c = self.clone(); // MetaSys is order-unstable across disks; fold it in separately. let mut crc = hash_deterministic_bytes(&c.meta_sys); c.meta_sys.clear(); if let Ok(bytes) = c.marshal_msg() { crc ^= xxhash_rust::xxh64::xxh64(&bytes, XXHASH_SEED); } fold_signature(crc) } } impl From for MetaDeleteMarker { fn from(value: FileInfo) -> Self { let mut meta_sys = HashMap::new(); let mut durable_metadata: HashMap = HashMap::new(); for (key, metadata_value) in &value.metadata { if !is_internal_key(key) || is_skip_meta_key(key) { continue; } let Some(suffix) = strip_internal_prefix(key) else { continue; }; let rustfs_preferred = key .get(..RUSTFS_INTERNAL_PREFIX.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(RUSTFS_INTERNAL_PREFIX)); match durable_metadata.entry(suffix) { std::collections::hash_map::Entry::Vacant(entry) => { entry.insert((metadata_value, rustfs_preferred)); } std::collections::hash_map::Entry::Occupied(mut entry) if rustfs_preferred && !entry.get().1 => { entry.insert((metadata_value, true)); } std::collections::hash_map::Entry::Occupied(_) => {} } } for (suffix, (metadata_value, _)) in durable_metadata { insert_bytes(&mut meta_sys, &suffix, metadata_value.as_bytes().to_vec()); } if value.tier_free_version() { insert_bytes(&mut meta_sys, SUFFIX_FREE_VERSION, vec![]); } if !value.transition_status.is_empty() { insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_STATUS, value.transition_status.as_bytes().to_vec()); } if !value.transitioned_objname.is_empty() { insert_bytes( &mut meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME, value.transitioned_objname.as_bytes().to_vec(), ); } if let Some(transition_version) = transitioned_version_bytes(&value) { insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version); } if !value.transition_status.is_empty() || value.tier_free_version() { set_transition_version_state(&mut meta_sys, value.transition_version_state); } if !value.transition_tier.is_empty() { insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec()); } Self { version_id: value.version_id, mod_time: value.mod_time, meta_sys, } } } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Default, Clone, PartialOrd, Ord, Hash)] pub enum VersionType { #[default] Invalid = 0, Object = 1, Delete = 2, Legacy = 3, } impl VersionType { pub fn valid(&self) -> bool { matches!(*self, VersionType::Object | VersionType::Delete | VersionType::Legacy) } pub fn to_u8(&self) -> u8 { match self { VersionType::Invalid => 0, VersionType::Object => 1, VersionType::Delete => 2, VersionType::Legacy => 3, } } pub fn from_u8(n: u8) -> Self { match n { 1 => VersionType::Object, 2 => VersionType::Delete, 3 => VersionType::Legacy, _ => VersionType::Invalid, } } } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Default, Clone)] pub enum ChecksumAlgo { #[default] Invalid = 0, HighwayHash = 1, } impl ChecksumAlgo { pub fn valid(&self) -> bool { *self > ChecksumAlgo::Invalid } pub fn to_u8(&self) -> u8 { match self { ChecksumAlgo::Invalid => 0, ChecksumAlgo::HighwayHash => 1, } } pub fn from_u8(u: u8) -> Self { match u { 1 => ChecksumAlgo::HighwayHash, _ => ChecksumAlgo::Invalid, } } } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Default, Clone)] pub enum Flags { #[default] FreeVersion = 1 << 0, UsesDataDir = 1 << 1, InlineData = 1 << 2, } // mergeXLV2Versions pub fn merge_file_meta_versions( quorum: usize, strict: bool, requested_versions: usize, versions: &[Vec], ) -> Vec { merge_file_meta_versions_inner(quorum, strict, requested_versions, false, versions) } pub(crate) fn merge_file_meta_versions_with_write_quorum( quorum: usize, strict: bool, requested_versions: usize, versions: &[Vec], ) -> Vec { merge_file_meta_versions_inner(quorum, strict, requested_versions, true, versions) } fn merge_file_meta_versions_inner( mut quorum: usize, mut strict: bool, requested_versions: usize, enforce_write_quorum: bool, versions: &[Vec], ) -> Vec { if quorum == 0 { quorum = 1; } if versions.len() < quorum || versions.is_empty() { return Vec::new(); } if versions.len() == 1 { if !enforce_write_quorum { return versions[0].clone(); } let required_quorum = versions[0] .first() .map(|version| version.write_quorum(quorum).max(quorum)) .unwrap_or(quorum); if versions.len() >= required_quorum { return versions[0].clone(); } return Vec::new(); } if quorum == 1 { strict = true; } let required_quorum = |version: &FileMetaShallowVersion| { if enforce_write_quorum { version.write_quorum(quorum).max(quorum) } else { quorum } }; let mut versions = versions.to_owned(); let mut n_versions = 0; let mut merged = Vec::new(); loop { let mut tops = Vec::new(); let mut top_sig = FileMetaVersionHeader::default(); let mut consistent = true; for vers in versions.iter() { if vers.is_empty() { consistent = false; continue; } if tops.is_empty() { consistent = true; top_sig = vers[0].header.clone(); } else { consistent = consistent && vers[0].header == top_sig; } tops.push(vers[0].clone()); } // check if done... if tops.len() < quorum { break; } let mut latest = FileMetaShallowVersion::default(); if consistent { latest = tops[0].clone(); if tops.len() >= required_quorum(&latest) { merged.push(latest.clone()); if !latest.header.free_version() { n_versions += 1; } } } else { let mut latest_count = 0; for (i, ver) in tops.iter().enumerate() { if ver.header == latest.header { latest_count += 1; continue; } if i == 0 || ver.header.sorts_before(&latest.header) { if i == 0 || latest_count == 0 { latest_count = 1; } else if !strict && ver.header.matches_not_strict(&latest.header) { latest_count += 1; } else { latest_count = 1; } latest = ver.clone(); continue; } // Mismatch, but older. if latest_count > 0 && !strict && ver.header.matches_not_strict(&latest.header) { latest_count += 1; continue; } if latest_count > 0 && ver.header.version_id == latest.header.version_id { let mut x: HashMap = HashMap::new(); for a in tops.iter() { if a.header.version_id != ver.header.version_id { continue; } let mut a_clone = a.clone(); if !strict { a_clone.header.signature = [0; 4]; } *x.entry(a_clone.header).or_insert(0) += 1; } latest_count = 0; for (k, v) in x.iter() { if *v < latest_count { continue; } if *v == latest_count && latest.header.sorts_before(k) { continue; } tops.iter().for_each(|a| { let mut hdr = a.header.clone(); if !strict { hdr.signature = [0; 4]; } if hdr == *k { latest = a.clone(); } }); latest_count = *v; } break; } } if latest_count >= required_quorum(&latest) { if !latest.header.free_version() { n_versions += 1; } merged.push(latest.clone()); } } // Remove from all streams up until latest modtime or if selected. versions.iter_mut().for_each(|vers| { // // Keep top entry (and remaining)... let mut bre = false; vers.retain(|ver| { if bre { return true; } if let Ordering::Greater = ver.header.mod_time.cmp(&latest.header.mod_time) { bre = true; return false; } if ver.header == latest.header { bre = true; return false; } if let Ordering::Equal = latest.header.version_id.cmp(&ver.header.version_id) { bre = true; return false; } for merged_v in merged.iter() { if let Ordering::Equal = ver.header.version_id.cmp(&merged_v.header.version_id) { bre = true; return false; } } true }); }); if requested_versions > 0 && requested_versions == n_versions { merged.append(&mut versions[0]); break; } } // Sanity check. Enable if duplicates show up. // todo merged } pub fn file_info_from_raw( ri: RawFileInfo, bucket: &str, object: &str, read_data: bool, include_free_versions: bool, ) -> Result { get_file_info( &ri.buf, bucket, object, "", FileInfoOpts { data: read_data, include_free_versions, }, ) } pub struct FileInfoOpts { pub data: bool, pub include_free_versions: bool, } pub fn get_file_info(buf: &[u8], volume: &str, path: &str, version_id: &str, opts: FileInfoOpts) -> Result { let vid = { if version_id.is_empty() { None } else { Some(Uuid::parse_str(version_id)?) } }; let meta = FileMeta::load(buf)?; if meta.versions.is_empty() { return Ok(FileInfo { volume: volume.to_owned(), name: path.to_owned(), version_id: vid, is_latest: true, deleted: true, mod_time: Some(OffsetDateTime::from_unix_timestamp(1)?), ..Default::default() }); } let fi = meta.into_fileinfo(volume, path, version_id, opts.data, opts.include_free_versions, true)?; Ok(fi) } async fn read_more( reader: &mut R, buf: &mut Vec, total_size: usize, read_size: usize, has_full: bool, ) -> Result<()> { use tokio::io::AsyncReadExt; let has = buf.len(); if has >= read_size { return Ok(()); } if has_full || read_size > total_size { return Err(Error::other(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Unexpected EOF"))); } let extra = read_size - has; if buf.capacity() >= read_size { // Extend the buffer if we have enough space. buf.resize(read_size, 0); } else { buf.extend(vec![0u8; extra]); } reader.read_exact(&mut buf[has..]).await?; Ok(()) } pub async fn read_xl_meta_no_data(reader: &mut R, size: usize) -> Result> { use tokio::io::AsyncReadExt; let mut initial = size; let mut has_full = true; if initial > META_DATA_READ_DEFAULT { initial = META_DATA_READ_DEFAULT; has_full = false; } let mut buf = vec![0u8; initial]; reader.read_exact(&mut buf).await?; let (tmp_buf, major, minor) = FileMeta::check_xl2_v1(&buf)?; match major { 1 => match minor { 0 => { read_more(reader, &mut buf, size, size, has_full).await?; Ok(buf) } 1..=3 => { let (sz, tmp_buf) = FileMeta::read_bytes_header(tmp_buf)?; let mut want = sz as usize + (buf.len() - tmp_buf.len()); if minor < 2 { read_more(reader, &mut buf, size, want, has_full).await?; buf.truncate(want); return Ok(buf); } let want_max = usize::min(want + MSGP_UINT32_SIZE, size); read_more(reader, &mut buf, size, want_max, has_full).await?; if buf.len() < want { return Err(Error::FileCorrupt); } // The metadata block is followed by a 5-byte msgp uint32 CRC trailer; // a file truncated inside the trailer is corrupt, not a shorter meta. let crc_size = 5; if buf.len() - want < crc_size { return Err(Error::FileCorrupt); } want += crc_size; buf.truncate(want); Ok(buf) } _ => Err(Error::other(std::io::Error::new( std::io::ErrorKind::InvalidData, "Unknown minor metadata version", ))), }, _ => Err(Error::other(std::io::Error::new( std::io::ErrorKind::InvalidData, "Unknown major metadata version", ))), } } /// Synchronous twin of [`read_more`]. /// /// Line-for-line mirror of the async version; the only difference is that /// `reader.read_exact(&mut buf[has..]).await?` becomes the blocking /// `std::io::Read::read_exact`. std's `read_exact` reports the same /// `ErrorKind::UnexpectedEof` on a short read as tokio's, so the `?` /// conversion into [`Error`] is identical. Kept in lockstep with `read_more` /// so the two paths stay byte-for-byte equivalent (see equivalence tests). fn read_more_sync( reader: &mut R, buf: &mut Vec, total_size: usize, read_size: usize, has_full: bool, ) -> Result<()> { let has = buf.len(); if has >= read_size { return Ok(()); } if has_full || read_size > total_size { return Err(Error::other(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Unexpected EOF"))); } let extra = read_size - has; if buf.capacity() >= read_size { // Extend the buffer if we have enough space. buf.resize(read_size, 0); } else { buf.extend(vec![0u8; extra]); } reader.read_exact(&mut buf[has..])?; Ok(()) } /// Synchronous twin of [`read_xl_meta_no_data`]. /// /// Byte-for-byte equivalent to the async version for any input: the parsing /// logic (`check_xl2_v1` / `read_bytes_header` / major-minor branches / `want` /// computation / 5-byte CRC trailer handling / `truncate` / `FileCorrupt` / /// `InvalidData` / `UnexpectedEof`) is copied verbatim; only the reads switch /// from async `read_exact(...).await` to blocking `std::io::Read::read_exact`. /// This lets a caller fold open+fstat+read into a single `spawn_blocking` /// closure without changing any observable result. pub fn read_xl_meta_no_data_sync(reader: &mut R, size: usize) -> Result> { let mut initial = size; let mut has_full = true; if initial > META_DATA_READ_DEFAULT { initial = META_DATA_READ_DEFAULT; has_full = false; } let mut buf = vec![0u8; initial]; reader.read_exact(&mut buf)?; let (tmp_buf, major, minor) = FileMeta::check_xl2_v1(&buf)?; match major { 1 => match minor { 0 => { read_more_sync(reader, &mut buf, size, size, has_full)?; Ok(buf) } 1..=3 => { let (sz, tmp_buf) = FileMeta::read_bytes_header(tmp_buf)?; let mut want = sz as usize + (buf.len() - tmp_buf.len()); if minor < 2 { read_more_sync(reader, &mut buf, size, want, has_full)?; buf.truncate(want); return Ok(buf); } let want_max = usize::min(want + MSGP_UINT32_SIZE, size); read_more_sync(reader, &mut buf, size, want_max, has_full)?; if buf.len() < want { return Err(Error::FileCorrupt); } // The metadata block is followed by a 5-byte msgp uint32 CRC trailer; // a file truncated inside the trailer is corrupt, not a shorter meta. let crc_size = 5; if buf.len() - want < crc_size { return Err(Error::FileCorrupt); } want += crc_size; buf.truncate(want); Ok(buf) } _ => Err(Error::other(std::io::Error::new( std::io::ErrorKind::InvalidData, "Unknown minor metadata version", ))), }, _ => Err(Error::other(std::io::Error::new( std::io::ErrorKind::InvalidData, "Unknown major metadata version", ))), } } #[cfg(test)] mod tests { use super::*; use serde::Serialize; #[test] fn delete_marker_conversion_preserves_only_durable_internal_metadata() { let mut marker = FileInfo::default(); marker .metadata .insert("x-minio-internal-purgestatus".to_string(), "pending".to_string()); marker .metadata .insert("x-rustfs-internal-healing".to_string(), "true".to_string()); marker.metadata.insert("content-type".to_string(), "text/plain".to_string()); let remote_version_id = Uuid::new_v4(); marker.transition_version = Some(remote_version_id.to_string()); let converted = MetaDeleteMarker::from(marker); assert_eq!(converted.meta_sys.get("x-rustfs-internal-purgestatus"), Some(&b"pending".to_vec())); assert_eq!(converted.meta_sys.get("x-minio-internal-purgestatus"), Some(&b"pending".to_vec())); assert_eq!( get_bytes(&converted.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID), Some(remote_version_id.to_string().into_bytes()) ); assert_eq!( converted .meta_sys .get(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}")), Some(&remote_version_id.to_string().into_bytes()) ); assert_eq!( converted .meta_sys .get(&format!("{}{SUFFIX_TRANSITIONED_VERSION_ID}", rustfs_utils::http::MINIO_INTERNAL_PREFIX)), Some(&remote_version_id.to_string().into_bytes()) ); assert!(!converted.meta_sys.contains_key("x-rustfs-internal-healing")); assert!(!converted.meta_sys.contains_key("content-type")); } #[derive(Serialize)] enum LegacyDeleteVersionTypeFixture { #[serde(rename = "DeleteMarker")] DeleteMarker, } #[derive(Serialize)] struct LegacyDeleteMarkerFixture { version_id: Vec, mod_time: Option, meta_sys: HashMap>, } #[derive(Serialize)] struct LegacyDeleteVersionFixture { version_type: LegacyDeleteVersionTypeFixture, object: Option<()>, delete_marker: Option, write_version: u64, } #[derive(Serialize)] struct LegacyDeleteMarkerNilFixture { version_id: Option>, mod_time: Option, meta_sys: HashMap>, } #[derive(Serialize)] struct LegacyDeleteVersionNilFixture { version_type: LegacyDeleteVersionTypeFixture, object: Option<()>, delete_marker: Option, write_version: u64, } #[derive(Serialize)] enum LegacyObjectVersionTypeFixture { #[serde(rename = "Object")] Object, } #[derive(Serialize)] struct LegacyObjectFixture { version_id: Option>, data_dir: Option>, erasure_algorithm: String, erasure_m: usize, erasure_n: usize, erasure_block_size: usize, erasure_index: usize, erasure_dist: Vec, bitrot_checksum_algo: String, part_numbers: Vec, part_etags: Vec, part_sizes: Vec, part_actual_sizes: Vec, part_indices: Vec>, size: i64, mod_time: Option, meta_sys: HashMap>, meta_user: HashMap, } #[derive(Serialize)] struct LegacyObjectVersionFixture { version_type: LegacyObjectVersionTypeFixture, object: Option, delete_marker: Option<()>, write_version: u64, } fn sample_version_id() -> Uuid { Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef").unwrap() } fn sample_mod_time() -> OffsetDateTime { OffsetDateTime::from_unix_timestamp_nanos(1_705_312_200_123_456_789).unwrap() } // ---------------------------------------------------------------------- // backlog#900: MetaObject::into_fileinfo part-array length guard. // ---------------------------------------------------------------------- fn object_with_parts(part_numbers: Vec, part_sizes: Vec, part_actual_sizes: Vec) -> MetaObject { MetaObject { version_id: Some(sample_version_id()), erasure_algorithm: ErasureAlgo::ReedSolomon, erasure_m: 2, erasure_n: 2, erasure_block_size: 1_048_576, bitrot_checksum_algo: ChecksumAlgo::HighwayHash, part_numbers, part_sizes, part_actual_sizes, mod_time: Some(sample_mod_time()), ..Default::default() } } #[test] fn into_fileinfo_rejects_short_part_sizes() { // part_numbers=2 but part_sizes=1 -> old code indexed self.part_sizes[1] and panicked. let obj = object_with_parts(vec![1, 2], vec![10], vec![10, 20]); let res = obj.into_fileinfo("bucket", "key", true); assert!(matches!(res, Err(Error::FileCorrupt)), "short part_sizes must map to FileCorrupt"); } #[test] fn into_fileinfo_rejects_short_part_actual_sizes_including_empty() { let obj = object_with_parts(vec![1, 2], vec![10, 20], vec![]); let res = obj.into_fileinfo("bucket", "key", true); assert!(matches!(res, Err(Error::FileCorrupt)), "empty part_actual_sizes must map to FileCorrupt"); } #[test] fn into_fileinfo_rejects_over_long_part_sizes() { // Strict equality: an over-long array is corrupt too (MinIO would panic on any mismatch). let obj = object_with_parts(vec![1], vec![10, 20], vec![10]); let res = obj.into_fileinfo("bucket", "key", true); assert!(matches!(res, Err(Error::FileCorrupt)), "over-long part_sizes must map to FileCorrupt"); } #[test] fn into_fileinfo_all_parts_false_skips_validation() { // all_parts=false never enters the parts loop -> Ok, empty parts. Regression guard. let obj = object_with_parts(vec![1, 2], vec![10], vec![10, 20]); let fi = obj .into_fileinfo("bucket", "key", false) .expect("all_parts=false must not validate parts"); assert!(fi.parts.is_empty()); } #[test] fn into_fileinfo_healthy_parts_decode_field_by_field() { let mut obj = object_with_parts(vec![1, 2], vec![100, 200], vec![111, 222]); obj.part_etags = vec!["etag-1".to_string(), "etag-2".to_string()]; obj.part_indices = vec![Bytes::from_static(b"idx1"), Bytes::new()]; let fi = obj.into_fileinfo("b", "k", true).expect("healthy parts must decode"); assert_eq!(fi.parts.len(), 2); assert_eq!(fi.parts[0].number, 1); assert_eq!(fi.parts[0].size, 100); assert_eq!(fi.parts[0].actual_size, 111); assert_eq!(fi.parts[0].etag, "etag-1"); assert_eq!(fi.parts[0].index.as_deref(), Some(&b"idx1"[..])); assert_eq!(fi.parts[1].number, 2); assert_eq!(fi.parts[1].size, 200); assert_eq!(fi.parts[1].actual_size, 222); assert_eq!(fi.parts[1].etag, "etag-2"); assert_eq!(fi.parts[1].index, None); // empty bytes -> None } #[test] fn into_fileinfo_empty_part_numbers_is_valid() { // No-part object (empty part_numbers) is legitimate: n==0 -> no guard -> Ok, no parts. let obj = object_with_parts(vec![], vec![], vec![]); let fi = obj.into_fileinfo("b", "k", true).expect("no-part object must be valid"); assert!(fi.parts.is_empty()); } #[test] fn into_fileinfo_etag_soft_guard_not_regressed() { // size/actual match (pass hard guard) but etag length differs -> soft guard: empty etag, not corrupt. let mut obj = object_with_parts(vec![1, 2], vec![10, 20], vec![10, 20]); obj.part_etags = vec!["only-one".to_string()]; let fi = obj.into_fileinfo("b", "k", true).expect("etag soft guard must not fail"); assert_eq!(fi.parts.len(), 2); assert_eq!(fi.parts[0].etag, ""); // soft guard: len mismatch -> default empty } fn sample_header() -> FileMetaVersionHeader { FileMetaVersionHeader { version_id: Some(sample_version_id()), mod_time: Some(sample_mod_time()), signature: [0x96, 0x33, 0x4c, 0x78], version_type: VersionType::Object, flags: 0x06, ec_n: 4, ec_m: 2, } } fn encode_v1_header(header: &FileMetaVersionHeader) -> Vec { let mut wr = Vec::new(); rmp::encode::write_array_len(&mut wr, 4).unwrap(); rmp::encode::write_bin(&mut wr, header.version_id.unwrap().as_bytes()).unwrap(); rmp::encode::write_i64(&mut wr, header.mod_time.unwrap().unix_timestamp_nanos() as i64).unwrap(); rmp::encode::write_uint8(&mut wr, header.version_type.to_u8()).unwrap(); rmp::encode::write_uint8(&mut wr, header.flags).unwrap(); wr } fn encode_v2_header(header: &FileMetaVersionHeader) -> Vec { let mut wr = Vec::new(); rmp::encode::write_array_len(&mut wr, 5).unwrap(); rmp::encode::write_bin(&mut wr, header.version_id.unwrap().as_bytes()).unwrap(); rmp::encode::write_i64(&mut wr, header.mod_time.unwrap().unix_timestamp_nanos() as i64).unwrap(); rmp::encode::write_bin(&mut wr, header.signature.as_slice()).unwrap(); rmp::encode::write_uint8(&mut wr, header.version_type.to_u8()).unwrap(); rmp::encode::write_uint8(&mut wr, header.flags).unwrap(); wr } fn write_legacy_time(wr: &mut Vec, ts: OffsetDateTime) { wr.push(MSGPACK_EXT8); wr.push(12); wr.push(MSGPACK_TIME_EXT_LEGACY as u8); wr.extend_from_slice(&ts.unix_timestamp().to_be_bytes()); wr.extend_from_slice(&ts.nanosecond().to_be_bytes()); } fn encode_legacy_v1_body() -> Vec { let mut wr = Vec::new(); let mod_time = sample_mod_time(); rmp::encode::write_map_len(&mut wr, 3).unwrap(); rmp::encode::write_str(&mut wr, "Type").unwrap(); rmp::encode::write_uint8(&mut wr, VersionType::Legacy.to_u8()).unwrap(); rmp::encode::write_str(&mut wr, "V1Obj").unwrap(); rmp::encode::write_map_len(&mut wr, 8).unwrap(); rmp::encode::write_str(&mut wr, "Version").unwrap(); rmp::encode::write_str(&mut wr, "1.0.1").unwrap(); rmp::encode::write_str(&mut wr, "Format").unwrap(); rmp::encode::write_str(&mut wr, "xl").unwrap(); rmp::encode::write_str(&mut wr, "Stat").unwrap(); rmp::encode::write_map_len(&mut wr, 5).unwrap(); rmp::encode::write_str(&mut wr, "Size").unwrap(); rmp::encode::write_sint(&mut wr, 11).unwrap(); rmp::encode::write_str(&mut wr, "ModTime").unwrap(); write_legacy_time(&mut wr, mod_time); rmp::encode::write_str(&mut wr, "Name").unwrap(); rmp::encode::write_str(&mut wr, "hello.txt").unwrap(); rmp::encode::write_str(&mut wr, "Dir").unwrap(); rmp::encode::write_bool(&mut wr, false).unwrap(); rmp::encode::write_str(&mut wr, "Mode").unwrap(); rmp::encode::write_u32(&mut wr, 0o644).unwrap(); rmp::encode::write_str(&mut wr, "Erasure").unwrap(); rmp::encode::write_map_len(&mut wr, 7).unwrap(); rmp::encode::write_str(&mut wr, "Algorithm").unwrap(); rmp::encode::write_str(&mut wr, "ReedSolomon").unwrap(); rmp::encode::write_str(&mut wr, "DataBlocks").unwrap(); rmp::encode::write_sint(&mut wr, 4).unwrap(); rmp::encode::write_str(&mut wr, "ParityBlocks").unwrap(); rmp::encode::write_sint(&mut wr, 2).unwrap(); rmp::encode::write_str(&mut wr, "BlockSize").unwrap(); rmp::encode::write_sint(&mut wr, 1_048_576).unwrap(); rmp::encode::write_str(&mut wr, "Index").unwrap(); rmp::encode::write_sint(&mut wr, 1).unwrap(); rmp::encode::write_str(&mut wr, "Distribution").unwrap(); rmp::encode::write_array_len(&mut wr, 6).unwrap(); for value in 1..=6 { rmp::encode::write_sint(&mut wr, value).unwrap(); } rmp::encode::write_str(&mut wr, "Checksums").unwrap(); rmp::encode::write_array_len(&mut wr, 0).unwrap(); rmp::encode::write_str(&mut wr, "Meta").unwrap(); rmp::encode::write_map_len(&mut wr, 1).unwrap(); rmp::encode::write_str(&mut wr, "content-type").unwrap(); rmp::encode::write_str(&mut wr, "text/plain").unwrap(); rmp::encode::write_str(&mut wr, "Parts").unwrap(); rmp::encode::write_array_len(&mut wr, 1).unwrap(); rmp::encode::write_map_len(&mut wr, 5).unwrap(); rmp::encode::write_str(&mut wr, "e").unwrap(); rmp::encode::write_str(&mut wr, "etag-1").unwrap(); rmp::encode::write_str(&mut wr, "n").unwrap(); rmp::encode::write_sint(&mut wr, 1).unwrap(); rmp::encode::write_str(&mut wr, "s").unwrap(); rmp::encode::write_sint(&mut wr, 11).unwrap(); rmp::encode::write_str(&mut wr, "as").unwrap(); rmp::encode::write_sint(&mut wr, 11).unwrap(); rmp::encode::write_str(&mut wr, "mt").unwrap(); write_legacy_time(&mut wr, mod_time); rmp::encode::write_str(&mut wr, "VersionID").unwrap(); rmp::encode::write_str(&mut wr, "").unwrap(); rmp::encode::write_str(&mut wr, "DataDir").unwrap(); rmp::encode::write_str(&mut wr, "legacy").unwrap(); rmp::encode::write_str(&mut wr, "v").unwrap(); rmp::encode::write_uint(&mut wr, 1).unwrap(); wr } fn encode_legacy_v2_object_body(data_blocks: usize, parity_blocks: usize) -> Vec { let drive_count = data_blocks + parity_blocks; let payload = LegacyObjectVersionFixture { version_type: LegacyObjectVersionTypeFixture::Object, object: Some(LegacyObjectFixture { version_id: Some(sample_version_id().as_bytes().to_vec()), data_dir: Some(Uuid::from_u128(42).as_bytes().to_vec()), erasure_algorithm: "ReedSolomon".to_string(), erasure_m: data_blocks, erasure_n: parity_blocks, erasure_block_size: 1_048_576, erasure_index: 1, erasure_dist: (1..=drive_count) .map(|idx| u8::try_from(idx).expect("test drive index should fit u8")) .collect(), bitrot_checksum_algo: "HighwayHash".to_string(), part_numbers: vec![1], part_etags: vec!["etag-1".to_string()], part_sizes: vec![11], part_actual_sizes: vec![11], part_indices: vec![Vec::new()], size: 11, mod_time: Some(sample_mod_time()), meta_sys: HashMap::new(), meta_user: HashMap::from([("content-type".to_string(), "text/plain".to_string())]), }), delete_marker: None, write_version: 3, }; rmp_serde::to_vec_named(&payload).expect("legacy object payload should marshal") } #[test] fn version_header_unmarshal_v1_uses_legacy_layout_defaults() { let expected = sample_header(); let encoded = encode_v1_header(&expected); let mut decoded = FileMetaVersionHeader::default(); decoded.unmarshal_v(1, &encoded).unwrap(); assert_eq!(decoded.version_id, expected.version_id); assert_eq!(decoded.mod_time, expected.mod_time); assert_eq!(decoded.version_type, expected.version_type); assert_eq!(decoded.flags, expected.flags); assert_eq!(decoded.signature, [0; 4]); assert_eq!(decoded.ec_n, 0); assert_eq!(decoded.ec_m, 0); } #[test] fn version_header_unmarshal_v2_keeps_signature_and_zeroes_ec() { let expected = sample_header(); let encoded = encode_v2_header(&expected); let mut decoded = FileMetaVersionHeader::default(); decoded.unmarshal_v(2, &encoded).unwrap(); assert_eq!(decoded.version_id, expected.version_id); assert_eq!(decoded.mod_time, expected.mod_time); assert_eq!(decoded.signature, expected.signature); assert_eq!(decoded.version_type, expected.version_type); assert_eq!(decoded.flags, expected.flags); assert_eq!(decoded.ec_n, 0); assert_eq!(decoded.ec_m, 0); } #[test] fn shallow_version_write_quorum_uses_legacy_object_payload_when_header_lacks_ec() { let expected = sample_header(); let encoded = encode_v2_header(&expected); let mut header = FileMetaVersionHeader::default(); header.unmarshal_v(2, &encoded).expect("legacy v2 header should decode"); let version = FileMetaShallowVersion { header, meta: encode_legacy_v2_object_body(7, 1), }; assert_eq!(version.header.write_quorum(5), 5); assert_eq!(version.write_quorum(5), 7); } #[test] fn shallow_version_write_quorum_uses_zero_parity_object_payload() { let expected = sample_header(); let encoded = encode_v2_header(&expected); let mut header = FileMetaVersionHeader::default(); header.unmarshal_v(2, &encoded).expect("legacy v2 header should decode"); let version = FileMetaShallowVersion { header, meta: encode_legacy_v2_object_body(4, 0), }; assert_eq!(version.header.write_quorum(2), 2); assert_eq!(version.write_quorum(2), 4); } #[test] fn version_header_unmarshal_v3_round_trips_current_layout() { let expected = sample_header(); let encoded = expected.marshal_msg().unwrap(); let mut decoded = FileMetaVersionHeader::default(); decoded.unmarshal_v(3, &encoded).unwrap(); assert_eq!(decoded, expected); } #[test] fn legacy_v1_object_body_decodes_into_fileinfo() { let encoded = encode_legacy_v1_body(); let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap(); assert_eq!(decoded.version_type, VersionType::Legacy); assert!(decoded.valid()); assert!(decoded.legacy_object.is_some()); let fi = decoded.into_fileinfo("bucket", "hello.txt", true).expect("into_fileinfo"); assert_eq!(fi.volume, "bucket"); assert_eq!(fi.name, "hello.txt"); assert_eq!(fi.size, 11); assert_eq!(fi.mod_time, Some(sample_mod_time())); assert_eq!(fi.mode, Some(0o644)); assert_eq!(fi.parts.len(), 1); assert_eq!(fi.parts[0].etag, "etag-1"); assert_eq!(fi.parts[0].size, 11); assert_eq!(fi.erasure.data_blocks, 4); assert_eq!(fi.erasure.parity_blocks, 2); assert_eq!(fi.metadata.get("content-type").map(String::as_str), Some("text/plain")); } #[test] fn legacy_meta_v2_delete_marker_decodes_into_delete_fileinfo() { let payload = LegacyDeleteVersionFixture { version_type: LegacyDeleteVersionTypeFixture::DeleteMarker, object: None, delete_marker: Some(LegacyDeleteMarkerFixture { version_id: sample_version_id().as_bytes().to_vec(), mod_time: Some(sample_mod_time()), meta_sys: HashMap::from([("x-rustfs-test".to_string(), b"gone".to_vec())]), }), write_version: 9, }; let encoded = rmp_serde::to_vec_named(&payload).unwrap(); let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap(); assert_eq!(decoded.version_type, VersionType::Delete); assert!(decoded.object.is_none()); assert!(decoded.delete_marker.is_some()); assert!(decoded.uses_legacy_checksum); let fi = decoded.into_fileinfo("bucket", "gone.txt", true).expect("into_fileinfo"); assert!(fi.deleted); assert_eq!(fi.volume, "bucket"); assert_eq!(fi.name, "gone.txt"); assert_eq!(fi.version_id, Some(sample_version_id())); assert_eq!(fi.mod_time, Some(sample_mod_time())); assert_eq!(fi.metadata.get("x-rustfs-test").map(String::as_str), Some("gone")); assert!(fi.uses_legacy_checksum); } #[test] fn legacy_meta_v2_delete_marker_decodes_into_delete_fileinfo_via_struct() { let version_id = sample_version_id(); let mod_time = sample_mod_time(); let version = LegacyMetaV2Version { version_type: LegacyMetaV2VersionType::DeleteMarker, object: None, delete_marker: Some(LegacyMetaV2DeleteMarker { version_id: version_id.as_bytes().to_vec(), mod_time: Some(mod_time), meta_sys: HashMap::from([("x-minio-internal".to_string(), b"present".to_vec())]), }), write_version: 7, }; let decoded = FileMetaVersion::try_from(version).unwrap(); assert_eq!(decoded.version_type, VersionType::Delete); assert!(decoded.uses_legacy_checksum); assert!(decoded.object.is_none()); let delete_marker = decoded.delete_marker.as_ref().expect("delete marker should be decoded"); assert_eq!(delete_marker.version_id, Some(version_id)); assert_eq!(delete_marker.mod_time, Some(mod_time)); let fi = decoded.into_fileinfo("bucket", "deleted.txt", true).expect("into_fileinfo"); assert!(fi.deleted); assert_eq!(fi.version_id, Some(version_id)); assert_eq!(fi.mod_time, Some(mod_time)); assert_eq!(fi.metadata.get("x-minio-internal").map(String::as_str), Some("present")); } #[test] fn legacy_meta_v2_delete_marker_rejects_invalid_uuid_bytes() { let payload = LegacyDeleteVersionFixture { version_type: LegacyDeleteVersionTypeFixture::DeleteMarker, object: None, delete_marker: Some(LegacyDeleteMarkerFixture { version_id: vec![7; 15], mod_time: Some(sample_mod_time()), meta_sys: HashMap::new(), }), write_version: 10, }; let encoded = rmp_serde::to_vec_named(&payload).unwrap(); let err = FileMetaVersion::try_from(encoded.as_slice()).expect_err("invalid legacy delete marker UUID must fail"); assert!(err.to_string().contains("legacy version_id must be 16 bytes")); } #[test] fn legacy_meta_v2_delete_marker_rejects_invalid_uuid_bytes_via_struct() { let err = MetaDeleteMarker::try_from(LegacyMetaV2DeleteMarker { version_id: vec![1, 2, 3], mod_time: Some(sample_mod_time()), meta_sys: HashMap::new(), }) .expect_err("invalid legacy delete-marker version ids should be rejected"); assert!(err.to_string().contains("legacy version_id must be 16 bytes")); } #[test] fn legacy_meta_v2_object_accepts_nil_uuid_fields() { let payload = LegacyObjectVersionFixture { version_type: LegacyObjectVersionTypeFixture::Object, object: Some(LegacyObjectFixture { version_id: None, data_dir: None, erasure_algorithm: "ReedSolomon".to_string(), erasure_m: 2, erasure_n: 4, erasure_block_size: 1_048_576, erasure_index: 1, erasure_dist: vec![1, 2, 3, 4, 5, 6], bitrot_checksum_algo: "HighwayHash".to_string(), part_numbers: vec![1], part_etags: vec!["etag-1".to_string()], part_sizes: vec![11], part_actual_sizes: vec![11], part_indices: vec![Vec::new()], size: 11, mod_time: Some(sample_mod_time()), meta_sys: HashMap::new(), meta_user: HashMap::from([("content-type".to_string(), "text/plain".to_string())]), }), delete_marker: None, write_version: 3, }; let encoded = rmp_serde::to_vec_named(&payload).unwrap(); let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap(); let object = decoded.object.as_ref().expect("object should be decoded"); assert_eq!(decoded.version_type, VersionType::Object); assert!(decoded.uses_legacy_checksum); assert_eq!(object.version_id, None); assert_eq!(object.data_dir, None); let fi = decoded .into_fileinfo("bucket", "legacy-nil.txt", true) .expect("into_fileinfo"); assert_eq!(fi.version_id, None); assert_eq!(fi.data_dir, None); assert_eq!(fi.metadata.get("content-type").map(String::as_str), Some("text/plain")); } #[test] fn legacy_meta_v2_delete_marker_accepts_nil_version_id() { let payload = LegacyDeleteVersionNilFixture { version_type: LegacyDeleteVersionTypeFixture::DeleteMarker, object: None, delete_marker: Some(LegacyDeleteMarkerNilFixture { version_id: None, mod_time: Some(sample_mod_time()), meta_sys: HashMap::from([("x-rustfs-test".to_string(), b"gone".to_vec())]), }), write_version: 11, }; let encoded = rmp_serde::to_vec_named(&payload).unwrap(); let decoded = FileMetaVersion::try_from(encoded.as_slice()).unwrap(); let delete_marker = decoded.delete_marker.as_ref().expect("delete marker should be decoded"); assert_eq!(decoded.version_type, VersionType::Delete); assert!(decoded.uses_legacy_checksum); assert_eq!(delete_marker.version_id, None); assert_eq!(delete_marker.mod_time, Some(sample_mod_time())); let fi = decoded.into_fileinfo("bucket", "deleted.txt", true).expect("into_fileinfo"); assert!(fi.deleted); assert_eq!(fi.version_id, None); assert_eq!(fi.mod_time, Some(sample_mod_time())); assert_eq!(fi.metadata.get("x-rustfs-test").map(String::as_str), Some("gone")); } #[test] fn decode_data_dir_from_meta_extracts_v2_object_fast_path() { let data_dir = Uuid::new_v4(); let version = FileMetaVersion { version_type: VersionType::Object, object: Some(MetaObject { version_id: Some(Uuid::new_v4()), data_dir: Some(data_dir), erasure_algorithm: ErasureAlgo::ReedSolomon, erasure_m: 2, erasure_n: 4, erasure_block_size: 1024 * 1024, erasure_index: 1, erasure_dist: vec![1, 2, 3, 4, 5, 6], bitrot_checksum_algo: ChecksumAlgo::HighwayHash, size: 64 * 1024, mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }), ..Default::default() }; let encoded = version.marshal_msg().expect("marshal"); let decoded = FileMetaVersion::decode_data_dir_from_meta(&encoded).expect("decode data_dir"); assert_eq!(decoded, Some(data_dir)); } fn make_meta_object_with_sys(meta_sys: HashMap>) -> MetaObject { MetaObject { erasure_algorithm: ErasureAlgo::ReedSolomon, erasure_m: 2, erasure_n: 4, erasure_block_size: 1_048_576, erasure_index: 1, erasure_dist: vec![1, 2, 3, 4, 5, 6], bitrot_checksum_algo: ChecksumAlgo::HighwayHash, meta_sys, ..Default::default() } } #[test] fn meta_object_transition_version_id_absent_yields_none() { let fi = make_meta_object_with_sys(HashMap::new()) .into_fileinfo("b", "k", false) .expect("into_fileinfo"); assert_eq!(fi.transition_version_id, None); } #[test] fn meta_object_transition_version_id_empty_bytes_yields_none() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, vec![]); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("into_fileinfo"); assert_eq!(fi.transition_version_id, None); } #[test] fn meta_object_transition_version_id_nil_uuid_yields_none() { // Regression: old code used unwrap_or_default() which turned nil bytes into Some(Uuid::nil()) let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, Uuid::nil().as_bytes().to_vec()); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("into_fileinfo"); assert_eq!(fi.transition_version_id, None); } #[test] fn meta_object_transition_version_id_valid_uuid_round_trips() { let id = sample_version_id(); let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.as_bytes().to_vec()); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("into_fileinfo"); assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version, Some(id.to_string())); assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown); } #[test] fn meta_object_transition_version_id_opaque_text_is_preserved() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"opaque-generation-42".to_vec()); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("opaque transition version id must decode"); assert_eq!(fi.transition_version_id, None); assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42")); assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown); } #[test] fn meta_object_transition_version_state_exact_round_trips_dual_keys() { let id = sample_version_id(); let expected_version = id.to_string(); let fi = FileInfo { transition_status: "complete".to_string(), transition_version: Some(expected_version.clone()), transition_version_state: TransitionVersionState::Exact, ..Default::default() }; let object = MetaObject::from(fi); assert_eq!( object .meta_sys .get(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}")) .map(Vec::as_slice), Some(b"exact".as_slice()) ); assert_eq!( object .meta_sys .get(&format!( "{}{SUFFIX_TRANSITIONED_VERSION_STATE}", rustfs_utils::http::MINIO_INTERNAL_PREFIX )) .map(Vec::as_slice), Some(b"exact".as_slice()) ); assert_eq!( legacy_transitioned_version_id_from_meta_sys(&object.meta_sys), Some(id), "UUID exact writes must remain readable by the legacy UUID consumer" ); let decoded = object.into_fileinfo("b", "k", false).expect("exact state should round trip"); assert_eq!(decoded.transition_version_state, TransitionVersionState::Exact); assert_eq!(decoded.transition_version.as_deref(), Some(expected_version.as_str())); } #[test] fn set_transition_known_disabled_removes_stale_version_dual_keys() { let mut meta_sys = HashMap::new(); insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, b"stale-legacy-version".to_vec()); let mut object = make_meta_object_with_sys(meta_sys); object.set_transition(&FileInfo { transition_status: TRANSITION_COMPLETE.to_string(), transitioned_objname: "remote/object".to_string(), transition_version_state: TransitionVersionState::KnownDisabled, transition_tier: "WARM".to_string(), ..Default::default() }); assert_eq!(get_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID), None); assert!( !object .meta_sys .contains_key(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}")) ); assert!( !object .meta_sys .contains_key(&format!("{}{SUFFIX_TRANSITIONED_VERSION_ID}", rustfs_utils::http::MINIO_INTERNAL_PREFIX)) ); let decoded = object .into_fileinfo("b", "k", false) .expect("known-disabled transition must remain readable after replacing stale metadata"); assert_eq!(decoded.transition_version, None); assert_eq!(decoded.transition_version_state, TransitionVersionState::KnownDisabled); } #[test] fn meta_object_transition_version_state_conflict_fails_closed() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, sample_version_id().as_bytes().to_vec()); sys.insert(format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}"), b"exact".to_vec()); sys.insert( format!("{}{SUFFIX_TRANSITIONED_VERSION_STATE}", rustfs_utils::http::MINIO_INTERNAL_PREFIX), b"known-disabled".to_vec(), ); make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect_err("conflicting state keys must fail closed"); } #[test] fn meta_object_transition_version_id_invalid_utf8_yields_none() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, vec![0xff]); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("invalid transition version bytes must not fail the object read"); assert_eq!(fi.transition_version_id, None); assert_eq!(fi.transition_version, None); } #[test] fn meta_object_transition_version_id_unsafe_text_yields_none() { for value in [b"opaque\0version".to_vec(), vec![b'x'; MAX_TRANSITION_VERSION_LEN + 1]] { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, value); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("unsafe transition version text must not fail the object read"); assert_eq!(fi.transition_version_id, None); assert_eq!(fi.transition_version, None); } } #[test] fn meta_object_transition_version_id_minio_string_form_is_recovered() { // MinIO-migrated tiered objects store the remote tier's version id as a // UUID string, not 16 raw bytes; recover it instead of dropping it. let id = sample_version_id(); let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.to_string().into_bytes()); let fi = make_meta_object_with_sys(sys) .into_fileinfo("b", "k", false) .expect("string-form transition version id must decode"); assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version, Some(id.to_string())); } #[test] fn delete_marker_free_version_transition_version_id_nil_uuid_yields_none() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, Uuid::nil().as_bytes().to_vec()); let fi = MetaDeleteMarker { version_id: None, mod_time: None, meta_sys: sys, } .into_fileinfo("b", "k", false); assert_eq!(fi.transition_version_id, None); } #[test] fn delete_marker_free_version_transition_version_id_valid_uuid_round_trips() { let id = sample_version_id(); let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.as_bytes().to_vec()); let fi = MetaDeleteMarker { version_id: None, mod_time: None, meta_sys: sys, } .into_fileinfo("b", "k", false); assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version, Some(id.to_string())); } #[test] fn delete_marker_free_version_transition_version_id_opaque_text_is_preserved() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"opaque-generation-42".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER, b"WARM".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".to_vec()); let fi = MetaDeleteMarker { version_id: Some(sample_version_id()), mod_time: Some(sample_mod_time()), meta_sys: sys, } .into_fileinfo("b", "k", false); assert_eq!(fi.transition_version_id, None); assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42")); fi.validate_for_metadata_read() .expect("free-version record with an opaque tier id must remain readable"); } #[test] fn delete_marker_free_version_transition_version_id_minio_string_form_is_recovered() { // MinIO stores the tier version id as a UUID string; recover it. let id = sample_version_id(); let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, id.to_string().into_bytes()); insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER, b"WARM".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".to_vec()); let fi = MetaDeleteMarker { version_id: Some(sample_version_id()), mod_time: Some(sample_mod_time()), meta_sys: sys, } .into_fileinfo("b", "k", false); assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version, Some(id.to_string())); } #[test] fn version_header_sorts_before_prefers_object_over_delete_marker_on_equal_mod_time() { let object = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(1)), mod_time: Some(sample_mod_time()), version_type: VersionType::Object, ..Default::default() }; let delete_marker = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(2)), version_type: VersionType::Delete, ..object }; // With equal mod_time the tie is broken by "prefer lower types": // Object (1) sorts before Delete (2), regardless of version_id. assert!(object.sorts_before(&delete_marker)); assert!(!delete_marker.sorts_before(&object)); } #[test] fn version_header_sorts_before_equal_mod_time_is_deterministic_and_antisymmetric() { let a = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(1)), mod_time: Some(sample_mod_time()), version_type: VersionType::Object, ..Default::default() }; let b = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(2)), ..a.clone() }; // Equal mod_time and version_type: the higher version_id sorts first, // and exactly one of the two orderings holds so merge sees a stable // total order for distinct headers. assert!(b.sorts_before(&a)); assert!(!a.sorts_before(&b)); assert_ne!(a.sorts_before(&b), b.sorts_before(&a)); // A header never sorts before an identical header. assert!(!a.sorts_before(&a.clone())); } #[test] fn version_header_sorts_before_breaks_signature_tie_before_version_id() { // Two same-type Object headers with equal mod_time but differing // signature and version_id. Matching MinIO's sortsBefore, the higher // signature wins the tie ahead of the version_id comparison, even when // version_id would order the two the other way. let lower_sig_higher_id = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(2)), mod_time: Some(sample_mod_time()), version_type: VersionType::Object, signature: [0x00, 0x00, 0x00, 0x01], ..Default::default() }; let higher_sig_lower_id = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(1)), signature: [0x00, 0x00, 0x00, 0x02], ..lower_sig_higher_id.clone() }; assert!(higher_sig_lower_id.sorts_before(&lower_sig_higher_id)); assert!(!lower_sig_higher_id.sorts_before(&higher_sig_lower_id)); } #[test] fn version_header_sorts_before_version_id_only_breaks_signature_tie() { // Equal mod_time, version_type, and signature: only then does version_id // decide, and the higher version_id sorts first. let a = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(1)), mod_time: Some(sample_mod_time()), version_type: VersionType::Object, signature: [0x0a, 0x0b, 0x0c, 0x0d], ..Default::default() }; let b = FileMetaVersionHeader { version_id: Some(Uuid::from_u128(2)), ..a.clone() }; assert!(b.sorts_before(&a)); assert!(!a.sorts_before(&b)); } #[test] fn merge_file_meta_versions_survives_garbage_header_stream() { let valid = FileMetaShallowVersion { header: sample_header(), meta: Vec::new(), }; let garbage = FileMetaShallowVersion { header: FileMetaVersionHeader { version_id: None, mod_time: None, signature: [0xde, 0xad, 0xbe, 0xef], version_type: VersionType::Invalid, flags: 0xff, ec_n: 0, ec_m: 0, }, meta: b"not-a-valid-msgpack-version".to_vec(), }; // One disk returns a garbage header alongside two healthy streams: // merge must not panic and must still return the quorum version. let merged = merge_file_meta_versions(2, true, 0, &[vec![valid.clone()], vec![valid.clone()], vec![garbage]]); assert_eq!(merged, vec![valid]); } /// Build a single distinct Object version header for the union tests. fn union_version(version_u128: u128, mod_unix: i64) -> FileMetaShallowVersion { FileMetaShallowVersion { header: FileMetaVersionHeader { version_id: Some(Uuid::from_u128(version_u128)), mod_time: Some(OffsetDateTime::from_unix_timestamp(mod_unix).expect("valid timestamp")), signature: [0x11, 0x22, 0x33, 0x44], version_type: VersionType::Object, flags: 0, ec_n: 4, ec_m: 2, }, meta: Vec::new(), } } /// backlog#920: quorum==1 (union) must surface EVERY version present on ANY /// per-disk stream, even when the per-disk version sets are fully DISJOINT /// (a version living on a single disk). This is the enumeration guarantee the /// sub-quorum disk-walk depends on. #[test] fn merge_union_quorum1_yields_full_union_over_disjoint_disks() { let a = union_version(0xA, 1_705_312_300); let b = union_version(0xB, 1_705_312_200); let c = union_version(0xC, 1_705_312_100); // Three disks, each holding exactly ONE distinct version (disjoint sets). let disks = [vec![a], vec![b], vec![c]]; let merged = merge_file_meta_versions(1, true, 0, &disks); let ids: std::collections::HashSet> = merged.iter().map(|v| v.header.version_id).collect(); assert_eq!(merged.len(), 3, "union must retain all three disjoint versions: {merged:?}"); assert!(ids.contains(&Some(Uuid::from_u128(0xA)))); assert!(ids.contains(&Some(Uuid::from_u128(0xB)))); assert!(ids.contains(&Some(Uuid::from_u128(0xC)))); // Contrast: read-quorum (2) over the same disjoint streams surfaces NONE, // because no version reaches two agreeing disks. This is the exact gap. let read_quorum = merge_file_meta_versions(2, true, 0, &disks); assert!( read_quorum.is_empty(), "read-quorum merge must drop every sub-quorum version, got {read_quorum:?}" ); } /// The equal-modTime / distinct-versionId retain-and-advance branch: two /// versions sharing a mod_time but with different ids must BOTH survive the /// union merge (they are not collapsed as duplicates). #[test] fn merge_union_quorum1_retains_equal_modtime_distinct_version_ids() { let same_time = 1_705_312_300; let a = union_version(0xAA, same_time); let b = union_version(0xBB, same_time); let disks = [vec![a], vec![b]]; let merged = merge_file_meta_versions(1, true, 0, &disks); let ids: std::collections::HashSet> = merged.iter().map(|v| v.header.version_id).collect(); assert_eq!(merged.len(), 2, "equal-modTime distinct-id versions must both survive: {merged:?}"); assert!(ids.contains(&Some(Uuid::from_u128(0xAA)))); assert!(ids.contains(&Some(Uuid::from_u128(0xBB)))); } #[test] fn meta_object_init_free_version_rejects_invalid_tier_free_version_id() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec()); let obj = make_meta_object_with_sys(sys); let mut fi = FileInfo::new("object", 2, 2); fi.set_tier_free_version_id("not-a-uuid"); let err = obj .init_free_version(&fi) .expect_err("invalid free-version UUID should return an error instead of panicking"); assert!(matches!(err, Error::UuidParse(_))); } #[test] fn meta_object_init_free_version_preserves_transition_destination_identity() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec()); let identity = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_vec(); insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, identity.clone()); let obj = make_meta_object_with_sys(sys); let mut fi = FileInfo::new("object", 2, 2); fi.set_tier_free_version_id(&Uuid::new_v4().to_string()); let (free_version, created) = obj .init_free_version(&fi) .expect("free-version initialization should succeed"); let meta_sys = &free_version .delete_marker .expect("free-version should be a delete marker") .meta_sys; assert!(created); assert_eq!(get_bytes(meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID), Some(identity)); assert!(meta_sys.contains_key(&format!( "{}{}", rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX, SUFFIX_TRANSITION_TIER_DESTINATION_ID ))); assert!(meta_sys.contains_key(&format!( "{}{}", rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX, SUFFIX_TRANSITION_TIER_DESTINATION_ID ))); } #[test] fn meta_object_init_free_version_accepts_single_prefix_transition_destination_identity() { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec()); let identity = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_vec(); sys.insert( format!( "{}{}", rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX, SUFFIX_TRANSITION_TIER_DESTINATION_ID ), identity.clone(), ); let obj = make_meta_object_with_sys(sys); let mut fi = FileInfo::new("object", 2, 2); fi.set_tier_free_version_id(&Uuid::new_v4().to_string()); let (free_version, created) = obj .init_free_version(&fi) .expect("single-prefix legacy destination identity should remain compatible"); let meta_sys = &free_version .delete_marker .expect("free-version should be a delete marker") .meta_sys; assert!(created); assert_eq!( get_consistent_bytes(meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID), Some(identity.as_slice()) ); } #[test] fn meta_object_init_free_version_rejects_conflicting_or_invalid_transition_destination_identity() { let valid_identity = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_vec(); for (rustfs_identity, minio_identity) in [ ( valid_identity, b"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_vec(), ), (b"not-hex".to_vec(), b"not-hex".to_vec()), (Vec::new(), Vec::new()), ] { let mut sys = HashMap::new(); insert_bytes(&mut sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec()); sys.insert( format!( "{}{}", rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX, SUFFIX_TRANSITION_TIER_DESTINATION_ID ), rustfs_identity, ); sys.insert( format!( "{}{}", rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX, SUFFIX_TRANSITION_TIER_DESTINATION_ID ), minio_identity, ); let obj = make_meta_object_with_sys(sys); let mut fi = FileInfo::new("object", 2, 2); fi.set_tier_free_version_id(&Uuid::new_v4().to_string()); assert_eq!( obj.init_free_version(&fi) .expect_err("unsafe destination identity must fail closed"), Error::FileCorrupt ); } } #[test] fn delete_marker_decode_skips_unknown_fields_for_forward_compat() { // A newer writer emits the three known fields plus an extra one. Decoding // must skip the unknown field instead of erroring (backlog#799 B17). let vid = Uuid::from_u128(0x1234); let mut buf = Vec::new(); rmp::encode::write_map_len(&mut buf, 4).unwrap(); rmp::encode::write_str(&mut buf, "ID").unwrap(); rmp::encode::write_bin(&mut buf, vid.as_bytes()).unwrap(); rmp::encode::write_str(&mut buf, "MTime").unwrap(); rmp::encode::write_sint(&mut buf, 1_700_000_000_000_000_000i64).unwrap(); rmp::encode::write_str(&mut buf, "MetaSys").unwrap(); rmp::encode::write_map_len(&mut buf, 0).unwrap(); // Unknown field a future version added. rmp::encode::write_str(&mut buf, "FutureField").unwrap(); rmp::encode::write_str(&mut buf, "ignored").unwrap(); let mut dm = MetaDeleteMarker::default(); dm.decode_from(&mut std::io::Cursor::new(buf)) .expect("unknown fields must be skipped, not rejected"); assert_eq!(dm.version_id, Some(vid)); assert!(dm.mod_time.is_some()); } #[test] fn get_internal_replication_state_keeps_canonical_reset_key() { // The reset status is stored on disk under the full internal key; parsing // must keep it keyed by `target_reset_header(arn)` (not the bare ARN) so // `ReplicationState::target_state` finds it after a round trip // (backlog#799 B16). let arn = "arn:rustfs:replication:us-east-1:target:bucket"; let ts = "2026-06-30T00:00:00Z;reset-1".to_string(); let key = crate::replication::target_reset_header(arn); let mut metadata = HashMap::new(); metadata.insert(key.clone(), ts.clone()); let rs = get_internal_replication_state(&metadata).expect("reset state should parse"); assert_eq!( rs.reset_statuses_map.get(&key), Some(&ts), "map must be keyed by target_reset_header, not the bare ARN" ); assert_eq!( rs.target_state(arn).resync_timestamp, ts, "lookup must find the round-tripped reset status" ); } // ---- Header signature (backlog#861 / B12) ---- fn signed_object() -> MetaObject { MetaObject { version_id: Some(sample_version_id()), data_dir: Some(sample_version_id()), erasure_algorithm: ErasureAlgo::ReedSolomon, erasure_m: 2, erasure_n: 4, erasure_block_size: 1_048_576, erasure_index: 3, erasure_dist: vec![1, 2, 3, 4, 5, 6], bitrot_checksum_algo: ChecksumAlgo::HighwayHash, part_numbers: vec![1], part_etags: vec!["etag-1".to_string()], part_sizes: vec![11], part_actual_sizes: vec![11], size: 11, mod_time: Some(sample_mod_time()), meta_user: HashMap::from([("content-type".to_string(), "text/plain".to_string())]), ..Default::default() } } #[test] fn signature_is_no_longer_hardcoded_zero() { // Regression for B12: the write-path header must carry a real signature. let version = FileMetaVersion { version_type: VersionType::Object, object: Some(signed_object()), ..Default::default() }; let header = version.header(); assert_ne!(header.signature, [0, 0, 0, 0], "object header signature must be computed, not zeroed"); assert_eq!(header.signature, version.get_signature(), "header must use the computed signature"); } #[test] fn signature_ignores_per_disk_erasure_index() { // Two disks in the same set hold the same version but differ only in the // per-disk EcIndex; their signatures must match so heal does not see a // false divergence. let mut disk_a = signed_object(); disk_a.erasure_index = 1; let mut disk_b = signed_object(); disk_b.erasure_index = 5; assert_eq!(disk_a.get_signature(), disk_b.get_signature()); } #[test] fn signature_detects_tag_and_metadata_divergence() { // The exact bug: same version_id + same mod_time, but a PutObjectTagging // that only reached some disks. Signatures must differ so the divergence // is detectable / healable. let base = signed_object(); let mut tagged = base.clone(); tagged .meta_sys .insert("x-rustfs-internal-tags".to_string(), b"env=prod".to_vec()); assert_ne!(base.get_signature(), tagged.get_signature(), "meta_sys change must move the signature"); let mut user_changed = base.clone(); user_changed .meta_user .insert("x-amz-meta-owner".to_string(), "alice".to_string()); assert_ne!( base.get_signature(), user_changed.get_signature(), "meta_user change must move the signature" ); let mut resized = base.clone(); resized.size += 1; resized.part_sizes = vec![12]; assert_ne!(base.get_signature(), resized.get_signature(), "body change must move the signature"); } #[test] fn signature_is_map_order_independent() { // HashMap iteration order is not stable across disks; folding maps in // order-independently must produce the same signature regardless. let mut a = signed_object(); let mut b = signed_object(); for obj in [&mut a, &mut b] { obj.meta_user.clear(); } a.meta_user.insert("k1".into(), "v1".into()); a.meta_user.insert("k2".into(), "v2".into()); a.meta_user.insert("k3".into(), "v3".into()); // Insert in a different order into b. b.meta_user.insert("k3".into(), "v3".into()); b.meta_user.insert("k1".into(), "v1".into()); b.meta_user.insert("k2".into(), "v2".into()); assert_eq!(a.get_signature(), b.get_signature()); // But a different value for the same key must still diverge. let mut c = a.clone(); c.meta_user.insert("k2".into(), "changed".into()); assert_ne!(a.get_signature(), c.get_signature()); } #[test] fn signature_treats_empty_and_all_empty_part_etags_alike() { let mut none = signed_object(); none.part_etags.clear(); let mut all_empty = signed_object(); all_empty.part_etags = vec![String::new(), String::new()]; assert_eq!(none.get_signature(), all_empty.get_signature()); } #[test] fn delete_marker_signature_detects_meta_sys_divergence() { let base = MetaDeleteMarker { version_id: Some(sample_version_id()), mod_time: Some(sample_mod_time()), meta_sys: HashMap::new(), }; let mut diverged = base.clone(); diverged .meta_sys .insert("x-rustfs-internal-purgestatus".to_string(), b"pending".to_vec()); assert_ne!(base.get_signature(), diverged.get_signature()); // Same content is stable across recomputation. assert_eq!(base.get_signature(), base.get_signature()); } #[test] fn invalid_version_uses_error_sentinel_not_zero() { // A version whose inner body is missing must not masquerade as an // all-zero legacy signature. let version = FileMetaVersion { version_type: VersionType::Object, object: None, ..Default::default() }; assert_eq!(version.get_signature(), SIGNATURE_ERR); assert_ne!(version.get_signature(), [0, 0, 0, 0]); } /// Regression for backlog#799 B18: a Legacy (V1Obj) version must survive an /// encode/decode round trip. `encode_to` used to omit the `V1Obj` field /// entirely, silently dropping the whole legacy body on re-marshal. #[test] fn legacy_version_body_round_trips_through_encode() { let mut meta = HashMap::new(); meta.insert("content-type".to_string(), "application/octet-stream".to_string()); let mut crc = HashMap::new(); crc.insert("crc32c".to_string(), "deadbeef".to_string()); let legacy = MetaObjectV1 { version: "1.0.1".to_string(), format: "xl".to_string(), stat: MetaObjectV1Stat { size: 4096, mod_time: Some( OffsetDateTime::from_unix_timestamp(1_700_000_123) .unwrap() .replace_nanosecond(456) .unwrap(), ), name: "obj".to_string(), dir: false, mode: 0o644, }, erasure: MetaObjectV1Erasure { algorithm: "klauspost/reedsolomon/vandermonde".to_string(), data_blocks: 4, parity_blocks: 2, block_size: 10 << 20, index: 1, distribution: vec![1, 2, 3, 4, 5, 6], checksums: vec![MetaObjectV1ChecksumInfo { part_number: 1, algorithm: "highwayhash256S".to_string(), hash: vec![0xaa, 0xbb, 0xcc, 0xdd], }], }, meta, parts: vec![MetaObjectV1Part { etag: "etag-1".to_string(), number: 1, size: 4096, actual_size: 4096, mod_time: Some(OffsetDateTime::from_unix_timestamp(1_700_000_100).unwrap()), index: Some(Bytes::from_static(&[1, 2, 3])), checksums: Some(crc), error: None, }], version_id: "00000000-0000-0000-0000-000000000001".to_string(), data_dir: "11111111-1111-1111-1111-111111111111".to_string(), }; let version = FileMetaVersion { version_type: VersionType::Legacy, legacy_object: Some(legacy.clone()), ..Default::default() }; let buf = version.marshal_msg().expect("marshal must succeed"); let mut decoded = FileMetaVersion::default(); decoded.unmarshal_msg(&buf).expect("unmarshal must succeed"); assert_eq!(decoded.version_type, VersionType::Legacy); let decoded_legacy = decoded.legacy_object.expect("legacy body must survive round trip"); assert_eq!(decoded_legacy, legacy, "legacy body must be byte-for-byte preserved"); } /// A `None` `Stat.ModTime` must stay `None` across encode/decode — the /// encoder writes the field only when present, mirroring the decoder, so it /// never silently becomes `Some(UNIX_EPOCH)` (backlog#799 B18 hardening). #[test] fn legacy_stat_none_mod_time_round_trips_as_none() { let stat = MetaObjectV1Stat { size: 10, mod_time: None, name: "n".to_string(), dir: true, mode: 0o755, }; let mut buf = Vec::new(); stat.encode_to(&mut buf).expect("encode must succeed"); let mut decoded = MetaObjectV1Stat::default(); decoded .decode_from(&mut std::io::Cursor::new(&buf)) .expect("decode must succeed"); assert_eq!(decoded, stat); assert!(decoded.mod_time.is_none(), "absent mod_time must not become Some(UNIX_EPOCH)"); } } /// Equivalence tests proving `read_xl_meta_no_data_sync` returns byte-for-byte /// identical `Result`s to the async `read_xl_meta_no_data` for every code path. /// /// This is the critical guard for HP-12 item 1: the ecstore metadata read path /// folds open+fstat+read into one `spawn_blocking` closure that calls the sync /// twin, and it MUST NOT drift from the async version in either the Ok bytes or /// the Err variant. #[cfg(test)] mod read_xl_meta_sync_equivalence_tests { use super::*; use std::io::Cursor as StdCursor; // tokio implements `AsyncRead` for `std::io::Cursor`, so the same type // drives both the async and the sync readers. use std::io::Cursor as TokioCursor; /// 8-byte xl.meta header: "XL2 " magic + LE major + LE minor. fn header(major: u16, minor: u16) -> Vec { let mut v = Vec::with_capacity(8); v.extend_from_slice(&XL_FILE_HEADER); v.extend_from_slice(&major.to_le_bytes()); v.extend_from_slice(&minor.to_le_bytes()); v } /// 5-byte msgpack `bin32` length prefix (marker 0xc6 + 4-byte BE length), /// matching what `read_bytes_header` decodes via `read_bin_len`. fn bin32_prefix(len: u32) -> [u8; 5] { let mut p = [0u8; 5]; p[0] = 0xc6; p[1..].copy_from_slice(&len.to_be_bytes()); p } /// Build a v1.x xl.meta buffer: header + bin32(meta_len) + meta payload + /// `crc_bytes` trailer bytes + `inline` trailing inline-data bytes. The meta /// payload content is irrelevant: `read_xl_meta_no_data` never parses it. fn build_v1x(minor: u16, meta_len: usize, crc_bytes: usize, inline: usize) -> Vec { let mut v = header(1, minor); v.extend_from_slice(&bin32_prefix(meta_len as u32)); v.extend(std::iter::repeat_n(0xABu8, meta_len)); v.extend(std::iter::repeat_n(0xC1u8, crc_bytes)); v.extend(std::iter::repeat_n(0xCDu8, inline)); v } async fn run_async(buf: &[u8], size: usize) -> Result> { let mut r = TokioCursor::new(buf.to_vec()); read_xl_meta_no_data(&mut r, size).await } fn run_sync(buf: &[u8], size: usize) -> Result> { let mut r = StdCursor::new(buf.to_vec()); read_xl_meta_no_data_sync(&mut r, size) } /// Drive both implementations with the same input and assert the results /// are equivalent (Ok bytes equal, or Err variant equal per `Error`'s /// `PartialEq`, which compares io kind + message for `Error::Io`). async fn assert_equivalent(label: &str, buf: &[u8], size: usize) -> Result> { let a = run_async(buf, size).await; let s = run_sync(buf, size); match (&a, &s) { (Ok(ab), Ok(sb)) => assert_eq!(ab, sb, "[{label}] Ok bytes must match"), (Err(ae), Err(se)) => assert_eq!(ae, se, "[{label}] Err variant must match"), _ => panic!("[{label}] async/sync disagree on Ok vs Err: async={a:?} sync={s:?}"), } a } #[tokio::test] async fn equivalence_across_all_paths() { // (a) v1.0: whole-file read path. { let mut buf = header(1, 0); buf.extend(std::iter::repeat_n(0x11u8, 24)); let len = buf.len(); let out = assert_equivalent("v1.0", &buf, len).await.unwrap(); assert_eq!(out, buf, "v1.0 returns the whole file"); } // (b) v1.1 / v1.2 / v1.3, each a clean well-formed buffer. // v1.1: minor < 2, no CRC trailer; returns header+prefix+meta. { let buf = build_v1x(1, 10, 0, 0); let len = buf.len(); let out = assert_equivalent("v1.1", &buf, len).await.unwrap(); assert_eq!(out.len(), 8 + 5 + 10, "v1.1 -> header+prefix+meta"); } // v1.2: minor >= 2, 5-byte CRC trailer retained. { let buf = build_v1x(2, 15, 5, 0); let len = buf.len(); let out = assert_equivalent("v1.2", &buf, len).await.unwrap(); assert_eq!(out.len(), 8 + 5 + 15 + 5, "v1.2 -> header+prefix+meta+crc"); } // v1.3: minor >= 2, 5-byte CRC trailer retained. { let buf = build_v1x(3, 20, 5, 0); let len = buf.len(); let out = assert_equivalent("v1.3", &buf, len).await.unwrap(); assert_eq!(out.len(), 8 + 5 + 20 + 5, "v1.3 -> header+prefix+meta+crc"); } // (c) size > META_DATA_READ_DEFAULT: initial 4KiB read then read_more. { let meta_len = 5000; // total = 8 + 5 + 5000 + 5 = 5018 > 4096 let buf = build_v1x(3, meta_len, 5, 0); assert!(buf.len() > META_DATA_READ_DEFAULT); let len = buf.len(); let out = assert_equivalent("v1.3-large", &buf, len).await.unwrap(); assert_eq!(out.len(), 8 + 5 + meta_len + 5); } // (d) truncated inside the header -> UnexpectedEof (initial read_exact // fails because the reader holds fewer bytes than the claimed size). { let buf = XL_FILE_HEADER.to_vec(); // only 4 bytes present assert_equivalent("truncated-header", &buf, 8).await.unwrap_err(); } // (e) truncated inside the CRC trailer -> FileCorrupt (v1.3 with only // 2 of the 5 trailer bytes present; size matches the buffer). { let buf = build_v1x(3, 10, 2, 0); // want=23, buf.len()=25, 25-23=2 < 5 let len = buf.len(); let e = assert_equivalent("truncated-crc", &buf, len).await.unwrap_err(); assert_eq!(e, Error::FileCorrupt); } // (f) unknown major / unknown minor -> InvalidData. // Unknown major: major=0 passes check_xl2_v1 (0 <= MAJOR) but misses the // `1 =>` arm. { let buf = header(0, 0); let e = assert_equivalent("unknown-major", &buf, buf.len()).await.unwrap_err(); assert_eq!( e, Error::other(std::io::Error::new(std::io::ErrorKind::InvalidData, "Unknown major metadata version")) ); } // Unknown minor: major=1, minor=4 misses the `0` and `1..=3` arms. { let buf = header(1, 4); let e = assert_equivalent("unknown-minor", &buf, buf.len()).await.unwrap_err(); assert_eq!( e, Error::other(std::io::Error::new(std::io::ErrorKind::InvalidData, "Unknown minor metadata version")) ); } // (g) want boundaries. // Exact fit: size == want + crc, no inline data. { let buf = build_v1x(3, 20, 5, 0); // size == want+5 exactly let len = buf.len(); let out = assert_equivalent("want-exact-fit", &buf, len).await.unwrap(); assert_eq!(out, buf); } // Inline data past the trailer: truncate drops it, want_max clamps below size. { let buf = build_v1x(3, 20, 5, 100); // size = want+5+100 let len = buf.len(); let out = assert_equivalent("want-with-inline", &buf, len).await.unwrap(); assert_eq!(out.len(), 8 + 5 + 20 + 5, "inline data is dropped by truncate"); } // (h) read_more's own read_exact hits EOF (has_full=false, read_size // within claimed size but the reader is physically shorter). { // prefix claims a 5000-byte meta so size=5018, but only 4100 bytes exist. let mut buf = header(1, 3); buf.extend_from_slice(&bin32_prefix(5000)); buf.extend(std::iter::repeat_n(0xABu8, 4100 - buf.len())); assert_eq!(buf.len(), 4100); let e = assert_equivalent("read_more-eof", &buf, 5018).await.unwrap_err(); assert_eq!(e, Error::Unexpected, "short read surfaces as Unexpected"); } } }