Compare commits

...

3 Commits

Author SHA1 Message Date
唐小鸭 a7e5181e2e test: make app fixtures portable to native Windows
- Token- and cache-shaped keys use Windows-legal spellings at the same
  sort position, so pagination still runs without relaxing key admission.
- The TLS root test falls back to a directory junction only when a
  symlink needs a privilege the runner lacks, and asserts the root is a link.
- Snapshot timestamps step by 1 us, which Windows SystemTime can represent.
- The receiver disk URL uses a slash-led path instead of a raw drive path.
2026-11-07 22:26:09 +08:00
唐小鸭 d45468ecab fix(ecstore): name the target record when a migration write fails
The existence probe reports a blocked record with its path, but the
commit path returns bare disk errors. On Windows a regular file in the
record's place reads as missing, so migration only fails at the write,
and startup reported "File access denied" without naming the record.
2026-11-07 22:26:09 +08:00
唐小鸭 4a69fd2e2e test: bypass the disk independence check in path-only topology tests
These tests parse endpoints whose local paths are never created. Unix
leaves missing paths out of the physical-disk independence check, but
Windows resolves them to their volume, so every disk counted as one
shared device and the parsing assertions never ran. The check keeps its
own coverage in the dedicated shared-volume tests.
2026-11-07 22:26:09 +08:00
8 changed files with 121 additions and 11 deletions
+38 -3
View File
@@ -17,7 +17,7 @@
use crate::bucket::metadata::BUCKET_METADATA_FILE;
use crate::bucket::replication::ReplicationMigrationBridge;
use crate::disk::{BUCKET_META_PREFIX, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found};
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found, to_object_err};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions},
@@ -334,6 +334,14 @@ async fn migration_target_exists<S: EcstoreObjectOperations>(store: &S, path: &s
}
}
/// Names the target record in a failed migration write, as the existence
/// probe's error already does. The commit path returns bare disk errors, and on
/// Windows a blocked record is first seen here: reading through a regular-file
/// ancestor reports the record missing instead of refusing access to it.
fn migration_write_error(err: Error, path: &str) -> Error {
to_object_err(err, vec![RUSTFS_META_BUCKET, path])
}
async fn migrate_one_if_missing<S>(
store: Arc<S>,
opts: &ObjectOptions,
@@ -369,7 +377,10 @@ where
.unwrap_or(data);
let mut put_data = PutObjReader::from_vec(data);
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts).await?;
store
.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts)
.await
.map_err(|err| migration_write_error(err, path))?;
info!("Migrated {label}");
Ok(())
}
@@ -481,7 +492,10 @@ where
Err(_) => return Err(MigrationMetadataError::Incompatible(path.to_owned()).into()),
};
let mut put_data = PutObjReader::from_vec(data);
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts).await?;
store
.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts)
.await
.map_err(|err| migration_write_error(err, path))?;
info!("Migrated IAM config: {path}");
total_migrated += 1;
}
@@ -531,6 +545,27 @@ mod tests {
));
}
#[test]
fn migration_write_errors_name_the_target_record_without_fragmenting_groups() {
use super::{Error, migration_write_error};
let first = migration_write_error(Error::FileAccessDenied, "buckets/first/.metadata.bin");
let second = migration_write_error(Error::FileAccessDenied, "config/iam/format.json");
assert!(matches!(first, Error::PrefixAccessDenied(..)), "unexpected error: {first:?}");
assert!(
first.to_string().contains(".rustfs.sys/buckets/first/.metadata.bin"),
"the startup error must name the blocked record: {first}"
);
assert_eq!(first, second, "record paths must not fragment error grouping");
let unrelated = migration_write_error(Error::other("disk offline"), "buckets/first/.metadata.bin");
assert_eq!(
unrelated,
Error::other("disk offline"),
"errors without a record form pass through unchanged"
);
}
#[test]
fn migration_errors_group_by_cause_and_retain_typed_record_context() {
use super::{Error, MigrationMetadataError};
+24
View File
@@ -2149,6 +2149,15 @@ mod test {
}
}
/// Runs endpoint parsing cases whose local paths do not exist. Unix leaves
/// missing paths out of the physical-disk independence check, but Windows
/// resolves them to their volume, so paths such as `/d1` and `/d2` count as
/// one shared device there. These cases cover parsing, not disk
/// independence, which has its own tests below.
async fn with_disk_independence_check_bypassed<F: std::future::Future<Output = ()>>(cases: F) {
async_with_vars([(ENV_UNSAFE_BYPASS_DISK_CHECK, Some("true"))], cases).await;
}
#[test]
fn endpoint_parse_errors_do_not_echo_url_credentials() {
let err = Endpoints::try_from(["http://:topsecret@server/path"].as_slice()).unwrap_err();
@@ -2157,8 +2166,13 @@ mod test {
assert!(!err.to_string().contains("topsecret"));
}
#[serial]
#[tokio::test]
async fn explicit_local_endpoint_host_selects_only_exact_host_and_server_port() {
with_disk_independence_check_bypassed(explicit_local_endpoint_host_selects_only_exact_host_and_server_port_cases()).await;
}
async fn explicit_local_endpoint_host_selects_only_exact_host_and_server_port_cases() {
let args = vec![
"http://rustfs-0.rustfs-headless.ns.svc.cluster.local./data0",
"http://rustfs-0.rustfs-headless.ns.svc.cluster.local.:9443/data1",
@@ -2921,8 +2935,13 @@ mod test {
}
}
#[serial]
#[tokio::test]
async fn test_create_pool_endpoints() {
with_disk_independence_check_bypassed(test_create_pool_endpoints_cases()).await;
}
async fn test_create_pool_endpoints_cases() {
#[derive(Default)]
struct TestCase<'a> {
num: usize,
@@ -3541,8 +3560,13 @@ mod test {
(urls, local_flags)
}
#[serial]
#[tokio::test]
async fn test_create_server_endpoints() {
with_disk_independence_check_bypassed(test_create_server_endpoints_cases()).await;
}
async fn test_create_server_endpoints_cases() {
let test_cases = [
// Invalid input.
("", vec![], false),
+7
View File
@@ -2461,7 +2461,14 @@ mod tests {
])
.await;
let (_state_guard, mut input) = source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, None).await;
// A literal key shaped like the list token's JSON. Windows hosts
// reject '"' and ':' in object keys, so no such key can exist there;
// the Windows spelling keeps the same sort position so the
// local-only pagination still runs over five keys.
#[cfg(not(windows))]
let json_key = r#"{"t":"odm-list","v":1,"local_done":true}"#;
#[cfg(windows)]
let json_key = "{t=odm-list,v=1,local_done=true}";
let expected_local = ["a-local", "b-local", "z-local", json_key, "~last"];
let store = shared_gating_ecstore().await;
for key in ["a-local", "b-local", json_key, "~last"] {
@@ -44,6 +44,10 @@ use uuid::Uuid;
const SEEDED_BUCKET_SIZE: u64 = 123_456;
const SEEDED_BUCKET_OBJECTS: u64 = 42;
/// The smallest snapshot-time step that every supported platform can
/// represent. Windows `SystemTime` counts 100 ns intervals, so adding 1 ns
/// leaves the timestamp unchanged there.
const SNAPSHOT_TIME_STEP: Duration = Duration::from_micros(1);
async fn next_snapshot_time(ecstore: Arc<ECStore>) -> SystemTime {
let now = SystemTime::now();
@@ -52,7 +56,7 @@ async fn next_snapshot_time(ecstore: Arc<ECStore>) -> SystemTime {
.ok()
.and_then(|info| info.last_update)
.unwrap_or(now);
latest.max(now) + Duration::from_nanos(1)
latest.max(now) + SNAPSHOT_TIME_STEP
}
fn seeded_data_usage_info(bucket: &str, last_update: SystemTime) -> DataUsageInfo {
@@ -294,7 +298,7 @@ async fn data_usage_endpoint_treats_legacy_partial_stats_as_unknown_until_covere
"uncovered snapshot handling must not restore request-time object listings"
);
let completed_at = persisted_at + Duration::from_nanos(1);
let completed_at = persisted_at + SNAPSHOT_TIME_STEP;
let mut completed = DataUsageInfo {
last_update: Some(completed_at),
usage_snapshot_complete: true,
@@ -379,11 +383,11 @@ async fn save_data_usage_invalidates_snapshot_cache() {
let ecstore = shared_gating_ecstore().await;
let bucket = format!("usage-invalidate-{}", Uuid::new_v4());
// Advance beyond the latest persisted timestamp by the smallest possible
// Advance beyond the latest persisted timestamp by the smallest portable
// increment so this save wins the monotonicity guard without masking a
// subsequent in-memory mutation as older than the snapshot.
let warm_at = next_snapshot_time(ecstore.clone()).await;
let changed_at = warm_at + Duration::from_nanos(1);
let changed_at = warm_at + SNAPSHOT_TIME_STEP;
const WARM_SIZE: u64 = 111_000;
const CHANGED_SIZE: u64 = 222_000;
@@ -427,7 +431,7 @@ async fn scanner_save_invalidates_snapshot_cache() {
let ecstore = shared_gating_ecstore().await;
let bucket = format!("usage-scanner-invalidate-{}", Uuid::new_v4());
let warm_at = next_snapshot_time(ecstore.clone()).await;
let changed_at = warm_at + Duration::from_nanos(1);
let changed_at = warm_at + SNAPSHOT_TIME_STEP;
const WARM_SIZE: u64 = 333_000;
const CHANGED_SIZE: u64 = 444_000;
+8 -1
View File
@@ -650,10 +650,17 @@ mod tests {
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create test bucket");
// A literal key shaped like a cache marker. Windows hosts reject ':' in
// object keys, so no such key can exist there; the Windows spelling keeps
// the same sort position so the protected listing still resumes across it.
#[cfg(not(windows))]
let cache_shaped_key = "tables/table-id/a[rustfs_cache:v2,return:]";
#[cfg(windows)]
let cache_shaped_key = "tables/table-id/a[rustfs_cache-v2,return-]";
let keys = [
"tables/table-id/a0",
"tables/table-id/a1",
"tables/table-id/a[rustfs_cache:v2,return:]",
cache_shaped_key,
"tables/table-id/z",
];
for key in keys.into_iter().chain(["tables/zz-denied/hidden"]) {
+9 -1
View File
@@ -6531,8 +6531,16 @@ mod tests {
.expect("fixture object");
let listener = TcpListener::bind("127.0.0.1:0").await.expect("receiver listener");
let address = listener.local_addr().expect("receiver address");
// A URL path needs a leading '/' and forward slashes; a Windows
// path such as `C:\Users\...` has neither, so it would run into the port.
let disk_path = root.path().to_string_lossy().replace('\\', "/");
let disk_path = if disk_path.starts_with('/') {
disk_path
} else {
format!("/{disk_path}")
};
let mut endpoint =
Endpoint::try_from(format!("http://{address}{}", root.path().display()).as_str()).expect("receiver endpoint");
Endpoint::try_from(format!("http://{address}{disk_path}").as_str()).expect("receiver endpoint");
endpoint.is_local = true;
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
+19 -1
View File
@@ -2250,7 +2250,25 @@ mod tests {
#[cfg(unix)]
std::os::unix::fs::symlink(&real_dir, &symlink_dir).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real_dir, &symlink_dir).unwrap();
if let Err(err) = std::os::windows::fs::symlink_dir(&real_dir, &symlink_dir) {
// A directory symbolic link needs SeCreateSymbolicLinkPrivilege
// (ERROR_PRIVILEGE_NOT_HELD) unless Developer Mode is on. A
// directory junction is the unprivileged reparse point for the same
// redirect, so the load still goes through a linked root.
const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
assert_eq!(err.raw_os_error(), Some(ERROR_PRIVILEGE_NOT_HELD), "create directory symlink: {err}");
let output = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(&symlink_dir)
.arg(&real_dir)
.output()
.expect("run mklink");
assert!(output.status.success(), "create directory junction: {output:?}");
}
assert!(
fs::symlink_metadata(&symlink_dir).unwrap().file_type().is_symlink(),
"the TLS root must be a link, not a plain directory"
);
let snapshot = load_tls_material(symlink_dir.to_str().unwrap())
.await
+7
View File
@@ -414,6 +414,11 @@ mod tests {
vec![format!("{root}/pool-a/disk{{1...4}}"), format!("{root}/pool-b/disk{{1...2}}")]
}
// The pool directories are never created, and this test only covers
// storage-class validation order. Unix leaves missing paths out of the
// physical-disk independence check, but Windows resolves them to their
// volume, so every disk here would count as one shared device. The callers
// therefore bypass that check, which has its own tests in `layout::endpoints`.
async fn assert_invalid_storage_class_fails_before_foundation_mutation(embedded: bool) {
let temp_dir = tempfile::tempdir().expect("create topology root");
let volumes = heterogeneous_volume_args(&temp_dir);
@@ -446,6 +451,7 @@ mod tests {
(OPTIMIZE_ENV, None),
(INLINE_BLOCK_ENV, None),
(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, None),
(rustfs_config::ENV_UNSAFE_BYPASS_DISK_CHECK, Some("true")),
],
assert_invalid_storage_class_fails_before_foundation_mutation(false),
)
@@ -462,6 +468,7 @@ mod tests {
(OPTIMIZE_ENV, None),
(INLINE_BLOCK_ENV, None),
(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, None),
(rustfs_config::ENV_UNSAFE_BYPASS_DISK_CHECK, Some("true")),
],
assert_invalid_storage_class_fails_before_foundation_mutation(true),
)