fix(ecstore): make inline-rollback reclamation file-precise to keep #5703's child-key safety (#5732)

#5724 reclaimed the synthetic inline-rollback dir after a committed rename with delete_data_dir(recursive: true), which has no notion of object metadata: for unversioned objects the synthetic UUID is a fixed, publicly-known constant, so object/<rollback-dir> can simultaneously be a legitimate child key's directory, and recursively deleting it reopens the authorization bypass #5703 closed (PutObject on K destroying K/<uuid> without DeleteObject permission).

Replace the recursive pass with a file-precise one: after quorum commit, delete exactly object/<rollback>/xl.meta.bkp with a non-recursive delete on every disk whose rollback dir is not also the cleanup dir. The parent-rmdir walk removes the dir only when the backup was its sole content, so the BucketNotEmpty leak fix is preserved (#5724's regression test passes unchanged) while a child key at the same path keeps its metadata. The undo path's restore_metadata_backup now also reclaims the emptied synthetic dir, mirroring restore_delete_rollback.
This commit is contained in:
Zhengchao An
2026-08-05 11:32:41 +08:00
committed by GitHub
parent 75d0c8d6b9
commit 8c9e884cf2
3 changed files with 235 additions and 50 deletions
+58 -3
View File
@@ -119,7 +119,7 @@ fn read_all_data_std(path: &Path) -> core::result::Result<(Vec<u8>, Option<Offse
Ok((bytes, modtime))
}
fn inline_metadata_rollback_dir(version_id: Uuid, meta: &FileMeta) -> Uuid {
pub(crate) fn inline_metadata_rollback_dir(version_id: Uuid, meta: &FileMeta) -> Uuid {
let used_data_dirs: HashSet<Uuid> = meta.get_data_dirs().unwrap_or_default().into_iter().flatten().collect();
let base = version_id.as_u128() ^ INLINE_METADATA_ROLLBACK_DIR_XOR;
let mut salt = 0u128;
@@ -240,8 +240,15 @@ async fn write_metadata_rollback_backup(object_dir: &Path, rollback_dir: Uuid, d
}
async fn restore_metadata_backup(object_dir: &Path, xl_path: &Path, rollback_dir: Uuid) -> Result<()> {
let backup_path = object_dir.join(rollback_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
rename_all(&backup_path, xl_path, object_dir).await
let rollback_path = object_dir.join(rollback_dir.to_string());
let backup_path = rollback_path.join(STORAGE_FORMAT_FILE_BACKUP);
rename_all(&backup_path, xl_path, object_dir).await?;
// A synthetic inline rollback dir held only the backup the rename above
// just consumed; reclaim it so the object dir can empty out. A real data
// dir still holds its parts, so the non-recursive remove is a benign
// no-op there (mirrors restore_delete_rollback).
let _ = fs::remove_dir(&rollback_path).await;
Ok(())
}
async fn restore_delete_rollback(object_dir: &Path, xl_path: &Path, rollback_dir: Uuid) -> Result<()> {
@@ -12228,6 +12235,54 @@ mod test {
);
}
// The undo_write restore consumes `<rollback>/xl.meta.bkp` by rename; a
// synthetic rollback dir is then empty and must be reclaimed so the object
// dir can empty out (BucketNotEmpty leak). A real data dir still holds its
// parts and must survive the non-recursive remove.
#[tokio::test]
async fn restore_metadata_backup_reclaims_empty_rollback_dir_only() {
use tempfile::tempdir;
let dir = tempdir().expect("temp dir should be created");
let object_dir = dir.path().join("bucket").join("obj");
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
let rollback_dir = Uuid::new_v4();
let rollback_path = object_dir.join(rollback_dir.to_string());
fs::create_dir_all(&rollback_path)
.await
.expect("rollback dir should be created");
fs::write(rollback_path.join(STORAGE_FORMAT_FILE_BACKUP), b"old-meta")
.await
.expect("backup should be written");
restore_metadata_backup(&object_dir, &xl_path, rollback_dir)
.await
.expect("restore should succeed");
assert_eq!(
fs::read(&xl_path).await.expect("xl.meta should be restored"),
b"old-meta",
"restore must move the backup back onto xl.meta"
);
assert!(!rollback_path.exists(), "an emptied synthetic rollback dir must be reclaimed");
// Real data dir: parts remain, the dir must survive.
let real_dir = Uuid::new_v4();
let real_path = object_dir.join(real_dir.to_string());
fs::create_dir_all(&real_path).await.expect("real data dir should be created");
fs::write(real_path.join(STORAGE_FORMAT_FILE_BACKUP), b"older-meta")
.await
.expect("backup should be written");
fs::write(real_path.join("part.1"), b"data")
.await
.expect("part should be written");
restore_metadata_backup(&object_dir, &xl_path, real_dir)
.await
.expect("restore should succeed");
assert!(real_path.join("part.1").exists(), "a real data dir must keep its parts");
assert!(real_path.exists(), "a non-empty data dir must not be removed");
}
#[tokio::test]
async fn rename_commit_failure_cleans_local_rollback_backup() {
use tempfile::tempdir;
@@ -49,7 +49,7 @@ use crate::diagnostics::get::{
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
use crate::disk::{
DataDirDeleteStatus, OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK,
PartTransactionAction, part_transaction_path,
PartTransactionAction, STORAGE_FORMAT_FILE_BACKUP, part_transaction_path,
};
use crate::erasure::coding::BitrotReader;
use crate::io_support::bitrot::ShardReader;
@@ -2950,63 +2950,63 @@ impl SetDisks {
return Err(ret_err);
}
// A synthetic inline-rollback dir (rollback_data_dir set, cleanup_data_dir
// unset) holds only the xl.meta.bkp consumed by the quorum-failure undo
// above. Once the commit holds quorum that window is closed, and the
// commit_rename_data_dir pass reclaims real old data dirs only, so the
// synthetic dir must be reclaimed here — otherwise every overwrite of an
// inline version by a non-inline one leaves <object>/<rollback-dir>/
// behind, and DeleteBucket keeps failing with BucketNotEmpty after the
// object is deleted. Best-effort: residue must not fail the durable write.
let rollback_only_futures: Vec<_> = disks
.iter()
.enumerate()
.filter_map(|(idx, disk)| {
let disk = disk.clone()?;
if errs[idx].is_some() || cleanup_data_dirs[idx].is_some() {
return None;
}
let rollback_dir = data_dirs[idx]?;
// Anti-misdelete guard, same posture as commit_rename_data_dir:
// never touch the data dir the commit just published.
if file_infos[idx].data_dir == Some(rollback_dir) {
return None;
}
let dst_bucket = dst_bucket.clone();
let path = format!("{dst_object}/{rollback_dir}");
Some(tokio::spawn(async move {
disk.delete_data_dir(
&dst_bucket,
&path,
DeleteOptions {
recursive: true,
..Default::default()
},
)
.await
}))
})
.collect();
for result in join_all(rollback_only_futures).await {
// The write is authoritatively committed, so the per-disk rollback
// backup (`object/<rollback_dir>/xl.meta.bkp`) is dead weight now.
// When the rollback dir doubles as the real dereferenced data dir it
// is reclaimed wholesale by `commit_rename_data_dir`; a rollback dir
// reported separately (an overwrite of an inline version, whose dir is
// synthetic) is excluded from that recursive reclamation for safety
// (#5703) and must be reclaimed here instead — otherwise every inline
// overwrite strands a backup file that keeps the object dir non-empty
// and makes a later DeleteBucket fail with BucketNotEmpty forever.
// Delete exactly the backup file, never the directory tree: the
// synthetic UUID is a fixed, publicly-known constant for unversioned
// objects, so `object/<rollback_dir>` can simultaneously be a
// legitimate child key's directory — recursively deleting it would
// reopen the authorization bypass #5703 closed. The non-recursive
// delete removes the directory only when the backup was its sole
// content. Best-effort space reclamation — like
// `commit_rename_data_dir`, this must never negate the already-durable
// ACK.
let mut backup_reclaims = Vec::new();
for (idx, disk) in disks.iter().enumerate() {
if errs[idx].is_some() {
continue;
}
let Some(rollback_dir) = data_dirs[idx] else {
continue;
};
if cleanup_data_dirs[idx] == Some(rollback_dir) {
continue;
}
let Some(disk) = disk.clone() else {
continue;
};
let dst_bucket = dst_bucket.clone();
let dst_object = dst_object.clone();
backup_reclaims.push(tokio::spawn(async move {
let backup_path = format!("{dst_object}/{rollback_dir}/{STORAGE_FORMAT_FILE_BACKUP}");
disk.delete(&dst_bucket, &backup_path, DeleteOptions::default()).await
}));
}
for result in join_all(backup_reclaims).await {
match result {
Ok(Ok(_)) => {}
Ok(Err(err)) if err == DiskError::FileNotFound || err == DiskError::VolumeNotFound => {}
Ok(Ok(())) => {}
Ok(Err(DiskError::FileNotFound | DiskError::VolumeNotFound)) => {}
Ok(Err(err)) => {
warn!(
target: "rustfs_ecstore::set_disk",
dst_bucket = %dst_bucket,
dst_object = %dst_object,
error = %err,
"failed to reclaim synthetic inline-rollback dir after commit"
"rollback backup reclamation failed after committed rename"
);
}
Err(err) => {
Err(join_err) => {
warn!(
target: "rustfs_ecstore::set_disk",
dst_bucket = %dst_bucket,
dst_object = %dst_object,
error = %err,
"synthetic inline-rollback reclaim task failed"
error = %join_err,
"rollback backup reclamation task failed after committed rename"
);
}
}
+130
View File
@@ -6309,6 +6309,136 @@ mod tests {
assert_eq!(read_back.size, 9, "HEAD must observe the new version, not stale metadata");
}
// Regression for the inline-overwrite rollback backup leak: #5703 stopped
// reporting the synthetic rollback dir for recursive post-commit cleanup,
// which stranded `object/<synthetic>/xl.meta.bkp` after every inline
// overwrite. The object dir then never emptied, so the s3-tests teardown
// sequence (delete object, delete bucket) failed with BucketNotEmpty
// forever. After a committed overwrite the backup must be reclaimed and a
// subsequent delete must leave nothing behind.
#[tokio::test]
async fn inline_overwrite_reclaims_synthetic_rollback_backup() {
let set_disks = make_local_bucket_test_set_disks().await;
let bucket = "bucket-inline-rollback-leak";
let object = "obj";
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
for body in [b"hello".to_vec(), b"goodbye".to_vec()] {
let mut reader = PutObjReader::from_vec(body);
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
no_lock: true,
..ObjectOptions::default()
},
)
.await
.expect("inline write should succeed");
}
// The committed overwrite must leave only xl.meta in the object dir on
// every disk; a stranded rollback dir keeps the bucket undeletable.
for endpoint in &set_disks.set_endpoints {
let object_dir = std::path::PathBuf::from(endpoint.get_file_path()).join(bucket).join(object);
let mut entries: Vec<String> = std::fs::read_dir(&object_dir)
.expect("object dir should exist")
.map(|entry| entry.expect("entry should read").file_name().to_string_lossy().into_owned())
.collect();
entries.sort();
assert_eq!(
entries,
vec![STORAGE_FORMAT_FILE.to_string()],
"only xl.meta may remain after an inline overwrite in {object_dir:?}"
);
}
// With only xl.meta left, the s3-tests teardown (delete object, delete
// bucket) empties the dir; the delete paths themselves are covered by
// their own tests. This harness has no bucket metadata sys, so the
// full delete_object flow cannot run here.
}
// #5703's security property must survive the backup reclamation: the
// synthetic rollback dir of key K maps to the directory `K/<uuid>`, which
// can simultaneously be a legitimate child key. Reclaiming the backup must
// remove exactly the backup file — never the child key's metadata.
#[tokio::test]
async fn inline_overwrite_backup_reclaim_spares_child_key_dir() {
let set_disks = make_local_bucket_test_set_disks().await;
let bucket = "bucket-inline-rollback-child";
let object = "obj";
let synthetic = crate::disk::local::inline_metadata_rollback_dir(Uuid::nil(), &FileMeta::new());
let child_object = format!("{object}/{synthetic}");
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"child".to_vec());
set_disks
.put_object(
bucket,
&child_object,
&mut reader,
&ObjectOptions {
no_lock: true,
..ObjectOptions::default()
},
)
.await
.expect("child write should succeed");
// Create then overwrite the parent key: the overwrite writes its
// rollback backup into the child's directory and must afterwards
// reclaim only that file.
for body in [b"first".to_vec(), b"second".to_vec()] {
let mut reader = PutObjReader::from_vec(body);
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
no_lock: true,
..ObjectOptions::default()
},
)
.await
.expect("parent write should succeed");
}
let child_info = set_disks
.get_object_info(bucket, &child_object, &ObjectOptions::default())
.await
.expect("child key must survive the parent's rollback backup reclamation");
assert_eq!(child_info.size, 5, "child key content must be untouched");
for endpoint in &set_disks.set_endpoints {
let child_dir = std::path::PathBuf::from(endpoint.get_file_path())
.join(bucket)
.join(object)
.join(synthetic.to_string());
let mut entries: Vec<String> = std::fs::read_dir(&child_dir)
.expect("child object dir should exist")
.map(|entry| entry.expect("entry should read").file_name().to_string_lossy().into_owned())
.collect();
entries.sort();
assert_eq!(
entries,
vec![STORAGE_FORMAT_FILE.to_string()],
"the child dir must keep its xl.meta and lose only the stray backup in {child_dir:?}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_acquire_dist_delete_object_locks_batch_succeeds_with_two_healthy_lockers() {