fix(notify): close core notify correctness and safety gaps (#4502)

Land the remaining notify-crate audit fixes.

backlog#979(b): remove_target now enforces the same bucket-binding guard as
remove_target_config, refusing to delete a target still referenced by a bucket
rule so notification rules are not left orphaned.

backlog#984:
- event.rs: an unversioned object omits versionId entirely instead of
  serializing versionId:"" (empty object/request versions treated as "no
  version").
- notifier.rs: RUSTFS_NOTIFY_SEND_CONCURRENCY=0 coerces back to the default
  instead of building a zero-permit semaphore that deadlocks every dispatch;
  init_bucket_targets_shared closes the replaced targets instead of dropping
  them without close() (connection leak).
- subscriber_index.rs: store_snapshot uses an atomic compute_if_absent upsert,
  removing the get-then-insert TOCTOU that could clobber a concurrent
  first-writer's snapshot cell.
- pipeline.rs: send_event assigns the history sequence and broadcasts to live
  subscribers under one critical section so broadcast order matches recorded
  sequence order.
- xml_config.rs: filter value length is bounded by character count, not byte
  length, so valid multi-byte keys are no longer wrongly rejected.
- global.rs: a losing initialize() race shuts the just-initialized system down
  instead of leaking its targets/replay workers.

backlog#970 (notify part): reload_config stops the running replay workers
before activating the new ones, so old and new workers do not concurrently
drain the same persisted stores. The full signal+join shutdown lives in the
targets crate under the same issue.

Tests: added regression coverage for each fix.
cargo build -p rustfs-notify, cargo test -p rustfs-notify --lib (98 passed),
cargo clippy -p rustfs-notify --all-targets (clean).

Relates to rustfs/backlog#979
Relates to rustfs/backlog#984
Relates to rustfs/backlog#970

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-09 00:20:02 +08:00
committed by GitHub
parent cd327c81f5
commit c9dba2c6c2
7 changed files with 418 additions and 24 deletions
+75 -1
View File
@@ -249,7 +249,19 @@ impl Event {
let key_name = form_urlencoded::byte_serialize(args.object.name.as_bytes()).collect::<String>();
let principal_id = args.req_params.get("principalId").unwrap_or(&String::new()).to_string();
let version_id = args.object.version_id.clone().or_else(|| Some(args.version_id.clone()));
// An unversioned object must omit `versionId` entirely rather than emit it as
// an empty string. Prefer the object's own version id, fall back to the
// request-scoped one, and treat an empty value from either source as "no
// version" so serialization skips the field (`skip_serializing_if` on
// `Object::version_id`). This matches S3 and the repo's tier convention that a
// `None`/`""` version means "unversioned" (backlog#984).
let version_id = args
.object
.version_id
.clone()
.filter(|v| !v.is_empty())
.or_else(|| Some(args.version_id.clone()))
.filter(|v| !v.is_empty());
let mut s3_metadata = Metadata {
schema_version: "1.0".to_string(),
@@ -619,6 +631,68 @@ mod tests {
assert_eq!(user_metadata.len(), 2);
}
#[test]
fn unversioned_object_omits_version_id() {
// Neither the object nor the request carries a version id: the field must be
// `None` so it is omitted from the serialized event, not `Some("")` (backlog#984).
let args = EventArgsBuilder::new(
EventName::ObjectCreatedPut,
"bucket",
NotifyObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
version_id: None,
..Default::default()
},
)
.version_id(String::new())
.build();
let event = Event::new(args);
assert_eq!(event.s3.object.version_id, None);
let json = serde_json::to_value(&event).expect("event should serialize");
assert!(
json.pointer("/s3/object/versionId").is_none(),
"unversioned object must not serialize a versionId field"
);
}
#[test]
fn empty_object_version_falls_back_then_omits() {
// Empty object version must not shadow a real request-scoped version.
let args = EventArgsBuilder::new(
EventName::ObjectCreatedPut,
"bucket",
NotifyObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
version_id: Some(String::new()),
..Default::default()
},
)
.version_id("v-42".to_string())
.build();
let event = Event::new(args);
assert_eq!(event.s3.object.version_id.as_deref(), Some("v-42"));
}
#[test]
fn present_object_version_is_preserved() {
let args = EventArgsBuilder::new(
EventName::ObjectCreatedPut,
"bucket",
NotifyObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
version_id: Some("v-1".to_string()),
..Default::default()
},
)
.build();
let event = Event::new(args);
assert_eq!(event.s3.object.version_id.as_deref(), Some("v-1"));
}
#[test]
fn event_time_serializes_with_millisecond_precision() {
let mut event = Event::new_test_event("bucket", "key", EventName::ObjectCreatedPut);