Fix S3 conformance: XML config round-trip, Suspended versioning, ListVersions pagination, per-bucket CORS, canned ACL/SSE rejection, checksum attrs, request logging
This commit is contained in:
@@ -232,7 +232,7 @@ impl ServerConfig {
|
||||
let stream_chunk_size = parse_usize_env("STREAM_CHUNK_SIZE", 1_048_576);
|
||||
let request_body_timeout_secs = parse_u64_env("REQUEST_BODY_TIMEOUT_SECONDS", 60);
|
||||
let ratelimit_default =
|
||||
parse_rate_limit_env("RATE_LIMIT_DEFAULT", RateLimitSetting::new(500, 60));
|
||||
parse_rate_limit_env("RATE_LIMIT_DEFAULT", RateLimitSetting::new(5000, 60));
|
||||
let ratelimit_list_buckets =
|
||||
parse_rate_limit_env("RATE_LIMIT_LIST_BUCKETS", ratelimit_default);
|
||||
let ratelimit_bucket_ops =
|
||||
@@ -407,11 +407,11 @@ impl Default for ServerConfig {
|
||||
bulk_delete_max_keys: 1000,
|
||||
stream_chunk_size: 1_048_576,
|
||||
request_body_timeout_secs: 60,
|
||||
ratelimit_default: RateLimitSetting::new(500, 60),
|
||||
ratelimit_list_buckets: RateLimitSetting::new(500, 60),
|
||||
ratelimit_bucket_ops: RateLimitSetting::new(500, 60),
|
||||
ratelimit_object_ops: RateLimitSetting::new(500, 60),
|
||||
ratelimit_head_ops: RateLimitSetting::new(500, 60),
|
||||
ratelimit_default: RateLimitSetting::new(5000, 60),
|
||||
ratelimit_list_buckets: RateLimitSetting::new(5000, 60),
|
||||
ratelimit_bucket_ops: RateLimitSetting::new(5000, 60),
|
||||
ratelimit_object_ops: RateLimitSetting::new(5000, 60),
|
||||
ratelimit_head_ops: RateLimitSetting::new(5000, 60),
|
||||
ratelimit_admin: RateLimitSetting::new(60, 60),
|
||||
ratelimit_storage_uri: "memory://".to_string(),
|
||||
ui_enabled: true,
|
||||
@@ -589,7 +589,7 @@ mod tests {
|
||||
|
||||
assert_eq!(config.object_key_max_length_bytes, 1024);
|
||||
assert_eq!(config.object_tag_limit, 50);
|
||||
assert_eq!(config.ratelimit_default, RateLimitSetting::new(500, 60));
|
||||
assert_eq!(config.ratelimit_default, RateLimitSetting::new(5000, 60));
|
||||
|
||||
std::env::remove_var("OBJECT_TAG_LIMIT");
|
||||
std::env::remove_var("RATE_LIMIT_DEFAULT");
|
||||
|
||||
@@ -20,6 +20,13 @@ fn xml_response(status: StatusCode, xml: String) -> Response {
|
||||
(status, [("content-type", "application/xml")], xml).into_response()
|
||||
}
|
||||
|
||||
fn stored_xml(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_err(err: myfsio_storage::error::StorageError) -> Response {
|
||||
let s3err = S3Error::from(err);
|
||||
let status =
|
||||
@@ -52,17 +59,31 @@ fn custom_xml_error(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
}
|
||||
|
||||
pub async fn get_versioning(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.is_versioning_enabled(bucket).await {
|
||||
Ok(enabled) => {
|
||||
let status_str = if enabled { "Enabled" } else { "Suspended" };
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
<Status>{}</Status>\
|
||||
</VersioningConfiguration>",
|
||||
status_str
|
||||
);
|
||||
xml_response(StatusCode::OK, xml)
|
||||
match state.storage.get_versioning_status(bucket).await {
|
||||
Ok(status) => {
|
||||
let body = match status {
|
||||
myfsio_common::types::VersioningStatus::Enabled => {
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
<Status>Enabled</Status>\
|
||||
</VersioningConfiguration>"
|
||||
.to_string()
|
||||
}
|
||||
myfsio_common::types::VersioningStatus::Suspended => {
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
<Status>Suspended</Status>\
|
||||
</VersioningConfiguration>"
|
||||
.to_string()
|
||||
}
|
||||
myfsio_common::types::VersioningStatus::Disabled => {
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
</VersioningConfiguration>"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
xml_response(StatusCode::OK, body)
|
||||
}
|
||||
Err(e) => storage_err(e),
|
||||
}
|
||||
@@ -80,9 +101,22 @@ pub async fn put_versioning(state: &AppState, bucket: &str, body: Body) -> Respo
|
||||
};
|
||||
|
||||
let xml_str = String::from_utf8_lossy(&body_bytes);
|
||||
let enabled = xml_str.contains("<Status>Enabled</Status>");
|
||||
let status = if xml_str.contains("<Status>Enabled</Status>") {
|
||||
myfsio_common::types::VersioningStatus::Enabled
|
||||
} else if xml_str.contains("<Status>Suspended</Status>") {
|
||||
myfsio_common::types::VersioningStatus::Suspended
|
||||
} else {
|
||||
return xml_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
S3Error::new(
|
||||
S3ErrorCode::MalformedXML,
|
||||
"VersioningConfiguration Status must be Enabled or Suspended",
|
||||
)
|
||||
.to_xml(),
|
||||
);
|
||||
};
|
||||
|
||||
match state.storage.set_versioning(bucket, enabled).await {
|
||||
match state.storage.set_versioning_status(bucket, status).await {
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => storage_err(e),
|
||||
}
|
||||
@@ -151,7 +185,7 @@ pub async fn get_cors(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(cors) = &config.cors {
|
||||
xml_response(StatusCode::OK, cors.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(cors))
|
||||
} else {
|
||||
xml_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -214,7 +248,7 @@ pub async fn get_encryption(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(enc) = &config.encryption {
|
||||
xml_response(StatusCode::OK, enc.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(enc))
|
||||
} else {
|
||||
xml_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -263,7 +297,7 @@ pub async fn get_lifecycle(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(lc) = &config.lifecycle {
|
||||
xml_response(StatusCode::OK, lc.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(lc))
|
||||
} else {
|
||||
xml_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -490,10 +524,7 @@ pub async fn get_replication(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(replication) = &config.replication {
|
||||
match replication {
|
||||
serde_json::Value::String(s) => xml_response(StatusCode::OK, s.clone()),
|
||||
other => xml_response(StatusCode::OK, other.to_string()),
|
||||
}
|
||||
xml_response(StatusCode::OK, stored_xml(replication))
|
||||
} else {
|
||||
xml_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -586,7 +617,7 @@ pub async fn get_acl(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(acl) = &config.acl {
|
||||
xml_response(StatusCode::OK, acl.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(acl))
|
||||
} else {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
@@ -626,7 +657,7 @@ pub async fn get_website(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(ws) = &config.website {
|
||||
xml_response(StatusCode::OK, ws.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(ws))
|
||||
} else {
|
||||
xml_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -678,7 +709,7 @@ pub async fn get_object_lock(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(ol) = &config.object_lock {
|
||||
xml_response(StatusCode::OK, ol.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(ol))
|
||||
} else {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<ObjectLockConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
@@ -695,7 +726,7 @@ pub async fn get_notification(state: &AppState, bucket: &str) -> Response {
|
||||
match state.storage.get_bucket_config(bucket).await {
|
||||
Ok(config) => {
|
||||
if let Some(n) = &config.notification {
|
||||
xml_response(StatusCode::OK, n.to_string())
|
||||
xml_response(StatusCode::OK, stored_xml(n))
|
||||
} else {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<NotificationConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
||||
@@ -1035,22 +1066,23 @@ pub async fn list_object_versions(
|
||||
state: &AppState,
|
||||
bucket: &str,
|
||||
prefix: Option<&str>,
|
||||
delimiter: Option<&str>,
|
||||
key_marker: Option<&str>,
|
||||
version_id_marker: Option<&str>,
|
||||
max_keys: usize,
|
||||
) -> Response {
|
||||
match state.storage.list_buckets().await {
|
||||
Ok(buckets) => {
|
||||
if !buckets.iter().any(|b| b.name == bucket) {
|
||||
return storage_err(myfsio_storage::error::StorageError::BucketNotFound(
|
||||
bucket.to_string(),
|
||||
));
|
||||
}
|
||||
match state.storage.bucket_exists(bucket).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return storage_err(myfsio_storage::error::StorageError::BucketNotFound(
|
||||
bucket.to_string(),
|
||||
));
|
||||
}
|
||||
Err(e) => return storage_err(e),
|
||||
}
|
||||
|
||||
let fetch_limit = max_keys.saturating_add(1).max(1);
|
||||
let params = myfsio_common::types::ListParams {
|
||||
max_keys: fetch_limit,
|
||||
max_keys: usize::MAX,
|
||||
prefix: prefix.map(ToOwned::to_owned),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1059,7 +1091,8 @@ pub async fn list_object_versions(
|
||||
Ok(result) => result,
|
||||
Err(e) => return storage_err(e),
|
||||
};
|
||||
let objects = object_result.objects;
|
||||
let live_objects = object_result.objects;
|
||||
|
||||
let archived_versions = match state
|
||||
.storage
|
||||
.list_bucket_object_versions(bucket, prefix)
|
||||
@@ -1069,107 +1102,215 @@ pub async fn list_object_versions(
|
||||
Err(e) => return storage_err(e),
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry {
|
||||
key: String,
|
||||
version_id: String,
|
||||
last_modified: chrono::DateTime<chrono::Utc>,
|
||||
etag: Option<String>,
|
||||
size: u64,
|
||||
storage_class: String,
|
||||
is_delete_marker: bool,
|
||||
}
|
||||
|
||||
let mut entries: Vec<Entry> = Vec::with_capacity(live_objects.len() + archived_versions.len());
|
||||
for obj in &live_objects {
|
||||
entries.push(Entry {
|
||||
key: obj.key.clone(),
|
||||
version_id: obj.version_id.clone().unwrap_or_else(|| "null".to_string()),
|
||||
last_modified: obj.last_modified,
|
||||
etag: obj.etag.clone(),
|
||||
size: obj.size,
|
||||
storage_class: obj
|
||||
.storage_class
|
||||
.clone()
|
||||
.unwrap_or_else(|| "STANDARD".to_string()),
|
||||
is_delete_marker: false,
|
||||
});
|
||||
}
|
||||
for version in &archived_versions {
|
||||
entries.push(Entry {
|
||||
key: version.key.clone(),
|
||||
version_id: version.version_id.clone(),
|
||||
last_modified: version.last_modified,
|
||||
etag: version.etag.clone(),
|
||||
size: version.size,
|
||||
storage_class: "STANDARD".to_string(),
|
||||
is_delete_marker: version.is_delete_marker,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
a.key
|
||||
.cmp(&b.key)
|
||||
.then_with(|| b.last_modified.cmp(&a.last_modified))
|
||||
.then_with(|| a.version_id.cmp(&b.version_id))
|
||||
});
|
||||
|
||||
let mut latest_marked: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut is_latest_flags: Vec<bool> = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
if latest_marked.insert(entry.key.clone()) {
|
||||
is_latest_flags.push(true);
|
||||
} else {
|
||||
is_latest_flags.push(false);
|
||||
}
|
||||
}
|
||||
|
||||
let km = key_marker.unwrap_or("");
|
||||
let vim = version_id_marker.unwrap_or("");
|
||||
let start_index = if km.is_empty() {
|
||||
0
|
||||
} else if vim.is_empty() {
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| e.key.as_str() > km)
|
||||
.unwrap_or(entries.len())
|
||||
} else if let Some(pos) = entries
|
||||
.iter()
|
||||
.position(|e| e.key == km && e.version_id == vim)
|
||||
{
|
||||
pos + 1
|
||||
} else {
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| e.key.as_str() > km)
|
||||
.unwrap_or(entries.len())
|
||||
};
|
||||
|
||||
let delim = delimiter.unwrap_or("");
|
||||
let prefix_str = prefix.unwrap_or("");
|
||||
|
||||
let mut common_prefixes: Vec<String> = Vec::new();
|
||||
let mut seen_prefixes: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut rendered = String::new();
|
||||
let mut count = 0usize;
|
||||
let mut is_truncated = false;
|
||||
let mut next_key_marker: Option<String> = None;
|
||||
let mut next_version_id_marker: Option<String> = None;
|
||||
let mut last_emitted: Option<(String, String)> = None;
|
||||
|
||||
let mut idx = start_index;
|
||||
while idx < entries.len() {
|
||||
let entry = &entries[idx];
|
||||
let is_latest = is_latest_flags[idx];
|
||||
|
||||
if !delim.is_empty() {
|
||||
let rest = entry.key.strip_prefix(prefix_str).unwrap_or(&entry.key);
|
||||
if let Some(delim_pos) = rest.find(delim) {
|
||||
let grouped = entry.key[..prefix_str.len() + delim_pos + delim.len()].to_string();
|
||||
if seen_prefixes.contains(&grouped) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if count >= max_keys {
|
||||
is_truncated = true;
|
||||
if let Some((k, v)) = last_emitted.clone() {
|
||||
next_key_marker = Some(k);
|
||||
next_version_id_marker = Some(v);
|
||||
}
|
||||
break;
|
||||
}
|
||||
common_prefixes.push(grouped.clone());
|
||||
seen_prefixes.insert(grouped.clone());
|
||||
count += 1;
|
||||
|
||||
let mut group_last = (entry.key.clone(), entry.version_id.clone());
|
||||
idx += 1;
|
||||
while idx < entries.len() && entries[idx].key.starts_with(&grouped) {
|
||||
group_last = (entries[idx].key.clone(), entries[idx].version_id.clone());
|
||||
idx += 1;
|
||||
}
|
||||
last_emitted = Some(group_last);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if count >= max_keys {
|
||||
is_truncated = true;
|
||||
if let Some((k, v)) = last_emitted.clone() {
|
||||
next_key_marker = Some(k);
|
||||
next_version_id_marker = Some(v);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let tag = if entry.is_delete_marker {
|
||||
"DeleteMarker"
|
||||
} else {
|
||||
"Version"
|
||||
};
|
||||
rendered.push_str(&format!("<{}>", tag));
|
||||
rendered.push_str(&format!("<Key>{}</Key>", xml_escape(&entry.key)));
|
||||
rendered.push_str(&format!(
|
||||
"<VersionId>{}</VersionId>",
|
||||
xml_escape(&entry.version_id)
|
||||
));
|
||||
rendered.push_str(&format!("<IsLatest>{}</IsLatest>", is_latest));
|
||||
rendered.push_str(&format!(
|
||||
"<LastModified>{}</LastModified>",
|
||||
myfsio_xml::response::format_s3_datetime(&entry.last_modified)
|
||||
));
|
||||
if !entry.is_delete_marker {
|
||||
if let Some(ref etag) = entry.etag {
|
||||
rendered.push_str(&format!("<ETag>\"{}\"</ETag>", xml_escape(etag)));
|
||||
}
|
||||
rendered.push_str(&format!("<Size>{}</Size>", entry.size));
|
||||
rendered.push_str(&format!(
|
||||
"<StorageClass>{}</StorageClass>",
|
||||
xml_escape(&entry.storage_class)
|
||||
));
|
||||
}
|
||||
rendered.push_str(&format!("</{}>", tag));
|
||||
|
||||
last_emitted = Some((entry.key.clone(), entry.version_id.clone()));
|
||||
count += 1;
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let mut xml = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<ListVersionsResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">",
|
||||
);
|
||||
xml.push_str(&format!("<Name>{}</Name>", xml_escape(bucket)));
|
||||
xml.push_str(&format!(
|
||||
"<Prefix>{}</Prefix>",
|
||||
xml_escape(prefix.unwrap_or(""))
|
||||
));
|
||||
xml.push_str(&format!("<Prefix>{}</Prefix>", xml_escape(prefix_str)));
|
||||
if !km.is_empty() {
|
||||
xml.push_str(&format!("<KeyMarker>{}</KeyMarker>", xml_escape(km)));
|
||||
} else {
|
||||
xml.push_str("<KeyMarker></KeyMarker>");
|
||||
}
|
||||
if !vim.is_empty() {
|
||||
xml.push_str(&format!(
|
||||
"<VersionIdMarker>{}</VersionIdMarker>",
|
||||
xml_escape(vim)
|
||||
));
|
||||
} else {
|
||||
xml.push_str("<VersionIdMarker></VersionIdMarker>");
|
||||
}
|
||||
xml.push_str(&format!("<MaxKeys>{}</MaxKeys>", max_keys));
|
||||
|
||||
let current_count = objects.len().min(max_keys);
|
||||
let remaining = max_keys.saturating_sub(current_count);
|
||||
let archived_count = archived_versions.len().min(remaining);
|
||||
let is_truncated = object_result.is_truncated
|
||||
|| objects.len() > current_count
|
||||
|| archived_versions.len() > archived_count;
|
||||
if !delim.is_empty() {
|
||||
xml.push_str(&format!("<Delimiter>{}</Delimiter>", xml_escape(delim)));
|
||||
}
|
||||
xml.push_str(&format!("<IsTruncated>{}</IsTruncated>", is_truncated));
|
||||
|
||||
let current_keys: std::collections::HashSet<String> = objects
|
||||
.iter()
|
||||
.take(current_count)
|
||||
.map(|o| o.key.clone())
|
||||
.collect();
|
||||
let mut latest_archived_per_key: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
for v in archived_versions.iter().take(archived_count) {
|
||||
if current_keys.contains(&v.key) {
|
||||
continue;
|
||||
}
|
||||
let existing = latest_archived_per_key.get(&v.key).cloned();
|
||||
match existing {
|
||||
None => {
|
||||
latest_archived_per_key.insert(v.key.clone(), v.version_id.clone());
|
||||
}
|
||||
Some(existing_id) => {
|
||||
let existing_ts = archived_versions
|
||||
.iter()
|
||||
.find(|x| x.key == v.key && x.version_id == existing_id)
|
||||
.map(|x| x.last_modified)
|
||||
.unwrap_or(v.last_modified);
|
||||
if v.last_modified > existing_ts {
|
||||
latest_archived_per_key.insert(v.key.clone(), v.version_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref nk) = next_key_marker {
|
||||
xml.push_str(&format!(
|
||||
"<NextKeyMarker>{}</NextKeyMarker>",
|
||||
xml_escape(nk)
|
||||
));
|
||||
}
|
||||
if let Some(ref nv) = next_version_id_marker {
|
||||
xml.push_str(&format!(
|
||||
"<NextVersionIdMarker>{}</NextVersionIdMarker>",
|
||||
xml_escape(nv)
|
||||
));
|
||||
}
|
||||
|
||||
for obj in objects.iter().take(current_count) {
|
||||
let version_id = obj.version_id.clone().unwrap_or_else(|| "null".to_string());
|
||||
xml.push_str("<Version>");
|
||||
xml.push_str(&format!("<Key>{}</Key>", xml_escape(&obj.key)));
|
||||
xml.push_str(&rendered);
|
||||
for cp in &common_prefixes {
|
||||
xml.push_str(&format!(
|
||||
"<VersionId>{}</VersionId>",
|
||||
xml_escape(&version_id)
|
||||
"<CommonPrefixes><Prefix>{}</Prefix></CommonPrefixes>",
|
||||
xml_escape(cp)
|
||||
));
|
||||
xml.push_str("<IsLatest>true</IsLatest>");
|
||||
xml.push_str(&format!(
|
||||
"<LastModified>{}</LastModified>",
|
||||
myfsio_xml::response::format_s3_datetime(&obj.last_modified)
|
||||
));
|
||||
if let Some(ref etag) = obj.etag {
|
||||
xml.push_str(&format!("<ETag>\"{}\"</ETag>", xml_escape(etag)));
|
||||
}
|
||||
xml.push_str(&format!("<Size>{}</Size>", obj.size));
|
||||
xml.push_str(&format!(
|
||||
"<StorageClass>{}</StorageClass>",
|
||||
xml_escape(obj.storage_class.as_deref().unwrap_or("STANDARD"))
|
||||
));
|
||||
xml.push_str("</Version>");
|
||||
}
|
||||
|
||||
for version in archived_versions.iter().take(archived_count) {
|
||||
let is_latest = latest_archived_per_key
|
||||
.get(&version.key)
|
||||
.map(|id| id == &version.version_id)
|
||||
.unwrap_or(false);
|
||||
let tag = if version.is_delete_marker {
|
||||
"DeleteMarker"
|
||||
} else {
|
||||
"Version"
|
||||
};
|
||||
xml.push_str(&format!("<{}>", tag));
|
||||
xml.push_str(&format!("<Key>{}</Key>", xml_escape(&version.key)));
|
||||
xml.push_str(&format!(
|
||||
"<VersionId>{}</VersionId>",
|
||||
xml_escape(&version.version_id)
|
||||
));
|
||||
xml.push_str(&format!("<IsLatest>{}</IsLatest>", is_latest));
|
||||
xml.push_str(&format!(
|
||||
"<LastModified>{}</LastModified>",
|
||||
myfsio_xml::response::format_s3_datetime(&version.last_modified)
|
||||
));
|
||||
if !version.is_delete_marker {
|
||||
if let Some(ref etag) = version.etag {
|
||||
xml.push_str(&format!("<ETag>\"{}\"</ETag>", xml_escape(etag)));
|
||||
}
|
||||
xml.push_str(&format!("<Size>{}</Size>", version.size));
|
||||
xml.push_str("<StorageClass>STANDARD</StorageClass>");
|
||||
}
|
||||
xml.push_str(&format!("</{}>", tag));
|
||||
}
|
||||
|
||||
xml.push_str("</ListVersionsResult>");
|
||||
|
||||
@@ -319,6 +319,10 @@ pub struct BucketQuery {
|
||||
pub notification: Option<String>,
|
||||
pub logging: Option<String>,
|
||||
pub versions: Option<String>,
|
||||
#[serde(rename = "key-marker")]
|
||||
pub key_marker: Option<String>,
|
||||
#[serde(rename = "version-id-marker")]
|
||||
pub version_id_marker: Option<String>,
|
||||
}
|
||||
|
||||
async fn virtual_host_bucket_from_headers(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||
@@ -410,6 +414,9 @@ pub async fn get_bucket(
|
||||
&state,
|
||||
&bucket,
|
||||
query.prefix.as_deref(),
|
||||
query.delimiter.as_deref(),
|
||||
query.key_marker.as_deref(),
|
||||
query.version_id_marker.as_deref(),
|
||||
query.max_keys.unwrap_or(1000),
|
||||
)
|
||||
.await;
|
||||
@@ -966,6 +973,71 @@ fn insert_standard_object_metadata(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const CANNED_ACL_VALUES: &[&str] = &[
|
||||
"private",
|
||||
"public-read",
|
||||
"public-read-write",
|
||||
"authenticated-read",
|
||||
"bucket-owner-read",
|
||||
"bucket-owner-full-control",
|
||||
"aws-exec-read",
|
||||
];
|
||||
|
||||
fn apply_canned_acl_header(
|
||||
headers: &HeaderMap,
|
||||
metadata: &mut HashMap<String, String>,
|
||||
) -> Result<(), Response> {
|
||||
let Some(raw) = headers.get("x-amz-acl").and_then(|v| v.to_str().ok()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if !CANNED_ACL_VALUES
|
||||
.iter()
|
||||
.any(|known| known.eq_ignore_ascii_case(value))
|
||||
{
|
||||
return Err(s3_error_response(S3Error::new(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
format!("Unsupported canned ACL: {}", value),
|
||||
)));
|
||||
}
|
||||
let acl = crate::services::acl::create_canned_acl(value, "myfsio");
|
||||
crate::services::acl::store_object_acl(metadata, &acl);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_sse_request(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
|
||||
let alg = headers
|
||||
.get("x-amz-server-side-encryption")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let Some(alg) = alg else {
|
||||
return Ok(());
|
||||
};
|
||||
if alg != "AES256" && alg != "aws:kms" {
|
||||
return Err(s3_error_response(S3Error::new(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
format!("Unsupported server-side encryption algorithm: {}", alg),
|
||||
)));
|
||||
}
|
||||
if alg == "aws:kms" && !state.config.kms_enabled {
|
||||
return Err(s3_error_response(S3Error::new(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"KMS is not enabled on this server",
|
||||
)));
|
||||
}
|
||||
if state.encryption.is_none() {
|
||||
return Err(s3_error_response(S3Error::new(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"Server-side encryption is not enabled on this server",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_stored_response_headers(headers: &mut HeaderMap, metadata: &HashMap<String, String>) {
|
||||
for (_, metadata_key, response_header) in internal_header_pairs() {
|
||||
if let Some(value) = metadata
|
||||
@@ -1289,6 +1361,12 @@ pub async fn put_object(
|
||||
if let Err(response) = insert_standard_object_metadata(&headers, &mut metadata) {
|
||||
return response;
|
||||
}
|
||||
if let Err(response) = apply_canned_acl_header(&headers, &mut metadata) {
|
||||
return response;
|
||||
}
|
||||
if let Err(response) = validate_sse_request(&state, &headers) {
|
||||
return response;
|
||||
}
|
||||
|
||||
for (name, value) in headers.iter() {
|
||||
let name_str = name.as_str();
|
||||
@@ -2143,6 +2221,12 @@ async fn object_attributes_handler(
|
||||
.collect();
|
||||
let all = attrs.is_empty();
|
||||
|
||||
let stored_meta = state
|
||||
.storage
|
||||
.get_object_metadata(bucket, key)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
xml.push_str("<GetObjectAttributesResponse xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">");
|
||||
|
||||
@@ -2159,8 +2243,32 @@ async fn object_attributes_handler(
|
||||
if all || attrs.contains("objectsize") {
|
||||
xml.push_str(&format!("<ObjectSize>{}</ObjectSize>", meta.size));
|
||||
}
|
||||
if attrs.contains("checksum") {
|
||||
xml.push_str("<Checksum></Checksum>");
|
||||
if all || attrs.contains("checksum") {
|
||||
let mut checksum_xml = String::new();
|
||||
for (algo, tag) in [
|
||||
("sha256", "ChecksumSHA256"),
|
||||
("sha1", "ChecksumSHA1"),
|
||||
("crc32", "ChecksumCRC32"),
|
||||
("crc32c", "ChecksumCRC32C"),
|
||||
("crc64nvme", "ChecksumCRC64NVME"),
|
||||
] {
|
||||
let key_name = format!("__checksum_{}__", algo);
|
||||
if let Some(value) = stored_meta.get(&key_name) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
checksum_xml.push_str(&format!(
|
||||
"<{tag}>{}</{tag}>",
|
||||
xml_escape(trimmed),
|
||||
tag = tag
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !checksum_xml.is_empty() {
|
||||
xml.push_str("<Checksum>");
|
||||
xml.push_str(&checksum_xml);
|
||||
xml.push_str("</Checksum>");
|
||||
}
|
||||
}
|
||||
if attrs.contains("objectparts") {
|
||||
xml.push_str("<ObjectParts></ObjectParts>");
|
||||
|
||||
@@ -588,6 +588,11 @@ pub fn create_router(state: state::AppState) -> Router {
|
||||
.merge(admin_router)
|
||||
.layer(axum::middleware::from_fn(middleware::server_header))
|
||||
.layer(cors_layer(&state.config))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::bucket_cors_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(middleware::request_log_layer))
|
||||
.layer(tower_http::compression::CompressionLayer::new())
|
||||
.layer(tower_http::timeout::RequestBodyTimeoutLayer::new(
|
||||
request_body_timeout,
|
||||
|
||||
281
crates/myfsio-server/src/middleware/bucket_cors.rs
Normal file
281
crates/myfsio-server/src/middleware/bucket_cors.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use myfsio_storage::traits::StorageEngine;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct CorsRule {
|
||||
allowed_origins: Vec<String>,
|
||||
allowed_methods: Vec<String>,
|
||||
allowed_headers: Vec<String>,
|
||||
expose_headers: Vec<String>,
|
||||
max_age_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
fn parse_cors_config(xml: &str) -> Vec<CorsRule> {
|
||||
let doc = match roxmltree::Document::parse(xml) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut rules = Vec::new();
|
||||
for rule_node in doc
|
||||
.descendants()
|
||||
.filter(|node| node.is_element() && node.tag_name().name() == "CORSRule")
|
||||
{
|
||||
let mut rule = CorsRule::default();
|
||||
for child in rule_node.children().filter(|n| n.is_element()) {
|
||||
let text = child.text().unwrap_or("").trim().to_string();
|
||||
match child.tag_name().name() {
|
||||
"AllowedOrigin" => rule.allowed_origins.push(text),
|
||||
"AllowedMethod" => rule.allowed_methods.push(text.to_ascii_uppercase()),
|
||||
"AllowedHeader" => rule.allowed_headers.push(text),
|
||||
"ExposeHeader" => rule.expose_headers.push(text),
|
||||
"MaxAgeSeconds" => {
|
||||
if let Ok(v) = text.parse::<u64>() {
|
||||
rule.max_age_seconds = Some(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
fn match_origin(pattern: &str, origin: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
if pattern == origin {
|
||||
return true;
|
||||
}
|
||||
if let Some(suffix) = pattern.strip_prefix('*') {
|
||||
return origin.ends_with(suffix);
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
return origin.starts_with(prefix);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn match_header(pattern: &str, header: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
pattern.eq_ignore_ascii_case(header)
|
||||
}
|
||||
|
||||
fn find_matching_rule<'a>(
|
||||
rules: &'a [CorsRule],
|
||||
origin: &str,
|
||||
method: &str,
|
||||
request_headers: &[&str],
|
||||
) -> Option<&'a CorsRule> {
|
||||
rules.iter().find(|rule| {
|
||||
let origin_match = rule
|
||||
.allowed_origins
|
||||
.iter()
|
||||
.any(|p| match_origin(p, origin));
|
||||
if !origin_match {
|
||||
return false;
|
||||
}
|
||||
let method_match = rule
|
||||
.allowed_methods
|
||||
.iter()
|
||||
.any(|m| m.eq_ignore_ascii_case(method));
|
||||
if !method_match {
|
||||
return false;
|
||||
}
|
||||
request_headers.iter().all(|h| {
|
||||
rule.allowed_headers
|
||||
.iter()
|
||||
.any(|pattern| match_header(pattern, h))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_matching_rule_for_actual<'a>(
|
||||
rules: &'a [CorsRule],
|
||||
origin: &str,
|
||||
method: &str,
|
||||
) -> Option<&'a CorsRule> {
|
||||
rules.iter().find(|rule| {
|
||||
rule.allowed_origins
|
||||
.iter()
|
||||
.any(|p| match_origin(p, origin))
|
||||
&& rule
|
||||
.allowed_methods
|
||||
.iter()
|
||||
.any(|m| m.eq_ignore_ascii_case(method))
|
||||
})
|
||||
}
|
||||
|
||||
fn bucket_from_path(path: &str) -> Option<&str> {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if trimmed.starts_with("admin/")
|
||||
|| trimmed.starts_with("myfsio/")
|
||||
|| trimmed.starts_with("kms/")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let first = trimmed.split('/').next().unwrap_or("");
|
||||
if myfsio_storage::validation::validate_bucket_name(first).is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(first)
|
||||
}
|
||||
|
||||
async fn bucket_from_host(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||
let host = headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(':').next())?
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let (candidate, _) = host.split_once('.')?;
|
||||
if myfsio_storage::validation::validate_bucket_name(candidate).is_some() {
|
||||
return None;
|
||||
}
|
||||
match state.storage.bucket_exists(candidate).await {
|
||||
Ok(true) => Some(candidate.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_bucket(state: &AppState, headers: &HeaderMap, path: &str) -> Option<String> {
|
||||
if let Some(name) = bucket_from_host(state, headers).await {
|
||||
return Some(name);
|
||||
}
|
||||
bucket_from_path(path).map(str::to_string)
|
||||
}
|
||||
|
||||
fn apply_rule_headers(headers: &mut axum::http::HeaderMap, rule: &CorsRule, origin: &str) {
|
||||
headers.remove("access-control-allow-origin");
|
||||
headers.remove("vary");
|
||||
if let Ok(val) = HeaderValue::from_str(origin) {
|
||||
headers.insert("access-control-allow-origin", val);
|
||||
}
|
||||
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||
if !rule.expose_headers.is_empty() {
|
||||
let value = rule.expose_headers.join(", ");
|
||||
if let Ok(val) = HeaderValue::from_str(&value) {
|
||||
headers.remove("access-control-expose-headers");
|
||||
headers.insert("access-control-expose-headers", val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_cors_response_headers(headers: &mut HeaderMap) {
|
||||
headers.remove("access-control-allow-origin");
|
||||
headers.remove("access-control-allow-credentials");
|
||||
headers.remove("access-control-expose-headers");
|
||||
headers.remove("access-control-allow-methods");
|
||||
headers.remove("access-control-allow-headers");
|
||||
headers.remove("access-control-max-age");
|
||||
}
|
||||
|
||||
pub async fn bucket_cors_layer(
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = req.uri().path().to_string();
|
||||
let bucket = match resolve_bucket(&state, req.headers(), &path).await {
|
||||
Some(name) => name,
|
||||
None => return next.run(req).await,
|
||||
};
|
||||
|
||||
let origin = req
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let bucket_rules = if origin.is_some() {
|
||||
match state.storage.get_bucket_config(&bucket).await {
|
||||
Ok(cfg) => cfg
|
||||
.cors
|
||||
.as_ref()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.map(|xml| parse_cors_config(&xml))
|
||||
.filter(|rules| !rules.is_empty()),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let is_preflight = req.method() == Method::OPTIONS
|
||||
&& req.headers().contains_key("access-control-request-method");
|
||||
|
||||
if is_preflight {
|
||||
if let (Some(origin), Some(rules)) = (origin.as_deref(), bucket_rules.as_ref()) {
|
||||
let req_method = req
|
||||
.headers()
|
||||
.get("access-control-request-method")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let req_headers_raw = req
|
||||
.headers()
|
||||
.get("access-control-request-headers")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let req_headers: Vec<&str> = req_headers_raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if let Some(rule) = find_matching_rule(rules, origin, req_method, &req_headers) {
|
||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||
apply_rule_headers(resp.headers_mut(), rule, origin);
|
||||
let methods_value = rule.allowed_methods.join(", ");
|
||||
if let Ok(val) = HeaderValue::from_str(&methods_value) {
|
||||
resp.headers_mut()
|
||||
.insert("access-control-allow-methods", val);
|
||||
}
|
||||
let headers_value = if rule.allowed_headers.iter().any(|h| h == "*") {
|
||||
req_headers_raw.to_string()
|
||||
} else {
|
||||
rule.allowed_headers.join(", ")
|
||||
};
|
||||
if !headers_value.is_empty() {
|
||||
if let Ok(val) = HeaderValue::from_str(&headers_value) {
|
||||
resp.headers_mut()
|
||||
.insert("access-control-allow-headers", val);
|
||||
}
|
||||
}
|
||||
if let Some(max_age) = rule.max_age_seconds {
|
||||
if let Ok(val) = HeaderValue::from_str(&max_age.to_string()) {
|
||||
resp.headers_mut().insert("access-control-max-age", val);
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
return (StatusCode::FORBIDDEN, "CORSResponse: CORS is not enabled").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let method = req.method().clone();
|
||||
let mut resp = next.run(req).await;
|
||||
|
||||
if let (Some(origin), Some(rules)) = (origin.as_deref(), bucket_rules.as_ref()) {
|
||||
if let Some(rule) = find_matching_rule_for_actual(rules, origin, method.as_str()) {
|
||||
apply_rule_headers(resp.headers_mut(), rule, origin);
|
||||
} else {
|
||||
strip_cors_response_headers(resp.headers_mut());
|
||||
}
|
||||
}
|
||||
|
||||
resp
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
mod auth;
|
||||
mod bucket_cors;
|
||||
pub mod ratelimit;
|
||||
pub mod session;
|
||||
pub(crate) mod sha_body;
|
||||
|
||||
pub use auth::auth_layer;
|
||||
pub use bucket_cors::bucket_cors_layer;
|
||||
pub use ratelimit::{rate_limit_layer, RateLimitLayerState};
|
||||
pub use session::{csrf_layer, session_layer, SessionHandle, SessionLayerState};
|
||||
|
||||
@@ -21,6 +23,42 @@ pub async fn server_header(req: Request, next: Next) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
pub async fn request_log_layer(req: Request, next: Next) -> Response {
|
||||
let start = Instant::now();
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
let version = req.version();
|
||||
let remote = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
let response = next.run(req).await;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let bytes_out = response
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok());
|
||||
|
||||
tracing::info!(
|
||||
target: "myfsio::access",
|
||||
remote = %remote,
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
version = ?version,
|
||||
status,
|
||||
bytes_out = bytes_out.unwrap_or(0),
|
||||
elapsed_ms = format!("{:.3}", elapsed_ms),
|
||||
"request"
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn ui_metrics_layer(State(state): State<AppState>, req: Request, next: Next) -> Response {
|
||||
let metrics = match state.metrics.clone() {
|
||||
Some(m) => m,
|
||||
|
||||
Reference in New Issue
Block a user