mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-28 09:08:58 +00:00
c9dba2c6c2
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>
739 lines
28 KiB
Rust
739 lines
28 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use chrono::{DateTime, SecondsFormat, Utc};
|
|
use hashbrown::HashMap;
|
|
use rustfs_s3_ops::is_object_removed_event;
|
|
use rustfs_s3_types::{EventName, event_schema_version};
|
|
use rustfs_utils::http::{is_encryption_metadata_key, is_internal_key};
|
|
use serde::{Deserialize, Serialize};
|
|
use url::form_urlencoded;
|
|
|
|
/// Legacy internal-metadata prefix retained for backward compatibility.
|
|
const LEGACY_AMZ_META_INTERNAL_PREFIX: &str = "x-amz-meta-internal-";
|
|
|
|
/// Returns `true` if `key` is RustFS/MinIO internal metadata that must not be exposed in
|
|
/// notification `userMetadata`. Covers the internal xl.meta prefixes
|
|
/// (`x-rustfs-internal-*` / `x-minio-internal-*`), the server-side-encryption prefixes
|
|
/// (`x-rustfs-encryption-*` / `x-minio-encryption-*`), and the legacy
|
|
/// `x-amz-meta-internal-*` prefix. Case-insensitive.
|
|
fn is_internal_metadata_key(key: &str) -> bool {
|
|
is_internal_key(key)
|
|
|| is_encryption_metadata_key(key)
|
|
|| key.len() >= LEGACY_AMZ_META_INTERNAL_PREFIX.len()
|
|
&& key.as_bytes()[..LEGACY_AMZ_META_INTERNAL_PREFIX.len()]
|
|
.eq_ignore_ascii_case(LEGACY_AMZ_META_INTERNAL_PREFIX.as_bytes())
|
|
}
|
|
|
|
/// Represents the identity of the user who triggered the event
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Identity {
|
|
/// The principal ID of the user
|
|
pub principal_id: String,
|
|
}
|
|
|
|
/// Represents the bucket that the object is in
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Bucket {
|
|
/// The name of the bucket
|
|
pub name: String,
|
|
/// The owner identity of the bucket
|
|
pub owner_identity: Identity,
|
|
/// The Amazon Resource Name (ARN) of the bucket
|
|
pub arn: String,
|
|
}
|
|
|
|
/// Represents the object that the event occurred on
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Object {
|
|
/// The key (name) of the object
|
|
pub key: String,
|
|
/// The size of the object in bytes
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub size: Option<i64>,
|
|
/// The entity tag (ETag) of the object
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub e_tag: Option<String>,
|
|
/// The content type of the object
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub content_type: Option<String>,
|
|
/// User-defined metadata associated with the object
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub user_metadata: Option<HashMap<String, String>>,
|
|
/// The version ID of the object (if versioning is enabled)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub version_id: Option<String>,
|
|
/// A unique identifier for the event
|
|
pub sequencer: String,
|
|
}
|
|
|
|
/// Object metadata required by notification event serialization.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NotifyObjectInfo {
|
|
pub bucket: String,
|
|
pub name: String,
|
|
pub size: i64,
|
|
pub etag: Option<String>,
|
|
pub content_type: Option<String>,
|
|
pub user_defined: HashMap<String, String>,
|
|
pub version_id: Option<String>,
|
|
pub mod_time: Option<DateTime<Utc>>,
|
|
pub restore_expires: Option<DateTime<Utc>>,
|
|
pub storage_class: Option<String>,
|
|
pub transitioned_tier: Option<String>,
|
|
}
|
|
|
|
/// Metadata about the event
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Metadata {
|
|
/// The schema version of the event
|
|
#[serde(rename = "s3SchemaVersion")]
|
|
pub schema_version: String,
|
|
/// The ID of the configuration that triggered the event
|
|
pub configuration_id: String,
|
|
/// Information about the bucket
|
|
pub bucket: Bucket,
|
|
/// Information about the object
|
|
pub object: Object,
|
|
}
|
|
|
|
/// Information about the source of the event
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Source {
|
|
/// The host where the event originated
|
|
pub host: String,
|
|
/// The port on the host
|
|
pub port: String,
|
|
/// The user agent that caused the event
|
|
pub user_agent: String,
|
|
}
|
|
|
|
/// Additional data included for restore-completed events.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GlacierEventData {
|
|
pub restore_event_data: RestoreEventData,
|
|
}
|
|
|
|
/// Restore-specific event attributes for `s3:ObjectRestore:Completed`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RestoreEventData {
|
|
pub lifecycle_restoration_expiry_time: String,
|
|
pub lifecycle_restore_storage_class: String,
|
|
}
|
|
|
|
/// Represents a storage event
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Event {
|
|
/// The version of the event
|
|
pub event_version: String,
|
|
/// The source of the event
|
|
pub event_source: String,
|
|
/// The AWS region where the event occurred
|
|
pub aws_region: String,
|
|
/// The time when the event occurred
|
|
#[serde(serialize_with = "serialize_event_time_millis")]
|
|
pub event_time: DateTime<Utc>,
|
|
/// The name of the event
|
|
pub event_name: EventName,
|
|
/// The identity of the user who triggered the event
|
|
pub user_identity: Identity,
|
|
/// Parameters from the request that caused the event
|
|
pub request_parameters: HashMap<String, String>,
|
|
/// Elements from the response
|
|
pub response_elements: HashMap<String, String>,
|
|
/// Metadata about the event
|
|
pub s3: Metadata,
|
|
/// Additional restore event data when present.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub glacier_event_data: Option<GlacierEventData>,
|
|
/// Information about the source of the event
|
|
pub source: Source,
|
|
}
|
|
|
|
impl Event {
|
|
/// Creates a test event for a given bucket and object
|
|
pub fn new_test_event(bucket: &str, key: &str, event_name: EventName) -> Self {
|
|
let mut user_metadata = HashMap::new();
|
|
user_metadata.insert("x-amz-meta-test".to_string(), "value".to_string());
|
|
user_metadata.insert("x-amz-storage-storage-options".to_string(), "value".to_string());
|
|
user_metadata.insert("x-amz-meta-".to_string(), "value".to_string());
|
|
user_metadata.insert("x-rustfs-meta-".to_string(), "rustfs-value".to_string());
|
|
user_metadata.insert("x-request-id".to_string(), "request-id-123".to_string());
|
|
user_metadata.insert("x-bucket".to_string(), "bucket".to_string());
|
|
user_metadata.insert("x-object".to_string(), "object".to_string());
|
|
user_metadata.insert("x-rustfs-origin-endpoint".to_string(), "http://127.0.0.1".to_string());
|
|
user_metadata.insert("x-rustfs-user-metadata".to_string(), "metadata".to_string());
|
|
user_metadata.insert("x-rustfs-deployment-id".to_string(), "deployment-id-123".to_string());
|
|
user_metadata.insert("x-rustfs-origin-endpoint-code".to_string(), "http://127.0.0.1".to_string());
|
|
user_metadata.insert("x-rustfs-bucket-name".to_string(), "bucket".to_string());
|
|
user_metadata.insert("x-rustfs-object-key".to_string(), key.to_string());
|
|
user_metadata.insert("x-rustfs-object-size".to_string(), "1024".to_string());
|
|
user_metadata.insert("x-rustfs-object-etag".to_string(), "etag123".to_string());
|
|
user_metadata.insert("x-rustfs-object-version-id".to_string(), "1".to_string());
|
|
user_metadata.insert("x-request-time".to_string(), Utc::now().to_rfc3339());
|
|
|
|
Event {
|
|
event_version: event_schema_version(event_name).to_string(),
|
|
event_source: "rustfs:s3".to_string(),
|
|
aws_region: "us-east-1".to_string(),
|
|
event_time: Utc::now(),
|
|
event_name,
|
|
user_identity: Identity {
|
|
principal_id: "rustfs".to_string(),
|
|
},
|
|
request_parameters: HashMap::new(),
|
|
response_elements: HashMap::new(),
|
|
s3: Metadata {
|
|
schema_version: "1.0".to_string(),
|
|
configuration_id: "test-config".to_string(),
|
|
bucket: Bucket {
|
|
name: bucket.to_string(),
|
|
owner_identity: Identity {
|
|
principal_id: "rustfs".to_string(),
|
|
},
|
|
arn: format!("arn:rustfs:s3:::{bucket}"),
|
|
},
|
|
object: Object {
|
|
key: key.to_string(),
|
|
size: Some(1024),
|
|
e_tag: Some("etag123".to_string()),
|
|
content_type: Some("application/octet-stream".to_string()),
|
|
user_metadata: Some(user_metadata),
|
|
version_id: Some("1".to_string()),
|
|
sequencer: "0055AED6DCD90281E5".to_string(),
|
|
},
|
|
},
|
|
glacier_event_data: None,
|
|
source: Source {
|
|
host: "127.0.0.1".to_string(),
|
|
port: "9000".to_string(),
|
|
user_agent: "RustFS (linux; amd64) rustfs-rs/0.1".to_string(),
|
|
},
|
|
}
|
|
}
|
|
/// Return event mask
|
|
pub fn mask(&self) -> u64 {
|
|
self.event_name.mask()
|
|
}
|
|
|
|
pub fn new(args: EventArgs) -> Self {
|
|
let event_time = Utc::now().naive_local();
|
|
let sequencer = match args.object.mod_time {
|
|
Some(t) => format!("{:X}", t.timestamp_nanos_opt().unwrap_or(0)),
|
|
None => format!("{:X}", event_time.and_utc().timestamp_nanos_opt().unwrap_or(0)),
|
|
};
|
|
|
|
let mut resp_elements = args.resp_elements.clone();
|
|
initialize_response_elements(&mut resp_elements, &["x-amz-request-id", "x-amz-id-2"]);
|
|
|
|
// URL encoding of object keys
|
|
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();
|
|
|
|
// 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(),
|
|
configuration_id: "Config".to_string(), // or from args
|
|
bucket: Bucket {
|
|
name: args.bucket_name.clone(),
|
|
owner_identity: Identity {
|
|
principal_id: principal_id.clone(),
|
|
},
|
|
arn: format!("arn:aws:s3:::{}", args.bucket_name),
|
|
},
|
|
object: Object {
|
|
key: key_name,
|
|
version_id,
|
|
sequencer,
|
|
..Default::default()
|
|
},
|
|
};
|
|
|
|
let is_removed_event = is_object_removed_event(args.event_name);
|
|
|
|
if !is_removed_event {
|
|
s3_metadata.object.size = Some(args.object.size);
|
|
s3_metadata.object.e_tag = args.object.etag.clone();
|
|
s3_metadata.object.content_type = args.object.content_type.clone();
|
|
// Filter out internal/reserved metadata so it never leaks to downstream
|
|
// notification targets (webhook/MQ). RustFS stores internal metadata under both
|
|
// `x-rustfs-internal-*` and `x-minio-internal-*` (see rustfs_utils metadata_compat),
|
|
// and server-side-encryption details under `x-rustfs-encryption-*` /
|
|
// `x-minio-encryption-*` (header_compat). All of these must be stripped; only genuine
|
|
// user-defined metadata (e.g. `x-amz-meta-*`) is preserved.
|
|
let mut user_metadata = HashMap::new();
|
|
for (k, v) in args.object.user_defined.iter() {
|
|
if is_internal_metadata_key(k) {
|
|
continue;
|
|
}
|
|
user_metadata.insert(k.clone(), v.clone());
|
|
}
|
|
s3_metadata.object.user_metadata = Some(user_metadata);
|
|
}
|
|
|
|
let glacier_event_data = if args.event_name == EventName::ObjectRestoreCompleted {
|
|
args.object.restore_expires.and_then(|expiry_time| {
|
|
let storage_class = args
|
|
.object
|
|
.storage_class
|
|
.clone()
|
|
.or_else(|| args.object.transitioned_tier.clone())?;
|
|
Some(GlacierEventData {
|
|
restore_event_data: RestoreEventData {
|
|
lifecycle_restoration_expiry_time: expiry_time.to_rfc3339_opts(SecondsFormat::Millis, true),
|
|
lifecycle_restore_storage_class: storage_class,
|
|
},
|
|
})
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Self {
|
|
event_version: event_schema_version(args.event_name).to_string(),
|
|
event_source: "rustfs:s3".to_string(),
|
|
aws_region: args.req_params.get("region").cloned().unwrap_or_default(),
|
|
event_time: event_time.and_utc(),
|
|
event_name: args.event_name,
|
|
user_identity: Identity { principal_id },
|
|
request_parameters: args.req_params,
|
|
response_elements: resp_elements,
|
|
s3: s3_metadata,
|
|
glacier_event_data,
|
|
source: Source {
|
|
host: args.host,
|
|
port: if args.port == 0 {
|
|
"".to_string()
|
|
} else {
|
|
args.port.to_string()
|
|
},
|
|
user_agent: args.user_agent,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn serialize_event_time_millis<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_str(&value.to_rfc3339_opts(SecondsFormat::Millis, true))
|
|
}
|
|
|
|
fn initialize_response_elements(elements: &mut HashMap<String, String>, keys: &[&str]) {
|
|
for key in keys {
|
|
elements.entry(key.to_string()).or_default();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct EventArgs {
|
|
pub event_name: EventName,
|
|
pub bucket_name: String,
|
|
pub object: NotifyObjectInfo,
|
|
pub req_params: HashMap<String, String>,
|
|
pub resp_elements: HashMap<String, String>,
|
|
pub version_id: String,
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub user_agent: String,
|
|
}
|
|
|
|
impl EventArgs {
|
|
/// True when the RustFS replication header is explicitly enabled (`true` or `1`).
|
|
///
|
|
/// Only `x-rustfs-source-replication-request` is considered here. Many clients (including the
|
|
/// console) send `x-minio-source-replication-request` for MinIO compatibility; treating that
|
|
/// as replication would suppress webhooks on normal browser deletes. Storage still honors both
|
|
/// prefixes when parsing the typed HTTP headers for `ObjectOptions`.
|
|
pub fn is_replication_request(&self) -> bool {
|
|
self.replication_header_value_true("x-rustfs-source-replication-request")
|
|
}
|
|
|
|
fn replication_header_value_true(&self, key: &str) -> bool {
|
|
self.req_params
|
|
.get(key)
|
|
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
/// Builder for [`EventArgs`].
|
|
///
|
|
/// This builder provides a fluent API to construct an `EventArgs` instance,
|
|
/// ensuring that all required fields are provided.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// let args = EventArgsBuilder::new(
|
|
/// EventName::ObjectCreatedPut,
|
|
/// "my-bucket",
|
|
/// object_info,
|
|
/// )
|
|
/// .host("localhost:9000")
|
|
/// .user_agent("my-app/1.0")
|
|
/// .build();
|
|
/// ```
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct EventArgsBuilder {
|
|
event_name: EventName,
|
|
bucket_name: String,
|
|
object: NotifyObjectInfo,
|
|
req_params: HashMap<String, String>,
|
|
resp_elements: HashMap<String, String>,
|
|
version_id: String,
|
|
host: String,
|
|
port: u16,
|
|
user_agent: String,
|
|
}
|
|
|
|
impl EventArgsBuilder {
|
|
/// Creates a new builder with the required fields.
|
|
pub fn new(event_name: EventName, bucket_name: impl Into<String>, object: impl Into<NotifyObjectInfo>) -> Self {
|
|
Self {
|
|
event_name,
|
|
bucket_name: bucket_name.into(),
|
|
object: object.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Sets the event name.
|
|
pub fn event_name(mut self, event_name: EventName) -> Self {
|
|
self.event_name = event_name;
|
|
self
|
|
}
|
|
|
|
/// Sets the bucket name.
|
|
pub fn bucket_name(mut self, bucket_name: impl Into<String>) -> Self {
|
|
self.bucket_name = bucket_name.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the object information.
|
|
pub fn object(mut self, object: impl Into<NotifyObjectInfo>) -> Self {
|
|
self.object = object.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the request parameters.
|
|
pub fn req_params(mut self, req_params: HashMap<String, String>) -> Self {
|
|
self.req_params = req_params;
|
|
self
|
|
}
|
|
|
|
/// Adds a single request parameter.
|
|
pub fn req_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
|
self.req_params.insert(key.into(), value.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the response elements.
|
|
pub fn resp_elements(mut self, resp_elements: HashMap<String, String>) -> Self {
|
|
self.resp_elements = resp_elements;
|
|
self
|
|
}
|
|
|
|
/// Adds a single response element.
|
|
pub fn resp_element(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
|
self.resp_elements.insert(key.into(), value.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the version ID.
|
|
pub fn version_id(mut self, version_id: impl Into<String>) -> Self {
|
|
self.version_id = version_id.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the host.
|
|
pub fn host(mut self, host: impl Into<String>) -> Self {
|
|
self.host = host.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the port.
|
|
pub fn port(mut self, port: u16) -> Self {
|
|
self.port = port;
|
|
self
|
|
}
|
|
|
|
/// Sets the user agent.
|
|
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
|
|
self.user_agent = user_agent.into();
|
|
self
|
|
}
|
|
|
|
/// Builds the final `EventArgs` instance.
|
|
///
|
|
/// This method consumes the builder and returns the constructed `EventArgs`.
|
|
pub fn build(self) -> EventArgs {
|
|
EventArgs {
|
|
event_name: self.event_name,
|
|
bucket_name: self.bucket_name,
|
|
object: self.object,
|
|
req_params: self.req_params,
|
|
resp_elements: self.resp_elements,
|
|
version_id: self.version_id,
|
|
host: self.host,
|
|
port: self.port,
|
|
user_agent: self.user_agent,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn new_test_event_uses_aws_compatible_event_versions() {
|
|
let acl_event = Event::new_test_event("bucket", "key", EventName::ObjectAclPut);
|
|
assert_eq!(acl_event.event_version, "2.3");
|
|
|
|
let tagging_event = Event::new_test_event("bucket", "key", EventName::ObjectTaggingPut);
|
|
assert_eq!(tagging_event.event_version, "2.3");
|
|
|
|
let lifecycle_event = Event::new_test_event("bucket", "key", EventName::LifecycleExpirationDelete);
|
|
assert_eq!(lifecycle_event.event_version, "2.3");
|
|
|
|
let put_event = Event::new_test_event("bucket", "key", EventName::ObjectCreatedPut);
|
|
assert_eq!(put_event.event_version, "2.1");
|
|
}
|
|
|
|
#[test]
|
|
fn event_new_uses_aws_compatible_event_versions() {
|
|
let args = EventArgsBuilder::new(
|
|
EventName::LifecycleTransition,
|
|
"bucket",
|
|
NotifyObjectInfo {
|
|
bucket: "bucket".to_string(),
|
|
name: "key".to_string(),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.build();
|
|
let event = Event::new(args);
|
|
assert_eq!(event.event_version, "2.3");
|
|
}
|
|
|
|
#[test]
|
|
fn object_restore_completed_includes_glacier_event_data() {
|
|
let args = EventArgsBuilder::new(
|
|
EventName::ObjectRestoreCompleted,
|
|
"bucket",
|
|
NotifyObjectInfo {
|
|
bucket: "bucket".to_string(),
|
|
name: "key".to_string(),
|
|
restore_expires: DateTime::<Utc>::from_timestamp(1_700_000_000, 0),
|
|
storage_class: Some("GLACIER".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.build();
|
|
let event = Event::new(args);
|
|
|
|
assert_eq!(event.event_version, "2.3");
|
|
let glacier = event.glacier_event_data.expect("glacier event data should be present");
|
|
assert_eq!(glacier.restore_event_data.lifecycle_restoration_expiry_time, "2023-11-14T22:13:20.000Z");
|
|
assert_eq!(glacier.restore_event_data.lifecycle_restore_storage_class, "GLACIER");
|
|
}
|
|
|
|
#[test]
|
|
fn event_user_metadata_strips_internal_and_encryption_keys() {
|
|
let mut user_defined = HashMap::new();
|
|
// RustFS internal (xl.meta) keys
|
|
user_defined.insert("x-rustfs-internal-inline-data".to_string(), "true".to_string());
|
|
user_defined.insert("x-rustfs-internal-transition-tier".to_string(), "WARM".to_string());
|
|
// MinIO internal keys (interop) + SSE internal metadata
|
|
user_defined.insert("x-minio-internal-compression".to_string(), "s2".to_string());
|
|
user_defined.insert("x-minio-internal-server-side-encryption-iv".to_string(), "secret-iv".to_string());
|
|
// Encryption prefixes (both flavors), including mixed-case
|
|
user_defined.insert("x-rustfs-encryption-key".to_string(), "wrapped-key".to_string());
|
|
user_defined.insert("X-Minio-Encryption-Iv".to_string(), "secret".to_string());
|
|
// Legacy internal prefix
|
|
user_defined.insert("x-amz-meta-internal-foo".to_string(), "bar".to_string());
|
|
// Genuine user metadata that MUST be preserved
|
|
user_defined.insert("x-amz-meta-project".to_string(), "rustfs".to_string());
|
|
user_defined.insert("content-type".to_string(), "text/plain".to_string());
|
|
|
|
let args = EventArgsBuilder::new(
|
|
EventName::ObjectCreatedPut,
|
|
"bucket",
|
|
NotifyObjectInfo {
|
|
bucket: "bucket".to_string(),
|
|
name: "key".to_string(),
|
|
user_defined,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.build();
|
|
let event = Event::new(args);
|
|
|
|
let user_metadata = event
|
|
.s3
|
|
.object
|
|
.user_metadata
|
|
.expect("user_metadata should be present for a create event");
|
|
|
|
// All internal / encryption keys stripped.
|
|
for internal in [
|
|
"x-rustfs-internal-inline-data",
|
|
"x-rustfs-internal-transition-tier",
|
|
"x-minio-internal-compression",
|
|
"x-minio-internal-server-side-encryption-iv",
|
|
"x-rustfs-encryption-key",
|
|
"X-Minio-Encryption-Iv",
|
|
"x-amz-meta-internal-foo",
|
|
] {
|
|
assert!(
|
|
!user_metadata.contains_key(internal),
|
|
"internal key {internal:?} leaked into notification userMetadata"
|
|
);
|
|
}
|
|
|
|
// Genuine user metadata preserved unchanged.
|
|
assert_eq!(user_metadata.get("x-amz-meta-project").map(String::as_str), Some("rustfs"));
|
|
assert_eq!(user_metadata.get("content-type").map(String::as_str), Some("text/plain"));
|
|
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);
|
|
event.event_time = DateTime::<Utc>::from_timestamp(1_711_423_698, 870_816_000).expect("timestamp should be valid");
|
|
|
|
let json = serde_json::to_value(&event).expect("event should serialize");
|
|
assert_eq!(json.get("eventTime").and_then(|value| value.as_str()), Some("2024-03-26T03:28:18.870Z"));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod event_args_tests {
|
|
use super::{EventArgs, NotifyObjectInfo as ObjectInfo};
|
|
use hashbrown::HashMap;
|
|
use rustfs_s3_types::EventName;
|
|
|
|
fn args_with_headers(pairs: &[(&str, &str)]) -> EventArgs {
|
|
let mut req_params = HashMap::new();
|
|
for (k, v) in pairs {
|
|
req_params.insert((*k).to_string(), (*v).to_string());
|
|
}
|
|
EventArgs {
|
|
event_name: EventName::ObjectRemovedDelete,
|
|
bucket_name: "b".to_string(),
|
|
object: ObjectInfo::default(),
|
|
req_params,
|
|
resp_elements: HashMap::new(),
|
|
version_id: String::new(),
|
|
host: String::new(),
|
|
port: 0,
|
|
user_agent: String::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn replication_request_requires_true_value() {
|
|
assert!(!args_with_headers(&[("x-rustfs-source-replication-request", "")]).is_replication_request());
|
|
assert!(!args_with_headers(&[("x-rustfs-source-replication-request", "false")]).is_replication_request());
|
|
assert!(args_with_headers(&[("x-rustfs-source-replication-request", "true")]).is_replication_request());
|
|
assert!(args_with_headers(&[("x-rustfs-source-replication-request", "True")]).is_replication_request());
|
|
assert!(!args_with_headers(&[("x-minio-source-replication-request", "true")]).is_replication_request());
|
|
}
|
|
}
|