mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 15:37:02 +00:00
fix(storage): resolve erasure parity per pool (#4977)
* fix(filemeta): add state-aware file info validation
* fix(filemeta): validate shard arithmetic and delete paths
* fix(ecstore): add fallible erasure construction
* fix(ecstore): resolve storage parity per pool
* fix(storage): report heterogeneous erasure layouts
* fix(admin): publish prepared storage config atomically
* fix(storage): harden per-pool parity boundaries
* fix(storage): address pre-PR validation findings
* test(ci): fix strict-topology validation fixtures
* fix(heal): preserve delete markers during repair
* refactor(filemeta): drop unused ValidatedFileInfo witness
ValidatedFileInfo wrapped an unread `_file_info` reference alongside an `Option<ValidatedErasureLayout>`, but only the layout was ever consumed. Return the layout directly from `FileInfo::validate` so the sole production consumer (`LocalDisk::check_parts`) and the two unit tests read it without the extra witness type and lifetime.
No behavior change.
* fix(filemeta): keep compressed and MinIO-migrated tiered objects readable
The new decode-path validation rejected several legitimate on-disk shapes that older RustFS and MinIO-migrated data carry, turning readable objects into FileCorrupt:
- Compressed objects written with an unknown upload size persist a negative per-part actual_size (the documented "unknown size" sentinel that ObjectInfo::get_actual_size already tolerates). validate_collection_contents rejected it via usize::try_from; now a negative actual_size skips shard validation and only real, non-negative sizes are checked.
- MinIO-migrated objects transitioned to a versioned remote tier store the tier version id as a UUID string, not 16 raw bytes. MetaObject::into_fileinfo returned FileCorrupt (main tolerated it as None), making all versions of the object unreadable; MetaDeleteMarker free-version records took a Some(nil) sentinel path with the same effect, which also breaks free-version expiry (remote-tier leak). Both now decode through a shared transitioned_version_id_from_meta_sys helper: 16 raw bytes or a UUID string are accepted, anything else is tolerated as None instead of failing the read.
Regression tests updated to assert the readable/compat behavior, with new tests covering MinIO string-form recovery.
* fix(scanner): build the delete-marker test fixture without erasure geometry
get_size_counts_delete_markers_separately_from_versions built its delete marker with `FileInfo::new(object, 1, 1)`, which attaches erasure geometry (data=1/parity=1/distribution). This PR classifies versions by shape via `is_storage_delete_marker()` (no geometry) rather than the raw `deleted` flag, so a geometry-bearing "delete marker" is correctly serialized as a purge-pending payload Object and counted as a version — CI saw summary.versions=3, expected 2.
Real delete markers carry no erasure geometry (delete paths build them as `FileInfo { deleted: true, ..Default::default() }`), so construct the fixture the same way. It then classifies as a storage delete marker and the counts (versions=2, delete_markers=1) hold. This keeps the PR's more-correct classification, which prevents a purge-pending object's geometry from being dropped when serialized as a bare delete marker.
* docs(changelog): note per-pool parity fix and storage-class startup upgrade caveat
Records the #4801 per-pool erasure parity fix under Fixed, and documents the upgrade behavior where a persisted storage class that a small or heterogeneous pool cannot satisfy now fails startup — with the RUSTFS_STORAGE_CLASS_STANDARD recovery steps. Docs-only; covers R4 from the on-disk compatibility audit.
* fix(heal): report parity from erasure geometry, not is_valid()
heal_object set HealResultItem.parity_blocks via `if lfi.is_valid()`, which was missed by the migration of the other quorum/metadata predicates. With the new `is_valid()` semantics (full payload validation; delete markers now return false), a delete marker or a geometry-bearing version with a benign collection quirk would misreport parity as the pool default instead of its own. Use `has_valid_erasure_geometry()` — the narrow "does this carry erasure geometry" predicate the rest of the migration uses — so reporting matches the object's actual layout. Reporting-only; no data-path change.
* fix(filemeta): do not silently serialize a non-canonical deleted FileInfo as an Object
`From<FileInfo> for FileMetaVersion` classifies by `is_storage_delete_marker()` (shape), which correctly routes canonical delete markers to Delete and purge-pending payloads (deleted=true with real erasure geometry) to Object. But a `deleted` FileInfo that is neither a canonical marker nor a valid erasure payload would silently serialize as a zero-geometry MetaObject that later fails `validate_for_metadata_read`. Write paths validate first (`validate_for_erasure_write` / `validate_for_metadata_read`), so this is a caller bug; `From` is infallible, so surface it with a structured `warn!` on the malformed branch instead of writing corrupt metadata silently. Legitimate purge-pending objects (valid geometry) are unaffected — the guard only fires for `deleted && !has_valid_erasure_geometry()`.
* test(filemeta): assert real historical xl.meta versions pass metadata-read validation
Empirical companion to the code-reasoned decode-tolerance invariants (docs/architecture/erasure-coding.md §11) and the rolling-upgrade / MinIO-migration compatibility concern: the tightened `validate_for_metadata_read` runs on every local disk read and peer-RPC-decoded FileInfo, so it must accept every version of real historically-written xl.meta, never reject it as FileCorrupt.
Loads five real fixtures — MinIO small-inline, MinIO versioned (two object versions + a delete marker), MinIO large multipart, a legacy V1 (xl.json-derived) object, and a legacy meta_ver 2 object — decodes every version with parts materialized, and asserts validate_for_metadata_read() is Ok for each. Reverting the tolerant handling (delete-marker shape, legacy per-part checksums, string/short transitioned-versionID, negative actual_size) turns this red.
* fix(ci): remove duplicate storage test re-exports
---------
Co-authored-by: overtrue <anzhengchao@gmail.com>
This commit is contained in:
+736
-15
@@ -17,8 +17,8 @@ use bytes::Bytes;
|
||||
use rmp_serde::Serializer;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_COMPRESSION, SUFFIX_DATA_MOVED, SUFFIX_HEALING, SUFFIX_INLINE_DATA, SUFFIX_TIER_FV_ID, SUFFIX_TIER_FV_MARKER,
|
||||
SUFFIX_TIER_SKIP_FV_ID, contains_key_str, get_str, insert_str,
|
||||
SUFFIX_COMPRESSION, SUFFIX_DATA_MOVED, SUFFIX_FREE_VERSION, SUFFIX_HEALING, SUFFIX_INLINE_DATA, SUFFIX_TIER_FV_ID,
|
||||
SUFFIX_TIER_FV_MARKER, SUFFIX_TIER_SKIP_FV_ID, contains_key_str, get_str, has_internal_suffix, insert_str,
|
||||
};
|
||||
use s3s::dto::{RestoreStatus, Timestamp};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
@@ -31,6 +31,12 @@ use uuid::Uuid;
|
||||
pub const ERASURE_ALGORITHM: &str = "rs-vandermonde";
|
||||
pub const BLOCK_SIZE_V2: usize = 1024 * 1024; // 1M
|
||||
|
||||
const MAX_ERASURE_SHARDS: usize = 16;
|
||||
const MAX_FILEINFO_PARTS: usize = 10_000;
|
||||
const MAX_FILEINFO_CHECKSUMS: usize = 10_000;
|
||||
const FILEINFO_PART_BITMAP_WORD_BITS: usize = std::mem::size_of::<u64>() * 8;
|
||||
const FILEINFO_PART_BITMAP_WORDS: usize = MAX_FILEINFO_PARTS.div_ceil(FILEINFO_PART_BITMAP_WORD_BITS);
|
||||
|
||||
// Additional constants from Go version
|
||||
pub const NULL_VERSION_ID: &str = "null";
|
||||
// pub const RUSTFS_ERASURE_UPGRADED: &str = "x-rustfs-internal-erasure-upgraded";
|
||||
@@ -137,6 +143,14 @@ pub fn calc_shard_size(block_size: usize, data_shards: usize) -> usize {
|
||||
(block_size.div_ceil(data_shards) + 1) & !1
|
||||
}
|
||||
|
||||
fn checked_calc_shard_size(block_size: usize, data_shards: usize) -> Option<usize> {
|
||||
if data_shards == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
block_size.div_ceil(data_shards).checked_add(1).map(|size| size & !1)
|
||||
}
|
||||
|
||||
impl ErasureInfo {
|
||||
pub fn get_checksum_info(&self, part_number: usize) -> ChecksumInfo {
|
||||
for sum in &self.checksums {
|
||||
@@ -243,6 +257,55 @@ pub struct FileInfo {
|
||||
pub uses_legacy_checksum: bool,
|
||||
}
|
||||
|
||||
/// Selects the validation policy for a trusted operation boundary.
|
||||
///
|
||||
/// This mode is deliberately caller-selected and is never inferred from
|
||||
/// serialized [`FileInfo`] state such as `deleted` or `transition_status`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationMode {
|
||||
/// Validate the complete storage erasure layout before payload access.
|
||||
RequireErasure,
|
||||
/// Validate delete metadata without requiring a payload erasure layout.
|
||||
DeleteOnly,
|
||||
}
|
||||
|
||||
/// Erasure geometry that passed the storage-layout validation policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ValidatedErasureLayout {
|
||||
data_blocks: usize,
|
||||
block_size: usize,
|
||||
shard_size: usize,
|
||||
}
|
||||
|
||||
impl ValidatedErasureLayout {
|
||||
/// Calculates a shard file size without overflowing the metadata's integer format.
|
||||
pub fn shard_file_size(&self, total_length: usize) -> Option<i64> {
|
||||
let full_blocks = total_length / self.block_size;
|
||||
let last_block_size = total_length % self.block_size;
|
||||
let last_shard_size = checked_calc_shard_size(last_block_size, self.data_blocks)?;
|
||||
let shard_file_size = full_blocks.checked_mul(self.shard_size)?.checked_add(last_shard_size)?;
|
||||
i64::try_from(shard_file_size).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_fileinfo_part(bitmap: &mut [u64; FILEINFO_PART_BITMAP_WORDS], part_number: usize) -> bool {
|
||||
let Some(index) = part_number.checked_sub(1).filter(|&index| index < MAX_FILEINFO_PARTS) else {
|
||||
return false;
|
||||
};
|
||||
let word = index / FILEINFO_PART_BITMAP_WORD_BITS;
|
||||
let mask = 1u64 << (index % FILEINFO_PART_BITMAP_WORD_BITS);
|
||||
let was_absent = bitmap[word] & mask == 0;
|
||||
bitmap[word] |= mask;
|
||||
was_absent
|
||||
}
|
||||
|
||||
fn has_fileinfo_part(bitmap: &[u64; FILEINFO_PART_BITMAP_WORDS], part_number: usize) -> bool {
|
||||
let Some(index) = part_number.checked_sub(1).filter(|&index| index < MAX_FILEINFO_PARTS) else {
|
||||
return false;
|
||||
};
|
||||
bitmap[index / FILEINFO_PART_BITMAP_WORD_BITS] & (1u64 << (index % FILEINFO_PART_BITMAP_WORD_BITS)) != 0
|
||||
}
|
||||
|
||||
/// Validates that an erasure `distribution` is a permutation of `1..=n`.
|
||||
///
|
||||
/// A well-formed distribution has exactly `n` entries and each 1-based slot
|
||||
@@ -252,11 +315,11 @@ pub struct FileInfo {
|
||||
/// `usize` underflow / out-of-bounds panic. Rejecting such distributions here
|
||||
/// lets the metadata surface as a clean quorum/corruption error instead.
|
||||
pub(crate) fn is_valid_distribution(distribution: &[usize], n: usize) -> bool {
|
||||
if n == 0 || distribution.len() != n {
|
||||
if n == 0 || n > MAX_ERASURE_SHARDS || distribution.len() != n {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut seen = vec![false; n];
|
||||
let mut seen = [false; MAX_ERASURE_SHARDS];
|
||||
for &block_idx in distribution {
|
||||
// Valid 1-based slots are `1..=n`; anything else (including `0`) is invalid.
|
||||
if block_idx < 1 || block_idx > n {
|
||||
@@ -304,19 +367,264 @@ impl FileInfo {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.deleted {
|
||||
return true;
|
||||
fn validate_erasure_geometry_with_index(&self, index: usize) -> Result<ValidatedErasureLayout> {
|
||||
let erasure = &self.erasure;
|
||||
if erasure.data_blocks == 0 || erasure.block_size == 0 || erasure.data_blocks < erasure.parity_blocks {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
|
||||
let data_blocks = self.erasure.data_blocks;
|
||||
let parity_blocks = self.erasure.parity_blocks;
|
||||
let total_blocks = erasure
|
||||
.data_blocks
|
||||
.checked_add(erasure.parity_blocks)
|
||||
.ok_or(Error::FileCorrupt)?;
|
||||
if total_blocks > MAX_ERASURE_SHARDS
|
||||
|| index == 0
|
||||
|| index > total_blocks
|
||||
|| !is_valid_distribution(&erasure.distribution, total_blocks)
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
|
||||
(data_blocks >= parity_blocks)
|
||||
&& (data_blocks > 0)
|
||||
&& (self.erasure.index > 0
|
||||
&& self.erasure.index <= data_blocks + parity_blocks
|
||||
&& is_valid_distribution(&self.erasure.distribution, data_blocks + parity_blocks))
|
||||
let shard_size = checked_calc_shard_size(erasure.block_size, erasure.data_blocks)
|
||||
.filter(|&size| i64::try_from(size).is_ok())
|
||||
.ok_or(Error::FileCorrupt)?;
|
||||
i64::try_from(erasure.block_size).map_err(|_| Error::FileCorrupt)?;
|
||||
|
||||
let layout = ValidatedErasureLayout {
|
||||
data_blocks: erasure.data_blocks,
|
||||
block_size: erasure.block_size,
|
||||
shard_size,
|
||||
};
|
||||
let object_size = usize::try_from(self.size).map_err(|_| Error::FileCorrupt)?;
|
||||
layout.shard_file_size(object_size).ok_or(Error::FileCorrupt)?;
|
||||
Ok(layout)
|
||||
}
|
||||
|
||||
fn validate_erasure_geometry(&self) -> Result<ValidatedErasureLayout> {
|
||||
self.validate_erasure_geometry_with_index(self.erasure.index)
|
||||
}
|
||||
|
||||
fn validate_collection_bounds(&self) -> Result<()> {
|
||||
if self.parts.len() > MAX_FILEINFO_PARTS
|
||||
|| self.erasure.checksums.len() > MAX_FILEINFO_CHECKSUMS
|
||||
|| self.erasure.distribution.len() > MAX_ERASURE_SHARDS
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_collection_contents(&self, layout: Option<&ValidatedErasureLayout>) -> Result<()> {
|
||||
let mut part_numbers = [0u64; FILEINFO_PART_BITMAP_WORDS];
|
||||
for part in &self.parts {
|
||||
if let Some(layout) = layout {
|
||||
i64::try_from(part.size).map_err(|_| Error::FileCorrupt)?;
|
||||
layout.shard_file_size(part.size).ok_or(Error::FileCorrupt)?;
|
||||
|
||||
// A negative `actual_size` is the documented "unknown size" sentinel for
|
||||
// compressed streaming objects (see `ObjectPartInfo::actual_size` /
|
||||
// `ObjectInfo::get_actual_size`), written to xl.meta by both RustFS and
|
||||
// MinIO. Only shard-validate a real, non-negative size; rejecting the
|
||||
// sentinel would make legitimate compressed objects unreadable.
|
||||
if let Ok(actual_size) = usize::try_from(part.actual_size) {
|
||||
layout.shard_file_size(actual_size).ok_or(Error::FileCorrupt)?;
|
||||
}
|
||||
}
|
||||
if !mark_fileinfo_part(&mut part_numbers, part.number) {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
}
|
||||
|
||||
let mut checksum_parts = [0u64; FILEINFO_PART_BITMAP_WORDS];
|
||||
for checksum in &self.erasure.checksums {
|
||||
if !has_fileinfo_part(&part_numbers, checksum.part_number)
|
||||
|| !mark_fileinfo_part(&mut checksum_parts, checksum.part_number)
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_delete_marker_shape(&self, expected_index: usize, allow_nil_version_id: bool) -> Result<()> {
|
||||
if !self.deleted {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
let erasure = &self.erasure;
|
||||
let stored_free_version = contains_key_str(&self.metadata, SUFFIX_FREE_VERSION);
|
||||
if self.mod_time.is_none_or(|mod_time| mod_time <= OffsetDateTime::UNIX_EPOCH)
|
||||
|| (!allow_nil_version_id && self.version_id.is_some_and(|version_id| version_id.is_nil()))
|
||||
|| self.transition_version_id.is_some_and(|version_id| version_id.is_nil())
|
||||
|| self.size != 0
|
||||
|| self.data_dir.is_some()
|
||||
|| self.mode.is_some()
|
||||
|| self.written_by_version.is_some()
|
||||
|| self.data.is_some()
|
||||
|| self.checksum.is_some()
|
||||
|| !self.parts.is_empty()
|
||||
|| !erasure.algorithm.is_empty()
|
||||
|| erasure.data_blocks != 0
|
||||
|| erasure.parity_blocks != 0
|
||||
|| erasure.block_size != 0
|
||||
|| erasure.index != expected_index
|
||||
|| !erasure.distribution.is_empty()
|
||||
|| !erasure.checksums.is_empty()
|
||||
|| stored_free_version != self.tier_free_version()
|
||||
|| (stored_free_version && (self.transition_tier.is_empty() || self.transitioned_objname.is_empty()))
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_tier_free_version_delete_shape(&self) -> Result<()> {
|
||||
let erasure = &self.erasure;
|
||||
if !self.deleted
|
||||
|| !self.tier_free_version()
|
||||
|| self.version_id.is_none_or(|version_id| version_id.is_nil())
|
||||
|| self.mod_time.is_some()
|
||||
|| self.is_latest
|
||||
|| !self.transition_status.is_empty()
|
||||
|| !self.transitioned_objname.is_empty()
|
||||
|| !self.transition_tier.is_empty()
|
||||
|| self.transition_version_id.is_some()
|
||||
|| self.expire_restored
|
||||
|| self.size != 0
|
||||
|| self.data_dir.is_some()
|
||||
|| self.mode.is_some()
|
||||
|| self.written_by_version.is_some()
|
||||
|| self.mark_deleted
|
||||
|| self.replication_state_internal.is_some()
|
||||
|| self.data.is_some()
|
||||
|| self.num_versions != 0
|
||||
|| self.successor_mod_time.is_some()
|
||||
|| self.fresh
|
||||
|| self.idx != 0
|
||||
|| self.checksum.is_some()
|
||||
|| self.versioned
|
||||
|| self.uses_legacy_checksum
|
||||
|| !self.parts.is_empty()
|
||||
|| self.metadata.is_empty()
|
||||
|| self.metadata.len() > 2
|
||||
|| self
|
||||
.metadata
|
||||
.iter()
|
||||
.any(|(key, value)| !has_internal_suffix(key, SUFFIX_TIER_FV_MARKER) || !value.is_empty())
|
||||
|| !erasure.algorithm.is_empty()
|
||||
|| erasure.data_blocks != 0
|
||||
|| erasure.parity_blocks != 0
|
||||
|| erasure.block_size != 0
|
||||
|| erasure.index != 0
|
||||
|| !erasure.distribution.is_empty()
|
||||
|| !erasure.checksums.is_empty()
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates this metadata for the caller-selected operation boundary and
|
||||
/// returns the payload erasure layout when one is established.
|
||||
///
|
||||
/// All modes enforce collection bounds and checksum-to-part associations.
|
||||
/// Only [`ValidationMode::RequireErasure`] establishes a payload erasure
|
||||
/// layout; the other modes cannot be upgraded based on serialized flags and
|
||||
/// return `None`.
|
||||
pub fn validate(&self, mode: ValidationMode) -> Result<Option<ValidatedErasureLayout>> {
|
||||
self.validate_collection_bounds()?;
|
||||
|
||||
let erasure_layout = match mode {
|
||||
ValidationMode::RequireErasure => Some(self.validate_erasure_geometry()?),
|
||||
ValidationMode::DeleteOnly => {
|
||||
self.validate_delete_marker_shape(0, false)?;
|
||||
None
|
||||
}
|
||||
};
|
||||
self.validate_collection_contents(erasure_layout.as_ref())?;
|
||||
|
||||
Ok(erasure_layout)
|
||||
}
|
||||
|
||||
/// Validate metadata returned by a disk or peer as a strict tagged union.
|
||||
/// Payload entries require complete erasure geometry, including
|
||||
/// purge-pending object versions whose replication state sets `deleted`.
|
||||
/// Only entries that are not valid payloads may fall back to the canonical
|
||||
/// delete-marker shape, so `deleted` cannot bypass payload validation.
|
||||
pub fn validate_for_metadata_read(&self) -> Result<()> {
|
||||
if self.validate(ValidationMode::RequireErasure).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.validate(ValidationMode::DeleteOnly).map(|_| ())
|
||||
}
|
||||
|
||||
/// Cheap shape check for metadata that already passed
|
||||
/// [`Self::validate_for_metadata_read`] at its decode boundary.
|
||||
pub fn has_valid_metadata_shape(&self) -> bool {
|
||||
self.has_valid_erasure_geometry() || self.is_canonical_delete_marker()
|
||||
}
|
||||
|
||||
/// Returns whether this is a canonical delete marker rather than an
|
||||
/// erasure-backed object version in a purge-pending state.
|
||||
pub fn is_canonical_delete_marker(&self) -> bool {
|
||||
self.validate_delete_marker_shape(0, false).is_ok()
|
||||
}
|
||||
|
||||
/// Storage-only marker classification after an absent version ID has been
|
||||
/// normalized to the internal nil UUID. Network and disk boundaries must
|
||||
/// continue to use [`Self::is_canonical_delete_marker`].
|
||||
pub(crate) fn is_storage_delete_marker(&self) -> bool {
|
||||
self.validate_delete_marker_shape(0, true).is_ok()
|
||||
}
|
||||
|
||||
/// Whether this is a canonical delete marker carrying only the per-disk
|
||||
/// index assigned by an older rename coordinator.
|
||||
pub fn is_legacy_indexed_delete_marker(&self) -> bool {
|
||||
self.erasure.index > 0
|
||||
&& self.erasure.index <= MAX_ERASURE_SHARDS
|
||||
&& self.validate_delete_marker_shape(self.erasure.index, false).is_ok()
|
||||
}
|
||||
|
||||
/// Validates complete metadata before a write fanout assigns its per-disk
|
||||
/// shard index. An index of zero is treated only as the pending assignment;
|
||||
/// every other geometry and collection invariant remains mandatory.
|
||||
pub fn validate_for_erasure_write(&self) -> Result<()> {
|
||||
self.validate_collection_bounds()?;
|
||||
let index = if self.erasure.index == 0 { 1 } else { self.erasure.index };
|
||||
let layout = self.validate_erasure_geometry_with_index(index)?;
|
||||
self.validate_collection_contents(Some(&layout))
|
||||
}
|
||||
|
||||
/// Validates metadata used to mutate a version list during a delete.
|
||||
/// Delete-marker creation requires the canonical marker shape; other
|
||||
/// delete operations do not access payload shards but still validate all
|
||||
/// collection bounds and checksum-to-part associations before mutation.
|
||||
pub fn validate_for_delete_operation(&self) -> Result<()> {
|
||||
self.validate_collection_bounds()?;
|
||||
|
||||
if self.deleted && self.tier_free_version() {
|
||||
return self.validate_tier_free_version_delete_shape();
|
||||
}
|
||||
|
||||
if self.deleted {
|
||||
return self.validate(ValidationMode::DeleteOnly).map(|_| ());
|
||||
}
|
||||
|
||||
self.validate_collection_contents(None)
|
||||
}
|
||||
|
||||
/// Performs the cheap layout check used by quorum selection after a decode
|
||||
/// boundary has already called [`Self::validate`].
|
||||
pub fn has_valid_erasure_geometry(&self) -> bool {
|
||||
self.validate_erasure_geometry().is_ok()
|
||||
}
|
||||
|
||||
/// Validates the complete payload metadata shape.
|
||||
///
|
||||
/// Repeated quorum passes over metadata that was already fully validated
|
||||
/// should use [`Self::has_valid_erasure_geometry`] instead.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.validate(ValidationMode::RequireErasure).is_ok()
|
||||
}
|
||||
|
||||
pub fn get_etag(&self) -> Option<String> {
|
||||
@@ -324,7 +632,7 @@ impl FileInfo {
|
||||
}
|
||||
|
||||
pub fn write_quorum(&self, quorum: usize) -> usize {
|
||||
if self.deleted {
|
||||
if self.deleted && !self.has_valid_erasure_geometry() {
|
||||
return quorum;
|
||||
}
|
||||
|
||||
@@ -817,6 +1125,419 @@ mod tests {
|
||||
assert!(!fi.is_valid());
|
||||
}
|
||||
|
||||
fn validation_test_fileinfo() -> FileInfo {
|
||||
let mut fi = FileInfo::new("bucket/object", 4, 2);
|
||||
fi.erasure.index = 1;
|
||||
fi.parts = vec![
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
},
|
||||
ObjectPartInfo {
|
||||
number: 2,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
fi.erasure.checksums = vec![
|
||||
ChecksumInfo {
|
||||
part_number: 1,
|
||||
algorithm: HashAlgorithm::HighwayHash256S,
|
||||
hash: Bytes::from_static(b"checksum-one"),
|
||||
},
|
||||
ChecksumInfo {
|
||||
part_number: 2,
|
||||
algorithm: HashAlgorithm::SHA256,
|
||||
hash: Bytes::from_static(b"checksum-two"),
|
||||
},
|
||||
];
|
||||
fi
|
||||
}
|
||||
|
||||
fn assert_file_corrupt(fi: &FileInfo, mode: ValidationMode) {
|
||||
assert_eq!(fi.validate(mode).expect_err("invalid FileInfo must be rejected"), Error::FileCorrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_require_erasure_returns_checked_layout() {
|
||||
let fi = validation_test_fileinfo();
|
||||
let layout = fi
|
||||
.validate(ValidationMode::RequireErasure)
|
||||
.expect("well-formed erasure metadata must validate")
|
||||
.expect("strict validation must return an erasure layout");
|
||||
assert_eq!(layout.data_blocks, 4);
|
||||
assert_eq!(layout.block_size, BLOCK_SIZE_V2);
|
||||
assert_eq!(layout.shard_size, calc_shard_size(BLOCK_SIZE_V2, 4));
|
||||
let shard_size = i64::try_from(layout.shard_size).expect("validated shard size must fit i64");
|
||||
assert_eq!(layout.shard_file_size(0), Some(0));
|
||||
assert_eq!(layout.shard_file_size(BLOCK_SIZE_V2 - 1), Some(shard_size));
|
||||
assert_eq!(layout.shard_file_size(BLOCK_SIZE_V2), Some(shard_size));
|
||||
assert_eq!(layout.shard_file_size(BLOCK_SIZE_V2 + 1), Some(shard_size + 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_require_erasure_accepts_zero_parity() {
|
||||
let mut fi = FileInfo::new("bucket/object", 16, 0);
|
||||
fi.erasure.index = 1;
|
||||
|
||||
let layout = fi
|
||||
.validate(ValidationMode::RequireErasure)
|
||||
.expect("zero parity is a valid storage layout");
|
||||
assert_eq!(layout.expect("strict layout must be present").data_blocks, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_for_erasure_write_only_relaxes_pending_shard_index() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.index = 0;
|
||||
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
fi.validate_for_erasure_write()
|
||||
.expect("write preparation must accept an index that fanout has not assigned yet");
|
||||
|
||||
fi.erasure.index = fi.erasure.data_blocks + fi.erasure.parity_blocks + 1;
|
||||
assert_eq!(fi.validate_for_erasure_write(), Err(Error::FileCorrupt));
|
||||
|
||||
fi.erasure.index = 0;
|
||||
fi.parts.push(fi.parts[0].clone());
|
||||
assert_eq!(fi.validate_for_erasure_write(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_require_erasure_rejects_invalid_geometry() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.data_blocks = 0;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.block_size = 0;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.data_blocks = usize::MAX;
|
||||
fi.erasure.parity_blocks = 1;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.data_blocks = 9;
|
||||
fi.erasure.parity_blocks = 8;
|
||||
fi.erasure.index = 1;
|
||||
fi.erasure.distribution = (1..=17).collect();
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.data_blocks = 1;
|
||||
fi.erasure.parity_blocks = 2;
|
||||
fi.erasure.index = 1;
|
||||
fi.erasure.distribution = vec![1, 2, 3];
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.index = 0;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.index = 7;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.distribution = vec![1, 2, 3, 4, 5, 5];
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
}
|
||||
|
||||
fn one_shard_validation_fileinfo(block_size: usize) -> FileInfo {
|
||||
let mut fi = FileInfo::new("bucket/object", 1, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.erasure.block_size = block_size;
|
||||
fi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_require_erasure_rejects_unrepresentable_shard_sizes() {
|
||||
let fi = one_shard_validation_fileinfo(usize::MAX);
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
if let Some((max_i64, above_i64_max)) = usize::try_from(i64::MAX)
|
||||
.ok()
|
||||
.and_then(|max_i64| max_i64.checked_add(1).map(|above_i64_max| (max_i64, above_i64_max)))
|
||||
{
|
||||
let mut fi = FileInfo::new("bucket/object", 2, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.erasure.block_size = above_i64_max;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = FileInfo::new("bucket/object", 2, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.erasure.block_size = max_i64;
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
size: above_i64_max,
|
||||
..Default::default()
|
||||
}];
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
}
|
||||
|
||||
let mut fi = one_shard_validation_fileinfo(2);
|
||||
fi.size = -1;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = one_shard_validation_fileinfo(2);
|
||||
fi.size = i64::MAX;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
let mut fi = one_shard_validation_fileinfo(2);
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
size: usize::MAX,
|
||||
..Default::default()
|
||||
}];
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
|
||||
// A negative `actual_size` is the compressed "unknown size" sentinel, not an
|
||||
// unrepresentable size: it must stay readable so existing compressed objects
|
||||
// remain accessible. Only real (non-negative) sizes are shard-validated.
|
||||
let mut fi = one_shard_validation_fileinfo(2);
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
actual_size: -1,
|
||||
..Default::default()
|
||||
}];
|
||||
fi.validate(ValidationMode::RequireErasure)
|
||||
.expect("negative actual_size sentinel (compressed objects) must remain readable");
|
||||
|
||||
let mut fi = one_shard_validation_fileinfo(2);
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
actual_size: i64::MAX,
|
||||
..Default::default()
|
||||
}];
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_read_validation_rejects_flags_that_try_to_relax_payload_validation() {
|
||||
let mut fi = FileInfo::new("bucket/legacy", 0, 2);
|
||||
fi.erasure.index = 1;
|
||||
fi.deleted = true;
|
||||
fi.transition_status = TRANSITION_COMPLETE.to_owned();
|
||||
fi.transitioned_objname = "remote/object".to_owned();
|
||||
fi.transition_tier = "WARM".to_owned();
|
||||
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert!(!fi.is_valid(), "wire-controlled state flags must not relax is_valid");
|
||||
assert!(matches!(fi.validate_for_metadata_read(), Err(Error::FileCorrupt)));
|
||||
assert_file_corrupt(&fi, ValidationMode::DeleteOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_read_validation_requires_canonical_delete_marker_shape() {
|
||||
let marker = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
marker
|
||||
.validate_for_metadata_read()
|
||||
.expect("canonical delete marker should validate");
|
||||
|
||||
let mut missing_time = marker.clone();
|
||||
missing_time.mod_time = None;
|
||||
assert!(matches!(missing_time.validate_for_metadata_read(), Err(Error::FileCorrupt)));
|
||||
|
||||
let mut epoch_time = marker.clone();
|
||||
epoch_time.mod_time = Some(OffsetDateTime::UNIX_EPOCH);
|
||||
assert!(matches!(epoch_time.validate_for_metadata_read(), Err(Error::FileCorrupt)));
|
||||
|
||||
let mut disguised_payload = marker.clone();
|
||||
disguised_payload.erasure.data_blocks = 1;
|
||||
assert!(matches!(disguised_payload.validate_for_metadata_read(), Err(Error::FileCorrupt)));
|
||||
|
||||
let mut marker_with_part = marker;
|
||||
marker_with_part.parts.push(ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(marker_with_part.validate_for_metadata_read(), Err(Error::FileCorrupt)));
|
||||
|
||||
let mut purge_pending = validation_test_fileinfo();
|
||||
purge_pending.deleted = true;
|
||||
purge_pending
|
||||
.validate_for_metadata_read()
|
||||
.expect("erasure-backed purge-pending objects remain valid payload metadata");
|
||||
assert!(!purge_pending.is_canonical_delete_marker());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_validation_accepts_only_legacy_assigned_delete_marker_index() {
|
||||
let marker = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut legacy_marker = marker.clone();
|
||||
legacy_marker.erasure.index = 2;
|
||||
assert_eq!(legacy_marker.validate_for_metadata_read(), Err(Error::FileCorrupt));
|
||||
assert!(legacy_marker.is_legacy_indexed_delete_marker());
|
||||
legacy_marker.erasure.index = 0;
|
||||
legacy_marker
|
||||
.validate_for_metadata_read()
|
||||
.expect("normalized legacy marker should pass strict metadata validation");
|
||||
|
||||
let mut malformed_marker = legacy_marker;
|
||||
malformed_marker.erasure.index = 2;
|
||||
malformed_marker.erasure.data_blocks = 1;
|
||||
assert!(!malformed_marker.is_legacy_indexed_delete_marker());
|
||||
assert_eq!(malformed_marker.validate_for_metadata_read(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut forged_free_marker = marker.clone();
|
||||
insert_str(&mut forged_free_marker.metadata, SUFFIX_FREE_VERSION, String::new());
|
||||
assert_eq!(forged_free_marker.validate_for_metadata_read(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut free_marker = marker.clone();
|
||||
insert_str(&mut free_marker.metadata, SUFFIX_FREE_VERSION, String::new());
|
||||
free_marker.set_tier_free_version();
|
||||
free_marker.transition_tier = "WARM".to_string();
|
||||
free_marker.transitioned_objname = "remote/object".to_string();
|
||||
free_marker
|
||||
.validate_for_metadata_read()
|
||||
.expect("complete tier free-version marker should remain valid");
|
||||
|
||||
let mut out_of_range_marker = marker;
|
||||
out_of_range_marker.erasure.index = MAX_ERASURE_SHARDS + 1;
|
||||
assert!(!out_of_range_marker.is_legacy_indexed_delete_marker());
|
||||
assert_eq!(out_of_range_marker.validate_for_metadata_read(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_operation_accepts_only_the_worker_tier_free_version_request_shape() {
|
||||
let mut worker_request = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
worker_request.set_tier_free_version();
|
||||
worker_request
|
||||
.validate_for_delete_operation()
|
||||
.expect("the lifecycle worker request shape must remain valid");
|
||||
|
||||
let mut timestamped = worker_request.clone();
|
||||
timestamped.mod_time = Some(OffsetDateTime::now_utc());
|
||||
assert_eq!(timestamped.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut with_payload = worker_request.clone();
|
||||
with_payload.size = 1;
|
||||
assert_eq!(with_payload.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut with_unrelated_metadata = worker_request;
|
||||
with_unrelated_metadata
|
||||
.metadata
|
||||
.insert("unexpected".to_string(), "value".to_string());
|
||||
assert_eq!(with_unrelated_metadata.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erasure_and_delete_operation_validation_enforce_basic_collection_bounds() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.parts = (1..=10_001)
|
||||
.map(|number| ObjectPartInfo {
|
||||
number,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert_eq!(fi.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.checksums = (1..=10_001)
|
||||
.map(|part_number| ChecksumInfo {
|
||||
part_number,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert_eq!(fi.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.distribution = vec![1; 17];
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert_eq!(fi.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_exact_collection_limits() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.parts = (1..=10_000)
|
||||
.map(|number| ObjectPartInfo {
|
||||
number,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
fi.erasure.checksums = (1..=10_000)
|
||||
.map(|part_number| ChecksumInfo {
|
||||
part_number,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
fi.validate(ValidationMode::RequireErasure)
|
||||
.expect("exact collection limits must remain valid");
|
||||
fi.validate_for_delete_operation()
|
||||
.expect("non-marker delete requests must accept exact collection limits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erasure_and_delete_operation_validation_reject_checksum_association_errors() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.erasure.checksums[0].part_number = 99;
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert_eq!(fi.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut fi = validation_test_fileinfo();
|
||||
let duplicate = fi.erasure.checksums[0].clone();
|
||||
fi.erasure.checksums.push(duplicate);
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert_eq!(fi.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erasure_and_delete_operation_validation_reject_duplicate_parts() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
fi.parts[1].number = fi.parts[0].number;
|
||||
fi.erasure.checksums.clear();
|
||||
|
||||
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
|
||||
assert_eq!(fi.validate_for_delete_operation(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_quorum_distinguishes_purge_pending_payload_from_delete_marker() {
|
||||
let mut purge_pending = FileInfo::new("bucket/object", 5, 1);
|
||||
purge_pending.erasure.index = 1;
|
||||
purge_pending.deleted = true;
|
||||
assert_eq!(purge_pending.write_quorum(4), 5);
|
||||
|
||||
let marker = FileInfo {
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(marker.write_quorum(4), 4);
|
||||
|
||||
let malformed_marker = FileInfo {
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(malformed_marker.write_quorum(4), 4, "invalid marker metadata must never reduce quorum");
|
||||
}
|
||||
|
||||
fn small_string_strategy() -> impl Strategy<Value = String> {
|
||||
proptest::string::string_regex("[A-Za-z0-9._/-]{0,16}").expect("small string regex should compile")
|
||||
}
|
||||
|
||||
@@ -395,7 +395,24 @@ impl FileMeta {
|
||||
// delete_version deletes version, returns data_dir
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
pub fn delete_version(&mut self, fi: &FileInfo) -> Result<Option<Uuid>> {
|
||||
fi.validate_for_delete_operation()?;
|
||||
|
||||
let vid = Some(fi.version_id.unwrap_or(Uuid::nil()));
|
||||
if fi.deleted && fi.tier_free_version() {
|
||||
let Some(index) = self.versions.iter().position(|version| {
|
||||
version.header.version_id == vid
|
||||
&& version.header.version_type == VersionType::Delete
|
||||
&& version.header.free_version()
|
||||
&& version
|
||||
.parse_version_meta()
|
||||
.is_ok_and(|decoded| decoded.free_version() && decoded.header().version_id == vid)
|
||||
}) else {
|
||||
return Err(Error::FileVersionNotFound);
|
||||
};
|
||||
self.versions.remove(index);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let target_is_delete_marker = self
|
||||
.versions
|
||||
.iter()
|
||||
@@ -410,10 +427,6 @@ impl FileMeta {
|
||||
mod_time: fi.mod_time,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if !fi.is_valid() {
|
||||
return Err(Error::other("invalid file meta version"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut update_version = false;
|
||||
@@ -978,6 +991,52 @@ mod test {
|
||||
assert!(!fi.parts.is_empty(), "multipart/part layout present");
|
||||
}
|
||||
|
||||
/// Compatibility guard for the tightened `validate_for_metadata_read()` decode
|
||||
/// boundary (the rolling-upgrade / MinIO-migration risk): every version of
|
||||
/// every real, historically-written `xl.meta` fixture must still be *accepted*
|
||||
/// on read, never rejected as `FileCorrupt`. This is the empirical companion to
|
||||
/// the code-reasoned decode-tolerance invariants in
|
||||
/// `docs/architecture/erasure-coding.md` §11 — reverting the tolerant handling
|
||||
/// (delete-marker shape, legacy per-part checksums, string/short
|
||||
/// `transitioned-versionID`, negative `actual_size`) turns one of these red.
|
||||
/// It covers real MinIO-written objects (inline, versioned incl. a delete
|
||||
/// marker, and multipart), a legacy V1 (`xl.json`-derived) object, and a legacy
|
||||
/// meta_ver 2 object.
|
||||
#[test]
|
||||
fn real_historical_xlmeta_versions_pass_metadata_read_validation() {
|
||||
let fixtures: [(&str, Vec<u8>); 5] = [
|
||||
("minio-small-inline", create_minio_small_object_xlmeta().expect("small fixture")),
|
||||
(
|
||||
"minio-versioned+delete-marker",
|
||||
create_minio_versioned_object_xlmeta().expect("versioned fixture"),
|
||||
),
|
||||
("minio-large-multipart", create_minio_large_object_xlmeta().expect("large fixture")),
|
||||
("legacy-v1-object", create_legacy_v1_object_xlmeta().expect("legacy v1 fixture")),
|
||||
(
|
||||
"legacy-meta-v2-object",
|
||||
create_issue_2265_legacy_meta_v2_object_xlmeta().expect("legacy meta v2 fixture"),
|
||||
),
|
||||
];
|
||||
|
||||
for (label, bytes) in fixtures {
|
||||
let fm = FileMeta::load(&bytes).unwrap_or_else(|e| panic!("{label}: load real xl.meta failed: {e}"));
|
||||
// `all_parts = true` materializes the part arrays so the checksum/part and
|
||||
// shard-size checks in `validate_for_metadata_read` are actually exercised.
|
||||
let versions = fm
|
||||
.list_versions("interop", "object", true)
|
||||
.unwrap_or_else(|e| panic!("{label}: list_versions failed: {e}"));
|
||||
assert!(!versions.is_empty(), "{label}: fixture must contain at least one version");
|
||||
for (i, fi) in versions.iter().enumerate() {
|
||||
fi.validate_for_metadata_read().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{label}: version {i} (deleted={}) rejected by validate_for_metadata_read: {e:?}",
|
||||
fi.deleted
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a raw meta block in a valid XL2 container (header, bin32 length
|
||||
/// prefix, and CRC trailer) so decode tests exercise the meta parsing
|
||||
/// itself rather than the envelope checks.
|
||||
@@ -2072,6 +2131,152 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_version_accepts_delete_only_marker_and_free_version_paths() {
|
||||
let marker_version_id = Uuid::new_v4();
|
||||
let mut marker_meta = FileMeta::new();
|
||||
marker_meta
|
||||
.delete_version(&FileInfo {
|
||||
version_id: Some(marker_version_id),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("delete-marker metadata must not require a payload erasure layout");
|
||||
assert_eq!(marker_meta.versions.len(), 1);
|
||||
assert_eq!(marker_meta.versions[0].header.version_type, VersionType::Delete);
|
||||
|
||||
let object_version_id = Uuid::new_v4();
|
||||
let remote_version_id = Uuid::new_v4();
|
||||
let free_version_id = Uuid::new_v4();
|
||||
let mut free_meta = FileMeta::new();
|
||||
free_meta
|
||||
.add_version(FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(object_version_id),
|
||||
transition_status: TRANSITION_COMPLETE.to_string(),
|
||||
transitioned_objname: "remote/object".to_string(),
|
||||
transition_version_id: Some(remote_version_id),
|
||||
transition_tier: "WARM".to_string(),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("transitioned object version must be seeded");
|
||||
|
||||
let mut transition_delete = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(object_version_id),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
transition_delete.set_tier_free_version_id(&free_version_id.to_string());
|
||||
free_meta
|
||||
.delete_version(&transition_delete)
|
||||
.expect("transitioned delete must persist free-version cleanup metadata");
|
||||
|
||||
let mut free_version_delete = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(free_version_id),
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
free_version_delete.set_tier_free_version();
|
||||
free_meta
|
||||
.delete_version(&free_version_delete)
|
||||
.expect("free-version cleanup must not require a payload erasure layout");
|
||||
|
||||
let versions = free_meta
|
||||
.get_file_info_versions("bucket", "object", false)
|
||||
.expect("versions must remain readable after free-version cleanup");
|
||||
assert!(versions.free_versions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_version_validates_requests_before_mutation() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let mut fm = FileMeta::new();
|
||||
fm.add_version(FileInfo {
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("object version must be seeded");
|
||||
let original = fm.clone();
|
||||
|
||||
let err = fm
|
||||
.delete_version(&FileInfo {
|
||||
version_id: Some(version_id),
|
||||
parts: vec![
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
},
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.expect_err("non-deleted requests must not bypass boundary validation");
|
||||
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
assert_eq!(fm, original, "failed validation must happen before metadata mutation");
|
||||
|
||||
let err = fm
|
||||
.delete_version(&FileInfo {
|
||||
version_id: Some(version_id),
|
||||
deleted: true,
|
||||
mod_time: None,
|
||||
..Default::default()
|
||||
})
|
||||
.expect_err("malformed delete markers must fail before removing the existing object version");
|
||||
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
assert_eq!(fm, original, "malformed delete-marker validation must precede metadata mutation");
|
||||
|
||||
let mut free_version_delete = FileInfo {
|
||||
version_id: Some(version_id),
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
free_version_delete.set_tier_free_version();
|
||||
let err = fm
|
||||
.delete_version(&free_version_delete)
|
||||
.expect_err("tier free-version cleanup must not remove an ordinary object version");
|
||||
|
||||
assert_eq!(err, Error::FileVersionNotFound);
|
||||
assert_eq!(fm, original, "missing free-version cleanup must not mutate ordinary metadata");
|
||||
|
||||
let mut poisoned_object_header = fm.clone();
|
||||
poisoned_object_header.versions[0].header.flags |= XL_FLAG_FREE_VERSION;
|
||||
let poisoned_original = poisoned_object_header.clone();
|
||||
let err = poisoned_object_header
|
||||
.delete_version(&free_version_delete)
|
||||
.expect_err("a free-version flag on an Object header must not authorize cleanup deletion");
|
||||
assert_eq!(err, Error::FileVersionNotFound);
|
||||
assert_eq!(
|
||||
poisoned_object_header, poisoned_original,
|
||||
"a poisoned Object header must remain unchanged after rejected cleanup"
|
||||
);
|
||||
|
||||
let mut mismatched_delete_header = fm.clone();
|
||||
mismatched_delete_header.versions[0].header.flags |= XL_FLAG_FREE_VERSION;
|
||||
mismatched_delete_header.versions[0].header.version_type = VersionType::Delete;
|
||||
let mismatched_original = mismatched_delete_header.clone();
|
||||
let err = mismatched_delete_header
|
||||
.delete_version(&free_version_delete)
|
||||
.expect_err("a Delete/free header over an Object body must fail closed");
|
||||
assert_eq!(err, Error::FileVersionNotFound);
|
||||
assert_eq!(
|
||||
mismatched_delete_header, mismatched_original,
|
||||
"header/body mismatch must not mutate metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_info_versions_excludes_free_versions_from_num_versions() {
|
||||
let object_version_id = Uuid::new_v4();
|
||||
|
||||
@@ -29,10 +29,10 @@ use super::*;
|
||||
use crate::ChecksumInfo;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
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,
|
||||
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;
|
||||
@@ -249,6 +249,27 @@ fn parse_legacy_uuid_bytes(bytes: &[u8], field: &str) -> Result<Option<Uuid>> {
|
||||
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,
|
||||
@@ -807,7 +828,7 @@ impl TryFrom<LegacyMetaV2Version> for FileMetaVersion {
|
||||
|
||||
impl From<FileInfo> for FileMetaVersion {
|
||||
fn from(value: FileInfo) -> Self {
|
||||
if value.deleted {
|
||||
if value.is_storage_delete_marker() {
|
||||
FileMetaVersion {
|
||||
version_type: VersionType::Delete,
|
||||
legacy_object: None,
|
||||
@@ -817,6 +838,22 @@ impl From<FileInfo> for FileMetaVersion {
|
||||
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,
|
||||
@@ -2361,9 +2398,7 @@ impl MetaObject {
|
||||
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 = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID)
|
||||
.and_then(|v| Uuid::from_slice(v.as_slice()).ok())
|
||||
.filter(|u| !u.is_nil());
|
||||
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();
|
||||
@@ -2671,9 +2706,7 @@ impl MetaDeleteMarker {
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
fi.transition_version_id = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID)
|
||||
.and_then(|v| Uuid::from_slice(v.as_slice()).ok())
|
||||
.filter(|u| !u.is_nil());
|
||||
fi.transition_version_id = transitioned_version_id_from_meta_sys(&self.meta_sys);
|
||||
}
|
||||
|
||||
fi
|
||||
@@ -2788,10 +2821,54 @@ impl MetaDeleteMarker {
|
||||
|
||||
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: HashMap::new(),
|
||||
meta_sys,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3324,6 +3401,31 @@ 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")]
|
||||
@@ -3997,6 +4099,32 @@ mod tests {
|
||||
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();
|
||||
@@ -4026,6 +4154,47 @@ mod tests {
|
||||
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 {
|
||||
|
||||
@@ -1333,7 +1333,6 @@ mod tests {
|
||||
name: "object".to_string(),
|
||||
version_id: Some(free_version_id),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
free_delete_fi.set_tier_free_version();
|
||||
|
||||
Reference in New Issue
Block a user