mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-08 14:23:13 +00:00
fix(s3): ignore empty conditional ETag headers (#2592)
Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
@@ -16,6 +16,7 @@ use crate::config::{RustFSBufferConfig, WorkloadProfile, get_global_buffer_confi
|
||||
use crate::error::ApiError;
|
||||
use crate::server::cors;
|
||||
use crate::storage::ecfs::ListObjectUnorderedQuery;
|
||||
use http::header::{IF_MATCH, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_UNMODIFIED_SINCE};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use metrics::counter;
|
||||
use rustfs_ecstore::bucket::metadata_sys;
|
||||
@@ -384,13 +385,17 @@ pub(crate) async fn validate_bucket_object_lock_enabled(bucket: &str) -> S3Resul
|
||||
pub(crate) fn check_preconditions(headers: &HeaderMap, info: &ObjectInfo) -> S3Result<()> {
|
||||
let mod_time = info.mod_time;
|
||||
let etag = info.etag.as_deref();
|
||||
let if_match = non_empty_header_value(headers, IF_MATCH);
|
||||
let if_none_match = non_empty_header_value(headers, IF_NONE_MATCH);
|
||||
let if_modified_since = non_empty_header_value(headers, IF_MODIFIED_SINCE);
|
||||
let if_unmodified_since = non_empty_header_value(headers, IF_UNMODIFIED_SINCE);
|
||||
|
||||
if mod_time.is_none() && etag.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If-Match: requires ETag to exist
|
||||
if let Some(if_match_val) = headers.get("if-match").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(if_match_val) = if_match {
|
||||
match etag {
|
||||
Some(e) if is_etag_equal(e, if_match_val) => {}
|
||||
_ => return Err(S3Error::new(S3ErrorCode::PreconditionFailed)),
|
||||
@@ -398,9 +403,9 @@ pub(crate) fn check_preconditions(headers: &HeaderMap, info: &ObjectInfo) -> S3R
|
||||
}
|
||||
|
||||
// If-Unmodified-Since (only when If-Match is absent)
|
||||
if headers.get("if-match").is_none()
|
||||
if if_match.is_none()
|
||||
&& let Some(t) = mod_time
|
||||
&& let Some(if_unmodified_since) = headers.get("if-unmodified-since").and_then(|v| v.to_str().ok())
|
||||
&& let Some(if_unmodified_since) = if_unmodified_since
|
||||
&& let Ok(given_time) = time::PrimitiveDateTime::parse(if_unmodified_since, &RFC1123).map(|dt| dt.assume_utc())
|
||||
&& t > given_time.add(time::Duration::seconds(1))
|
||||
{
|
||||
@@ -408,7 +413,7 @@ pub(crate) fn check_preconditions(headers: &HeaderMap, info: &ObjectInfo) -> S3R
|
||||
}
|
||||
|
||||
// If-None-Match
|
||||
if let Some(if_none_match) = headers.get("if-none-match").and_then(|v| v.to_str().ok())
|
||||
if let Some(if_none_match) = if_none_match
|
||||
&& let Some(e) = etag
|
||||
&& is_etag_equal(e, if_none_match)
|
||||
{
|
||||
@@ -431,9 +436,9 @@ pub(crate) fn check_preconditions(headers: &HeaderMap, info: &ObjectInfo) -> S3R
|
||||
}
|
||||
|
||||
// If-Modified-Since (only when If-None-Match is absent — semantics per RFC 7232; dates use RFC 1123 format)
|
||||
if headers.get("if-none-match").is_none()
|
||||
if if_none_match.is_none()
|
||||
&& let Some(t) = mod_time
|
||||
&& let Some(if_modified_since) = headers.get("if-modified-since").and_then(|v| v.to_str().ok())
|
||||
&& let Some(if_modified_since) = if_modified_since
|
||||
&& let Ok(given_time) = time::PrimitiveDateTime::parse(if_modified_since, &RFC1123).map(|dt| dt.assume_utc())
|
||||
&& t < given_time.add(time::Duration::seconds(1))
|
||||
{
|
||||
@@ -460,6 +465,14 @@ pub(crate) fn check_preconditions(headers: &HeaderMap, info: &ObjectInfo) -> S3R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn non_empty_header_value(headers: &HeaderMap, name: http::header::HeaderName) -> Option<&str> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Compares an object ETag with an ETag value from an HTTP header.
|
||||
///
|
||||
/// This helper implements HTTP ETag comparison semantics for headers such as
|
||||
|
||||
@@ -1055,6 +1055,17 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
assert!(check_preconditions(&headers17, &info17).is_ok());
|
||||
|
||||
// [18] Empty conditional ETag headers are ignored
|
||||
let mut headers18 = HeaderMap::new();
|
||||
headers18.insert("if-match", HeaderValue::from_static(""));
|
||||
headers18.insert("if-none-match", HeaderValue::from_static(" "));
|
||||
let info18 = ObjectInfo {
|
||||
mod_time: Some(valid_mod_time),
|
||||
etag: Some(valid_etag.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(check_preconditions(&headers18, &info18).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use http::header::{IF_MATCH, IF_NONE_MATCH};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use rustfs_ecstore::bucket::versioning_sys::BucketVersioningSys;
|
||||
use rustfs_ecstore::error::Result;
|
||||
@@ -170,31 +171,41 @@ pub async fn get_opts(
|
||||
}
|
||||
|
||||
fn fill_conditional_writes_opts_from_header(headers: &HeaderMap<HeaderValue>, opts: &mut ObjectOptions) -> std::io::Result<()> {
|
||||
if headers.contains_key("If-None-Match") || headers.contains_key("If-Match") {
|
||||
let mut preconditions = HTTPPreconditions::default();
|
||||
if let Some(if_none_match) = headers.get("If-None-Match") {
|
||||
preconditions.if_none_match = Some(
|
||||
if_none_match
|
||||
.to_str()
|
||||
.map_err(|_| std::io::Error::other("Invalid If-None-Match header"))?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(if_match) = headers.get("If-Match") {
|
||||
preconditions.if_match = Some(
|
||||
if_match
|
||||
.to_str()
|
||||
.map_err(|_| std::io::Error::other("Invalid If-Match header"))?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let if_none_match = conditional_etag_header(headers, IF_NONE_MATCH, "If-None-Match")?;
|
||||
let if_match = conditional_etag_header(headers, IF_MATCH, "If-Match")?;
|
||||
|
||||
opts.http_preconditions = Some(preconditions);
|
||||
if if_none_match.is_some() || if_match.is_some() {
|
||||
opts.http_preconditions = Some(HTTPPreconditions {
|
||||
if_match,
|
||||
if_none_match,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn conditional_etag_header(
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
name: http::header::HeaderName,
|
||||
display_name: &str,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
let Some(value) = headers.get(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| std::io::Error::other(format!("Invalid {display_name} header")))?
|
||||
.trim();
|
||||
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates options for putting an object in a bucket.
|
||||
pub async fn put_opts(
|
||||
bucket: &str,
|
||||
@@ -917,6 +928,32 @@ mod tests {
|
||||
assert_eq!(opts.version_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_opts_ignores_empty_conditional_headers() {
|
||||
let mut headers = create_test_headers();
|
||||
headers.insert(http::header::IF_MATCH, HeaderValue::from_static(""));
|
||||
headers.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static(" "));
|
||||
|
||||
let result = get_opts("test-bucket", "test-object", None, None, &headers).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().http_preconditions.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_opts_keeps_non_empty_conditional_headers() {
|
||||
let mut headers = create_test_headers();
|
||||
headers.insert(http::header::IF_MATCH, HeaderValue::from_static(" \"etag-a\" "));
|
||||
headers.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("\"etag-b\""));
|
||||
|
||||
let result = get_opts("test-bucket", "test-object", None, None, &headers).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let preconditions = result.unwrap().http_preconditions.expect("conditional headers");
|
||||
assert_eq!(preconditions.if_match.as_deref(), Some("\"etag-a\""));
|
||||
assert_eq!(preconditions.if_none_match.as_deref(), Some("\"etag-b\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_opts_with_part_number() {
|
||||
let headers = create_test_headers();
|
||||
|
||||
Reference in New Issue
Block a user