mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-12 16:16:55 +00:00
b0c6c4cbce
* 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>
4958 lines
185 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|