Compare commits

..

8 Commits

Author SHA1 Message Date
houseme 7faa9a0e69 fix(app): keep list-through header import test-only
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-06 03:07:38 +08:00
overtrue 7661fc1dd1 test(scanner): remove redundant disk path clone 2026-09-06 02:14:39 +08:00
houseme 533cc487f4 Merge branch 'main' into houseme/test/scanner-enumeration-restart 2026-09-06 02:09:54 +08:00
houseme a0e746feb7 Merge branch 'main' into houseme/test/scanner-enumeration-restart 2026-09-06 02:09:06 +08:00
houseme 9877ce8231 Merge branch 'main' into houseme/test/scanner-enumeration-restart 2026-09-06 01:44:26 +08:00
houseme ed026a0f1f test(scanner): reject unobserved enumeration budget evidence
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-06 01:32:21 +08:00
houseme f25df09f61 test(scanner): observe the canonical synthetic disk path
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-06 01:28:33 +08:00
houseme 99081d0e78 test(scanner): diagnose raw enumeration across process restarts
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-06 01:26:12 +08:00
27 changed files with 548 additions and 1808 deletions
@@ -20,10 +20,9 @@
//! journal (`count_requests`) carries the assertion in every one of them.
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use crate::fake_s3_target::Operation;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt};
use std::time::Duration;
type TestResult = Result<(), BoxError>;
@@ -146,38 +145,14 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
.await?;
let body = payload(128 * 1024);
let blocker = "queue/blocker.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(blocker, body.clone())]);
// The one-chunk range completes immediately; its full background pull
// occupies the only slot while the remaining requests fill the queue.
env.source.inject_for_key(
Operation::GetObject,
blocker,
FaultAction::SlowSendBody {
chunk_bytes: 1024,
delay: Duration::from_millis(100),
},
2,
);
let response = env
.raw_object_request(http::Method::GET, bucket, blocker, &[("range", "bytes=0-1023")])
.await?;
assert_eq!(response.status, 206);
assert_eq!(response.body, body.slice(0..1024));
env.wait_for_status_counter(bucket, "/inflight_pulls", 1, SETTLE).await?;
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
// Bound source connections below the fixture's limit while still
// submitting all 100 requests to the eight-slot background queue.
let responses: Vec<RawResponse> = futures::stream::iter(
let responses: Vec<RawResponse> = futures::future::try_join_all(
keys.iter()
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
)
.buffered(16)
.try_collect()
.await?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
@@ -193,15 +168,6 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
.await?;
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
let queue_full = usize::try_from(queue_full)?;
assert!(queue_full <= REQUESTS);
env.wait_for_status_counter(
bucket,
"/counters/pulled_objects_total/background",
u64::try_from(REQUESTS + 1 - queue_full)?,
SETTLE,
)
.await?;
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
assert!(
@@ -209,6 +175,9 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
);
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
assert_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
assert!(
dropped > 0,
"the overflowed keys are the ones with no backfill GET, but every key got one"
);
Ok(())
}
@@ -265,13 +265,16 @@ async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":3").as_bytes());
assert_ne!(tampered, token, "the test must change the token version");
let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
let rejected = env.raw_list_objects_v2(bucket, &query).await?;
let error_body = String::from_utf8_lossy(&rejected.body);
assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
let rejected = env
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
.await?;
assert_eq!(
rejected.status,
400,
"a bumped token version is a client error: {}",
String::from_utf8_lossy(&rejected.body)
);
Ok(())
}
@@ -1 +1 @@
{"bucket":"photos","config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z"}
{"bucket":"photos","config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z"}
@@ -1 +1 @@
{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"sourceSecretKey123","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}}
{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"sourceSecretKey123","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}}
@@ -1 +1 @@
{"bucket":"photos","dry_run":false,"config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z","probe":{"reachable":true,"listable":true,"sample_key":"photos/2024/01.jpg"}}
{"bucket":"photos","dry_run":false,"config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z","probe":{"reachable":true,"listable":true,"sample_key":"photos/2024/01.jpg"}}
@@ -1,80 +0,0 @@
// Strict source reader frozen from e2a921bc1608823c8efec955d7463ab8350a8a01.
// Wire declarations and credential Debug are copied verbatim; runtime methods are omitted.
use serde::{Deserialize, Serialize};
use std::fmt;
const REDACTED: &str = "REDACTED";
/// The external S3-compatible source bucket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceConfig {
pub provider: Provider,
/// `http(s)://host[:port]` with no path or query. Optional only for
/// [`Provider::Aws`], where it is derived from `region`.
#[serde(default)]
pub endpoint: Option<String>,
pub region: String,
pub bucket: String,
#[serde(default)]
pub path_style: PathStyle,
/// `None` means anonymous access to a public source bucket.
#[serde(default)]
pub credentials: Option<SourceCredentials>,
#[serde(default)]
pub tls: TlsConfig,
}
/// Source vendor family. `azure` is deliberately absent from this version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
/// Generic S3-compatible endpoint.
S3,
Aws,
Minio,
Rustfs,
R2,
/// GCS XML interoperability API with HMAC keys.
Gcs,
}
/// Bucket addressing style. `auto` is resolved by the source client builder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PathStyle {
#[default]
Auto,
Path,
Virtual,
}
/// Static credentials for the source. `Debug` never prints the secret or
/// the session token.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceCredentials {
pub access_key: String,
pub secret_key: String,
#[serde(default)]
pub session_token: Option<String>,
}
impl fmt::Debug for SourceCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SourceCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &REDACTED)
.field("session_token", &self.session_token.as_ref().map(|_| REDACTED))
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
#[serde(default)]
pub skip_verify: bool,
#[serde(default)]
pub ca_cert_pem: Option<String>,
}
+4 -36
View File
@@ -85,10 +85,10 @@ pub struct OnDemandMigrationSource {
#[serde(default)]
pub tls: OnDemandMigrationTls,
/// Required for `azure` and rejected for every other provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub azure: Option<OnDemandMigrationAzure>,
/// Required for `gcs_native` and rejected for every other provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub gcs: Option<OnDemandMigrationGcs>,
}
@@ -651,10 +651,6 @@ mod tests {
use super::*;
use crate::test_support::TestServer;
mod before_native_sources {
include!("../fixtures/on_demand_migration/source_config_e2a.rs");
}
const SET_REQUEST_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/set_request.json");
const SET_RESPONSE_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/set_response.json");
const GET_RESPONSE_FIXTURE: &str = include_str!("../fixtures/on_demand_migration/get_response.json");
@@ -688,20 +684,6 @@ mod tests {
assert_eq!(config.source.tls, OnDemandMigrationTls::default());
}
#[test]
fn s3_admin_writes_remain_readable_by_the_strict_pre_native_server() {
for provider in ["s3", "aws", "minio", "rustfs", "r2", "gcs"] {
let historical = SET_REQUEST_FIXTURE.replace("\"provider\":\"minio\"", &format!("\"provider\":\"{provider}\""));
let config: OnDemandMigrationConfig = serde_json::from_str(&historical).expect("historical set request");
let wire = serde_json::to_string(&config).expect("current admin set request");
let actual: serde_json::Value = serde_json::from_str(&wire).expect("admin request JSON");
let old_source: before_native_sources::SourceConfig = serde_json::from_value(actual["source"].clone())
.expect("the strict e2a server must accept an ordinary S3 source from the new admin client");
assert_eq!(serde_json::to_value(old_source).expect("old source wire"), actual["source"]);
assert_eq!(wire, historical.trim(), "provider={provider}: preserve the historical request bytes");
}
}
#[test]
fn set_response_fixture_round_trips_and_is_redacted() {
let response: OnDemandMigrationSetResponse = round_trip(SET_RESPONSE_FIXTURE);
@@ -896,11 +878,11 @@ mod tests {
for (label, json) in [
(
"azure",
r#"{"provider":"azure","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":{"account":"legacyaccount","account_key":null,"sas_token":"sv=2021-08-06&sig=topsecret"}}"#,
r#"{"provider":"azure","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":{"account":"legacyaccount","account_key":null,"sas_token":"sv=2021-08-06&sig=topsecret"},"gcs":null}"#,
),
(
"gcs_native",
r#"{"provider":"gcs_native","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"gcs":{"service_account_json":"{\"type\":\"service_account\"}"}}"#,
r#"{"provider":"gcs_native","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":{"service_account_json":"{\"type\":\"service_account\"}"}}"#,
),
] {
let source: OnDemandMigrationSource = serde_json::from_str(json).unwrap_or_else(|err| panic!("{label}: {err}"));
@@ -909,16 +891,6 @@ mod tests {
json,
"{label} must reproduce the server wire shape byte for byte"
);
let mut wire: serde_json::Value = serde_json::from_str(json).expect("native wire fixture");
assert!(
serde_json::from_value::<before_native_sources::SourceConfig>(wire.clone()).is_err(),
"native provider names and fields still require an upgraded server"
);
wire[if label == "azure" { "gcs" } else { "azure" }] = serde_json::Value::Null;
assert_eq!(
serde_json::from_value::<OnDemandMigrationSource>(wire).expect("the prior explicit-null wire still decodes"),
source
);
}
let azure = OnDemandMigrationAzure {
@@ -973,10 +945,6 @@ mod tests {
.is_some_and(|auth| auth.starts_with("AWS4-HMAC-SHA256"))
);
assert_eq!(request.body, SET_REQUEST_FIXTURE.trim(), "the body is the canonical config document");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("signed admin request JSON");
let old_source: before_native_sources::SourceConfig = serde_json::from_value(body["source"].clone())
.expect("the strict pre-native server must accept the actual signed PUT source");
assert_eq!(old_source.provider, before_native_sources::Provider::Minio);
}
#[tokio::test]
+2
View File
@@ -1281,6 +1281,8 @@ impl FolderScanner {
}
Err(e) => return Err(ScannerError::Io(e)),
};
#[cfg(test)]
tests::enumeration_restart::observe_raw_entry(&dir_path, &entry.file_name(), &self.budget);
pending_entry_progress = pending_entry_progress.saturating_add(1);
if pending_entry_progress >= SCANNER_ENTRY_PROGRESS_BATCH
|| last_entry_progress.elapsed() >= SCANNER_ENTRY_PROGRESS_INTERVAL
@@ -25,6 +25,7 @@ use std::os::unix::fs::{PermissionsExt, symlink};
use std::sync::Mutex;
mod checkpoint_fixture;
pub(super) mod enumeration_restart;
/// Reset the process-global alert cooldown map; test-only.
fn reset_alert_cooldowns() {
@@ -0,0 +1,181 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
use std::path::{Path, PathBuf};
use tokio::io::AsyncReadExt;
const MAX_CACHE_BYTES: u64 = 1024 * 1024;
const REQUEST_ENV: &str = "RUSTFS_ENUMERATION_REQUEST";
struct Observation {
root: PathBuf,
limit: u64,
entries: u64,
name_bytes: u64,
}
static OBSERVATION: Mutex<Option<Observation>> = Mutex::new(None);
// Only the selected synthetic disk is observed; concurrent unrelated scanners
// do not consume its budget. This hook is absent from non-test builds.
pub(in crate::scanner_folder) fn observe_raw_entry(dir: &str, name: &std::ffi::OsStr, budget: &ScannerCycleBudget) {
let mut guard = OBSERVATION.lock().expect("enumeration observation lock");
if let Some(observation) = guard.as_mut()
&& Path::new(dir).starts_with(&observation.root)
{
observation.entries += 1;
observation.name_bytes += u64::try_from(name.as_encoded_bytes().len()).expect("bounded entry name");
if observation.entries >= observation.limit {
budget.cancel_for_runtime();
}
}
}
struct ObservationGuard;
impl Drop for ObservationGuard {
fn drop(&mut self) {
*OBSERVATION.lock().expect("enumeration observation cleanup") = None;
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
workspace: PathBuf,
objects: usize,
raw_entry_budget: u64,
round: u32,
}
async fn read_bounded(path: &Path) -> Vec<u8> {
let file = tokio::fs::File::open(path).await.expect("open fixture artifact");
let mut bytes = Vec::new();
file.take(MAX_CACHE_BYTES + 1)
.read_to_end(&mut bytes)
.await
.expect("read fixture artifact");
assert!(u64::try_from(bytes.len()).expect("artifact size") <= MAX_CACHE_BYTES);
bytes
}
async fn round(request: &Request) -> serde_json::Value {
assert!((1..=1024).contains(&request.objects));
assert!((1..=4096).contains(&request.raw_entry_budget));
assert!(request.round < 64);
let disk_root = request.workspace.join("disk");
let cache_path = request.workspace.join("cache.bin");
if request.round == 0 {
tokio::fs::create_dir(&disk_root).await.expect("create fresh synthetic disk");
for index in 0..request.objects {
let object = format!("object-{index:04}");
let version = Uuid::from_u128(u128::try_from(index).expect("fixture index") + 1);
let bytes = metadata_for_object_version("bucket", &object, Some(version));
write_test_object_metadata_bytes(&disk_root, "bucket", &object, &bytes).await;
}
let mut initial = DataUsageCache::default();
initial.info.name = "bucket".to_string();
initial.info.skip_healing = true;
initial.info.snapshot_complete = false;
initial.replace("bucket", "", DataUsageEntry::default());
tokio::fs::write(&cache_path, initial.marshal_msg().expect("initial cache codec"))
.await
.expect("persist initial cache");
}
let cache = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload cache codec before scan");
assert_eq!(cache.info.name, "bucket");
let before = cache.checked_flatten("bucket").expect("persisted bucket root").objects;
let endpoint = Endpoint::try_from(disk_root.to_string_lossy().as_ref()).expect("fixture endpoint");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open synthetic disk in this process");
let parent = CancellationToken::new();
let budget = ScannerCycleBudget::new_with_progress_tracking(&parent, Default::default());
*OBSERVATION.lock().expect("install observation") = Some(Observation {
root: disk.path(),
limit: request.raw_entry_budget,
entries: 0,
name_bytes: 0,
});
let _observation_guard = ObservationGuard;
let result = scan_data_folder(
budget.token(),
budget.clone(),
vec![disk.clone()],
disk,
cache.clone(),
None,
HealScanMode::Normal,
SCANNER_SLEEPER.clone(),
)
.await;
let (returned, outcome) = match result {
Ok(cache) => (cache, "complete"),
Err(ScannerError::PartialCache(cache)) => (*cache, "partial"),
Err(ScannerError::Other(message)) if budget.token().is_cancelled() && message == "Operation cancelled" => {
(cache, "cancelled_without_cache")
}
Err(error) => panic!("unexpected real scanner failure: {error}"),
};
let encoded = returned.marshal_msg().expect("returned cache codec");
assert!(u64::try_from(encoded.len()).expect("encoded length") <= MAX_CACHE_BYTES);
tokio::fs::write(&cache_path, encoded).await.expect("persist returned cache");
let reloaded = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload returned cache codec");
let retained = reloaded.checked_flatten("bucket").expect("reloaded bucket root");
let scanned = returned.checked_flatten("bucket").expect("returned bucket root");
assert_eq!(
(retained.objects, retained.versions, retained.size),
(scanned.objects, scanned.versions, scanned.size)
);
assert_eq!(reloaded.info.snapshot_complete, returned.info.snapshot_complete);
let guard = OBSERVATION.lock().expect("read observation");
let observation = guard.as_ref().expect("installed observation");
serde_json::json!({
"schema": 1, "pid": std::process::id(), "round": request.round,
"objects_expected": request.objects, "raw_entry_budget": request.raw_entry_budget,
"raw_entries": observation.entries, "raw_name_bytes": observation.name_bytes,
"objects_processed": budget.progress().0,
"objects_before": before, "objects_retained": retained.objects,
"versions_retained": retained.versions, "bytes_retained": retained.size,
"snapshot_complete": reloaded.info.snapshot_complete, "outcome": outcome,
})
}
/// Default CI is a positive healthy control. The external driver selects the
/// same worker in a fresh OS process per round and applies its strict oracle.
#[tokio::test]
#[serial]
async fn enumeration_restart_worker() {
if let Some(path) = std::env::var_os(REQUEST_ENV) {
let request: Request = serde_json::from_slice(&read_bounded(Path::new(&path)).await).expect("bounded worker request");
let report = round(&request).await;
tokio::fs::write(
request.workspace.join(format!("round-{}.json", request.round)),
serde_json::to_vec(&report).expect("report JSON"),
)
.await
.expect("write worker report");
} else {
let temp = tempfile::tempdir().expect("healthy fixture directory");
let report = round(&Request {
workspace: temp.path().to_path_buf(),
objects: 4,
raw_entry_budget: 16,
round: 0,
})
.await;
assert_eq!(report["outcome"], "complete");
assert_eq!(report["snapshot_complete"], true);
assert_eq!(report["objects_retained"], 4);
assert_eq!(report["versions_retained"], 4);
assert_eq!(report["bytes_retained"], 4);
assert!(report["raw_entries"].as_u64().expect("observed entries") >= 8, "{report}");
}
}
@@ -11,7 +11,6 @@
## Open Items
- `odm-list-bare-envelope` historical ODM continuation tokens: preserve complete bare v1/v2 envelopes and default bare issuance while framed readers deploy. Remove the legacy classifier and default-off framing issuance gate only after every supported reader accepts framing and outstanding bare listings have drained or clients explicitly restarted them; tokens have no automatic expiry. Exact full-envelope object keys remain intrinsically ambiguous during this compatibility period.
- `backlog-2263` legacy heal MRF inspection: retained per-record journals remain readable while committed-snapshot ownership and writer activation are staged. Remove legacy import only after all supported direct-upgrade and rollback readers understand committed snapshots and migration tooling confirms that no retained or restorable legacy journal requires it. This does not enable a new writer or change the automatic legacy consumer.
- `backlog-1337` legacy restore orphan recovery: releases that predate the restore worker-lock marker can leave a valid operation-id and `ongoing-request="true"` after cancellation or process failure, with no durable liveness proof. New servers allow an exact, non-nil legacy generation to be superseded only when its consistently parsed request date is at least 24 hours old. Remove the clock-based legacy fallback after the minimum supported direct-upgrade release writes the v1 worker-lock marker on every restore and operators have resolved every retained pre-v1 ongoing generation.
- `backlog-2133-tier-delete-chunk-parent` bounded tier-delete dispatch compatibility: prefixes at or below the legacy manifest limit keep the byte-compatible v1 single-manifest protocol, while larger prefixes place a chunk-parent sentinel at the original deterministic root path and use operation-scoped child manifests. Older binaries reject the sentinel and child paths, preserving the v6 sole-owner downgrade fence instead of starting a competing local delete. Remove the v1 reader and fail-closed mixed-version sentinel only after every supported rollback release validates the parent/child protocol and migration tooling confirms that no retained v1 dispatch manifest remains.
+6 -13
View File
@@ -23,20 +23,13 @@ Both builds can read, redact and preserve GCS configuration. A build without `gc
## List continuation token rollout
Two independent rollout switches default to `false`; unset or invalid boolean values also keep them off. Both are node environment variables, not bucket settings:
`RUSTFS_ON_DEMAND_MIGRATION_LIST_V2_TOKENS` defaults to `false`; unset or invalid boolean values also keep it off. It controls only whether a v1 listing may first issue a v2 continuation token after an empty truncated merged page. Every node with this reader support accepts existing v2 tokens and continues their budget even with the switch off. Ordinary pages that consume an object or common prefix retain the original v1 token shape.
- `RUSTFS_ON_DEMAND_MIGRATION_LIST_V2_TOKENS` allows a v1 listing to first issue a v2 token after an empty truncated merged page. Existing v2 tokens keep their budget even on reader-only nodes. Consuming an object/common prefix or reaching a new EOF resets the budget to v1 without changing the chain's framing.
- `RUSTFS_ON_DEMAND_MIGRATION_LIST_FRAMED_TOKENS` allows a bare/new merged listing to first issue a NUL-prefixed JSON envelope inside the existing base64 encoding. Existing framed chains stay framed even with this switch off, including a reset to v1 and local continuation after list-through is disabled. With framing issuance off, new bare v1 output keeps its historical bytes; ordinary local listings remain unchanged. This switch does not enable the v2 budget.
Leave the switch off while deploying v2 reader support to every node that can receive a continuation request, including nodes behind other load-balancer routes. Then set it to `true` in each node's environment and restart those nodes to enable issuance. A v1-only binary rejects v2 with `400 InvalidArgument` before the source-error policy runs; neither `not_found` nor turning off list-through makes that old reader compatible. With issuance still off, a new v1 chain retains the existing limitation: an empty source cursor cycle spanning requests can continue indefinitely. The default rollout does not claim to fix that chain until issuance is enabled.
Deploy readers before enabling either writer switch. Every node serving continuation requests must understand the selected token version and framing, including nodes behind other load-balancer routes. This build reads both complete historical bare envelopes and framed v1/v2 envelopes with the same strict version/count validation. A bare-v1-only binary rejects bare v2 with `400 InvalidArgument`; an old bare reader mistakes framed input for a local marker, while a framed-only reader mistakes bare input for one. Neither format mismatch is safe: it can restart a merged scan and lose its budget rather than returning an error. The interim framed-only build from #7187 must be replaced on every serving node before a mixed-format rollout. Enable framing only after all readers support both formats; enable the budget only after all readers support v2. Restart nodes after updating their environment.
An active v2 budget rejects the sixteenth consecutive merged page that consumes no new object/common prefix and reaches no new end-of-list state. The first fifteen empty pages can be resumed; with the existing two-fetch-per-side limit, that interval costs at most 32 fetches per side, including the failing request. A key, common prefix, or a newly exhausted side on the sixteenth request succeeds and resets the budget. A side that was already exhausted does not reset it again. This is a resource bound, not proof of a cursor cycle: an unusually long but valid empty source-page chain also reaches the limit. Tokens are unsigned base64 JSON, so this budget applies to clients that continue with the returned token unchanged; replaying or editing a token can reset it, and it is not a malicious-client defense or a global request quota. The two-fetch-per-side request limit and existing source rate limiter still apply. A source failure follows `policy.source_error`: `propagate` returns `424 SourceUnavailable` with `invalid_pagination`; `not_found` returns the fetched local listing with `x-rustfs-on-demand-migration-list: local_only`. A blocking local-side failure returns `InternalError`, without silently discarding local entries.
Partial JSON-shaped object keys remain local markers. To retain already issued cursors, a bare JSON object with the ODM tag and every historical writer field (`v`, `local`, `local_done`, `source`, `source_done`, `last_key`) is treated as an envelope, then strictly validated. A valid object key can be identical to that complete envelope: the two byte strings are indistinguishable, so legacy compatibility necessarily gives the envelope interpretation precedence. Framing identifies new merged tokens unambiguously, but dual-format readers do not eliminate this old full-envelope key collision. There is no signature, session store, or automatic format negotiation.
An active v2 budget rejects the sixteenth consecutive merged page that consumes no new object/common prefix and reaches no new end-of-list state. The first fifteen empty pages can be resumed; with the existing two-fetch-per-side limit, that interval costs at most 32 fetches per side, including the failing request. A key, common prefix, or a newly exhausted side on the sixteenth request succeeds and resets the budget. A side that was already exhausted does not reset it again. A zero-sized request does not spend an existing budget. This is a resource bound, not proof of a cursor cycle: an unusually long but valid empty source-page chain also reaches the limit. Tokens are unsigned base64-encoded JSON, optionally framed, so this budget applies to clients that continue with the returned token unchanged; replaying or editing a token can reset it, and it is not a malicious-client defense or a global request quota. The two-fetch-per-side request limit and existing source rate limiter still apply. A source failure follows `policy.source_error`: `propagate` returns `424 SourceUnavailable` with `invalid_pagination`; `not_found` returns the fetched local listing with `x-rustfs-on-demand-migration-list: local_only`. A blocking local-side failure returns `InternalError`, without silently discarding local entries.
With budget issuance off, a new v1 chain retains the existing limitation: an empty source cursor cycle spanning requests can continue indefinitely. Default rollout does not fix that chain until the v2 switch is enabled. Framing alone does not impose the budget.
For rollback, first turn both issuance switches off on every node. Keep readers compatible with outstanding framed and v2 chains: neither switch rewrites existing tokens, and tokens have no expiration that proves those chains have drained. Route those continuations to compatible readers or have clients explicitly restart their listings before restoring older binaries. Restarting a listing is a new scan and can repeat entries. Do not assume switching issuance off makes outstanding framed or v2 tokens disappear.
For rollback, first turn issuance off on every node. Keep v2-capable readers available for outstanding v2 chains: switching issuance off does not erase their budgets, and tokens have no expiration that proves those chains have drained. Route those continuations to compatible readers or have clients explicitly restart their listings before restoring v1-only binaries. Restarting a listing is a new scan and can repeat entries. Do not roll back readers while assuming the issuance switch makes existing v2 tokens disappear.
## Positioning
@@ -195,9 +188,9 @@ No write, delete, ACL or versioning permission is required or used. Scope the po
Behaviour a client can observe. The "Test" column names the case that pins it: `*_test.rs` files live under `crates/e2e_test/src/on_demand_migration/`, and the unit tests live next to the code in `rustfs/src/app/object/get.rs`, `head.rs` and `shared.rs`.
ODM merged continuation tokens use bare or NUL-prefixed JSON inside the existing base64 encoding. The default writer preserves bare output; compatible readers accept both formats and retain existing budgets. See [List continuation token rollout](#list-continuation-token-rollout) for the independent issuance switches, rolling-upgrade requirements, and the unavoidable ambiguity between a complete historical envelope and an identically named local key.
ODM merged continuation tokens use a NUL-prefixed JSON envelope inside the existing base64 encoding. NUL is not valid in a local object key, so a legitimate JSON-shaped key can never be mistaken for a merged cursor. Upgrade every node before using list-through, and restart any in-progress ODM listing issued by an older build: its unframed JSON tokens cannot be distinguished from legitimate local keys. Ordinary local listing tokens remain unchanged. Tokens issued by this build can still resume the local side after list-through is disabled.
Source `HEAD` responses with status 404 require a successful bucket probe before being negative-cached. The source credential therefore needs permission for `HeadBucket` (S3 `ListBucket`); a prefix-restricted ListBucket policy can deny that probe, in which case the response is a source failure rather than a cached miss. A missing/inaccessible source bucket or a missing source version is not proof that the requested key is absent. Native GCS verifies the bucket after either HEAD or GET returns 404 and preserves a failed probe as a source error. Azure accepts explicit `BlobNotFound` only on an unversioned object read with status 404; an ambiguous HEAD may make one container probe, while an ambiguous GET remains a source error. Native probes add at most one request and retain the existing per-request timeouts, rather than a single deadline for the pair. Conditional GET validators are checked against the actual source GET metadata as well as the advisory HEAD; a missing required validator fails with 424. Source LIST entries without a key or a non-negative size fail the page rather than fabricating an empty object.
Source `HEAD` responses with status 404 require a successful bucket probe before being negative-cached. The source credential therefore needs permission for `HeadBucket` (S3 `ListBucket`); a prefix-restricted ListBucket policy can deny that probe, in which case the response is a source failure rather than a cached miss. A missing/inaccessible source bucket, a missing source version, or an ambiguous GET 404 is not proof that the requested key is absent. Conditional GET validators are checked against the actual source GET metadata as well as the advisory HEAD; a missing required validator fails with 424. Source LIST entries without a key or a non-negative size fail the page rather than fabricating an empty object.
Write-back currently requires namespace locking enabled and exactly one pool with one erasure set. Other topologies fail write-back explicitly as `unsupported`: source reads remain available, but backfill cannot complete successfully or certify cutover. This restriction avoids relying on a set-local condition across distinct pool or lock domains; it does not restrict ordinary S3 writes. Full cross-pool migration requires a globally fenced commit protocol.
@@ -1,5 +1,38 @@
# Scanner Checkpoint Fixture
## Raw Enumeration Restart Diagnostic
`enumeration_restart_worker` exercises the real `scan_data_folder` with a local disk and valid `xl.meta` objects. Without configuration it is a positive CI control: four one-byte objects must complete and survive a cache codec round trip. It is not an ignored test or an assertion that a known defect must persist.
```sh
RUST_MIN_STACK=4194304 cargo test -p rustfs-scanner --lib enumeration_restart_worker -- --nocapture
cargo test -p rustfs-scanner --lib --no-run --message-format=json
python3 -m unittest discover -s scripts -p 'test_diagnose_scanner_enumeration_restart.py'
```
Use the `executable` from the scanner library test `compiler-artifact` JSON record as `--test-binary` below. The driver verifies that it contains the exact worker test before doing any work; a zero-test filter cannot pass.
```sh
python3 scripts/diagnose_scanner_enumeration_restart.py \
--test-binary /path/to/compiled/scanner-libtest \
--output /tmp/scanner-enumeration-new-run \
--objects 128 --raw-entry-budget 8 --rounds 8
```
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, processed objects, retained object/version/byte counts, and completeness. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
The `cfg(test)` hook observes actual entries delivered by `read_dir` and cancels the existing cycle token at the fixed entry limit. This is a deterministic injected **raw-entry work budget**, not a wall-clock performance measurement or a claim that kernel prefetch, probes, allocations, name bytes, or cache I/O are independently budgeted. The watchdog timeout only bounds worker lifetime. The hook does not replace enumeration, classification, or recursion, and does not exist in production builds. In particular, `xl.meta` object-boundary classification is unchanged.
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
### Missing Storage Capability
The current `scanner_folder::FolderScanner::scan_folder` collects child folders before recursing. `LocalDisk::scan_dir` also reads the whole parent before sorting and applying `forward_to`. The persistent key-only listing index's `collect_persistent_key_only_index_objects` / `rebuild_persistent_key_only_index` collects all objects in memory before publication and excludes deleted entries. It cannot supply a restartable first-build cursor over per-disk raw entries, orphan directories, and metadata boundaries. Repeated listing from the beginning is real work, not free pagination.
A future storage-owner capability must expose an explicit unsupported/building/ready state and a durable snapshot/index identity bound to disk mount, bucket incarnation, and directory identity. It must budget the first build and every page, including entry count, name bytes, metadata probes, I/O and time; survive a process restart during first build; seal page data before advancing the manifest; and distinguish enumerated, classified, and fully processed frontiers. An uncommitted page may be replayed only within a bounded cost. `xl.meta` classification must finish before descendants become traversable namespace. Missing capability or invalid identities must not become fabricated progress or completeness. No such capability is implemented by this diagnostic, and ordinary local storage remains without this R-E guarantee.
## Completed Subtree Checkpoint Fixture
The `checkpoint_fixture` tests exercise a bounded namespace of 24 static objects and one repeatedly updated hot object. Each of three rounds runs the production local disk scanner with an object budget, saves the returned partial cache through the production persistence codec and revision checks to a two-file test backend, and reloads it before preparing the next round. The fixture prints static-subtree coverage at each boundary and cumulative visited entries. This is a diagnostic of retained coverage, not a throughput benchmark.
Run the fixture and confirm the test filter selects a nonzero number of tests:
@@ -1,80 +0,0 @@
// Strict source reader frozen from e2a921bc1608823c8efec955d7463ab8350a8a01.
// Wire declarations and credential Debug are copied verbatim; runtime methods are omitted.
use serde::{Deserialize, Serialize};
use std::fmt;
const REDACTED: &str = "REDACTED";
/// The external S3-compatible source bucket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceConfig {
pub provider: Provider,
/// `http(s)://host[:port]` with no path or query. Optional only for
/// [`Provider::Aws`], where it is derived from `region`.
#[serde(default)]
pub endpoint: Option<String>,
pub region: String,
pub bucket: String,
#[serde(default)]
pub path_style: PathStyle,
/// `None` means anonymous access to a public source bucket.
#[serde(default)]
pub credentials: Option<SourceCredentials>,
#[serde(default)]
pub tls: TlsConfig,
}
/// Source vendor family. `azure` is deliberately absent from this version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
/// Generic S3-compatible endpoint.
S3,
Aws,
Minio,
Rustfs,
R2,
/// GCS XML interoperability API with HMAC keys.
Gcs,
}
/// Bucket addressing style. `auto` is resolved by the source client builder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PathStyle {
#[default]
Auto,
Path,
Virtual,
}
/// Static credentials for the source. `Debug` never prints the secret or
/// the session token.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceCredentials {
pub access_key: String,
pub secret_key: String,
#[serde(default)]
pub session_token: Option<String>,
}
impl fmt::Debug for SourceCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SourceCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &REDACTED)
.field("session_token", &self.session_token.as_ref().map(|_| REDACTED))
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
#[serde(default)]
pub skip_verify: bool,
#[serde(default)]
pub ca_cert_pem: Option<String>,
}
+19 -693
View File
@@ -33,10 +33,9 @@ use super::storage_api::bucket_usecase::s3_api::bucket::ListObjectsV2Params;
use crate::app::object::shared::{odm_source_unavailable_error, odm_state_error_class};
use crate::error::ApiError;
use crate::on_demand_migration::{
BucketOdmState, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListPageError, ListThroughCursor, ListThroughMerger,
ListThroughToken, ListThroughTokenError, MergeSide, OnDemandMigrationSys, SOURCE_LIST_MAX_RATE_WAIT, SourceClient,
SourceError, SourceErrorPolicy, SourceListPlan, SourceListRequest, SourceObject, SourcePage, decode_continuation_token,
source_list_plan,
BucketOdmState, ListEntryKey, ListPageError, ListThroughCursor, ListThroughMerger, ListThroughToken, ListThroughTokenError,
MergeSide, OnDemandMigrationSys, SOURCE_LIST_MAX_RATE_WAIT, SourceClient, SourceError, SourceErrorPolicy, SourceListPlan,
SourceListRequest, SourceObject, SourcePage, decode_continuation_token, source_list_plan,
};
use futures::StreamExt;
use rustfs_utils::http::{SUFFIX_SOURCE_PROXY_REQUEST, get_header};
@@ -54,9 +53,6 @@ const SOURCE_STORAGE_CLASS: &str = "STANDARD";
/// Enable only after every node serving continuation requests can read v2.
const ENV_LIST_PROGRESS_TOKENS: &str = "RUSTFS_ON_DEMAND_MIGRATION_LIST_V2_TOKENS";
/// Independent of the budget version: bare-v2 readers cannot read framing.
const ENV_LIST_FRAMED_TOKENS: &str = "RUSTFS_ON_DEMAND_MIGRATION_LIST_FRAMED_TOKENS";
/// Concurrent local metadata probes when a versioned bucket has to check
/// source-only keys for a shadowing delete marker.
const DELETE_MARKER_PROBE_CONCURRENCY: usize = 32;
@@ -93,37 +89,6 @@ pub(crate) fn local_cursor(decoded: Option<&str>, merged: Option<&ListThroughTok
}
}
/// A framed chain keeps its envelope when the bucket stops consulting source.
pub(crate) fn preserve_framed_local_cursor(info: &mut ListObjectsV2Info, previous: Option<&ListThroughToken>) {
let Some(previous) = previous.filter(|token| token.framed) else {
return;
};
let Some(next) = info.next_continuation_token.take() else {
return;
};
let mut token = previous.clone();
token.local = Some(next);
token.local_done = false;
if let Some(last_key) = info
.objects
.iter()
.map(|object| object.name.as_str())
.chain(info.prefixes.iter().map(String::as_str))
.max()
{
token.last_key = Some(
token
.last_key
.as_deref()
.map_or(last_key, |previous| previous.max(last_key))
.to_string(),
);
token.v = LIST_THROUGH_TOKEN_VERSION;
token.no_progress = None;
}
info.next_continuation_token = Some(token.encode());
}
fn invalid_continuation_token(err: &ListThroughTokenError) -> S3Error {
debug!(error = %err, "rejected an on-demand migration list continuation token");
S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid continuation token".to_string())
@@ -324,7 +289,6 @@ pub(crate) async fn merged_list_objects_v2(
}
let issue_progress_tokens = rustfs_utils::get_env_bool(ENV_LIST_PROGRESS_TOKENS, false);
let framed = token.is_some_and(|token| token.framed) || rustfs_utils::get_env_bool(ENV_LIST_FRAMED_TOKENS, false);
let outcome = match merger.finish(issue_progress_tokens) {
Ok(outcome) => outcome,
Err(ListPageError::NoProgress(MergeSide::Source)) => {
@@ -362,10 +326,7 @@ pub(crate) async fn merged_list_objects_v2(
info: ListObjectsV2Info {
is_truncated: outcome.is_truncated,
continuation_token: None,
next_continuation_token: outcome.next_token.map(|mut token| {
token.framed = framed;
token.encode()
}),
next_continuation_token: outcome.next_token.map(|token| token.encode()),
objects,
prefixes,
},
@@ -520,7 +481,6 @@ mod tests {
fn token(local: Option<&str>, local_done: bool) -> ListThroughToken {
ListThroughToken {
framed: false,
t: "odm-list".to_string(),
v: 1,
local: local.map(str::to_string),
@@ -598,23 +558,20 @@ mod tests {
#[test]
fn a_v2_token_keeps_the_local_cursor_when_list_through_is_turned_off() {
for framed in [false, true] {
let mut resume = token(Some("local-2"), false);
resume.framed = framed;
resume.v = 2;
resume.no_progress = Some(MAX_LIST_NO_PROGRESS_PAGES - 1);
let encoded = resume.encode();
let decoded = decode_list_cursor(Some(&encoded)).expect("a v2 envelope decodes");
assert_eq!(decoded.as_ref(), Some(&resume));
assert!(matches!(
local_cursor(Some(&encoded), decoded.as_ref()),
LocalListCursor::Token(Some(local)) if local == "local-2"
));
resume.local_done = true;
let encoded = resume.encode();
let decoded = decode_list_cursor(Some(&encoded)).expect("v2 with local EOF decodes");
assert!(matches!(local_cursor(Some(&encoded), decoded.as_ref()), LocalListCursor::Exhausted));
}
let mut resume = token(Some("local-2"), false);
resume.v = 2;
resume.no_progress = Some(MAX_LIST_NO_PROGRESS_PAGES - 1);
let encoded = resume.encode();
let decoded = decode_list_cursor(Some(&encoded)).expect("a v2 envelope decodes");
assert_eq!(decoded.as_ref(), Some(&resume));
assert!(matches!(
local_cursor(Some(&encoded), decoded.as_ref()),
LocalListCursor::Token(Some(local)) if local == "local-2"
));
resume.local_done = true;
let encoded = resume.encode();
let decoded = decode_list_cursor(Some(&encoded)).expect("v2 with local EOF decodes");
assert!(matches!(local_cursor(Some(&encoded), decoded.as_ref()), LocalListCursor::Exhausted));
}
#[test]
@@ -805,7 +762,6 @@ mod tests {
);
let continuation_token = resume_source.map(|source| {
let token = ListThroughToken {
framed: false,
t: "odm-list".into(),
v: 1,
local: None,
@@ -853,286 +809,6 @@ mod tests {
.expect("listing must complete within its bounded source budget")
}
async fn native_list_source(
provider: Provider,
pages: Vec<(String, String)>,
) -> (
String,
tokio_util::task::AbortOnDropHandle<Vec<String>>,
tokio_util::sync::CancellationToken,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind native listing source");
let address = listener.local_addr().expect("native source address");
let stop = tokio_util::sync::CancellationToken::new();
let server_stop = stop.clone();
let server = tokio::spawn(async move {
let mut pages = pages.into_iter();
let mut requests = Vec::new();
loop {
let (mut stream, _) = tokio::select! {
_ = server_stop.cancelled() => break,
accepted = listener.accept() => accepted.expect("accept native source request"),
};
let (target, body) = pages.next().expect("native source must not receive an extra request");
let mut request = Vec::new();
let mut chunk = [0; 4096];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let count = stream.read(&mut chunk).await.expect("read native source request");
assert!(count > 0, "native request needs complete headers");
request.extend_from_slice(&chunk[..count]);
assert!(request.len() <= 32 * 1024, "native request headers must be bounded");
}
let text = String::from_utf8(request).expect("native HTTP request text");
let first_line = text.lines().next().expect("native request line");
assert_eq!(first_line, format!("GET {target} HTTP/1.1"));
let authorization = text
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
.map(|(_, value)| value.trim())
.expect("native credential must be used");
assert!(authorization.starts_with(if provider == Provider::Azure {
"SharedKey acct:"
} else {
"Bearer "
}));
requests.push(first_line.to_string());
let response = format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len());
stream.write_all(response.as_bytes()).await.expect("write native source page");
stream.shutdown().await.expect("finish native source response");
}
assert!(pages.next().is_none(), "every scripted native page must have been requested");
requests
});
(format!("http://{address}"), tokio_util::task::AbortOnDropHandle::new(server), stop)
}
fn native_list_target(provider: Provider, cursor: bool, max_keys: i32) -> String {
if provider == Provider::Azure {
format!(
"/source-bucket?restype=container&comp=list{}&maxresults={max_keys}",
if cursor { "&marker=opaque%2B%2F%3D" } else { "" }
)
} else {
format!(
"/storage/v1/b/source-bucket/o?{}maxResults={max_keys}",
if cursor { "pageToken=opaque%2B%2F%3D&" } else { "" }
)
}
}
fn native_list_body(provider: Provider, entries: &str, prefixes: bool, next: bool) -> String {
if provider == Provider::Azure {
format!(
"<EnumerationResults><Blobs>{entries}{}</Blobs><NextMarker>{}</NextMarker></EnumerationResults>",
if prefixes {
"<BlobPrefix><Name>目录/子/</Name></BlobPrefix>"
} else {
""
},
if next { "opaque+/=" } else { "" }
)
} else {
format!(
r#"{{"items":[{entries}],"prefixes":{},"nextPageToken":{}}}"#,
if prefixes { r#"["目录/子/"]"# } else { "[]" },
if next { r#""opaque+/=""# } else { "null" }
)
}
}
#[cfg(feature = "gcs")]
fn native_test_service_account() -> String {
// The real Google credentials implementation signs locally. Generate a
// disposable key instead of storing private key material in the fixture.
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).expect("generate fixture service-account key");
serde_json::json!({
"type": "service_account",
"client_email": "fixture@example.invalid",
"private_key_id": "fixture-key",
"private_key": key.serialize_pem(),
"project_id": "fixture-project"
})
.to_string()
}
async fn native_source_policy_request(
provider: Provider,
policy: SourceErrorPolicy,
pages: Vec<(String, String)>,
service_account: &str,
max_keys: i32,
) -> (S3Result<S3Response<ListObjectsV2Output>>, Vec<String>) {
let (endpoint, server, stop) = native_list_source(provider, pages).await;
let (_state_guard, mut input) = source_policy_input(endpoint.clone(), policy, None, None).await;
input.max_keys = Some(max_keys);
let sys = OnDemandMigrationSys::get();
let installed = sys.state(&input.bucket).expect("installed source state");
let mut config = installed.config().clone();
config.source = serde_json::from_value(serde_json::json!({
"provider": provider,
"endpoint": endpoint,
"region": "us-east-1",
"bucket": "source-bucket",
"azure": if provider == Provider::Azure { serde_json::json!({ "account": "acct", "account_key": "c2VjcmV0LWtleQ==" }) } else { serde_json::Value::Null },
"gcs": if provider == Provider::GcsNative { serde_json::json!({ "service_account_json": service_account }) } else { serde_json::Value::Null }
})).expect("native source configuration");
sys.apply_for_incarnation(&input.bucket, installed.incarnation_id(), Some(&config))
.await;
let state = sys.state(&input.bucket).expect("native source state");
state
.client()
.unwrap_or_else(|error| panic!("{provider:?} native client must build: {error:?}"));
let result = execute_source_list(input).await;
stop.cancel();
let requests = tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("native source server must finish")
.expect("native source requests must match the script");
(result, requests)
}
#[test]
#[serial_test::serial]
fn native_list_through_malformed_fields_follow_both_source_policies() {
run_large_stack_test("native-list-through-fields", || async {
temp_env::async_with_vars(
[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")), ("HTTP_PROXY", None), ("HTTPS_PROXY", None),
("ALL_PROXY", None), ("http_proxy", None), ("https_proxy", None), ("all_proxy", None),
("NO_PROXY", Some("*")), ("no_proxy", Some("*"))],
async {
#[cfg(feature = "gcs")]
let service_account = native_test_service_account();
#[cfg(not(feature = "gcs"))]
let service_account = String::new();
for provider in [Provider::Azure, #[cfg(feature = "gcs")] Provider::GcsNative] {
let invalid = if provider == Provider::Azure {
["<Blob><Name>bad</Name></Blob>",
"<Blob><Name>bad</Name><Properties><Content-Length>-1</Content-Length></Properties></Blob>",
"<Blob><Name>bad</Name><Properties><Content-Length>18446744073709551616</Content-Length></Properties></Blob>",
"<Blob><Properties><Content-Length>1</Content-Length></Properties></Blob>"]
} else {
[r#"{"name":"bad"}"#, r#"{"name":"bad","size":"-1"}"#,
r#"{"name":"bad","size":"18446744073709551616"}"#, r#"{"size":"1"}"#]
};
let valid = if provider == Provider::Azure {
"<Blob><Name>a-source</Name><Properties><Content-Length>1</Content-Length></Properties></Blob>"
} else { r#"{"name":"a-source","size":"1"}"# };
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
for entry in invalid {
for refill in [false, true] {
let mut pages = Vec::new();
if refill {
pages.push((native_list_target(provider, false, 2), native_list_body(provider, valid, false, true)));
}
let entries = if refill { entry.to_string() } else if provider == Provider::Azure {
format!("{valid}{entry}")
} else { format!("{valid},{entry}") };
pages.push((native_list_target(provider, refill, 2), native_list_body(provider, &entries, false, false)));
let (result, requests) = native_source_policy_request(provider, policy, pages, &service_account, 2).await;
assert_eq!(requests.len(), if refill { 2 } else { 1 }, "{provider:?} {policy:?} {entry}");
if policy == SourceErrorPolicy::Propagate {
let err = result.expect_err("malformed native page must propagate");
assert_eq!(err.status_code(), Some(http::StatusCode::FAILED_DEPENDENCY));
assert_eq!(err.code(), &S3ErrorCode::Custom("SourceUnavailable".into()));
assert_eq!(err.message(), Some("other"));
} else {
// Reuse the complete local-only assertions, including no
// leaked source objects, no cursor and the degraded header.
assert_source_policy_result(result, policy);
}
}
}
}
}
},
).await;
});
}
#[test]
#[serial_test::serial]
fn native_list_through_preserves_valid_empty_pages_and_zero_size_objects() {
run_large_stack_test("native-list-through-valid", || async {
temp_env::async_with_vars(
[
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None),
("HTTPS_PROXY", None),
("ALL_PROXY", None),
("http_proxy", None),
("https_proxy", None),
("all_proxy", None),
("NO_PROXY", Some("*")),
("no_proxy", Some("*")),
],
async {
#[cfg(feature = "gcs")]
let service_account = native_test_service_account();
#[cfg(not(feature = "gcs"))]
let service_account = String::new();
for provider in [
Provider::Azure,
#[cfg(feature = "gcs")]
Provider::GcsNative,
] {
let valid = if provider == Provider::Azure {
"<Blob><Name>目录/空</Name><Properties><Content-Length>0</Content-Length></Properties></Blob>"
} else {
r#"{"name":"目录/空","size":"0"}"#
};
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
let (result, requests) = native_source_policy_request(
provider,
policy,
vec![
(native_list_target(provider, false, 3), native_list_body(provider, "", false, true)),
(native_list_target(provider, true, 3), native_list_body(provider, valid, true, false)),
],
&service_account,
3,
)
.await;
assert_eq!(requests.len(), 2);
let response = result.expect("valid native listing must succeed under either policy");
assert_ne!(
response
.headers
.get("x-rustfs-on-demand-migration-list")
.and_then(|value| value.to_str().ok()),
Some("local_only")
);
let output = response.output;
let objects = output.contents.expect("local and source objects");
assert_eq!(
objects
.iter()
.map(|object| (object.key.as_deref(), object.size))
.collect::<Vec<_>>(),
vec![(Some("z-local"), Some(1)), (Some("目录/空"), Some(0))]
);
assert_eq!(
output
.common_prefixes
.expect("native prefix")
.into_iter()
.map(|prefix| prefix.prefix)
.collect::<Vec<_>>(),
vec![Some("目录/子/".to_string())]
);
assert_eq!(output.key_count, Some(3));
assert_eq!(output.is_truncated, Some(false));
assert!(output.next_continuation_token.is_none());
}
}
},
)
.await;
});
}
async fn source_policy_request(
pages: Vec<String>,
policy: SourceErrorPolicy,
@@ -1486,7 +1162,6 @@ mod tests {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, Some("true")),
(ENV_LIST_FRAMED_TOKENS, None),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None),
("HTTPS_PROXY", None),
@@ -1530,7 +1205,6 @@ mod tests {
seen.insert(next.clone()),
"a cross-request source cursor cycle must not return an identical empty merged token"
);
assert!(!decode_wire_token(&next).framed, "the budget switch cannot enable framing");
empty_pages += 1;
input.continuation_token = Some(next);
}
@@ -1567,7 +1241,6 @@ mod tests {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, None),
(ENV_LIST_FRAMED_TOKENS, None),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None),
("HTTPS_PROXY", None),
@@ -1579,10 +1252,7 @@ mod tests {
("no_proxy", Some("*")),
],
async {
for (policy, framed) in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound]
.into_iter()
.flat_map(|policy| [false, true].map(|framed| (policy, framed)))
{
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
let pages = ["B", "C", "A"].map(|next| source_xml(Some(next), true, None));
let (endpoint, server, stop) = list_source(pages.into_iter().cycle()).await;
let (_state_guard, mut input) = source_policy_input(endpoint, policy, Some("A"), None).await;
@@ -1596,7 +1266,6 @@ mod tests {
let raw = base64_simd::STANDARD.decode_to_vec(&next).expect("base64 continuation token");
let decoded = std::str::from_utf8(&raw).expect("JSON token");
let token = decode_list_cursor(Some(decoded)).expect("v1 reader").expect("merged token");
assert!(!token.framed, "the default cannot begin issuing framed tokens");
assert_eq!(token.v, 1, "the default rollout cannot begin issuing v2");
assert_eq!(token.no_progress, None);
assert!(!decoded.contains("no_progress"), "ordinary v1 wire shape stays unchanged");
@@ -1610,7 +1279,6 @@ mod tests {
let mut token = decode_list_cursor(Some(std::str::from_utf8(&raw).expect("JSON token")))
.expect("v1 reader")
.expect("merged token");
token.framed = framed;
token.v = 2;
token.no_progress = Some(MAX_LIST_NO_PROGRESS_PAGES - 2);
input.continuation_token = Some(base64_simd::STANDARD.encode_to_string(token.encode().as_bytes()));
@@ -1622,7 +1290,6 @@ mod tests {
let token = decode_list_cursor(Some(std::str::from_utf8(&raw).expect("JSON token")))
.expect("v2 reader")
.expect("merged token");
assert_eq!(token.framed, framed, "reader-only nodes retain the incoming framing");
assert_eq!(token.v, 2);
assert_eq!(token.no_progress, Some(MAX_LIST_NO_PROGRESS_PAGES - 1));
input.continuation_token = Some(next);
@@ -1651,7 +1318,6 @@ mod tests {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, Some("true")),
(ENV_LIST_FRAMED_TOKENS, None),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None),
("HTTPS_PROXY", None),
@@ -1727,346 +1393,6 @@ mod tests {
});
}
fn decode_wire_token(wire: &str) -> ListThroughToken {
let raw = base64_simd::STANDARD.decode_to_vec(wire).expect("base64 continuation token");
decode_list_cursor(Some(std::str::from_utf8(&raw).expect("UTF-8 cursor")))
.expect("valid continuation token")
.expect("merged continuation token")
}
#[test]
fn framed_local_continuations_preserve_json_markers_and_zero_sized_budgets() {
let json_key = r#"{"t":"odm-list","v":1,"local_done":true}"#;
let mut resume = token(Some("local-2"), false);
resume.framed = true;
resume.v = 2;
resume.no_progress = Some(15);
let mut page = ListObjectsV2Info {
is_truncated: true,
next_continuation_token: Some(json_key.to_string()),
objects: vec![info(json_key)],
..Default::default()
};
preserve_framed_local_cursor(&mut page, Some(&resume));
let raw = page.next_continuation_token.expect("local continuation");
assert!(raw.starts_with("\0odm-list:"));
let decoded = decode_list_cursor(Some(&raw))
.expect("framed local continuation")
.expect("envelope");
assert!(decoded.framed);
assert_eq!(
decoded.local.as_deref(),
Some(json_key),
"the local marker is embedded without another encoding"
);
assert_eq!(decoded.source, resume.source);
assert_eq!(decoded.last_key.as_deref(), Some(json_key));
assert_eq!(decoded.v, 1);
assert_eq!(decoded.no_progress, None);
assert!(matches!(local_cursor(Some(&raw), Some(&decoded)), LocalListCursor::Token(Some(local)) if local == json_key));
let mut prefix_page = ListObjectsV2Info {
is_truncated: true,
next_continuation_token: Some("photos/".to_string()),
prefixes: vec!["photos/".to_string()],
..Default::default()
};
preserve_framed_local_cursor(&mut prefix_page, Some(&resume));
let prefix = decode_list_cursor(prefix_page.next_continuation_token.as_deref())
.expect("prefix continuation")
.expect("framed prefix envelope");
assert!(prefix.framed);
assert_eq!(prefix.last_key.as_deref(), Some("photos/"));
assert_eq!(prefix.v, 1);
assert_eq!(prefix.no_progress, None);
let mut zero = ListObjectsV2Info {
is_truncated: true,
next_continuation_token: resume.local.clone(),
..Default::default()
};
preserve_framed_local_cursor(&mut zero, Some(&resume));
assert_eq!(
decode_list_cursor(zero.next_continuation_token.as_deref()).expect("zero-sized continuation"),
Some(resume.clone())
);
resume.framed = false;
let mut ordinary = ListObjectsV2Info {
is_truncated: true,
next_continuation_token: Some(json_key.to_string()),
..Default::default()
};
preserve_framed_local_cursor(&mut ordinary, Some(&resume));
assert_eq!(ordinary.next_continuation_token.as_deref(), Some(json_key));
}
#[test]
#[serial_test::serial]
fn list_through_historical_v2_budget_exhausts_on_the_next_reader_only_request() {
run_large_stack_test("list-through-historical-budget", || async {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, None),
(ENV_LIST_FRAMED_TOKENS, None),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None), ("HTTPS_PROXY", None), ("ALL_PROXY", None),
("http_proxy", None), ("https_proxy", None), ("all_proxy", None),
("NO_PROXY", Some("*")), ("no_proxy", Some("*")),
],
async {
for policy in [SourceErrorPolicy::Propagate, SourceErrorPolicy::NotFound] {
let (endpoint, server) = scripted_list_source(vec![
source_xml(Some("B"), true, None), source_xml(Some("C"), true, None),
]).await;
let (_state_guard, mut input) = source_policy_input(endpoint, policy, None, None).await;
// Fixed bytes from the pre-framing writer, independent of today's encoder.
input.continuation_token = Some("eyJ0Ijoib2RtLWxpc3QiLCJ2IjoyLCJsb2NhbCI6bnVsbCwibG9jYWxfZG9uZSI6ZmFsc2UsInNvdXJjZSI6IkEiLCJzb3VyY2VfZG9uZSI6ZmFsc2UsImxhc3Rfa2V5IjpudWxsLCJub19wcm9ncmVzcyI6MTV9".to_string());
assert_source_policy_result(execute_source_list(input).await, policy);
let requests = tokio::time::timeout(Duration::from_secs(5), server).await
.expect("finite source server must finish").expect("source server must not panic");
assert_eq!(requests.len(), 2, "the old count=15 must terminate without starting a new budget");
for (request, cursor) in requests.iter().zip(["A", "B"]) {
assert!(request.contains(&format!("continuation-token={cursor}")), "{request}");
}
}
},
).await;
});
}
#[test]
#[serial_test::serial]
fn list_through_framing_survives_budget_reset_and_local_only_pagination() {
run_large_stack_test("list-through-framing-local-pagination", || async {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, None),
(ENV_LIST_FRAMED_TOKENS, Some("true")),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None),
("HTTPS_PROXY", None),
("ALL_PROXY", None),
("http_proxy", None),
("https_proxy", None),
("all_proxy", None),
("NO_PROXY", Some("*")),
("no_proxy", Some("*")),
],
async {
let (endpoint, server) = scripted_list_source(vec![
source_xml(Some("A"), true, None),
source_xml(Some("B"), true, None),
source_xml(Some("C"), true, None),
source_xml(Some("D"), true, None),
source_xml(None, false, Some("0-source")),
])
.await;
let (_state_guard, mut input) = source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, None).await;
let json_key = r#"{"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"] {
store
.put_object(
&input.bucket,
key,
&mut StoragePutObjReader::from_vec(vec![1]),
&StorageObjectOptions::default(),
)
.await
.expect("seed paginated local keys");
}
input.max_keys = Some(1);
let first = execute_source_list(input.clone()).await.expect("legitimate empty page");
assert_eq!(first.output.key_count, Some(0));
assert_eq!(first.output.is_truncated, Some(true));
let next = first.output.next_continuation_token.expect("first framed cursor");
let token = decode_wire_token(&next);
assert!(token.framed, "the independent switch permits first framing issuance");
assert_eq!(token.v, 1, "framing issuance cannot enable the no-progress budget");
assert_eq!(token.no_progress, None);
input.continuation_token = Some(next);
let budget_page =
temp_env::async_with_vars([(ENV_LIST_PROGRESS_TOKENS, Some("true"))], execute_source_list(input.clone()))
.await
.expect("another valid empty page starts a budget only when enabled");
assert_eq!(budget_page.output.key_count, Some(0));
assert_eq!(budget_page.output.is_truncated, Some(true));
let next = budget_page.output.next_continuation_token.expect("framed v2 cursor");
let token = decode_wire_token(&next);
assert!(token.framed);
assert_eq!(token.v, 2);
assert_eq!(token.no_progress, Some(1));
input.continuation_token = Some(next);
temp_env::async_with_vars(
[(ENV_LIST_PROGRESS_TOKENS, None::<&str>), (ENV_LIST_FRAMED_TOKENS, None)],
async {
let second = execute_source_list(input.clone())
.await
.expect("reader-only node reaches source data");
assert_eq!(second.output.key_count, Some(1));
assert_eq!(second.output.is_truncated, Some(true));
assert_eq!(second.output.contents.expect("source object")[0].key.as_deref(), Some("0-source"));
let next = second.output.next_continuation_token.expect("remaining local listing");
let token = decode_wire_token(&next);
assert!(token.framed, "resetting the budget must not downgrade the framing");
assert_eq!(token.v, 1);
assert_eq!(token.no_progress, None);
assert!(token.source_done);
input.continuation_token = Some(next);
OnDemandMigrationSys::get().remove(&input.bucket);
for (index, key) in expected_local.iter().enumerate() {
let page = execute_source_list(input.clone()).await.expect("local-only continuation");
assert!(!page.headers.contains_key("x-rustfs-on-demand-migration-list"));
assert_eq!(page.output.key_count, Some(1));
let keys: Vec<_> = page
.output
.contents
.expect("one local object")
.into_iter()
.map(|object| object.key.expect("local key"))
.collect();
assert_eq!(
keys,
vec![key.to_string()],
"no duplicate or omitted key after disabling list-through"
);
let truncated = index + 1 < expected_local.len();
assert_eq!(page.output.is_truncated, Some(truncated));
if truncated {
let next = page.output.next_continuation_token.expect("local side still has keys");
let token = decode_wire_token(&next);
assert!(token.framed);
assert_eq!(token.v, 1);
assert_eq!(token.no_progress, None);
assert!(token.local.as_deref().expect("local marker").starts_with(*key));
assert_eq!(token.last_key.as_deref(), Some(*key));
input.continuation_token = Some(next);
} else {
assert!(page.output.next_continuation_token.is_none());
}
}
},
)
.await;
let requests = tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("finite source server must finish")
.expect("source server must not panic");
assert_eq!(
requests.len(),
5,
"format changes and local-only continuation perform no additional source I/O"
);
assert!(!requests[0].contains("continuation-token="));
for (request, cursor) in requests[1..].iter().zip(["A", "B", "C", "D"]) {
assert!(request.contains(&format!("continuation-token={cursor}")), "{request}");
}
},
)
.await;
});
}
#[test]
#[serial_test::serial]
fn list_through_zero_sized_framed_request_preserves_its_budget() {
run_large_stack_test("list-through-framed-zero-size", || async {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, None), (ENV_LIST_FRAMED_TOKENS, None),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None), ("HTTPS_PROXY", None), ("ALL_PROXY", None),
("http_proxy", None), ("https_proxy", None), ("all_proxy", None),
("NO_PROXY", Some("*")), ("no_proxy", Some("*")),
],
async {
let (endpoint, server) = scripted_list_source(vec![
source_xml(Some("B"), true, None), source_xml(Some("C"), true, None),
]).await;
let (_state_guard, mut input) = source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, None).await;
let wire = concat!("\0odm-list:", r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":null,"no_progress":15}"#);
input.continuation_token = Some(base64_simd::STANDARD.encode_to_string(wire.as_bytes()));
input.max_keys = Some(0);
let zero = execute_source_list(input.clone()).await.expect("zero-sized request does not spend the budget");
assert_eq!(zero.output.key_count, Some(0));
assert_eq!(zero.output.is_truncated, Some(true));
let next = zero.output.next_continuation_token.expect("unconsumed source");
let token = decode_wire_token(&next);
assert!(token.framed);
assert_eq!(token.v, 2);
assert_eq!(token.no_progress, Some(15));
assert_eq!(token.source.as_deref(), Some("A"));
assert_eq!(next, input.continuation_token.as_ref().expect("original cursor").as_str());
let state = OnDemandMigrationSys::get().state(&input.bucket).expect("source state");
assert_eq!(state.stats().snapshot(state.breaker().state()).source_latency.count, 0, "zero-sized request must not fetch the source");
input.continuation_token = Some(next);
input.max_keys = Some(2);
assert_source_policy_result(execute_source_list(input).await, SourceErrorPolicy::Propagate);
let requests = tokio::time::timeout(Duration::from_secs(5), server).await
.expect("finite source server must finish").expect("source server must not panic");
assert_eq!(requests.len(), 2, "only the resumed nonzero request fetches the source");
assert_eq!(state.stats().snapshot(state.breaker().state()).source_latency.count, 2);
for (request, cursor) in requests.iter().zip(["A", "B"]) {
assert!(request.contains(&format!("continuation-token={cursor}")), "{request}");
}
},
).await;
});
}
#[test]
#[serial_test::serial]
fn zero_sized_merged_cursors_preserve_each_side_and_wire_format() {
run_large_stack_test("list-through-zero-side-matrix", || async {
temp_env::async_with_vars(
[
(ENV_LIST_PROGRESS_TOKENS, Some("true")),
(ENV_LIST_FRAMED_TOKENS, Some("true")),
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", Some("true")),
("HTTP_PROXY", None), ("HTTPS_PROXY", None), ("ALL_PROXY", None),
("http_proxy", None), ("https_proxy", None), ("all_proxy", None),
("NO_PROXY", Some("*")), ("no_proxy", Some("*")),
],
async {
for framed in [false, true] {
for (local_done, source_done) in [(false, true), (true, false), (false, false), (true, true)] {
let (endpoint, server, stop) = list_source(std::iter::repeat(source_xml(Some("unexpected"), true, None))).await;
let (_state_guard, mut input) = source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, None).await;
let json = format!(r#"{{"t":"odm-list","v":2,"local":"local-marker","local_done":{local_done},"source":"source-marker","source_done":{source_done},"last_key":null,"no_progress":15}}"#);
let wire = if framed { format!("\0odm-list:{json}") } else { json };
let original = base64_simd::STANDARD.encode_to_string(wire.as_bytes());
input.continuation_token = Some(original.clone());
input.max_keys = Some(0);
let output = execute_source_list(input).await.expect("zero page remains local").output;
let has_more = !local_done || !source_done;
assert_eq!(output.key_count, Some(0));
assert_eq!(output.is_truncated, Some(has_more), "framed={framed}, local_done={local_done}, source_done={source_done}");
assert_eq!(output.next_continuation_token.as_deref(), has_more.then_some(original.as_str()));
if let Some(next) = output.next_continuation_token {
let token = decode_wire_token(&next);
assert_eq!(token.framed, framed);
assert_eq!(token.v, 2);
assert_eq!(token.no_progress, Some(15));
assert_eq!(token.local_done, local_done);
assert_eq!(token.source_done, source_done);
assert_eq!(token.local.as_deref(), Some("local-marker"));
assert_eq!(token.source.as_deref(), Some("source-marker"));
}
stop.cancel();
let requests = tokio::time::timeout(Duration::from_secs(5), server).await
.expect("unused source must finish").expect("source server must not panic");
assert!(requests.is_empty(), "zero-sized request must not access the source: {requests:?}");
}
}
},
).await;
});
}
fn assert_source_policy_result(result: S3Result<S3Response<ListObjectsV2Output>>, policy: SourceErrorPolicy) {
match policy {
SourceErrorPolicy::Propagate => {
+4 -18
View File
@@ -2768,21 +2768,8 @@ impl DefaultBucketUsecase {
} else {
(None, None)
};
let (object_infos, degraded) = match (source_state, merged_token.as_ref()) {
(None, Some(token)) if params.max_keys == 0 => {
// No source was consulted, so retain every unconsumed side and
// the original wire format without spending its progress budget.
let is_truncated = !token.local_done || !token.source_done;
(
StorageListObjectsV2Info {
is_truncated,
next_continuation_token: params.decoded_continuation_token.clone().filter(|_| is_truncated),
..Default::default()
},
false,
)
}
(Some(state), _) => {
let (object_infos, degraded) = match source_state {
Some(state) => {
let outcome = list_through::merged_list_objects_v2(
&store,
&state,
@@ -2795,12 +2782,12 @@ impl DefaultBucketUsecase {
.await?;
(outcome.info, outcome.degraded)
}
(None, _) => {
None => {
let cursor = list_through::local_cursor(params.decoded_continuation_token.as_deref(), merged_token.as_ref());
match cursor {
list_through::LocalListCursor::Exhausted => (StorageListObjectsV2Info::default(), false),
list_through::LocalListCursor::Token(token) => {
let mut infos = store
let infos = store
.list_objects_v2(
&bucket,
&params.prefix,
@@ -2813,7 +2800,6 @@ impl DefaultBucketUsecase {
)
.await
.map_err(ApiError::from)?;
list_through::preserve_framed_local_cursor(&mut infos, merged_token.as_ref());
(infos, false)
}
}
+18 -372
View File
@@ -162,32 +162,6 @@ impl AzureSourceBackend {
Ok(request)
}
/// A missing blob is distinct from a missing container or version. Only
/// object reads may use BlobNotFound as positive evidence of absence.
async fn send_object_request(&self, request: reqwest::Request) -> Result<reqwest::Response, SourceError> {
let is_head = request.method() == Method::HEAD;
let versioned = request
.url()
.query_pairs()
.any(|(name, _)| name.eq_ignore_ascii_case("versionid") || name.eq_ignore_ascii_case("snapshot"));
let response = self.http.execute(request).await?;
if response.status() == http::StatusCode::NOT_FOUND && !versioned {
match header(response.headers(), HEADER_ERROR_CODE) {
Some("BlobNotFound") => return Err(SourceError::NotFound),
None | Some("ResourceNotFound") if is_head => {
// HEAD may omit an error code. One successful container
// probe proves key absence; a failed probe keeps its error.
// These are two independently timed requests, not one deadline.
drop(response);
self.probe().await?;
return Err(SourceError::NotFound);
}
_ => {}
}
}
NativeHttp::check_response(response, Some(HEADER_ERROR_CODE))
}
/// Shared mapping for Get Blob and Get Blob Properties.
fn head_from_response(headers: &HeaderMap) -> Result<SourceHead, SourceError> {
// A customer-provided key means the service holds ciphertext it cannot
@@ -214,7 +188,7 @@ impl AzureSourceBackend {
impl SourceBackend for AzureSourceBackend {
async fn head(&self, key: &str) -> Result<SourceHead, SourceError> {
let request = self.request(Method::HEAD, self.blob_url(key)?, HeaderMap::new())?;
let response = self.send_object_request(request).await?;
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
Self::head_from_response(response.headers())
}
@@ -227,7 +201,7 @@ impl SourceBackend for AzureSourceBackend {
);
}
let request = self.request(Method::GET, self.blob_url(key)?, headers)?;
let response = self.send_object_request(request).await?;
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
let head = Self::head_from_response(response.headers())?;
let content_range = header(response.headers(), "content-range").map(str::to_string);
Ok(SourceGet {
@@ -265,7 +239,7 @@ impl SourceBackend for AzureSourceBackend {
}
let request = self.request(Method::GET, url, HeaderMap::new())?;
let response = self.http.send(request, Some(HEADER_ERROR_CODE)).await?;
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
let body = read_text(response, MAX_XML_BYTES).await?;
let listing = parse_list_blobs(&body)?;
@@ -281,7 +255,7 @@ impl SourceBackend for AzureSourceBackend {
let mut url = self.blob_url(key)?;
url.query_pairs_mut().append_pair("comp", "tags");
let request = self.request(Method::GET, url, HeaderMap::new())?;
let response = self.http.send(request, Some(HEADER_ERROR_CODE)).await?;
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
let body = read_text(response, MAX_XML_BYTES).await?;
parse_blob_tags(&body)
}
@@ -290,7 +264,7 @@ impl SourceBackend for AzureSourceBackend {
let mut url = self.container_url()?;
url.query_pairs_mut().append_pair("restype", "container");
let request = self.request(Method::HEAD, url, HeaderMap::new())?;
self.http.send(request, Some(HEADER_ERROR_CODE)).await?;
self.http.send(request, HEADER_ERROR_CODE).await?;
Ok(())
}
}
@@ -368,9 +342,9 @@ struct AzureListing {
#[derive(Default)]
struct BlobEntry {
name: Option<String>,
name: String,
etag: Option<String>,
size: Option<u64>,
size: u64,
last_modified: Option<std::time::SystemTime>,
access_tier: Option<String>,
}
@@ -383,7 +357,6 @@ fn parse_list_blobs(xml: &str) -> Result<AzureListing, SourceError> {
let mut next_marker = None;
let mut blob: Option<BlobEntry> = None;
let mut in_blob_prefix = false;
let mut blob_prefix: Option<String> = None;
// Open container elements. quick-xml reports a truncated document as a
// plain end of input, so a non-zero depth at EOF is the only signal that
// the page was cut short and must not be read as a complete listing.
@@ -393,9 +366,6 @@ fn parse_list_blobs(xml: &str) -> Result<AzureListing, SourceError> {
match reader.read_event() {
Ok(Event::Start(start)) => {
let name = local_name(start.name().as_ref());
if matches!(name.as_str(), "blob" | "blobprefix") && (blob.is_some() || in_blob_prefix) {
return Err(SourceError::Other("source listing entries must not be nested".to_string()));
}
match name.as_str() {
"blob" => {
depth += 1;
@@ -409,30 +379,22 @@ fn parse_list_blobs(xml: &str) -> Result<AzureListing, SourceError> {
_ => {
let end = start.to_end().into_owned();
let text = leaf_text(&mut reader, end.name())?;
apply_list_field(&name, text, &mut blob, &mut blob_prefix, &mut next_marker, in_blob_prefix)?;
apply_list_field(&name, text, &mut blob, &mut prefixes, &mut next_marker, in_blob_prefix);
}
}
}
Ok(Event::Empty(empty)) => {
let name = local_name(empty.name().as_ref());
if matches!(name.as_str(), "blob" | "blobprefix") {
return Err(SourceError::Other("source listing entry has no name".to_string()));
}
apply_list_field(&name, String::new(), &mut blob, &mut blob_prefix, &mut next_marker, in_blob_prefix)?;
apply_list_field(&name, String::new(), &mut blob, &mut prefixes, &mut next_marker, in_blob_prefix);
}
Ok(Event::End(end)) => match local_name(end.name().as_ref()).as_str() {
"blob" => {
depth = depth.saturating_sub(1);
if let Some(entry) = blob.take() {
objects.push(SourceObject {
key: entry
.name
.filter(|name| !name.is_empty())
.ok_or_else(|| SourceError::Other("source listing object has no name".to_string()))?,
key: entry.name,
etag: entry.etag,
size: entry
.size
.ok_or_else(|| SourceError::Other("source listing object has no valid size".to_string()))?,
size: entry.size,
last_modified: entry.last_modified,
storage_class: entry.access_tier,
// Azure ETags carry no part count; the listing
@@ -444,12 +406,6 @@ fn parse_list_blobs(xml: &str) -> Result<AzureListing, SourceError> {
"blobprefix" => {
depth = depth.saturating_sub(1);
in_blob_prefix = false;
prefixes.push(
blob_prefix
.take()
.filter(|name| !name.is_empty())
.ok_or_else(|| SourceError::Other("source listing prefix has no name".to_string()))?,
);
}
"properties" | "blobs" | "enumerationresults" => depth = depth.saturating_sub(1),
_ => {}
@@ -474,22 +430,16 @@ fn apply_list_field(
name: &str,
text: String,
blob: &mut Option<BlobEntry>,
blob_prefix: &mut Option<String>,
prefixes: &mut Vec<String>,
next_marker: &mut Option<String>,
in_blob_prefix: bool,
) -> Result<(), SourceError> {
) {
match name {
"name" => {
if in_blob_prefix {
if blob_prefix.is_some() {
return Err(SourceError::Other("source listing prefix has duplicate names".to_string()));
}
*blob_prefix = Some(text);
prefixes.push(text);
} else if let Some(entry) = blob.as_mut() {
if entry.name.is_some() {
return Err(SourceError::Other("source listing object has duplicate names".to_string()));
}
entry.name = Some(text);
entry.name = text;
}
}
"nextmarker" => *next_marker = Some(text),
@@ -500,14 +450,7 @@ fn apply_list_field(
}
"content-length" => {
if let Some(entry) = blob.as_mut() {
if entry.size.is_some() {
return Err(SourceError::Other("source listing object has duplicate sizes".to_string()));
}
entry.size = Some(
text.trim()
.parse()
.map_err(|_| SourceError::Other("source listing object has no valid size".to_string()))?,
);
entry.size = text.trim().parse().unwrap_or(0);
}
}
"last-modified" => {
@@ -522,7 +465,6 @@ fn apply_list_field(
}
_ => {}
}
Ok(())
}
/// Parses a `Get Blob Tags` response.
@@ -609,7 +551,7 @@ mod tests {
use super::*;
use crate::on_demand_migration::backend_contract::{BackendCapabilities, assert_backend_contract};
use crate::on_demand_migration::source_client::SourceError;
use crate::on_demand_migration::test_http_fixture::{ScriptedResponse, assert_requests, scripted_server};
use crate::on_demand_migration::test_http_fixture::{ScriptedResponse, scripted_server};
const LIST_PAGE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<EnumerationResults ServiceEndpoint="https://acct.blob.core.windows.net/" ContainerName="legacy">
@@ -691,107 +633,6 @@ mod tests {
assert!(parse_blob_tags("<Tags><TagSet>").is_err(), "a truncated tag set must fail");
}
#[tokio::test]
async fn native_listing_rejects_missing_or_invalid_required_object_fields() {
for entry in [
"<Blob />",
"<Blob><Properties><Content-Length>1</Content-Length></Properties></Blob>",
"<Blob><Name /><Properties><Content-Length>1</Content-Length></Properties></Blob>",
"<Blob><Name>broken</Name></Blob>",
"<Blob><Name>broken</Name><Properties><Content-Length /></Properties></Blob>",
"<Blob><Name>broken</Name><Properties><Content-Length>-1</Content-Length></Properties></Blob>",
"<Blob><Name>broken</Name><Properties><Content-Length>18446744073709551616</Content-Length></Properties></Blob>",
"<Blob><Name>broken</Name><Properties><Content-Length>not-a-size</Content-Length></Properties></Blob>",
"<BlobPrefix />",
"<BlobPrefix><Name /></BlobPrefix>",
"<BlobPrefix></BlobPrefix>",
] {
// Reject the entire page even if a valid object precedes the bad
// entry, so callers cannot expose partial data or advance its cursor.
let body = format!(
"<EnumerationResults><Blobs><Blob><Name>valid</Name><Properties><Content-Length>1</Content-Length></Properties></Blob>{entry}</Blobs><NextMarker>next</NextMarker></EnumerationResults>"
);
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body)]).await;
let err = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]))
.list(&SourceListRequest {
prefix: Some("dir/"),
delimiter: Some("/"),
continuation_token: Some("opaque+/="),
max_keys: 2,
..Default::default()
})
.await
.expect_err("malformed object must reject the complete native page");
assert!(matches!(err, SourceError::Other(_)), "{entry}: {err:?}");
assert!(!err.is_retryable());
assert_requests(
&recorded,
&[(
"GET",
"/legacy?restype=container&comp=list&prefix=dir%2F&delimiter=%2F&marker=opaque%2B%2F%3D&maxresults=2",
)],
);
}
}
#[tokio::test]
async fn native_listing_rejects_duplicate_fields_and_nested_entries() {
for entry in [
"<Blob><Name>a</Name><Name>b</Name><Properties><Content-Length>1</Content-Length></Properties></Blob>",
"<Blob><Name /><Name>b</Name><Properties><Content-Length>1</Content-Length></Properties></Blob>",
"<Blob><Name>a</Name><Properties><Content-Length>1</Content-Length><Content-Length>2</Content-Length></Properties></Blob>",
"<BlobPrefix><Name>a/</Name><Name>b/</Name></BlobPrefix>",
"<BlobPrefix><Name /><Name>b/</Name></BlobPrefix>",
"<Blob><Name>a</Name><Properties><Content-Length>1</Content-Length></Properties><Blob><Name>b</Name><Properties><Content-Length>2</Content-Length></Properties></Blob></Blob>",
"<Blob><Name>a</Name><Properties><Content-Length>1</Content-Length></Properties><BlobPrefix><Name>b/</Name></BlobPrefix></Blob>",
"<BlobPrefix><Name>a/</Name><Blob><Name>b</Name><Properties><Content-Length>2</Content-Length></Properties></Blob></BlobPrefix>",
"<BlobPrefix><Name>a/</Name><BlobPrefix><Name>b/</Name></BlobPrefix></BlobPrefix>",
] {
let body = format!(
"<EnumerationResults><Blobs><Blob><Name>valid</Name><Properties><Content-Length>0</Content-Length></Properties></Blob>{entry}</Blobs><NextMarker>next</NextMarker></EnumerationResults>"
);
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body)]).await;
let result = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]))
.list(&SourceListRequest {
delimiter: Some("/"),
continuation_token: Some("opaque+/="),
max_keys: 2,
..Default::default()
})
.await;
let err = result.expect_err("ambiguous entries must reject the entire page and its cursor");
assert!(matches!(err, SourceError::Other(_)), "{entry}: {err:?}");
assert!(!err.is_retryable(), "{entry}: {err:?}");
assert_requests(
&recorded,
&[(
"GET",
"/legacy?restype=container&comp=list&delimiter=%2F&marker=opaque%2B%2F%3D&maxresults=2",
)],
);
}
}
#[tokio::test]
async fn native_listing_preserves_zero_size_unicode_prefixes_and_opaque_cursors() {
let body = "<EnumerationResults><Blobs><Blob><Name>目录/空 &amp; file</Name><Properties><Content-Length>0</Content-Length></Properties></Blob><BlobPrefix><Name>目录/子/</Name></BlobPrefix></Blobs><NextMarker>opaque+/=</NextMarker></EnumerationResults>";
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
let page = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]))
.list(&SourceListRequest {
max_keys: 2,
..Default::default()
})
.await
.expect("valid native page");
assert_eq!(page.objects.len(), 1);
assert_eq!(page.objects[0].key, "目录/空 & file");
assert_eq!(page.objects[0].size, 0);
assert_eq!(page.common_prefixes, ["目录/子/"]);
assert!(page.is_truncated);
assert_eq!(page.next_continuation_token.as_deref(), Some("opaque+/="));
assert_requests(&recorded, &[("GET", "/legacy?restype=container&comp=list&maxresults=2")]);
}
#[test]
fn blob_tags_parse_into_the_shared_tag_map() {
let tags = parse_blob_tags(TAGS).expect("tags should parse");
@@ -1150,184 +991,6 @@ mod tests {
]
}
#[tokio::test]
async fn object_not_found_requires_provider_evidence_or_one_successful_head_probe() {
for method in [Method::HEAD, Method::GET] {
for (status, code, expected) in [
(404, Some("BlobNotFound"), "not_found"),
(403, Some("BlobNotFound"), "access_denied"),
(404, Some("ContainerNotFound"), "other"),
(404, Some("BlobVersionNotFound"), "other"),
(404, Some("UnrecognizedError"), "other"),
(404, None, if method == Method::HEAD { "not_found" } else { "other" }),
(404, Some("ResourceNotFound"), if method == Method::HEAD { "not_found" } else { "other" }),
] {
let probes = method == Method::HEAD && status == 404 && matches!(code, None | Some("ResourceNotFound"));
let headers = code
.map(|value| vec![(HEADER_ERROR_CODE, value.to_string())])
.unwrap_or_default();
let mut responses = vec![ScriptedResponse::new(status, headers, "untrusted-error-body".to_string())];
if probes {
responses.push(ScriptedResponse::new(200, Vec::new(), String::new()));
}
let (endpoint, recorded) = scripted_server(responses).await;
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
let result = if method == Method::HEAD {
backend.head("missing").await.map(|_| ())
} else {
backend.get("missing", None).await.map(|_| ())
};
let err = result.expect_err("object error must remain an error");
assert_eq!(err.class_label(), expected, "{method} {status} {code:?}: {err:?}");
assert!(!err.is_retryable(), "{err:?}");
assert!(!err.to_string().contains("untrusted-error-body"));
let mut requests = vec![(method.as_str(), "/legacy/missing")];
if probes {
requests.push(("HEAD", "/legacy?restype=container"));
}
assert_requests(&recorded, &requests);
}
}
}
#[tokio::test]
async fn s3_not_found_alias_never_proves_native_object_absence() {
for selector in [None, Some("versionid"), Some("snapshot")] {
for operation in ["head", "get", "list", "tags", "probe"] {
if selector.is_some() && !matches!(operation, "head" | "get") {
continue;
}
for (status, expected, retryable) in [
(403, "access_denied", false),
(404, "other", false),
(416, "other", false),
(500, "server_error", true),
] {
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(
status,
vec![(HEADER_ERROR_CODE, "NoSuchKey".to_string())],
"untrusted-error-body".to_string(),
)])
.await;
let credential = selector.map_or_else(
|| Credential::SharedKey(vec![7_u8; 32]),
|selector| Credential::Sas(vec![(selector.to_string(), "old-version".to_string())]),
);
let backend = backend(&endpoint, credential);
let result = match operation {
"head" => backend.head("missing").await.map(|_| ()),
"get" => backend.get("missing", None).await.map(|_| ()),
"list" => backend.list(&SourceListRequest::default()).await.map(|_| ()),
"tags" => backend.tagging("missing").await.map(|_| ()),
"probe" => backend.probe().await,
_ => unreachable!(),
};
let err = result.expect_err("an S3 error alias is not Azure absence evidence");
assert_eq!(err.class_label(), expected, "{operation} {selector:?} HTTP {status}: {err:?}");
assert_eq!(err.is_retryable(), retryable, "{operation} {selector:?} HTTP {status}: {err:?}");
if status == 500 {
assert!(matches!(err, SourceError::ServerError(500)));
}
assert!(!err.to_string().contains("untrusted-error-body"));
let (method, mut target) = match operation {
"head" => ("HEAD", "/legacy/missing".to_string()),
"get" => ("GET", "/legacy/missing".to_string()),
"list" => ("GET", "/legacy?restype=container&comp=list".to_string()),
"tags" => ("GET", "/legacy/missing?comp=tags".to_string()),
"probe" => ("HEAD", "/legacy?restype=container".to_string()),
_ => unreachable!(),
};
if let Some(selector) = selector {
target.push_str(&format!("?{selector}=old-version"));
}
assert_requests(&recorded, &[(method, target.as_str())]);
}
}
}
}
#[tokio::test]
async fn ambiguous_head_preserves_the_container_probe_failure() {
for (status, expected, retryable) in [
(403, "access_denied", false),
(404, "other", false),
(429, "throttled", true),
(500, "server_error", true),
(503, "throttled", true),
] {
let (endpoint, recorded) = scripted_server(vec![
ScriptedResponse::new(404, Vec::new(), String::new()),
// A BlobNotFound header on a container request cannot prove
// that the object is missing, regardless of this status.
ScriptedResponse::new(status, vec![(HEADER_ERROR_CODE, "BlobNotFound".to_string())], String::new()),
])
.await;
let err = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]))
.head("missing")
.await
.expect_err("failed probe must not become object absence");
assert_eq!(err.class_label(), expected, "probe {status}: {err:?}");
assert_eq!(err.is_retryable(), retryable, "probe {status}: {err:?}");
if status == 500 {
assert!(matches!(err, SourceError::ServerError(500)));
}
assert_requests(&recorded, &[("HEAD", "/legacy/missing"), ("HEAD", "/legacy?restype=container")]);
}
}
#[tokio::test]
async fn version_and_snapshot_absence_are_not_missing_current_blobs() {
for selector in ["versionid", "snapshot"] {
for code in [None, Some("BlobNotFound"), Some("ResourceNotFound")] {
for method in [Method::HEAD, Method::GET] {
let headers = code
.map(|value| vec![(HEADER_ERROR_CODE, value.to_string())])
.unwrap_or_default();
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(404, headers, String::new())]).await;
let backend = backend(&endpoint, Credential::Sas(vec![(selector.to_string(), "old-version".to_string())]));
let result = if method == Method::HEAD {
backend.head("object").await.map(|_| ())
} else {
backend.get("object", None).await.map(|_| ())
};
let err = result.expect_err("missing selected version must remain a source error");
assert!(matches!(err, SourceError::Other(_)), "{method} {selector} {code:?}: {err:?}");
assert_requests(&recorded, &[(method.as_str(), &format!("/legacy/object?{selector}=old-version"))]);
}
}
}
}
#[tokio::test]
async fn blob_not_found_header_is_not_object_absence_for_list_or_tags() {
for tags in [false, true] {
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(
404,
vec![(HEADER_ERROR_CODE, "BlobNotFound".to_string())],
String::new(),
)])
.await;
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
let result = if tags {
backend.tagging("missing").await.map(|_| ())
} else {
backend.list(&SourceListRequest::default()).await.map(|_| ())
};
assert!(matches!(result, Err(SourceError::Other(_))), "tags={tags}: {result:?}");
assert_requests(
&recorded,
&[(
"GET",
if tags {
"/legacy/missing?comp=tags"
} else {
"/legacy?restype=container&comp=list"
},
)],
);
}
}
#[tokio::test]
async fn azure_backend_satisfies_the_shared_backend_contract() {
let mut ranged = contract_blob_headers();
@@ -1335,7 +998,7 @@ mod tests {
// A HEAD reports the object size with no body, exactly as Azure does.
let mut head_only = contract_blob_headers();
head_only.push(("Content-Length", "5".to_string()));
let (endpoint, recorded) = scripted_server(vec![
let (endpoint, _) = scripted_server(vec![
ScriptedResponse::new(200, head_only, String::new()),
ScriptedResponse::new(200, contract_blob_headers(), "hello".to_string()),
ScriptedResponse::new(206, ranged, "ell".to_string()),
@@ -1365,23 +1028,6 @@ mod tests {
},
)
.await;
assert_requests(
&recorded,
&[
("HEAD", "/legacy/dir/a.txt"),
("GET", "/legacy/dir/a.txt"),
("GET", "/legacy/dir/a.txt"),
("GET", "/legacy?restype=container&comp=list&prefix=dir%2F&delimiter=%2F&maxresults=2"),
(
"GET",
"/legacy?restype=container&comp=list&prefix=dir%2F&delimiter=%2F&marker=cursor-1&maxresults=2",
),
("GET", "/legacy/dir/a.txt?comp=tags"),
("HEAD", "/legacy?restype=container"),
("HEAD", "/legacy/missing"),
("HEAD", "/legacy/secret"),
],
);
}
#[tokio::test]
+2 -45
View File
@@ -106,12 +106,12 @@ pub struct SourceConfig {
pub tls: TlsConfig,
/// Required for [`Provider::Azure`] and rejected for every other
/// provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub azure: Option<AzureSourceConfig>,
/// Required for [`Provider::GcsNative`] and rejected for every other
/// provider. [`Provider::Gcs`] keeps using `credentials` because it
/// speaks the S3 interoperability API.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub gcs: Option<GcsSourceConfig>,
}
@@ -808,10 +808,6 @@ impl EndpointKey {
mod tests {
use super::*;
mod before_native_sources {
include!("../../fixtures/on_demand_migration/source_config_e2a.rs");
}
const FULL_JSON: &str = r#"{
"version": 1,
"enabled": true,
@@ -882,32 +878,6 @@ mod tests {
assert_eq!(minimal.policy.source_timeout.first_byte_ms, 15_000);
}
#[test]
fn s3_config_writes_remain_readable_by_the_strict_pre_native_reader() {
// FULL_JSON is the complete config fixture already present in e2a921bc.
for provider in ["s3", "aws", "minio", "rustfs", "r2", "gcs"] {
let mut old_wire: serde_json::Value = serde_json::from_str(FULL_JSON).expect("historical config fixture");
old_wire["source"]["provider"] = provider.into();
let config = OnDemandMigrationConfig::from_json(&serde_json::to_vec(&old_wire).expect("historical wire"))
.expect("current reader accepts the historical source");
let wire = config.to_json().expect("persist current config");
let actual: serde_json::Value = serde_json::from_slice(&wire).expect("persisted config JSON");
let old_source: before_native_sources::SourceConfig = serde_json::from_value(actual["source"].clone())
.expect("an existing S3 source must remain readable by the strict e2a source consumer");
assert_eq!(serde_json::to_value(old_source).expect("old reader wire"), old_wire["source"]);
assert_eq!(actual, old_wire, "provider={provider}: no existing config field or value may change");
for field in ["azure", "gcs"] {
let mut rejected = old_wire["source"].clone();
rejected[field] = serde_json::Value::Null;
assert!(
serde_json::from_value::<before_native_sources::SourceConfig>(rejected).is_err(),
"the frozen old reader must reject {field}, even when null"
);
}
}
}
#[test]
fn unknown_fields_are_rejected_at_every_level() {
for (label, json) in [
@@ -1110,19 +1080,6 @@ mod tests {
for cfg in [azure_cfg(), gcs_native_cfg()] {
let json = cfg.to_json().expect("config must serialize");
assert_eq!(OnDemandMigrationConfig::from_json(&json).expect("config must parse"), cfg);
let wire: serde_json::Value = serde_json::from_slice(&json).expect("native config JSON");
let (present, absent, expected) = match cfg.source.provider {
Provider::Azure => ("azure", "gcs", serde_json::to_value(&cfg.source.azure).expect("Azure block")),
Provider::GcsNative => ("gcs", "azure", serde_json::to_value(&cfg.source.gcs).expect("GCS block")),
_ => unreachable!("native fixture"),
};
assert!(expected.is_object(), "native credentials must be present");
assert_eq!(wire["source"][present], expected);
assert!(wire["source"].get(absent).is_none());
assert!(
serde_json::from_value::<before_native_sources::SourceConfig>(wire["source"].clone()).is_err(),
"native providers still require upgraded readers"
);
}
// The wire labels are part of the admin contract.
assert!(
+12 -209
View File
@@ -55,6 +55,10 @@ use url::Url;
/// Read-only object scope: this backend never writes to the source.
const READ_ONLY_SCOPE: &str = "https://www.googleapis.com/auth/devstorage.read_only";
const METADATA_PREFIX: &str = "x-goog-meta-";
/// GCS reports its error code in the response body, not a header; the shared
/// transport takes a header name, so it is given one that never matches and
/// classification falls back to the status.
const NO_ERROR_CODE_HEADER: &str = "x-goog-unused-error-code";
/// One `objects.list` page is small; refuse an unbounded document.
const MAX_JSON_BYTES: usize = 8 * 1024 * 1024;
@@ -121,7 +125,7 @@ impl GcsNativeSourceBackend {
}
async fn send_object(&self, request: reqwest::Request) -> Result<reqwest::Response, SourceError> {
match self.http.send_object(request, None).await {
match self.http.send_object(request, NO_ERROR_CODE_HEADER).await {
Err(SourceError::NotFound) => {
// An XML object URL also returns 404 when its bucket is gone.
// Reuse the read-only listing probe before caching a key miss.
@@ -221,7 +225,7 @@ impl SourceBackend for GcsNativeSourceBackend {
}
let request = self.request(Method::GET, url, HeaderMap::new()).await?;
let response = self.http.send(request, None).await?;
let response = self.http.send(request, NO_ERROR_CODE_HEADER).await?;
let body = read_text(response, MAX_JSON_BYTES).await?;
parse_objects_list(&body)
}
@@ -241,7 +245,7 @@ impl SourceBackend for GcsNativeSourceBackend {
let mut url = self.objects_url()?;
url.query_pairs_mut().append_pair("maxResults", "1");
let request = self.request(Method::GET, url, HeaderMap::new()).await?;
let response = self.http.send(request, None).await?;
let response = self.http.send(request, NO_ERROR_CODE_HEADER).await?;
read_text(response, MAX_JSON_BYTES)
.await
.and_then(|body| parse_objects_list(&body))?;
@@ -280,38 +284,28 @@ struct ListedObject {
fn parse_objects_list(body: &str) -> Result<SourcePage, SourceError> {
let listing: ObjectsList =
serde_json::from_str(body).map_err(|err| SourceError::Other(format!("source listing is not valid JSON: {err}")))?;
if listing.prefixes.iter().any(|prefix| prefix.is_empty()) {
return Err(SourceError::Other("source listing prefix has no name".to_string()));
}
let next_continuation_token = listing.next_page_token.filter(|token| !token.is_empty());
let objects = listing
.items
.into_iter()
.map(|item| {
if item.name.is_empty() {
return Err(SourceError::Other("source listing object has no name".to_string()));
}
let size = item
.size
.and_then(|size| size.parse::<u64>().ok())
.ok_or_else(|| SourceError::Other("source listing object has no valid size".to_string()))?;
let etag = item
.md5_hash
.as_deref()
.and_then(base64_md5_to_hex)
.or_else(|| item.etag.map(|etag| etag.trim_matches('"').to_string()))
.filter(|etag| !etag.is_empty());
Ok(SourceObject {
SourceObject {
key: item.name,
etag,
size,
size: item.size.and_then(|size| size.parse().ok()).unwrap_or(0),
last_modified: item.updated.as_deref().and_then(parse_http_timestamp),
storage_class: item.storage_class,
// GCS never encodes a part count in a digest or an ETag.
is_multipart_etag: false,
})
}
})
.collect::<Result<_, SourceError>>()?;
.collect();
Ok(SourcePage {
objects,
@@ -325,7 +319,7 @@ fn parse_objects_list(body: &str) -> Result<SourcePage, SourceError> {
mod tests {
use super::*;
use crate::on_demand_migration::backend_contract::{BackendCapabilities, assert_backend_contract};
use crate::on_demand_migration::test_http_fixture::{ScriptedResponse, assert_requests, scripted_server};
use crate::on_demand_migration::test_http_fixture::{ScriptedResponse, scripted_server};
use google_cloud_auth::credentials::anonymous::Builder as AnonymousBuilder;
const LIST_PAGE_ONE: &str = r#"{
@@ -567,195 +561,4 @@ mod tests {
}
}
}
#[tokio::test]
async fn native_listing_rejects_missing_or_invalid_required_object_fields() {
for entry in [
r#"{"size":"1"}"#,
r#"{"name":"","size":"1"}"#,
r#"{"name":"broken"}"#,
r#"{"name":"broken","size":null}"#,
r#"{"name":"broken","size":""}"#,
r#"{"name":"broken","size":"-1"}"#,
r#"{"name":"broken","size":"18446744073709551616"}"#,
r#"{"name":"broken","size":"not-a-size"}"#,
r#"{"name":"broken","size":1}"#,
] {
let body = format!(r#"{{"items":[{{"name":"valid","size":"1"}},{entry}],"nextPageToken":"next"}}"#);
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body)]).await;
let err = backend(&endpoint)
.list(&SourceListRequest {
prefix: Some("dir/"),
delimiter: Some("/"),
continuation_token: Some("opaque+/="),
max_keys: 2,
..Default::default()
})
.await
.expect_err("malformed object must reject the complete native page");
assert!(matches!(err, SourceError::Other(_)), "{entry}: {err:?}");
assert!(!err.is_retryable());
assert_requests(
&recorded,
&[(
"GET",
"/storage/v1/b/legacy/o?prefix=dir%2F&delimiter=%2F&pageToken=opaque%2B%2F%3D&maxResults=2",
)],
);
}
}
#[tokio::test]
async fn native_listing_rejects_empty_prefix_entries() {
for body in [
r#"{"items":[{"name":"valid","size":"1"}],"prefixes":[""],"nextPageToken":"next"}"#,
r#"{"prefixes":[""],"nextPageToken":"next"}"#,
r#"{"prefixes":["目录/子/",""],"nextPageToken":"next"}"#,
] {
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
let result = backend(&endpoint)
.list(&SourceListRequest {
delimiter: Some("/"),
continuation_token: Some("opaque+/="),
max_keys: 2,
..Default::default()
})
.await;
let err = result.expect_err("an empty prefix must reject the entire page and its cursor");
assert!(matches!(err, SourceError::Other(_)), "{body}: {err:?}");
assert!(!err.is_retryable());
assert_requests(
&recorded,
&[("GET", "/storage/v1/b/legacy/o?delimiter=%2F&pageToken=opaque%2B%2F%3D&maxResults=2")],
);
}
let body = r#"{"prefixes":["目录/子/"],"nextPageToken":"opaque+/="}"#;
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
let page = backend(&endpoint)
.list(&SourceListRequest {
delimiter: Some("/"),
max_keys: 1,
..Default::default()
})
.await
.expect("a valid prefix-only page must remain usable");
assert!(page.objects.is_empty());
assert_eq!(page.common_prefixes, ["目录/子/"]);
assert!(page.is_truncated);
assert_eq!(page.next_continuation_token.as_deref(), Some("opaque+/="));
assert_requests(&recorded, &[("GET", "/storage/v1/b/legacy/o?delimiter=%2F&maxResults=1")]);
}
#[tokio::test]
async fn native_listing_preserves_zero_size_unicode_prefixes_and_opaque_cursors() {
let body = r#"{"items":[{"name":"目录/空 & file","size":"0"}],"prefixes":["目录/子/"],"nextPageToken":"opaque+/="}"#;
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
let page = backend(&endpoint)
.list(&SourceListRequest {
max_keys: 2,
..Default::default()
})
.await
.expect("valid native page");
assert_eq!(page.objects.len(), 1);
assert_eq!(page.objects[0].key, "目录/空 & file");
assert_eq!(page.objects[0].size, 0);
assert_eq!(page.common_prefixes, ["目录/子/"]);
assert!(page.is_truncated);
assert_eq!(page.next_continuation_token.as_deref(), Some("opaque+/="));
assert_requests(&recorded, &[("GET", "/storage/v1/b/legacy/o?maxResults=2")]);
}
#[tokio::test]
async fn missing_object_head_requires_one_successful_bucket_probe() {
for (status, body, expected, retryable) in [
(200, "{}", "not_found", false),
(403, "", "access_denied", false),
(404, "", "other", false),
(429, "", "throttled", true),
(500, "", "server_error", true),
(503, "", "throttled", true),
(200, "not JSON", "other", false),
] {
let (endpoint, recorded) = scripted_server(vec![
ScriptedResponse::new(404, Vec::new(), String::new()),
ScriptedResponse::new(status, Vec::new(), body.to_string()),
])
.await;
let err = backend(&endpoint).head("missing").await.expect_err("missing HEAD must fail");
assert_eq!(err.class_label(), expected, "probe {status} {body:?}: {err:?}");
assert_eq!(err.is_retryable(), retryable, "probe {status} {body:?}: {err:?}");
if status == 500 {
assert!(matches!(err, SourceError::ServerError(500)));
}
assert_requests(&recorded, &[("HEAD", "/legacy/missing"), ("GET", "/storage/v1/b/legacy/o?maxResults=1")]);
}
}
#[tokio::test]
async fn denied_object_reads_do_not_probe_or_become_object_absence() {
for method in [Method::HEAD, Method::GET] {
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(
403,
vec![("x-goog-unused-error-code", "NoSuchKey".to_string())],
"untrusted-error-body".to_string(),
)])
.await;
let backend = backend(&endpoint);
let result = if method == Method::HEAD {
backend.head("missing").await.map(|_| ())
} else {
backend.get("missing", None).await.map(|_| ())
};
let err = result.expect_err("denied object read must remain a failure");
assert_eq!(err.class_label(), "access_denied");
assert!(!err.is_retryable());
assert!(!err.to_string().contains("untrusted-error-body"));
assert_requests(&recorded, &[(method.as_str(), "/legacy/missing")]);
}
}
#[tokio::test]
async fn non_object_errors_ignore_untrusted_error_code_headers() {
for probe in [false, true] {
for (status, expected, retryable) in [(403, "access_denied", false), (500, "server_error", true)] {
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(
status,
vec![("x-goog-unused-error-code", "NoSuchKey".to_string())],
"untrusted-error-body".to_string(),
)])
.await;
let backend = backend(&endpoint);
let result = if probe {
backend.probe().await
} else {
backend
.list(&SourceListRequest {
max_keys: 2,
..Default::default()
})
.await
.map(|_| ())
};
let err = result.expect_err("a synthetic provider header cannot change the source status");
assert_eq!(err.class_label(), expected, "probe={probe} status={status}: {err:?}");
assert_eq!(err.is_retryable(), retryable);
assert!(!err.to_string().contains("untrusted-error-body"));
if status == 500 {
assert!(matches!(err, SourceError::ServerError(500)));
}
assert_requests(
&recorded,
&[(
"GET",
if probe {
"/storage/v1/b/legacy/o?maxResults=1"
} else {
"/storage/v1/b/legacy/o?maxResults=2"
},
)],
);
}
}
}
}
+33 -134
View File
@@ -94,16 +94,13 @@ pub struct MergePick {
}
/// The continuation-token envelope. Opaque to clients: it is serialized as
/// JSON, optionally framed, then base64-encoded like a local marker.
/// framed JSON and then base64-encoded by the same helper as a local marker.
///
/// A `null` cursor with `done = false` means "list that side from the start";
/// `done = true` means the side is finished and must not be listed again.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListThroughToken {
/// Transport framing observed by the decoder, never an envelope field.
#[serde(skip)]
pub framed: bool,
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
pub t: String,
pub v: u32,
@@ -130,7 +127,6 @@ pub struct ListThroughToken {
impl ListThroughToken {
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
Self {
framed: false,
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: local.token,
@@ -145,12 +141,7 @@ impl ListThroughToken {
pub fn encode(&self) -> String {
// The envelope is built here from owned strings, so serialization
// cannot fail; the fallback keeps the signature infallible.
let json = serde_json::to_string(self).unwrap_or_default();
if self.framed {
format!("{LIST_THROUGH_TOKEN_PREFIX}{json}")
} else {
json
}
format!("{LIST_THROUGH_TOKEN_PREFIX}{}", serde_json::to_string(self).unwrap_or_default())
}
}
@@ -174,30 +165,16 @@ pub enum ListThroughTokenError {
/// Classifies an already base64-decoded continuation token.
///
/// Framed envelopes and complete historical writer envelopes are merged tokens.
/// Partial JSON-shaped keys remain local markers. A key identical to a complete
/// historical envelope is inherently ambiguous and retains merged semantics.
/// Recognized envelopes share the same version, count and field validation.
/// Only a framed JSON object is read as a merged token;
/// anything else is a local marker, so a bucket that turns `list_through` off
/// keeps paginating with the tokens it handed out. A token that *is* an
/// envelope but was tampered with (unknown version, unknown field, truncated
/// JSON) is an error, never a silent fallback.
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
let (payload, framed) = match decoded.strip_prefix(LIST_THROUGH_TOKEN_PREFIX) {
Some(payload) => (payload, true),
None if decoded.starts_with('{') => (decoded, false),
None => return Ok(ListThroughCursor::Local(decoded.to_string())),
};
let value = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value,
Err(_) if framed => return Err(ListThroughTokenError::Malformed),
Err(_) => return Ok(ListThroughCursor::Local(decoded.to_string())),
};
// RUSTFS_COMPAT_TODO(odm-list-bare-envelope): old writers issued bare JSON. Remove after all supported readers understand framing and outstanding bare listings have drained or explicitly restarted.
if !framed
&& (value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG)
|| ["v", "local", "local_done", "source", "source_done", "last_key"]
.iter()
.any(|field| value.get(field).is_none()))
{
let Some(payload) = decoded.strip_prefix(LIST_THROUGH_TOKEN_PREFIX) else {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
};
let value = serde_json::from_str::<serde_json::Value>(payload).map_err(|_| ListThroughTokenError::Malformed)?;
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
return Err(ListThroughTokenError::Malformed);
}
@@ -221,10 +198,7 @@ pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, Lis
None => return Err(ListThroughTokenError::Malformed),
}
serde_json::from_value::<ListThroughToken>(value)
.map(|mut token| {
token.framed = framed;
ListThroughCursor::Merged(Box::new(token))
})
.map(|token| ListThroughCursor::Merged(Box::new(token)))
.map_err(|_| ListThroughTokenError::Malformed)
}
@@ -823,7 +797,6 @@ mod tests {
#[test]
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
let resume = ListThroughToken {
framed: false,
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: Some("local-1".to_string()),
@@ -1060,7 +1033,7 @@ mod tests {
#[test]
fn token_round_trips_and_rejects_tampering() {
let mut token = ListThroughToken::new(
let token = ListThroughToken::new(
SideCursor {
token: Some("l".to_string()),
done: false,
@@ -1068,7 +1041,6 @@ mod tests {
SideCursor { token: None, done: true },
Some("k".to_string()),
);
token.framed = true;
let encoded = token.encode();
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
@@ -1117,108 +1089,35 @@ mod tests {
#[test]
fn progress_tokens_preserve_v1_bytes_and_validate_v2_counts() {
fn framed(payload: &str) -> String {
format!("{LIST_THROUGH_TOKEN_PREFIX}{payload}")
}
let token = progress_token(None, true, false);
assert_eq!(
token.encode(),
r#"{"t":"odm-list","v":1,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key"}"#
concat!(
"\0odm-list:",
r#"{"t":"odm-list","v":1,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key"}"#
)
);
for framed in [false, true] {
let prefix = if framed { LIST_THROUGH_TOKEN_PREFIX } else { "" };
for count in 1..MAX_LIST_NO_PROGRESS_PAGES {
let mut token = progress_token(Some(count), true, false);
token.framed = framed;
assert_eq!(decode_continuation_token(&token.encode()), Ok(ListThroughCursor::Merged(Box::new(token))));
}
// Bare recognition requires the complete shape emitted by old writers;
// partial JSON objects are also valid local keys.
for version in [1, 2] {
for value in ["null", "0", "16", "-1", "1.5", "256", "18446744073709551616", "\"1\""] {
let encoded = format!(
r#"{prefix}{{"t":"odm-list","v":{version},"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":{value}}}"#
);
assert_eq!(decode_continuation_token(&encoded), Err(ListThroughTokenError::Malformed), "{encoded}");
}
}
for encoded in [
r#"{"t":"odm-list","v":1,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":1}"#,
r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key"}"#,
r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":1,"extra":true}"#,
r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":1,"framed":true}"#,
] {
let encoded = format!("{prefix}{encoded}");
for count in 1..MAX_LIST_NO_PROGRESS_PAGES {
let token = progress_token(Some(count), true, false);
assert_eq!(decode_continuation_token(&token.encode()), Ok(ListThroughCursor::Merged(Box::new(token))));
}
for version in [1, 2] {
for value in ["null", "0", "16", "-1", "1.5", "256", "18446744073709551616", "\"1\""] {
let encoded = framed(&format!(r#"{{"t":"odm-list","v":{version},"no_progress":{value}}}"#));
assert_eq!(decode_continuation_token(&encoded), Err(ListThroughTokenError::Malformed), "{encoded}");
}
let bumped = format!("{prefix}{}", token.encode().replace("\"v\":1", "\"v\":9"));
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(9)));
}
}
// Frozen decoder from 447f3c704, before framing was introduced. Keeping this
// independent of the current decoder catches a default-writer rollout break.
fn decode_before_framing(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
if !decoded.starts_with('{') {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
// Not JSON at all: an object key may legitimately start with '{'.
return Ok(ListThroughCursor::Local(decoded.to_string()));
};
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
match value.get("v").and_then(serde_json::Value::as_u64) {
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {
// v1 readers reject this field even when it is null or zero.
if value.get("no_progress").is_some() {
return Err(ListThroughTokenError::Malformed);
}
}
Some(version) if version == u64::from(LIST_THROUGH_PROGRESS_TOKEN_VERSION) => {
if !value
.get("no_progress")
.and_then(serde_json::Value::as_u64)
.is_some_and(|count| (1..u64::from(MAX_LIST_NO_PROGRESS_PAGES)).contains(&count))
{
return Err(ListThroughTokenError::Malformed);
}
}
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
None => return Err(ListThroughTokenError::Malformed),
}
serde_json::from_value::<ListThroughToken>(value)
.map(|token| ListThroughCursor::Merged(Box::new(token)))
.map_err(|_| ListThroughTokenError::Malformed)
}
#[test]
fn historical_writer_fixtures_and_default_output_remain_readable() {
for (wire, version, count) in [
(
r#"{"t":"odm-list","v":1,"local":"local-2","local_done":false,"source":"source-2","source_done":false,"last_key":"k"}"#,
1,
None,
),
(
r#"{"t":"odm-list","v":2,"local":"local-2","local_done":false,"source":"source-2","source_done":false,"last_key":"k","no_progress":15}"#,
2,
Some(15),
),
for payload in [
r#"{"t":"odm-list","v":1,"no_progress":1}"#,
r#"{"t":"odm-list","v":2}"#,
r#"{"t":"odm-list","v":2,"no_progress":1,"extra":true}"#,
] {
let ListThroughCursor::Merged(mut token) = decode_continuation_token(wire).expect("historical issued token") else {
panic!("a historical cursor must not silently become a local marker, even if a key has identical JSON");
};
assert_eq!(token.local.as_deref(), Some("local-2"));
assert_eq!(token.source.as_deref(), Some("source-2"));
assert_eq!(token.last_key.as_deref(), Some("k"));
assert_eq!(token.v, version);
assert_eq!(token.no_progress, count);
assert!(!token.framed);
assert_eq!(token.encode(), wire, "bare output retains the historical bytes");
assert_eq!(decode_before_framing(&token.encode()), Ok(ListThroughCursor::Merged(token.clone())));
token.framed = true;
let framed = format!("\0odm-list:{wire}");
assert_eq!(token.encode(), framed, "framing leaves the JSON payload unchanged");
assert_eq!(decode_continuation_token(&framed), Ok(ListThroughCursor::Merged(token)));
let encoded = framed(payload);
assert_eq!(decode_continuation_token(&encoded), Err(ListThroughTokenError::Malformed), "{encoded}");
}
}
+11 -30
View File
@@ -99,7 +99,6 @@ impl NativeHttp {
pub(super) fn for_test(endpoint: Url) -> Self {
Self {
client: reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("test http client should build"),
@@ -123,22 +122,21 @@ impl NativeHttp {
}
/// Sends the request and returns the response only for a 2xx status.
/// Non-2xx statuses are classified from the status and an optional provider
/// Non-2xx statuses are classified from the status and the provider's own
/// error-code header; response bodies are not read, so no provider message
/// can smuggle credentials or markup into a log line.
pub(super) async fn send(
&self,
request: reqwest::Request,
error_code_header: Option<&str>,
error_code_header: &str,
) -> Result<reqwest::Response, SourceError> {
self.send_classified(request, error_code_header, false).await
}
#[cfg(feature = "gcs")]
pub(super) async fn send_object(
&self,
request: reqwest::Request,
error_code_header: Option<&str>,
error_code_header: &str,
) -> Result<reqwest::Response, SourceError> {
self.send_classified(request, error_code_header, true).await
}
@@ -146,42 +144,26 @@ impl NativeHttp {
async fn send_classified(
&self,
request: reqwest::Request,
error_code_header: Option<&str>,
error_code_header: &str,
not_found_on_404_without_code: bool,
) -> Result<reqwest::Response, SourceError> {
let response = self.execute(request).await?;
let status = response.status();
match Self::check_response(response, error_code_header) {
Err(SourceError::Other(_)) if not_found_on_404_without_code && status.as_u16() == 404 => Err(SourceError::NotFound),
result => result,
}
}
pub(super) async fn execute(&self, request: reqwest::Request) -> Result<reqwest::Response, SourceError> {
self.client.execute(request).await.map_err(classify_transport_error)
}
pub(super) fn check_response(
response: reqwest::Response,
error_code_header: Option<&str>,
) -> Result<reqwest::Response, SourceError> {
let response = self.client.execute(request).await.map_err(classify_transport_error)?;
let status = response.status();
if status.is_success() {
return Ok(response);
}
let code = error_code_header
.and_then(|header| response.headers().get(header))
let code = response
.headers()
.get(error_code_header)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let message = match &code {
Some(code) => format!("source returned HTTP {status} ({code})"),
None => format!("source returned HTTP {status}"),
};
match classify_status(status.as_u16(), code.as_deref(), message.clone()) {
// Native object absence needs provider-specific evidence or a
// successful bucket probe, never an alias from the S3 classifier.
SourceError::NotFound => Err(classify_status(status.as_u16(), None, message)),
error => Err(error),
match classify_status(status.as_u16(), code.as_deref(), message) {
SourceError::Other(_) if not_found_on_404_without_code && status.as_u16() == 404 => Err(SourceError::NotFound),
err => Err(err),
}
}
}
@@ -228,7 +210,6 @@ pub(super) async fn read_text(response: reqwest::Response, max_bytes: usize) ->
/// Base64 digest (`Content-MD5`, `md5Hash`, `x-goog-hash`) as lowercase hex.
/// `None` when the value is not a 16-byte digest, so a CRC32C never passes as
/// an MD5.
#[cfg(any(test, feature = "gcs"))]
pub(super) fn base64_md5_to_hex(value: &str) -> Option<String> {
let raw = base64_simd::STANDARD.decode_to_vec(value.trim().as_bytes()).ok()?;
(raw.len() == 16).then(|| faster_hex::hex_string(&raw))
@@ -337,7 +337,7 @@ const THROTTLE_CODES: &[&str] = &[
"RequestThrottled",
"ServerBusy",
];
const NOT_FOUND_CODES: &[&str] = &["NoSuchKey"];
const NOT_FOUND_CODES: &[&str] = &["NoSuchKey", "BlobNotFound"];
const ACCESS_DENIED_CODES: &[&str] = &[
"AccessDenied",
"InvalidAccessKeyId",
@@ -1813,11 +1813,10 @@ mod tests {
/// The S3 backend behind the scripted connector, without the prefix-mapping
/// client on top: the contract is a property of the backend itself.
async fn scripted_s3_backend(responses: Vec<Scripted>) -> (S3SourceBackend, Recorded) {
async fn scripted_s3_backend(responses: Vec<Scripted>) -> S3SourceBackend {
let spec = spec(None);
let requests: Recorded = Arc::new(Mutex::new(Vec::new()));
let connector = SharedHttpConnector::new(ScriptedConnector {
requests: Arc::clone(&requests),
requests: Arc::new(Mutex::new(Vec::new())),
responses: Arc::new(Mutex::new(responses.into_iter().collect())),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
@@ -1827,20 +1826,17 @@ mod tests {
.expect("test spec should build")
.http_client(http_client)
.interceptor(SourceProxyMarkerInterceptor::new());
(
S3SourceBackend {
client: S3Client::from_conf(config.build()),
bucket: spec.bucket.clone(),
},
requests,
)
S3SourceBackend {
client: S3Client::from_conf(config.build()),
bucket: spec.bucket.clone(),
}
}
#[tokio::test]
async fn s3_backend_satisfies_the_shared_backend_contract() {
let mut ranged = contract_object_headers(3);
ranged.push(("content-range", "bytes 1-3/5".to_string()));
let (backend, requests) = scripted_s3_backend(vec![
let backend = scripted_s3_backend(vec![
ok(contract_object_headers(5), ""),
ok(contract_object_headers(5), "hello"),
ok(ranged, "ell"),
@@ -1849,7 +1845,6 @@ mod tests {
ok(Vec::new(), CONTRACT_TAGGING),
ok(Vec::new(), ""),
status(404, ""),
// An object HEAD 404 requires the existing S3 bucket HEAD probe.
ok(Vec::new(), ""),
status(403, ACCESS_DENIED_BODY),
])
@@ -1864,32 +1859,6 @@ mod tests {
},
)
.await;
let requests = recorded(&requests);
let actual: Vec<_> = requests
.iter()
.map(|request| {
(
request.method.as_str(),
url::Url::parse(&request.uri).expect("recorded S3 URL").path().to_string(),
)
})
.collect();
let expected = [
("HEAD", "/source-bucket/dir/a.txt"),
("GET", "/source-bucket/dir/a.txt"),
("GET", "/source-bucket/dir/a.txt"),
("GET", "/source-bucket/"),
("GET", "/source-bucket/"),
("GET", "/source-bucket/dir/a.txt"),
("HEAD", "/source-bucket/"),
("HEAD", "/source-bucket/missing"),
("HEAD", "/source-bucket/"),
("HEAD", "/source-bucket/secret"),
];
assert_eq!(actual, expected.map(|(method, path)| (method, path.to_string())));
for request in &requests {
assert_outbound_markers(request);
}
}
fn prefix_client(prefix: Option<String>) -> SourceClient {
@@ -56,16 +56,6 @@ impl RecordedRequest {
pub(super) type Recorder = Arc<Mutex<Vec<RecordedRequest>>>;
/// Checks the full request sequence, including the absence of extra probes.
pub(super) fn assert_requests(recorder: &Recorder, expected: &[(&str, &str)]) {
let recorded = recorder.lock().expect("recorder lock");
let actual: Vec<_> = recorded
.iter()
.map(|request| (request.method.as_str(), request.target.as_str()))
.collect();
assert_eq!(actual, expected, "unexpected native source request sequence");
}
/// Binds a loopback listener that answers `responses` in order and returns its
/// origin plus the recorder. The task ends once the script is exhausted.
pub(super) async fn scripted_server(responses: Vec<ScriptedResponse>) -> (Url, Recorder) {
+1 -1
View File
@@ -4034,7 +4034,7 @@ mod tests {
let enabled_before = sys.is_module_enabled();
sys.set_module_enabled(true);
let bucket = format!("odm-capture-failure-{}", uuid::Uuid::new_v4());
let mut config: crate::on_demand_migration::OnDemandMigrationConfig = serde_json::from_str(r#"{"source":{"provider":"minio","endpoint":"https://source.example.com","region":"us-east-1","bucket":"source","credentials":{"access_key":"test","secret_key":"test"}}}"#).expect("source config");
let mut config: crate::on_demand_migration::OnDemandMigrationConfig = serde_json::from_str(r#"{"source":{"provider":"minio","endpoint":"https://source.example.com","bucket":"source","credentials":{"access_key":"test","secret_key":"test"}}}"#).expect("source config");
config.policy.list_through = true;
sys.apply_for_incarnation(&bucket, uuid::Uuid::new_v4(), Some(&config)).await;
let mut get = build_request(
+2
View File
@@ -46,6 +46,8 @@ their issue closes.
| Entry | Status | Purpose | Wiring / docs |
|---|---|---|---|
| `diagnose_scanner_enumeration_restart.py` | dev-tool | Strict fixed raw-entry-budget scanner-worker restart diagnostic | [Checkpoint fixture](../docs/testing/scanner-checkpoint-fixture.md) |
| `test_diagnose_scanner_enumeration_restart.py` | dev-tool | Driver report validation and positive convergence oracle tests | Python unittest; same guide |
| `e2e-run.sh` | ci-gate | Boots a rustfs server and runs the `s3s-e2e` black-box conformance tool against it | ci.yml `e2e-tests` jobs; `docs/testing/README.md` |
| `run_ecstore_validation_suite.sh` | dev-tool | ecstore black-box validation suite (`quick`/`full`/`destructive`/`fuzz` profiles) | `docs/testing/README.md`, `docs/testing/ecstore-validation-suite-design.md` |
| `run_e2e_tests.sh` | dev-tool | Local `e2e_test` crate runner (starts a server, applies filters, cleans up) | `crates/e2e_test/README.md` |
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Strict restart diagnostic using the real scanner libtest worker, not a walker model."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
WORKER = "scanner_folder::tests::enumeration_restart::enumeration_restart_worker"
MAX_REPORT_BYTES = 16384
def bounded_int(low, high):
def parse(value):
number = int(value)
if not low <= number <= high:
raise argparse.ArgumentTypeError(f"must be between {low} and {high}")
return number
return parse
def validate_report(report, *, round_number, pid, objects, budget):
if not isinstance(report, dict):
raise ValueError("worker report must be an object")
expected = {"schema": 1, "round": round_number, "pid": pid,
"objects_expected": objects, "raw_entry_budget": budget}
for key, value in expected.items():
if type(report.get(key)) is not int or report[key] != value:
raise ValueError(f"worker report mismatch: {key}")
for key in ("raw_entries", "raw_name_bytes", "objects_before", "objects_retained",
"versions_retained", "bytes_retained", "objects_processed"):
if type(report.get(key)) is not int or not 0 <= report[key] <= 1048576:
raise ValueError(f"invalid bounded counter: {key}")
if report["raw_entries"] == 0:
raise ValueError("nonempty fixture must observe raw entries; budget hook may not have run")
if report["raw_entries"] > budget:
raise ValueError("raw-entry budget exceeded; no unbudgeted tail is permitted")
if type(report.get("snapshot_complete")) is not bool:
raise ValueError("missing explicit completeness")
if report.get("outcome") not in ("complete", "partial", "cancelled_without_cache"):
raise ValueError("unexpected scanner outcome")
def converged(report, objects):
return (report["snapshot_complete"] and report["outcome"] == "complete"
and all(report[key] == objects for key in
("objects_retained", "versions_retained", "bytes_retained")))
def run(args):
binary = args.test_binary.resolve(strict=True)
listed = subprocess.run([str(binary), WORKER, "--exact", "--list"],
check=True, capture_output=True, text=True, timeout=30)
if f"{WORKER}: test" not in listed.stdout.splitlines():
raise ValueError("binary does not contain the exact scanner worker test")
workspace = args.output.resolve()
workspace.mkdir() # Refuse reuse/overwrite of previous evidence or customer data.
reports = []
for round_number in range(args.rounds):
request = {"workspace": str(workspace), "objects": args.objects,
"raw_entry_budget": args.raw_entry_budget, "round": round_number}
request_path = workspace / "request.json"
request_path.write_text(json.dumps(request), encoding="utf-8")
env = dict(os.environ, RUSTFS_ENUMERATION_REQUEST=str(request_path),
RUST_MIN_STACK="4194304", NO_PROXY="localhost,127.0.0.1,::1",
no_proxy="localhost,127.0.0.1,::1")
with subprocess.Popen([str(binary), WORKER, "--exact", "--test-threads=1"],
env=env, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) as worker:
try:
status = worker.wait(timeout=args.timeout)
except subprocess.TimeoutExpired:
worker.kill()
worker.wait()
raise ValueError(f"worker round {round_number} timed out") from None
if status:
raise ValueError(f"real scanner worker round {round_number} exited {status}")
report_path = workspace / f"round-{round_number}.json"
with report_path.open("rb") as handle:
raw = handle.read(MAX_REPORT_BYTES + 1)
if len(raw) > MAX_REPORT_BYTES:
raise ValueError("oversized worker report")
report = json.loads(raw)
validate_report(report, round_number=round_number, pid=worker.pid,
objects=args.objects, budget=args.raw_entry_budget)
if reports and report["objects_before"] != reports[-1]["objects_retained"]:
raise ValueError("cache coverage did not survive the process boundary")
reports.append(report)
print(json.dumps(report, sort_keys=True), flush=True)
if converged(report, args.objects):
print("PASS: bounded scanner-worker restart convergence for this fixture only")
return 0
print("FAIL: fixed-budget restart convergence not established; R-E gate remains unmet",
file=sys.stderr)
return 1
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--test-binary", type=Path, required=True,
help="compiled rustfs-scanner libtest executable")
parser.add_argument("--output", type=Path, required=True, help="new evidence directory (must not exist)")
parser.add_argument("--objects", type=bounded_int(1, 1024), default=128)
parser.add_argument("--raw-entry-budget", type=bounded_int(1, 4096), default=8)
parser.add_argument("--rounds", type=bounded_int(1, 64), default=8)
parser.add_argument("--timeout", type=bounded_int(1, 120), default=60,
help="per-worker watchdog seconds, not the scan work budget")
args = parser.parse_args()
try:
return run(args)
except (OSError, ValueError, subprocess.SubprocessError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,73 @@
"""Driver contract tests; these do not replace the real scanner diagnostic."""
import unittest
from diagnose_scanner_enumeration_restart import converged, validate_report
class ReportTests(unittest.TestCase):
def report(self):
return dict(schema=1, round=0, pid=123, objects_expected=4, raw_entry_budget=16,
raw_entries=8, raw_name_bytes=64, objects_before=0, objects_retained=4,
versions_retained=4, bytes_retained=4, objects_processed=4,
snapshot_complete=True, outcome="complete")
def validate(self, report):
validate_report(report, round_number=0, pid=123, objects=4, budget=16)
def test_complete_exact_coverage_satisfies_oracle(self):
report = self.report()
self.validate(report)
self.assertTrue(converged(report, 4))
def test_incomplete_or_inexact_coverage_cannot_pass(self):
for key, value in (("snapshot_complete", False), ("objects_retained", 3),
("versions_retained", 3), ("bytes_retained", 3), ("outcome", "partial")):
with self.subTest(key=key):
report = self.report()
report[key] = value
self.assertFalse(converged(report, 4))
def test_wrong_process_or_round_rejected(self):
for key in ("pid", "round", "schema", "raw_entry_budget", "objects_expected"):
with self.subTest(key=key):
report = self.report()
report[key] += 1
with self.assertRaises(ValueError):
self.validate(report)
def test_unbudgeted_tail_rejected(self):
report = self.report()
report["raw_entries"] = 17
with self.assertRaises(ValueError):
self.validate(report)
def test_complete_coverage_without_entry_observation_rejected(self):
report = self.report()
report["raw_entries"] = 0
with self.assertRaises(ValueError):
self.validate(report)
def test_missing_wrong_type_and_negative_counter_rejected(self):
for value in (None, True, -1, "8", 1048577):
with self.subTest(value=value):
report = self.report()
report["raw_entries"] = value
with self.assertRaises(ValueError):
self.validate(report)
def test_missing_completeness_or_unknown_outcome_rejected(self):
for key in ("snapshot_complete", "outcome"):
report = self.report()
del report[key]
with self.assertRaises(ValueError):
self.validate(report)
def test_non_object_report_rejected(self):
for report in (None, [], "report"):
with self.assertRaises(ValueError):
self.validate(report)
if __name__ == "__main__":
unittest.main()