Files
rustfs/crates/filemeta/src/filemeta/version.rs
T
cxymds b0c6c4cbce fix(storage): resolve erasure parity per pool (#4977)
* fix(filemeta): add state-aware file info validation

* fix(filemeta): validate shard arithmetic and delete paths

* fix(ecstore): add fallible erasure construction

* fix(ecstore): resolve storage parity per pool

* fix(storage): report heterogeneous erasure layouts

* fix(admin): publish prepared storage config atomically

* fix(storage): harden per-pool parity boundaries

* fix(storage): address pre-PR validation findings

* test(ci): fix strict-topology validation fixtures

* fix(heal): preserve delete markers during repair

* refactor(filemeta): drop unused ValidatedFileInfo witness

ValidatedFileInfo wrapped an unread `_file_info` reference alongside an `Option<ValidatedErasureLayout>`, but only the layout was ever consumed. Return the layout directly from `FileInfo::validate` so the sole production consumer (`LocalDisk::check_parts`) and the two unit tests read it without the extra witness type and lifetime.

No behavior change.

* fix(filemeta): keep compressed and MinIO-migrated tiered objects readable

The new decode-path validation rejected several legitimate on-disk shapes that older RustFS and MinIO-migrated data carry, turning readable objects into FileCorrupt:

- Compressed objects written with an unknown upload size persist a negative per-part actual_size (the documented "unknown size" sentinel that ObjectInfo::get_actual_size already tolerates). validate_collection_contents rejected it via usize::try_from; now a negative actual_size skips shard validation and only real, non-negative sizes are checked.
- MinIO-migrated objects transitioned to a versioned remote tier store the tier version id as a UUID string, not 16 raw bytes. MetaObject::into_fileinfo returned FileCorrupt (main tolerated it as None), making all versions of the object unreadable; MetaDeleteMarker free-version records took a Some(nil) sentinel path with the same effect, which also breaks free-version expiry (remote-tier leak). Both now decode through a shared transitioned_version_id_from_meta_sys helper: 16 raw bytes or a UUID string are accepted, anything else is tolerated as None instead of failing the read.

Regression tests updated to assert the readable/compat behavior, with new tests covering MinIO string-form recovery.

* fix(scanner): build the delete-marker test fixture without erasure geometry

get_size_counts_delete_markers_separately_from_versions built its delete marker with `FileInfo::new(object, 1, 1)`, which attaches erasure geometry (data=1/parity=1/distribution). This PR classifies versions by shape via `is_storage_delete_marker()` (no geometry) rather than the raw `deleted` flag, so a geometry-bearing "delete marker" is correctly serialized as a purge-pending payload Object and counted as a version — CI saw summary.versions=3, expected 2.

Real delete markers carry no erasure geometry (delete paths build them as `FileInfo { deleted: true, ..Default::default() }`), so construct the fixture the same way. It then classifies as a storage delete marker and the counts (versions=2, delete_markers=1) hold. This keeps the PR's more-correct classification, which prevents a purge-pending object's geometry from being dropped when serialized as a bare delete marker.

* docs(changelog): note per-pool parity fix and storage-class startup upgrade caveat

Records the #4801 per-pool erasure parity fix under Fixed, and documents the upgrade behavior where a persisted storage class that a small or heterogeneous pool cannot satisfy now fails startup — with the RUSTFS_STORAGE_CLASS_STANDARD recovery steps. Docs-only; covers R4 from the on-disk compatibility audit.

* fix(heal): report parity from erasure geometry, not is_valid()

heal_object set HealResultItem.parity_blocks via `if lfi.is_valid()`, which was missed by the migration of the other quorum/metadata predicates. With the new `is_valid()` semantics (full payload validation; delete markers now return false), a delete marker or a geometry-bearing version with a benign collection quirk would misreport parity as the pool default instead of its own. Use `has_valid_erasure_geometry()` — the narrow "does this carry erasure geometry" predicate the rest of the migration uses — so reporting matches the object's actual layout. Reporting-only; no data-path change.

* fix(filemeta): do not silently serialize a non-canonical deleted FileInfo as an Object

`From<FileInfo> for FileMetaVersion` classifies by `is_storage_delete_marker()` (shape), which correctly routes canonical delete markers to Delete and purge-pending payloads (deleted=true with real erasure geometry) to Object. But a `deleted` FileInfo that is neither a canonical marker nor a valid erasure payload 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; `From` is infallible, so surface it with a structured `warn!` on the malformed branch instead of writing corrupt metadata silently. Legitimate purge-pending objects (valid geometry) are unaffected — the guard only fires for `deleted && !has_valid_erasure_geometry()`.

* test(filemeta): assert real historical xl.meta versions pass metadata-read validation

Empirical companion to the code-reasoned decode-tolerance invariants (docs/architecture/erasure-coding.md §11) and the rolling-upgrade / MinIO-migration compatibility concern: the tightened `validate_for_metadata_read` runs on every local disk read and peer-RPC-decoded FileInfo, so it must accept every version of real historically-written xl.meta, never reject it as FileCorrupt.

Loads five real fixtures — MinIO small-inline, MinIO versioned (two object versions + a delete marker), MinIO large multipart, a legacy V1 (xl.json-derived) object, and a legacy meta_ver 2 object — decodes every version with parts materialized, and asserts validate_for_metadata_read() is Ok for each. Reverting the tolerant handling (delete-marker shape, legacy per-part checksums, string/short transitioned-versionID, negative actual_size) turns this red.

* fix(ci): remove duplicate storage test re-exports

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-07-19 21:52:31 +08:00

4958 lines
185 KiB
Rust

// 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;
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, 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;
/// 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<String, String>) -> 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<u8>` map, mirroring MinIO's
/// `hashDeterministicBytes`. See [`hash_deterministic_string`].
fn hash_deterministic_bytes(m: &HashMap<String, Vec<u8>>) -> 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<R: std::io::Read>(rd: &mut R) -> Result<String> {
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<R: std::io::Read>(rd: &mut R) -> Result<Vec<u8>> {
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<W: std::io::Write>(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<Vec<u8>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct LegacyUuidBytesVisitor;
impl<'de> serde::de::Visitor<'de> for LegacyUuidBytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("nil or binary UUID bytes")
}
fn visit_none<E>(self) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(Vec::new())
}
fn visit_unit<E>(self) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(Vec::new())
}
fn visit_bytes<E>(self, value: &[u8]) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(value.to_vec())
}
fn visit_byte_buf<E>(self, value: Vec<u8>) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(value)
}
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
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<OffsetDateTime> {
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<R: std::io::Read>(rd: &mut R) -> Result<OffsetDateTime> {
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<Option<Uuid>> {
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`.
///
/// RustFS writes it as 16 raw UUID bytes; MinIO-migrated tiered objects store
/// the remote tier's version id as a UUID *string*. Accept both, and treat any
/// absent / nil / otherwise-unparseable value as "no tier version" (matching the
/// tolerant pre-hardening behavior) rather than failing the whole object read —
/// a malformed tier id must not make an otherwise-readable object unreadable.
fn transitioned_version_id_from_meta_sys(meta_sys: &HashMap<String, Vec<u8>>) -> Option<Uuid> {
let value = get_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID)?;
if value.is_empty() {
return None;
}
if let Ok(id) = Uuid::from_slice(&value) {
return (!id.is_nil()).then_some(id);
}
std::str::from_utf8(&value)
.ok()
.and_then(|s| Uuid::parse_str(s.trim()).ok())
.filter(|id| !id.is_nil())
}
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<LegacyMetaV2Object>,
delete_marker: Option<LegacyMetaV2DeleteMarker>,
write_version: u64,
}
#[derive(Debug, Deserialize)]
struct LegacyMetaV2Object {
#[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")]
version_id: Vec<u8>,
#[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")]
data_dir: Vec<u8>,
erasure_algorithm: String,
erasure_m: usize,
erasure_n: usize,
erasure_block_size: usize,
erasure_index: usize,
erasure_dist: Vec<u8>,
bitrot_checksum_algo: String,
part_numbers: Vec<usize>,
part_etags: Vec<String>,
part_sizes: Vec<usize>,
part_actual_sizes: Vec<i64>,
part_indices: Vec<Vec<u8>>,
size: i64,
mod_time: Option<OffsetDateTime>,
meta_sys: HashMap<String, Vec<u8>>,
meta_user: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct LegacyMetaV2DeleteMarker {
#[serde(default, deserialize_with = "deserialize_legacy_uuid_bytes")]
version_id: Vec<u8>,
mod_time: Option<OffsetDateTime>,
meta_sys: HashMap<String, Vec<u8>>,
}
#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, Eq, PartialOrd, Ord)]
pub struct FileMetaShallowVersion {
pub header: FileMetaVersionHeader,
pub meta: Vec<u8>, // FileMetaVersion.marshal_msg
}
fn write_quorum_from_erasure(data_blocks: usize, parity_blocks: usize) -> Option<usize> {
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> {
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<FileInfo> {
self.parse_version_meta()?.into_fileinfo(volume, path, all_parts)
}
}
impl TryFrom<FileMetaVersion> for FileMetaShallowVersion {
type Error = Error;
fn try_from(value: FileMetaVersion) -> std::result::Result<Self, Self::Error> {
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<MetaObjectV1>,
#[serde(rename = "V2Obj")]
pub object: Option<MetaObject>,
#[serde(rename = "DelObj")]
pub delete_marker: Option<MetaDeleteMarker>,
#[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<usize> {
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<Option<Uuid>> {
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<Uuid> = 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<Uuid> {
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<Uuid> {
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<OffsetDateTime> {
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<Option<Uuid>> {
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<R: std::io::Read>(&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<W: std::io::Write>(&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<u64> {
let mut cur = std::io::Cursor::new(buf);
self.decode_from(&mut cur)?;
Ok(cur.position())
}
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
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<FileInfo> {
// 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<Self, Self::Error> {
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::<LegacyMetaV2Version>(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<LegacyMetaV2Version> for FileMetaVersion {
type Error = Error;
fn try_from(value: LegacyMetaV2Version) -> std::result::Result<Self, Self::Error> {
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<FileInfo> 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<FileMetaShallowVersion> for FileMetaVersion {
type Error = Error;
fn try_from(value: FileMetaShallowVersion) -> std::result::Result<Self, Self::Error> {
FileMetaVersion::try_from(value.meta.as_slice())
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone, Eq, Hash)]
pub struct FileMetaVersionHeader {
pub version_id: Option<Uuid>,
pub mod_time: Option<OffsetDateTime>,
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<Vec<u8>> {
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<u64> {
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<u64> {
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<u64> {
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<u64> {
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<Ordering> {
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<FileMetaVersion> 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<Uuid>, // Version ID
#[serde(rename = "DDir")]
pub data_dir: Option<Uuid>, // 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<u8>, // Erasure distribution
#[serde(rename = "CSumAlgo")]
pub bitrot_checksum_algo: ChecksumAlgo, // Bitrot checksum algo
#[serde(rename = "PartNums")]
pub part_numbers: Vec<usize>, // Part Numbers
#[serde(rename = "PartETags")]
pub part_etags: Vec<String>, // Part ETags
#[serde(rename = "PartSizes")]
pub part_sizes: Vec<usize>, // Part Sizes
#[serde(rename = "PartASizes")]
pub part_actual_sizes: Vec<i64>, // Part ActualSizes (compression)
#[serde(rename = "PartIdx")]
pub part_indices: Vec<Bytes>, // Part Indexes (compression)
#[serde(rename = "Size")]
pub size: i64, // Object version size
#[serde(rename = "MTime")]
pub mod_time: Option<OffsetDateTime>, // Object version modified time
#[serde(rename = "MetaSys")]
pub meta_sys: HashMap<String, Vec<u8>>, // Object version internal metadata
#[serde(rename = "MetaUsr")]
pub meta_user: HashMap<String, String>, // Object version metadata set by user
}
impl TryFrom<LegacyMetaV2Object> for MetaObject {
type Error = Error;
fn try_from(value: LegacyMetaV2Object) -> std::result::Result<Self, Self::Error> {
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<String, String>,
#[serde(rename = "Parts")]
pub parts: Vec<MetaObjectV1Part>,
#[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<OffsetDateTime>,
#[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<u8>,
}
#[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<usize>,
#[serde(rename = "Checksums")]
pub checksums: Vec<MetaObjectV1ChecksumInfo>,
}
#[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<OffsetDateTime>,
#[serde(rename = "i")]
pub index: Option<Bytes>,
#[serde(rename = "crc")]
pub checksums: Option<HashMap<String, String>>,
#[serde(rename = "err")]
pub error: Option<String>,
}
impl MetaObjectV1 {
fn version_id(&self) -> Option<Uuid> {
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<R: std::io::Read>(&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<W: std::io::Write>(&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<R: std::io::Read>(&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<W: std::io::Write>(&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<R: std::io::Read>(&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::<i64, _>(rd)? as usize,
"ParityBlocks" => self.parity_blocks = rmp::decode::read_int::<i64, _>(rd)? as usize,
"BlockSize" => self.block_size = rmp::decode::read_int::<i64, _>(rd)? as usize,
"Index" => self.index = rmp::decode::read_int::<i64, _>(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::<i64, _>(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<W: std::io::Write>(&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<R: std::io::Read>(&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::<i64, _>(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<W: std::io::Write>(&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<R: std::io::Read>(&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::<i64, _>(rd)? as usize,
"s" => self.size = rmp::decode::read_int::<i64, _>(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<W: std::io::Write>(&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<MetaObjectV1Erasure> 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<MetaObjectV1ChecksumInfo> 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<MetaObjectV1Part> 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<u64> {
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<Vec<u8>> {
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<W: std::io::Write>(&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<R: std::io::Read>(&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<FileInfo> {
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_id = transitioned_version_id_from_meta_sys(&self.meta_sys);
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_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_id) = fi.transition_version_id.as_ref() {
insert_bytes(
&mut self.meta_sys,
SUFFIX_TRANSITIONED_VERSION_ID,
transition_version_id.as_bytes().to_vec(),
);
}
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::<String, Vec<u8>>::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,
] {
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<FileInfo> 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(vid) = &value.transition_version_id {
insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, vid.as_bytes().to_vec());
}
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<String, String>) -> Option<ReplicationState> {
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<Uuid>, // Version ID for delete marker
#[serde(rename = "MTime")]
pub mod_time: Option<OffsetDateTime>, // Object delete marker modified time
#[serde(rename = "MetaSys")]
pub meta_sys: HashMap<String, Vec<u8>>, // Delete marker internal metadata
}
impl TryFrom<LegacyMetaV2DeleteMarker> for MetaDeleteMarker {
type Error = Error;
fn try_from(value: LegacyMetaV2DeleteMarker) -> std::result::Result<Self, Self::Error> {
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_id = transitioned_version_id_from_meta_sys(&self.meta_sys);
}
fi
}
pub fn encode_to<W: std::io::Write>(&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<R: std::io::Read>(&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<u64> {
let mut cur = std::io::Cursor::new(buf);
self.decode_from(&mut cur)?;
Ok(cur.position())
}
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
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<FileInfo> for MetaDeleteMarker {
fn from(value: FileInfo) -> Self {
let mut meta_sys = HashMap::new();
let mut durable_metadata: HashMap<String, (&str, bool)> = 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(version_id) = value.transition_version_id {
insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, version_id.as_bytes().to_vec());
}
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<FileMetaShallowVersion>],
) -> Vec<FileMetaShallowVersion> {
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<FileMetaShallowVersion>],
) -> Vec<FileMetaShallowVersion> {
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<FileMetaShallowVersion>],
) -> Vec<FileMetaShallowVersion> {
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<FileMetaVersionHeader, usize> = 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<FileInfo> {
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<FileInfo> {
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<R: AsyncRead + Unpin>(
reader: &mut R,
buf: &mut Vec<u8>,
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<R: AsyncRead + Unpin>(reader: &mut R, size: usize) -> Result<Vec<u8>> {
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<R: std::io::Read>(
reader: &mut R,
buf: &mut Vec<u8>,
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<R: std::io::Read>(reader: &mut R, size: usize) -> Result<Vec<u8>> {
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_id = Some(remote_version_id);
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.as_bytes().to_vec())
);
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<u8>,
mod_time: Option<OffsetDateTime>,
meta_sys: HashMap<String, Vec<u8>>,
}
#[derive(Serialize)]
struct LegacyDeleteVersionFixture {
version_type: LegacyDeleteVersionTypeFixture,
object: Option<()>,
delete_marker: Option<LegacyDeleteMarkerFixture>,
write_version: u64,
}
#[derive(Serialize)]
struct LegacyDeleteMarkerNilFixture {
version_id: Option<Vec<u8>>,
mod_time: Option<OffsetDateTime>,
meta_sys: HashMap<String, Vec<u8>>,
}
#[derive(Serialize)]
struct LegacyDeleteVersionNilFixture {
version_type: LegacyDeleteVersionTypeFixture,
object: Option<()>,
delete_marker: Option<LegacyDeleteMarkerNilFixture>,
write_version: u64,
}
#[derive(Serialize)]
enum LegacyObjectVersionTypeFixture {
#[serde(rename = "Object")]
Object,
}
#[derive(Serialize)]
struct LegacyObjectFixture {
version_id: Option<Vec<u8>>,
data_dir: Option<Vec<u8>>,
erasure_algorithm: String,
erasure_m: usize,
erasure_n: usize,
erasure_block_size: usize,
erasure_index: usize,
erasure_dist: Vec<u8>,
bitrot_checksum_algo: String,
part_numbers: Vec<usize>,
part_etags: Vec<String>,
part_sizes: Vec<usize>,
part_actual_sizes: Vec<i64>,
part_indices: Vec<Vec<u8>>,
size: i64,
mod_time: Option<OffsetDateTime>,
meta_sys: HashMap<String, Vec<u8>>,
meta_user: HashMap<String, String>,
}
#[derive(Serialize)]
struct LegacyObjectVersionFixture {
version_type: LegacyObjectVersionTypeFixture,
object: Option<LegacyObjectFixture>,
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<usize>, part_sizes: Vec<usize>, part_actual_sizes: Vec<i64>) -> 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<u8> {
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<u8> {
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<u8>, 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<u8> {
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<u8> {
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<String, Vec<u8>>) -> 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));
}
#[test]
fn meta_object_transition_version_id_unparseable_stays_readable_as_none() {
// A non-UUID / non-16-byte tier version id must NOT make the object
// unreadable; it is tolerated as "no tier version" (compat with
// pre-hardening behavior and foreign/edge metadata).
let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"not-a-uuid".to_vec());
let fi = make_meta_object_with_sys(sys)
.into_fileinfo("b", "k", false)
.expect("unparseable transition version id must not fail the object read");
assert_eq!(fi.transition_version_id, 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));
}
#[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));
}
#[test]
fn delete_marker_free_version_transition_version_id_unparseable_stays_readable() {
// A malformed tier version id must not make a free-version record corrupt:
// it decodes to None and stays readable. Otherwise free-version expiry
// fails and the remote-tier object leaks.
let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"not-a-uuid".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);
fi.validate_for_metadata_read()
.expect("free-version record with an unparseable 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));
}
#[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<Option<Uuid>> = 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<Option<Uuid>> = 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<u8> {
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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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");
}
}
}