From c2b22c1daf4f0b297d72880dfb93eda712af5e6e Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 2 Nov 2026 23:34:50 +0800 Subject: [PATCH] feat(phase-2): align watch event order with SigV4 signing Signed-off-by: overtrue --- crates/s3/src/watch.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/s3/src/watch.rs b/crates/s3/src/watch.rs index 2de054c2..3de1709a 100644 --- a/crates/s3/src/watch.rs +++ b/crates/s3/src/watch.rs @@ -136,7 +136,11 @@ impl S3Client { { let mut query = url.query_pairs_mut(); - for event in &request.events { + // RustFS preserves repeated query values during verification, so their + // wire order must match SigV4's encoded value order. + let mut events = request.events.iter().collect::>(); + events.sort_unstable_by_key(|event| urlencoding::encode(event).into_owned()); + for event in events { query.append_pair("events", event); } if let Some(prefix) = &request.prefix { @@ -613,6 +617,36 @@ mod tests { ); } + #[test] + fn watch_url_sorts_repeated_events_to_match_sigv4_canonical_order() { + let client = test_client("https://example.com"); + let mut watch = request(Some("photos")); + watch.events = vec![ + "s3:ObjectCreated:*".to_string(), + "s3:ObjectRemoved:*".to_string(), + "s3:ObjectAccessed:*".to_string(), + ]; + + for bucket in [None, Some("photos".to_string())] { + watch.bucket = bucket; + let url = client.watch_url(&watch).expect("watch URL should build"); + let events = url + .query_pairs() + .filter(|(name, _)| name == "events") + .map(|(_, value)| value.into_owned()) + .collect::>(); + + assert_eq!( + events, + [ + "s3:ObjectAccessed:*", + "s3:ObjectCreated:*", + "s3:ObjectRemoved:*", + ] + ); + } + } + #[test] fn bucket_watch_url_signs_encoded_prefix_suffix_and_ping_filters() { let client = test_client("https://example.com");