From 6c0bb1c9b62d5cd1bf95bf7e421e1411421299ed Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Fri, 6 Mar 2026 10:33:16 +0100 Subject: [PATCH] refactoring: move xml definitions for bucket cors/lifecycle/website config move these defnitions to garage_api_common so that they can also be used in admin api --- Cargo.lock | 1 + src/api/common/Cargo.toml | 1 + src/api/common/common_error.rs | 6 + src/api/common/lib.rs | 1 + src/api/common/xml/cors.rs | 197 +++++++++++++++++ src/api/common/xml/lifecycle.rs | 337 ++++++++++++++++++++++++++++ src/api/common/xml/mod.rs | 45 ++++ src/api/common/xml/website.rs | 378 +++++++++++++++++++++++++++++++ src/api/s3/api_server.rs | 5 +- src/api/s3/cors.rs | 202 +---------------- src/api/s3/error.rs | 6 - src/api/s3/lib.rs | 8 - src/api/s3/lifecycle.rs | 342 +--------------------------- src/api/s3/website.rs | 380 +------------------------------- src/api/s3/xml.rs | 36 +-- 15 files changed, 982 insertions(+), 963 deletions(-) create mode 100644 src/api/common/xml/cors.rs create mode 100644 src/api/common/xml/lifecycle.rs create mode 100644 src/api/common/xml/mod.rs create mode 100644 src/api/common/xml/website.rs diff --git a/Cargo.lock b/Cargo.lock index 5780ca65..b60ae3d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1549,6 +1549,7 @@ dependencies = [ "nom", "opentelemetry", "pin-project", + "quick-xml", "serde", "serde_json", "sha1", diff --git a/src/api/common/Cargo.toml b/src/api/common/Cargo.toml index dba58202..05c640db 100644 --- a/src/api/common/Cargo.toml +++ b/src/api/common/Cargo.toml @@ -41,6 +41,7 @@ hyper = { workspace = true, default-features = false, features = ["server", "htt hyper-util.workspace = true url.workspace = true +quick-xml.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/src/api/common/common_error.rs b/src/api/common/common_error.rs index 0de490c2..aa4c36fa 100644 --- a/src/api/common/common_error.rs +++ b/src/api/common/common_error.rs @@ -36,6 +36,10 @@ pub enum CommonError { #[error("Invalid header value: {0}")] InvalidHeader(#[from] hyper::header::ToStrError), + /// The client sent a request for an action not supported by garage + #[error("Unimplemented action: {0}")] + NotImplemented(String), + // ---- SPECIFIC ERROR CONDITIONS ---- // These have to be error codes referenced in the S3 spec here: // https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html#ErrorCodeList @@ -101,6 +105,7 @@ impl CommonError { } CommonError::BadRequest(_) => StatusCode::BAD_REQUEST, CommonError::Forbidden(_) => StatusCode::FORBIDDEN, + CommonError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED, CommonError::NoSuchBucket(_) => StatusCode::NOT_FOUND, CommonError::BucketNotEmpty | CommonError::BucketAlreadyExists @@ -127,6 +132,7 @@ impl CommonError { CommonError::InvalidBucketName(_) => "InvalidBucketName", CommonError::InvalidHeader(_) => "InvalidHeaderValue", CommonError::BucketAlreadyOwnedByYou => "BucketAlreadyOwnedByYou", + CommonError::NotImplemented(_) => "NotImplemented", } } diff --git a/src/api/common/lib.rs b/src/api/common/lib.rs index 0e655a53..219f3624 100644 --- a/src/api/common/lib.rs +++ b/src/api/common/lib.rs @@ -10,3 +10,4 @@ pub mod generic_server; pub mod helpers; pub mod router_macros; pub mod signature; +pub mod xml; diff --git a/src/api/common/xml/cors.rs b/src/api/common/xml/cors.rs new file mode 100644 index 00000000..63e3e618 --- /dev/null +++ b/src/api/common/xml/cors.rs @@ -0,0 +1,197 @@ +use serde::{Deserialize, Serialize}; + +use hyper::{header::HeaderName, Method}; + +use garage_model::bucket_table::CorsRule as GarageCorsRule; + +use super::{xmlns_tag, IntValue, Value}; +use crate::common_error::{CommonError as Error, OkOrBadRequest}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename = "CORSConfiguration")] +pub struct CorsConfiguration { + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] + pub xmlns: (), + #[serde(rename = "CORSRule")] + pub cors_rules: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct CorsRule { + #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "MaxAgeSeconds", skip_serializing_if = "Option::is_none")] + pub max_age_seconds: Option, + #[serde(rename = "AllowedOrigin")] + pub allowed_origins: Vec, + #[serde(rename = "AllowedMethod")] + pub allowed_methods: Vec, + #[serde(rename = "AllowedHeader", default)] + pub allowed_headers: Vec, + #[serde(rename = "ExposeHeader", default)] + pub expose_headers: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AllowedMethod { + #[serde(rename = "AllowedMethod")] + pub allowed_method: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AllowedHeader { + #[serde(rename = "AllowedHeader")] + pub allowed_header: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct ExposeHeader { + #[serde(rename = "ExposeHeader")] + pub expose_header: Value, +} + +impl CorsConfiguration { + pub fn validate(&self) -> Result<(), Error> { + for r in self.cors_rules.iter() { + r.validate()?; + } + Ok(()) + } + + pub fn into_garage_cors_config(self) -> Result, Error> { + Ok(self + .cors_rules + .iter() + .map(CorsRule::to_garage_cors_rule) + .collect()) + } +} + +impl CorsRule { + pub fn validate(&self) -> Result<(), Error> { + for method in self.allowed_methods.iter() { + method + .0 + .parse::() + .ok_or_bad_request("Invalid CORSRule method")?; + } + for header in self + .allowed_headers + .iter() + .chain(self.expose_headers.iter()) + { + header + .0 + .parse::() + .ok_or_bad_request("Invalid HTTP header name")?; + } + Ok(()) + } + + pub fn to_garage_cors_rule(&self) -> GarageCorsRule { + let convert_vec = + |vval: &[Value]| vval.iter().map(|x| x.0.to_owned()).collect::>(); + GarageCorsRule { + id: self.id.as_ref().map(|x| x.0.to_owned()), + max_age_seconds: self.max_age_seconds.as_ref().map(|x| x.0 as u64), + allow_origins: convert_vec(&self.allowed_origins), + allow_methods: convert_vec(&self.allowed_methods), + allow_headers: convert_vec(&self.allowed_headers), + expose_headers: convert_vec(&self.expose_headers), + } + } + + pub fn from_garage_cors_rule(rule: &GarageCorsRule) -> Self { + let convert_vec = |vval: &[String]| { + vval.iter() + .map(|x| Value(x.clone())) + .collect::>() + }; + Self { + id: rule.id.as_ref().map(|x| Value(x.clone())), + max_age_seconds: rule.max_age_seconds.map(|x| IntValue(x as i64)), + allowed_origins: convert_vec(&rule.allow_origins), + allowed_methods: convert_vec(&rule.allow_methods), + allowed_headers: convert_vec(&rule.allow_headers), + expose_headers: convert_vec(&rule.expose_headers), + } + } +} + +#[cfg(test)] +mod tests { + use crate::xml::{to_xml_with_header, unprettify_xml}; + + use super::*; + + use quick_xml::de::from_str; + + #[test] + fn test_deserialize() { + let message = r#" + + + http://www.example.com + + PUT + POST + DELETE + + * + + + * + GET + + + qsdfjklm + 12345 + https://perdu.com + + GET + DELETE + * + * + +"#; + let conf: CorsConfiguration = + from_str(message).expect("failed to deserialize xml into `CorsConfiguration` struct"); + let ref_value = CorsConfiguration { + xmlns: (), + cors_rules: vec![ + CorsRule { + id: None, + max_age_seconds: None, + allowed_origins: vec!["http://www.example.com".into()], + allowed_methods: vec!["PUT".into(), "POST".into(), "DELETE".into()], + allowed_headers: vec!["*".into()], + expose_headers: vec![], + }, + CorsRule { + id: None, + max_age_seconds: None, + allowed_origins: vec!["*".into()], + allowed_methods: vec!["GET".into()], + allowed_headers: vec![], + expose_headers: vec![], + }, + CorsRule { + id: Some("qsdfjklm".into()), + max_age_seconds: Some(IntValue(12345)), + allowed_origins: vec!["https://perdu.com".into()], + allowed_methods: vec!["GET".into(), "DELETE".into()], + allowed_headers: vec!["*".into()], + expose_headers: vec!["*".into()], + }, + ], + }; + assert_eq! { + ref_value, + conf + }; + + let message2 = to_xml_with_header(&ref_value).expect("xml serialization"); + + assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); + } +} diff --git a/src/api/common/xml/lifecycle.rs b/src/api/common/xml/lifecycle.rs new file mode 100644 index 00000000..2997d070 --- /dev/null +++ b/src/api/common/xml/lifecycle.rs @@ -0,0 +1,337 @@ +use serde::{Deserialize, Serialize}; + +use garage_model::bucket_table::{ + parse_lifecycle_date, LifecycleExpiration as GarageLifecycleExpiration, + LifecycleFilter as GarageLifecycleFilter, LifecycleRule as GarageLifecycleRule, +}; + +use super::{xmlns_tag, IntValue, Value}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct LifecycleConfiguration { + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] + pub xmlns: (), + #[serde(rename = "Rule")] + pub lifecycle_rules: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct LifecycleRule { + #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "Status")] + pub status: Value, + #[serde(rename = "Filter", default, skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde( + rename = "Expiration", + default, + skip_serializing_if = "Option::is_none" + )] + pub expiration: Option, + #[serde( + rename = "AbortIncompleteMultipartUpload", + default, + skip_serializing_if = "Option::is_none" + )] + pub abort_incomplete_mpu: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct Filter { + #[serde(rename = "And", skip_serializing_if = "Option::is_none")] + pub and: Option>, + #[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde( + rename = "ObjectSizeGreaterThan", + skip_serializing_if = "Option::is_none" + )] + pub size_gt: Option, + #[serde(rename = "ObjectSizeLessThan", skip_serializing_if = "Option::is_none")] + pub size_lt: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Expiration { + #[serde(rename = "Days", skip_serializing_if = "Option::is_none")] + pub days: Option, + #[serde(rename = "Date", skip_serializing_if = "Option::is_none")] + pub at_date: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AbortIncompleteMpu { + #[serde(rename = "DaysAfterInitiation")] + pub days: IntValue, +} + +impl LifecycleConfiguration { + pub fn validate_into_garage_lifecycle_config( + self, + ) -> Result, &'static str> { + let mut ret = vec![]; + for rule in self.lifecycle_rules { + ret.push(rule.validate_into_garage_lifecycle_rule()?); + } + Ok(ret) + } + + pub fn from_garage_lifecycle_config(config: &[GarageLifecycleRule]) -> Self { + Self { + xmlns: (), + lifecycle_rules: config + .iter() + .map(LifecycleRule::from_garage_lifecycle_rule) + .collect(), + } + } +} + +impl LifecycleRule { + pub fn validate_into_garage_lifecycle_rule(self) -> Result { + let enabled = match self.status.0.as_str() { + "Enabled" => true, + "Disabled" => false, + _ => return Err("invalid value for "), + }; + + let filter = self + .filter + .map(Filter::validate_into_garage_lifecycle_filter) + .transpose()? + .unwrap_or_default(); + + let abort_incomplete_mpu_days = self.abort_incomplete_mpu.map(|x| x.days.0 as usize); + + let expiration = self + .expiration + .map(Expiration::validate_into_garage_lifecycle_expiration) + .transpose()?; + + Ok(GarageLifecycleRule { + id: self.id.map(|x| x.0), + enabled, + filter, + abort_incomplete_mpu_days, + expiration, + }) + } + + pub fn from_garage_lifecycle_rule(rule: &GarageLifecycleRule) -> Self { + Self { + id: rule.id.as_deref().map(Value::from), + status: if rule.enabled { + Value::from("Enabled") + } else { + Value::from("Disabled") + }, + filter: Filter::from_garage_lifecycle_filter(&rule.filter), + abort_incomplete_mpu: rule + .abort_incomplete_mpu_days + .map(|days| AbortIncompleteMpu { + days: IntValue(days as i64), + }), + expiration: rule + .expiration + .as_ref() + .map(Expiration::from_garage_lifecycle_expiration), + } + } +} + +impl Filter { + pub fn count(&self) -> i32 { + fn count(x: &Option) -> i32 { + x.as_ref().map(|_| 1).unwrap_or(0) + } + count(&self.prefix) + count(&self.size_gt) + count(&self.size_lt) + } + + pub fn validate_into_garage_lifecycle_filter( + self, + ) -> Result { + if self.count() > 0 && self.and.is_some() { + Err("Filter tag cannot contain both and another condition") + } else if let Some(and) = self.and { + if and.and.is_some() { + return Err("Nested tags"); + } + Ok(and.internal_into_garage_lifecycle_filter()) + } else if self.count() > 1 { + Err("Multiple Filter conditions must be wrapped in an tag") + } else { + Ok(self.internal_into_garage_lifecycle_filter()) + } + } + + fn internal_into_garage_lifecycle_filter(self) -> GarageLifecycleFilter { + GarageLifecycleFilter { + prefix: self.prefix.map(|x| x.0), + size_gt: self.size_gt.map(|x| x.0 as u64), + size_lt: self.size_lt.map(|x| x.0 as u64), + } + } + + pub fn from_garage_lifecycle_filter(rule: &GarageLifecycleFilter) -> Option { + let filter = Filter { + and: None, + prefix: rule.prefix.as_deref().map(Value::from), + size_gt: rule.size_gt.map(|x| IntValue(x as i64)), + size_lt: rule.size_lt.map(|x| IntValue(x as i64)), + }; + match filter.count() { + 0 => None, + 1 => Some(filter), + _ => Some(Filter { + and: Some(Box::new(filter)), + ..Default::default() + }), + } + } +} + +impl Expiration { + pub fn validate_into_garage_lifecycle_expiration( + self, + ) -> Result { + match (self.days, self.at_date) { + (Some(_), Some(_)) => Err("cannot have both and in "), + (None, None) => Err(" must contain either or "), + (Some(days), None) => Ok(GarageLifecycleExpiration::AfterDays(days.0 as usize)), + (None, Some(date)) => { + parse_lifecycle_date(&date.0)?; + Ok(GarageLifecycleExpiration::AtDate(date.0)) + } + } + } + + pub fn from_garage_lifecycle_expiration(exp: &GarageLifecycleExpiration) -> Self { + match exp { + GarageLifecycleExpiration::AfterDays(days) => Expiration { + days: Some(IntValue(*days as i64)), + at_date: None, + }, + GarageLifecycleExpiration::AtDate(date) => Expiration { + days: None, + at_date: Some(Value(date.to_string())), + }, + } + } +} + +#[cfg(test)] +mod tests { + use crate::xml::{to_xml_with_header, unprettify_xml}; + + use super::*; + + use quick_xml::de::from_str; + + #[test] + fn test_deserialize_lifecycle_config() { + let message = r#" + + + id1 + Enabled + + documents/ + + + 7 + + + + id2 + Enabled + + + logs/ + 1000000 + + + + 365 + + +"#; + let conf: LifecycleConfiguration = from_str(message).unwrap(); + let ref_value = LifecycleConfiguration { + xmlns: (), + lifecycle_rules: vec![ + LifecycleRule { + id: Some("id1".into()), + status: "Enabled".into(), + filter: Some(Filter { + prefix: Some("documents/".into()), + ..Default::default() + }), + expiration: None, + abort_incomplete_mpu: Some(AbortIncompleteMpu { days: IntValue(7) }), + }, + LifecycleRule { + id: Some("id2".into()), + status: "Enabled".into(), + filter: Some(Filter { + and: Some(Box::new(Filter { + prefix: Some("logs/".into()), + size_gt: Some(IntValue(1000000)), + ..Default::default() + })), + ..Default::default() + }), + expiration: Some(Expiration { + days: Some(IntValue(365)), + at_date: None, + }), + abort_incomplete_mpu: None, + }, + ], + }; + assert_eq! { + ref_value, + conf + }; + + let message2 = to_xml_with_header(&ref_value).expect("serialize xml"); + + assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); + + // Check validation + let validated = ref_value + .validate_into_garage_lifecycle_config() + .expect("invalid xml config"); + + let ref_config = vec![ + GarageLifecycleRule { + id: Some("id1".into()), + enabled: true, + filter: GarageLifecycleFilter { + prefix: Some("documents/".into()), + ..Default::default() + }, + expiration: None, + abort_incomplete_mpu_days: Some(7), + }, + GarageLifecycleRule { + id: Some("id2".into()), + enabled: true, + filter: GarageLifecycleFilter { + prefix: Some("logs/".into()), + size_gt: Some(1000000), + ..Default::default() + }, + expiration: Some(GarageLifecycleExpiration::AfterDays(365)), + abort_incomplete_mpu_days: None, + }, + ]; + assert_eq!(validated, ref_config); + + let message3 = to_xml_with_header(&LifecycleConfiguration::from_garage_lifecycle_config( + &validated, + )) + .expect("serialize xml"); + assert_eq!(unprettify_xml(message), unprettify_xml(&message3)); + } +} diff --git a/src/api/common/xml/mod.rs b/src/api/common/xml/mod.rs new file mode 100644 index 00000000..701e2834 --- /dev/null +++ b/src/api/common/xml/mod.rs @@ -0,0 +1,45 @@ +pub mod cors; +pub mod lifecycle; +pub mod website; + +use serde::{Deserialize, Serialize, Serializer}; + +pub fn to_xml_with_header(x: &T) -> Result { + use quick_xml::se::{self, EmptyElementHandling, QuoteLevel}; + + let mut xml = r#""#.to_string(); + + let mut ser = se::Serializer::new(&mut xml); + ser.set_quote_level(QuoteLevel::Full) + .empty_element_handling(EmptyElementHandling::Expanded); + let _serialized = x.serialize(ser)?; + Ok(xml) +} + +#[cfg(test)] +pub fn unprettify_xml(xml_in: &str) -> String { + xml_in.trim().lines().fold(String::new(), |mut val, line| { + val.push_str(line.trim()); + val + }) +} + +pub fn xmlns_tag(_v: &(), s: S) -> Result { + s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/") +} + +pub fn xmlns_xsi_tag(_v: &(), s: S) -> Result { + s.serialize_str("http://www.w3.org/2001/XMLSchema-instance") +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Value(#[serde(rename = "$value")] pub String); + +impl From<&str> for Value { + fn from(s: &str) -> Value { + Value(s.to_string()) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct IntValue(#[serde(rename = "$value")] pub i64); diff --git a/src/api/common/xml/website.rs b/src/api/common/xml/website.rs new file mode 100644 index 00000000..fc9d0b27 --- /dev/null +++ b/src/api/common/xml/website.rs @@ -0,0 +1,378 @@ +use serde::{Deserialize, Serialize}; + +use garage_model::bucket_table::{self, WebsiteConfig}; + +use crate::common_error::CommonError as Error; +use crate::xml::{xmlns_tag, IntValue, Value}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct WebsiteConfiguration { + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] + pub xmlns: (), + #[serde(rename = "ErrorDocument")] + pub error_document: Option, + #[serde(rename = "IndexDocument")] + pub index_document: Option, + #[serde(rename = "RedirectAllRequestsTo")] + pub redirect_all_requests_to: Option, + #[serde( + rename = "RoutingRules", + default, + skip_serializing_if = "RoutingRules::is_empty" + )] + pub routing_rules: RoutingRules, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct RoutingRules { + #[serde(rename = "RoutingRule")] + pub rules: Vec, +} + +impl RoutingRules { + fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct RoutingRule { + #[serde(rename = "Condition")] + pub condition: Option, + #[serde(rename = "Redirect")] + pub redirect: Redirect, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Key { + #[serde(rename = "Key")] + pub key: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Suffix { + #[serde(rename = "Suffix")] + pub suffix: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Target { + #[serde(rename = "HostName")] + pub hostname: Value, + #[serde(rename = "Protocol")] + pub protocol: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Condition { + #[serde( + rename = "HttpErrorCodeReturnedEquals", + skip_serializing_if = "Option::is_none" + )] + pub http_error_code: Option, + #[serde(rename = "KeyPrefixEquals", skip_serializing_if = "Option::is_none")] + pub prefix: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Redirect { + #[serde(rename = "HostName", skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")] + pub protocol: Option, + #[serde(rename = "HttpRedirectCode", skip_serializing_if = "Option::is_none")] + pub http_redirect_code: Option, + #[serde( + rename = "ReplaceKeyPrefixWith", + skip_serializing_if = "Option::is_none" + )] + pub replace_prefix: Option, + #[serde(rename = "ReplaceKeyWith", skip_serializing_if = "Option::is_none")] + pub replace_full: Option, +} + +impl WebsiteConfiguration { + pub fn validate(&self) -> Result<(), Error> { + if self.redirect_all_requests_to.is_some() + && (self.error_document.is_some() + || self.index_document.is_some() + || !self.routing_rules.is_empty()) + { + return Err(Error::bad_request( + "Bad XML: can't have RedirectAllRequestsTo and other fields", + )); + } + if let Some(ref ed) = self.error_document { + ed.validate()?; + } + if let Some(ref id) = self.index_document { + id.validate()?; + } + if let Some(ref rart) = self.redirect_all_requests_to { + rart.validate()?; + } + for rr in &self.routing_rules.rules { + rr.validate()?; + } + if self.routing_rules.rules.len() > 1000 { + // we will do linear scans, best to avoid overly long configuration. The + // limit was chosen arbitrarily + return Err(Error::bad_request( + "Bad XML: RoutingRules can't have more than 1000 child elements", + )); + } + + Ok(()) + } + + pub fn into_garage_website_config(self) -> Result { + if self.redirect_all_requests_to.is_some() { + Err(Error::NotImplemented( + "RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single unconditional RoutingRule.".into(), + )) + } else { + Ok(WebsiteConfig { + index_document: self + .index_document + .map(|x| x.suffix.0) + .unwrap_or_else(|| "index.html".to_string()), + error_document: self.error_document.map(|x| x.key.0), + redirect_all: None, + routing_rules: self + .routing_rules + .rules + .into_iter() + .map(|rule| { + bucket_table::RoutingRule { + condition: rule.condition.map(|condition| { + bucket_table::RedirectCondition { + http_error_code: condition.http_error_code.map(|c| c.0 as u16), + prefix: condition.prefix.map(|p| p.0), + } + }), + redirect: bucket_table::Redirect { + hostname: rule.redirect.hostname.map(|h| h.0), + protocol: rule.redirect.protocol.map(|p| p.0), + // aws default to 301, which i find punitive in case of + // misconfiguration (can be permanently cached on the + // user agent) + http_redirect_code: rule + .redirect + .http_redirect_code + .map(|c| c.0 as u16) + .unwrap_or(302), + replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0), + replace_key: rule.redirect.replace_full.map(|k| k.0), + }, + } + }) + .collect(), + }) + } + } +} + +impl Key { + pub fn validate(&self) -> Result<(), Error> { + if self.key.0.is_empty() { + Err(Error::bad_request( + "Bad XML: error document specified but empty", + )) + } else { + Ok(()) + } + } +} + +impl Suffix { + pub fn validate(&self) -> Result<(), Error> { + if self.suffix.0.is_empty() | self.suffix.0.contains('/') { + Err(Error::bad_request( + "Bad XML: index document is empty or contains /", + )) + } else { + Ok(()) + } + } +} + +impl Target { + pub fn validate(&self) -> Result<(), Error> { + if let Some(ref protocol) = self.protocol { + if protocol.0 != "http" && protocol.0 != "https" { + return Err(Error::bad_request("Bad XML: invalid protocol")); + } + } + Ok(()) + } +} + +impl RoutingRule { + pub fn validate(&self) -> Result<(), Error> { + if let Some(condition) = &self.condition { + condition.validate()?; + } + self.redirect.validate() + } +} + +impl Condition { + pub fn validate(&self) -> Result { + if let Some(ref error_code) = self.http_error_code { + // TODO do other error codes make sense? Aws only allows 4xx and 5xx + if error_code.0 != 404 { + return Err(Error::bad_request( + "Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent", + )); + } + } + Ok(self.prefix.is_some()) + } +} + +impl Redirect { + pub fn validate(&self) -> Result<(), Error> { + if self.replace_prefix.is_some() && self.replace_full.is_some() { + return Err(Error::bad_request( + "Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set", + )); + } + if let Some(ref protocol) = self.protocol { + if protocol.0 != "http" && protocol.0 != "https" { + return Err(Error::bad_request("Bad XML: invalid protocol")); + } + } + if let Some(ref http_redirect_code) = self.http_redirect_code { + match http_redirect_code.0 { + // aws allows all 3xx except 300, but some are non-sensical (not modified, + // use proxy...) + 301 | 302 | 303 | 307 | 308 => { + if self.hostname.is_none() && self.protocol.is_some() { + return Err(Error::bad_request( + "Bad XML: HostName must be set if Protocol is set", + )); + } + } + // aws doesn't allow these codes, but netlify does, and it seems like a + // cool feature (change the page seen without changing the url shown by the + // user agent) + 200 | 404 => { + if self.hostname.is_some() || self.protocol.is_some() { + // hostname would mean different bucket, protocol doesn't make + // sense + return Err(Error::bad_request( + "Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol", + )); + } + } + _ => { + return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode")); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::xml::{to_xml_with_header, unprettify_xml}; + + use super::*; + + use quick_xml::de::from_str; + + #[test] + fn test_deserialize() { + let message = r#" + + + my-error-doc + + + my-index + + + garage.tld + https + + + + + 404 + prefix1 + + + gara.ge + http + 303 + prefix2 + fullkey + + + + + + + + 404 + missing + + + +"#; + let conf: WebsiteConfiguration = + from_str(message).expect("failed to deserialize xml in `WebsiteConfiguration`"); + let ref_value = WebsiteConfiguration { + xmlns: (), + error_document: Some(Key { + key: Value("my-error-doc".to_owned()), + }), + index_document: Some(Suffix { + suffix: Value("my-index".to_owned()), + }), + redirect_all_requests_to: Some(Target { + hostname: Value("garage.tld".to_owned()), + protocol: Some(Value("https".to_owned())), + }), + routing_rules: RoutingRules { + rules: vec![ + RoutingRule { + condition: Some(Condition { + http_error_code: Some(IntValue(404)), + prefix: Some(Value("prefix1".to_owned())), + }), + redirect: Redirect { + hostname: Some(Value("gara.ge".to_owned())), + protocol: Some(Value("http".to_owned())), + http_redirect_code: Some(IntValue(303)), + replace_prefix: Some(Value("prefix2".to_owned())), + replace_full: Some(Value("fullkey".to_owned())), + }, + }, + RoutingRule { + condition: Some(Condition { + http_error_code: None, + prefix: Some(Value("".to_owned())), + }), + redirect: Redirect { + hostname: None, + protocol: None, + http_redirect_code: Some(IntValue(404)), + replace_prefix: None, + replace_full: Some(Value("missing".to_owned())), + }, + }, + ], + }, + }; + assert_eq! { + ref_value, + conf + } + + let message2 = to_xml_with_header(&ref_value).expect("xml serialization"); + + assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); + } +} diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index fba32ec9..689d174d 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -13,6 +13,7 @@ use garage_util::socket_address::UnixOrTCPSocketAddress; use garage_model::garage::Garage; use garage_model::key_table::Key; +use garage_api_common::common_error::CommonError; use garage_api_common::cors::*; use garage_api_common::generic_server::*; use garage_api_common::helpers::*; @@ -64,7 +65,7 @@ impl S3ApiServer { ) -> Result, Error> { match endpoint { Endpoint::ListBuckets => handle_list_buckets(&self.garage, &api_key).await, - endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())), + endpoint => Err(CommonError::NotImplemented(endpoint.name().to_owned()).into()), } } } @@ -327,7 +328,7 @@ impl ApiHandler for S3ApiServer { Endpoint::GetBucketLifecycleConfiguration {} => handle_get_lifecycle(ctx).await, Endpoint::PutBucketLifecycleConfiguration {} => handle_put_lifecycle(ctx, req).await, Endpoint::DeleteBucketLifecycle {} => handle_delete_lifecycle(ctx).await, - endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())), + endpoint => Err(CommonError::NotImplemented(endpoint.name().to_owned()).into()), }; // If request was a success and we have a CORS rule that applies to it, diff --git a/src/api/s3/cors.rs b/src/api/s3/cors.rs index 3318a3ea..9356983f 100644 --- a/src/api/s3/cors.rs +++ b/src/api/s3/cors.rs @@ -1,16 +1,15 @@ use quick_xml::de::from_reader; -use hyper::{header::HeaderName, Method, Request, Response, StatusCode}; +use hyper::{Request, Response, StatusCode}; -use serde::{Deserialize, Serialize}; - -use garage_model::bucket_table::{Bucket, CorsRule as GarageCorsRule}; +use garage_model::bucket_table::Bucket; use garage_api_common::helpers::*; +use garage_api_common::xml::cors::*; use crate::api_server::{ReqBody, ResBody}; use crate::error::*; -use crate::xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; +use crate::xml::to_xml_with_header; pub async fn handle_get_cors(ctx: ReqCtx) -> Result, Error> { let ReqCtx { bucket_params, .. } = ctx; @@ -78,196 +77,3 @@ pub async fn handle_put_cors( .status(StatusCode::OK) .body(empty_body())?) } - -// ---- SERIALIZATION AND DESERIALIZATION TO/FROM S3 XML ---- - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename = "CORSConfiguration")] -pub struct CorsConfiguration { - #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] - pub xmlns: (), - #[serde(rename = "CORSRule")] - pub cors_rules: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct CorsRule { - #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(rename = "MaxAgeSeconds", skip_serializing_if = "Option::is_none")] - pub max_age_seconds: Option, - #[serde(rename = "AllowedOrigin")] - pub allowed_origins: Vec, - #[serde(rename = "AllowedMethod")] - pub allowed_methods: Vec, - #[serde(rename = "AllowedHeader", default)] - pub allowed_headers: Vec, - #[serde(rename = "ExposeHeader", default)] - pub expose_headers: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct AllowedMethod { - #[serde(rename = "AllowedMethod")] - pub allowed_method: Value, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct AllowedHeader { - #[serde(rename = "AllowedHeader")] - pub allowed_header: Value, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct ExposeHeader { - #[serde(rename = "ExposeHeader")] - pub expose_header: Value, -} - -impl CorsConfiguration { - pub fn validate(&self) -> Result<(), Error> { - for r in self.cors_rules.iter() { - r.validate()?; - } - Ok(()) - } - - pub fn into_garage_cors_config(self) -> Result, Error> { - Ok(self - .cors_rules - .iter() - .map(CorsRule::to_garage_cors_rule) - .collect()) - } -} - -impl CorsRule { - pub fn validate(&self) -> Result<(), Error> { - for method in self.allowed_methods.iter() { - method - .0 - .parse::() - .ok_or_bad_request("Invalid CORSRule method")?; - } - for header in self - .allowed_headers - .iter() - .chain(self.expose_headers.iter()) - { - header - .0 - .parse::() - .ok_or_bad_request("Invalid HTTP header name")?; - } - Ok(()) - } - - pub fn to_garage_cors_rule(&self) -> GarageCorsRule { - let convert_vec = - |vval: &[Value]| vval.iter().map(|x| x.0.to_owned()).collect::>(); - GarageCorsRule { - id: self.id.as_ref().map(|x| x.0.to_owned()), - max_age_seconds: self.max_age_seconds.as_ref().map(|x| x.0 as u64), - allow_origins: convert_vec(&self.allowed_origins), - allow_methods: convert_vec(&self.allowed_methods), - allow_headers: convert_vec(&self.allowed_headers), - expose_headers: convert_vec(&self.expose_headers), - } - } - - pub fn from_garage_cors_rule(rule: &GarageCorsRule) -> Self { - let convert_vec = |vval: &[String]| { - vval.iter() - .map(|x| Value(x.clone())) - .collect::>() - }; - Self { - id: rule.id.as_ref().map(|x| Value(x.clone())), - max_age_seconds: rule.max_age_seconds.map(|x| IntValue(x as i64)), - allowed_origins: convert_vec(&rule.allow_origins), - allowed_methods: convert_vec(&rule.allow_methods), - allowed_headers: convert_vec(&rule.allow_headers), - expose_headers: convert_vec(&rule.expose_headers), - } - } -} - -#[cfg(test)] -mod tests { - use crate::unprettify_xml; - - use super::*; - - use quick_xml::de::from_str; - - #[test] - fn test_deserialize() -> Result<(), Error> { - let message = r#" - - - http://www.example.com - - PUT - POST - DELETE - - * - - - * - GET - - - qsdfjklm - 12345 - https://perdu.com - - GET - DELETE - * - * - -"#; - let conf: CorsConfiguration = - from_str(message).expect("failed to deserialize xml into `CorsConfiguration` struct"); - let ref_value = CorsConfiguration { - xmlns: (), - cors_rules: vec![ - CorsRule { - id: None, - max_age_seconds: None, - allowed_origins: vec!["http://www.example.com".into()], - allowed_methods: vec!["PUT".into(), "POST".into(), "DELETE".into()], - allowed_headers: vec!["*".into()], - expose_headers: vec![], - }, - CorsRule { - id: None, - max_age_seconds: None, - allowed_origins: vec!["*".into()], - allowed_methods: vec!["GET".into()], - allowed_headers: vec![], - expose_headers: vec![], - }, - CorsRule { - id: Some("qsdfjklm".into()), - max_age_seconds: Some(IntValue(12345)), - allowed_origins: vec!["https://perdu.com".into()], - allowed_methods: vec!["GET".into(), "DELETE".into()], - allowed_headers: vec!["*".into()], - expose_headers: vec!["*".into()], - }, - ], - }; - assert_eq! { - ref_value, - conf - }; - - let message2 = to_xml_with_header(&ref_value)?; - - assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); - - Ok(()) - } -} diff --git a/src/api/s3/error.rs b/src/api/s3/error.rs index b316b095..74ef6692 100644 --- a/src/api/s3/error.rs +++ b/src/api/s3/error.rs @@ -99,10 +99,6 @@ pub enum Error { /// The provided digest (checksum) value was invalid #[error("Invalid digest: {0}")] InvalidDigest(String), - - /// The client sent a request for an action not supported by garage - #[error("Unimplemented action: {0}")] - NotImplemented(String), } commonErrorDerivative!(Error); @@ -151,7 +147,6 @@ impl Error { Error::InvalidPartOrder => "InvalidPartOrder", Error::EntityTooSmall => "EntityTooSmall", Error::AuthorizationHeaderMalformed(_) => "AuthorizationHeaderMalformed", - Error::NotImplemented(_) => "NotImplemented", Error::InvalidXml(_) => "MalformedXML", Error::InvalidXmlDe(_) => "MalformedXML", Error::InvalidXmlSe(_) => "InternalError", @@ -176,7 +171,6 @@ impl ApiError for Error { | Error::NoSuchLifecycleConfiguration => StatusCode::NOT_FOUND, Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED, Error::InvalidRange(_) => StatusCode::RANGE_NOT_SATISFIABLE, - Error::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED, Error::InvalidXmlSe(_) => StatusCode::INTERNAL_SERVER_ERROR, Error::AuthorizationHeaderMalformed(_) | Error::InvalidPart diff --git a/src/api/s3/lib.rs b/src/api/s3/lib.rs index f5fab57a..83f684f8 100644 --- a/src/api/s3/lib.rs +++ b/src/api/s3/lib.rs @@ -19,11 +19,3 @@ pub mod website; mod encryption; mod router; pub mod xml; - -#[cfg(test)] -pub(crate) fn unprettify_xml(xml_in: &str) -> String { - xml_in.trim().lines().fold(String::new(), |mut val, line| { - val.push_str(line.trim()); - val - }) -} diff --git a/src/api/s3/lifecycle.rs b/src/api/s3/lifecycle.rs index 1e99fcc1..83064bca 100644 --- a/src/api/s3/lifecycle.rs +++ b/src/api/s3/lifecycle.rs @@ -2,18 +2,14 @@ use quick_xml::de::from_reader; use hyper::{Request, Response, StatusCode}; -use serde::{Deserialize, Serialize}; - use garage_api_common::helpers::*; +use garage_api_common::xml::lifecycle::*; use crate::api_server::{ReqBody, ResBody}; use crate::error::*; -use crate::xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; +use crate::xml::to_xml_with_header; -use garage_model::bucket_table::{ - parse_lifecycle_date, Bucket, LifecycleExpiration as GarageLifecycleExpiration, - LifecycleFilter as GarageLifecycleFilter, LifecycleRule as GarageLifecycleRule, -}; +use garage_model::bucket_table::Bucket; pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result, Error> { let ReqCtx { bucket_params, .. } = ctx; @@ -76,335 +72,3 @@ pub async fn handle_put_lifecycle( .status(StatusCode::OK) .body(empty_body())?) } - -// ---- SERIALIZATION AND DESERIALIZATION TO/FROM S3 XML ---- - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct LifecycleConfiguration { - #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] - pub xmlns: (), - #[serde(rename = "Rule")] - pub lifecycle_rules: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct LifecycleRule { - #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(rename = "Status")] - pub status: Value, - #[serde(rename = "Filter", default, skip_serializing_if = "Option::is_none")] - pub filter: Option, - #[serde( - rename = "Expiration", - default, - skip_serializing_if = "Option::is_none" - )] - pub expiration: Option, - #[serde( - rename = "AbortIncompleteMultipartUpload", - default, - skip_serializing_if = "Option::is_none" - )] - pub abort_incomplete_mpu: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] -pub struct Filter { - #[serde(rename = "And", skip_serializing_if = "Option::is_none")] - pub and: Option>, - #[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")] - pub prefix: Option, - #[serde( - rename = "ObjectSizeGreaterThan", - skip_serializing_if = "Option::is_none" - )] - pub size_gt: Option, - #[serde(rename = "ObjectSizeLessThan", skip_serializing_if = "Option::is_none")] - pub size_lt: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Expiration { - #[serde(rename = "Days", skip_serializing_if = "Option::is_none")] - pub days: Option, - #[serde(rename = "Date", skip_serializing_if = "Option::is_none")] - pub at_date: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct AbortIncompleteMpu { - #[serde(rename = "DaysAfterInitiation")] - pub days: IntValue, -} - -impl LifecycleConfiguration { - pub fn validate_into_garage_lifecycle_config( - self, - ) -> Result, &'static str> { - let mut ret = vec![]; - for rule in self.lifecycle_rules { - ret.push(rule.validate_into_garage_lifecycle_rule()?); - } - Ok(ret) - } - - pub fn from_garage_lifecycle_config(config: &[GarageLifecycleRule]) -> Self { - Self { - xmlns: (), - lifecycle_rules: config - .iter() - .map(LifecycleRule::from_garage_lifecycle_rule) - .collect(), - } - } -} - -impl LifecycleRule { - pub fn validate_into_garage_lifecycle_rule(self) -> Result { - let enabled = match self.status.0.as_str() { - "Enabled" => true, - "Disabled" => false, - _ => return Err("invalid value for "), - }; - - let filter = self - .filter - .map(Filter::validate_into_garage_lifecycle_filter) - .transpose()? - .unwrap_or_default(); - - let abort_incomplete_mpu_days = self.abort_incomplete_mpu.map(|x| x.days.0 as usize); - - let expiration = self - .expiration - .map(Expiration::validate_into_garage_lifecycle_expiration) - .transpose()?; - - Ok(GarageLifecycleRule { - id: self.id.map(|x| x.0), - enabled, - filter, - abort_incomplete_mpu_days, - expiration, - }) - } - - pub fn from_garage_lifecycle_rule(rule: &GarageLifecycleRule) -> Self { - Self { - id: rule.id.as_deref().map(Value::from), - status: if rule.enabled { - Value::from("Enabled") - } else { - Value::from("Disabled") - }, - filter: Filter::from_garage_lifecycle_filter(&rule.filter), - abort_incomplete_mpu: rule - .abort_incomplete_mpu_days - .map(|days| AbortIncompleteMpu { - days: IntValue(days as i64), - }), - expiration: rule - .expiration - .as_ref() - .map(Expiration::from_garage_lifecycle_expiration), - } - } -} - -impl Filter { - pub fn count(&self) -> i32 { - fn count(x: &Option) -> i32 { - x.as_ref().map(|_| 1).unwrap_or(0) - } - count(&self.prefix) + count(&self.size_gt) + count(&self.size_lt) - } - - pub fn validate_into_garage_lifecycle_filter( - self, - ) -> Result { - if self.count() > 0 && self.and.is_some() { - Err("Filter tag cannot contain both and another condition") - } else if let Some(and) = self.and { - if and.and.is_some() { - return Err("Nested tags"); - } - Ok(and.internal_into_garage_lifecycle_filter()) - } else if self.count() > 1 { - Err("Multiple Filter conditions must be wrapped in an tag") - } else { - Ok(self.internal_into_garage_lifecycle_filter()) - } - } - - fn internal_into_garage_lifecycle_filter(self) -> GarageLifecycleFilter { - GarageLifecycleFilter { - prefix: self.prefix.map(|x| x.0), - size_gt: self.size_gt.map(|x| x.0 as u64), - size_lt: self.size_lt.map(|x| x.0 as u64), - } - } - - pub fn from_garage_lifecycle_filter(rule: &GarageLifecycleFilter) -> Option { - let filter = Filter { - and: None, - prefix: rule.prefix.as_deref().map(Value::from), - size_gt: rule.size_gt.map(|x| IntValue(x as i64)), - size_lt: rule.size_lt.map(|x| IntValue(x as i64)), - }; - match filter.count() { - 0 => None, - 1 => Some(filter), - _ => Some(Filter { - and: Some(Box::new(filter)), - ..Default::default() - }), - } - } -} - -impl Expiration { - pub fn validate_into_garage_lifecycle_expiration( - self, - ) -> Result { - match (self.days, self.at_date) { - (Some(_), Some(_)) => Err("cannot have both and in "), - (None, None) => Err(" must contain either or "), - (Some(days), None) => Ok(GarageLifecycleExpiration::AfterDays(days.0 as usize)), - (None, Some(date)) => { - parse_lifecycle_date(&date.0)?; - Ok(GarageLifecycleExpiration::AtDate(date.0)) - } - } - } - - pub fn from_garage_lifecycle_expiration(exp: &GarageLifecycleExpiration) -> Self { - match exp { - GarageLifecycleExpiration::AfterDays(days) => Expiration { - days: Some(IntValue(*days as i64)), - at_date: None, - }, - GarageLifecycleExpiration::AtDate(date) => Expiration { - days: None, - at_date: Some(Value(date.to_string())), - }, - } - } -} - -#[cfg(test)] -mod tests { - use crate::unprettify_xml; - - use super::*; - - use quick_xml::de::from_str; - - #[test] - fn test_deserialize_lifecycle_config() -> Result<(), Error> { - let message = r#" - - - id1 - Enabled - - documents/ - - - 7 - - - - id2 - Enabled - - - logs/ - 1000000 - - - - 365 - - -"#; - let conf: LifecycleConfiguration = from_str(message).unwrap(); - let ref_value = LifecycleConfiguration { - xmlns: (), - lifecycle_rules: vec![ - LifecycleRule { - id: Some("id1".into()), - status: "Enabled".into(), - filter: Some(Filter { - prefix: Some("documents/".into()), - ..Default::default() - }), - expiration: None, - abort_incomplete_mpu: Some(AbortIncompleteMpu { days: IntValue(7) }), - }, - LifecycleRule { - id: Some("id2".into()), - status: "Enabled".into(), - filter: Some(Filter { - and: Some(Box::new(Filter { - prefix: Some("logs/".into()), - size_gt: Some(IntValue(1000000)), - ..Default::default() - })), - ..Default::default() - }), - expiration: Some(Expiration { - days: Some(IntValue(365)), - at_date: None, - }), - abort_incomplete_mpu: None, - }, - ], - }; - assert_eq! { - ref_value, - conf - }; - - let message2 = to_xml_with_header(&ref_value)?; - - assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); - - // Check validation - let validated = ref_value - .validate_into_garage_lifecycle_config() - .ok_or_bad_request("invalid xml config")?; - - let ref_config = vec![ - GarageLifecycleRule { - id: Some("id1".into()), - enabled: true, - filter: GarageLifecycleFilter { - prefix: Some("documents/".into()), - ..Default::default() - }, - expiration: None, - abort_incomplete_mpu_days: Some(7), - }, - GarageLifecycleRule { - id: Some("id2".into()), - enabled: true, - filter: GarageLifecycleFilter { - prefix: Some("logs/".into()), - size_gt: Some(1000000), - ..Default::default() - }, - expiration: Some(GarageLifecycleExpiration::AfterDays(365)), - abort_incomplete_mpu_days: None, - }, - ]; - assert_eq!(validated, ref_config); - - let message3 = to_xml_with_header(&LifecycleConfiguration::from_garage_lifecycle_config( - &validated, - ))?; - assert_eq!(unprettify_xml(message), unprettify_xml(&message3)); - - Ok(()) - } -} diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 4c14f922..d1241805 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -1,15 +1,15 @@ use quick_xml::de::from_reader; use hyper::{header::HeaderName, Request, Response, StatusCode}; -use serde::{Deserialize, Serialize}; -use garage_model::bucket_table::{self, Bucket, WebsiteConfig}; +use garage_model::bucket_table::Bucket; use garage_api_common::helpers::*; +use garage_api_common::xml::website::*; use crate::api_server::{ReqBody, ResBody}; use crate::error::*; -use crate::xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; +use crate::xml::{to_xml_with_header, IntValue, Value}; pub const X_AMZ_WEBSITE_REDIRECT_LOCATION: HeaderName = HeaderName::from_static("x-amz-website-redirect-location"); @@ -107,377 +107,3 @@ pub async fn handle_put_website( .status(StatusCode::OK) .body(empty_body())?) } - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct WebsiteConfiguration { - #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] - pub xmlns: (), - #[serde(rename = "ErrorDocument")] - pub error_document: Option, - #[serde(rename = "IndexDocument")] - pub index_document: Option, - #[serde(rename = "RedirectAllRequestsTo")] - pub redirect_all_requests_to: Option, - #[serde( - rename = "RoutingRules", - default, - skip_serializing_if = "RoutingRules::is_empty" - )] - pub routing_rules: RoutingRules, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] -pub struct RoutingRules { - #[serde(rename = "RoutingRule")] - pub rules: Vec, -} - -impl RoutingRules { - fn is_empty(&self) -> bool { - self.rules.is_empty() - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct RoutingRule { - #[serde(rename = "Condition")] - pub condition: Option, - #[serde(rename = "Redirect")] - pub redirect: Redirect, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Key { - #[serde(rename = "Key")] - pub key: Value, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Suffix { - #[serde(rename = "Suffix")] - pub suffix: Value, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Target { - #[serde(rename = "HostName")] - pub hostname: Value, - #[serde(rename = "Protocol")] - pub protocol: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Condition { - #[serde( - rename = "HttpErrorCodeReturnedEquals", - skip_serializing_if = "Option::is_none" - )] - pub http_error_code: Option, - #[serde(rename = "KeyPrefixEquals", skip_serializing_if = "Option::is_none")] - pub prefix: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Redirect { - #[serde(rename = "HostName", skip_serializing_if = "Option::is_none")] - pub hostname: Option, - #[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")] - pub protocol: Option, - #[serde(rename = "HttpRedirectCode", skip_serializing_if = "Option::is_none")] - pub http_redirect_code: Option, - #[serde( - rename = "ReplaceKeyPrefixWith", - skip_serializing_if = "Option::is_none" - )] - pub replace_prefix: Option, - #[serde(rename = "ReplaceKeyWith", skip_serializing_if = "Option::is_none")] - pub replace_full: Option, -} - -impl WebsiteConfiguration { - pub fn validate(&self) -> Result<(), Error> { - if self.redirect_all_requests_to.is_some() - && (self.error_document.is_some() - || self.index_document.is_some() - || !self.routing_rules.is_empty()) - { - return Err(Error::bad_request( - "Bad XML: can't have RedirectAllRequestsTo and other fields", - )); - } - if let Some(ref ed) = self.error_document { - ed.validate()?; - } - if let Some(ref id) = self.index_document { - id.validate()?; - } - if let Some(ref rart) = self.redirect_all_requests_to { - rart.validate()?; - } - for rr in &self.routing_rules.rules { - rr.validate()?; - } - if self.routing_rules.rules.len() > 1000 { - // we will do linear scans, best to avoid overly long configuration. The - // limit was chosen arbitrarily - return Err(Error::bad_request( - "Bad XML: RoutingRules can't have more than 1000 child elements", - )); - } - - Ok(()) - } - - pub fn into_garage_website_config(self) -> Result { - if self.redirect_all_requests_to.is_some() { - Err(Error::NotImplemented( - "RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single unconditional RoutingRule.".into(), - )) - } else { - Ok(WebsiteConfig { - index_document: self - .index_document - .map(|x| x.suffix.0) - .unwrap_or_else(|| "index.html".to_string()), - error_document: self.error_document.map(|x| x.key.0), - redirect_all: None, - routing_rules: self - .routing_rules - .rules - .into_iter() - .map(|rule| { - bucket_table::RoutingRule { - condition: rule.condition.map(|condition| { - bucket_table::RedirectCondition { - http_error_code: condition.http_error_code.map(|c| c.0 as u16), - prefix: condition.prefix.map(|p| p.0), - } - }), - redirect: bucket_table::Redirect { - hostname: rule.redirect.hostname.map(|h| h.0), - protocol: rule.redirect.protocol.map(|p| p.0), - // aws default to 301, which i find punitive in case of - // misconfiguration (can be permanently cached on the - // user agent) - http_redirect_code: rule - .redirect - .http_redirect_code - .map(|c| c.0 as u16) - .unwrap_or(302), - replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0), - replace_key: rule.redirect.replace_full.map(|k| k.0), - }, - } - }) - .collect(), - }) - } - } -} - -impl Key { - pub fn validate(&self) -> Result<(), Error> { - if self.key.0.is_empty() { - Err(Error::bad_request( - "Bad XML: error document specified but empty", - )) - } else { - Ok(()) - } - } -} - -impl Suffix { - pub fn validate(&self) -> Result<(), Error> { - if self.suffix.0.is_empty() | self.suffix.0.contains('/') { - Err(Error::bad_request( - "Bad XML: index document is empty or contains /", - )) - } else { - Ok(()) - } - } -} - -impl Target { - pub fn validate(&self) -> Result<(), Error> { - if let Some(ref protocol) = self.protocol { - if protocol.0 != "http" && protocol.0 != "https" { - return Err(Error::bad_request("Bad XML: invalid protocol")); - } - } - Ok(()) - } -} - -impl RoutingRule { - pub fn validate(&self) -> Result<(), Error> { - if let Some(condition) = &self.condition { - condition.validate()?; - } - self.redirect.validate() - } -} - -impl Condition { - pub fn validate(&self) -> Result { - if let Some(ref error_code) = self.http_error_code { - // TODO do other error codes make sense? Aws only allows 4xx and 5xx - if error_code.0 != 404 { - return Err(Error::bad_request( - "Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent", - )); - } - } - Ok(self.prefix.is_some()) - } -} - -impl Redirect { - pub fn validate(&self) -> Result<(), Error> { - if self.replace_prefix.is_some() && self.replace_full.is_some() { - return Err(Error::bad_request( - "Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set", - )); - } - if let Some(ref protocol) = self.protocol { - if protocol.0 != "http" && protocol.0 != "https" { - return Err(Error::bad_request("Bad XML: invalid protocol")); - } - } - if let Some(ref http_redirect_code) = self.http_redirect_code { - match http_redirect_code.0 { - // aws allows all 3xx except 300, but some are non-sensical (not modified, - // use proxy...) - 301 | 302 | 303 | 307 | 308 => { - if self.hostname.is_none() && self.protocol.is_some() { - return Err(Error::bad_request( - "Bad XML: HostName must be set if Protocol is set", - )); - } - } - // aws doesn't allow these codes, but netlify does, and it seems like a - // cool feature (change the page seen without changing the url shown by the - // user agent) - 200 | 404 => { - if self.hostname.is_some() || self.protocol.is_some() { - // hostname would mean different bucket, protocol doesn't make - // sense - return Err(Error::bad_request( - "Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol", - )); - } - } - _ => { - return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode")); - } - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use crate::unprettify_xml; - - use super::*; - - use quick_xml::de::from_str; - - #[test] - fn test_deserialize() -> Result<(), Error> { - let message = r#" - - - my-error-doc - - - my-index - - - garage.tld - https - - - - - 404 - prefix1 - - - gara.ge - http - 303 - prefix2 - fullkey - - - - - - - - 404 - missing - - - -"#; - let conf: WebsiteConfiguration = - from_str(message).expect("failed to deserialize xml in `WebsiteConfiguration`"); - let ref_value = WebsiteConfiguration { - xmlns: (), - error_document: Some(Key { - key: Value("my-error-doc".to_owned()), - }), - index_document: Some(Suffix { - suffix: Value("my-index".to_owned()), - }), - redirect_all_requests_to: Some(Target { - hostname: Value("garage.tld".to_owned()), - protocol: Some(Value("https".to_owned())), - }), - routing_rules: RoutingRules { - rules: vec![ - RoutingRule { - condition: Some(Condition { - http_error_code: Some(IntValue(404)), - prefix: Some(Value("prefix1".to_owned())), - }), - redirect: Redirect { - hostname: Some(Value("gara.ge".to_owned())), - protocol: Some(Value("http".to_owned())), - http_redirect_code: Some(IntValue(303)), - replace_prefix: Some(Value("prefix2".to_owned())), - replace_full: Some(Value("fullkey".to_owned())), - }, - }, - RoutingRule { - condition: Some(Condition { - http_error_code: None, - prefix: Some(Value("".to_owned())), - }), - redirect: Redirect { - hostname: None, - protocol: None, - http_redirect_code: Some(IntValue(404)), - replace_prefix: None, - replace_full: Some(Value("missing".to_owned())), - }, - }, - ], - }, - }; - assert_eq! { - ref_value, - conf - } - - let message2 = to_xml_with_header(&ref_value)?; - - assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); - - Ok(()) - } -} diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index 952a46ee..5970c964 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -1,37 +1,6 @@ -use quick_xml::se::{self, EmptyElementHandling, QuoteLevel}; -use serde::{Deserialize, Serialize, Serializer}; +use serde::Serialize; -use crate::error::Error as ApiError; - -pub fn to_xml_with_header(x: &T) -> Result { - let mut xml = r#""#.to_string(); - - let mut ser = se::Serializer::new(&mut xml); - ser.set_quote_level(QuoteLevel::Full) - .empty_element_handling(EmptyElementHandling::Expanded); - let _serialized = x.serialize(ser)?; - Ok(xml) -} - -pub fn xmlns_tag(_v: &(), s: S) -> Result { - s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/") -} - -pub fn xmlns_xsi_tag(_v: &(), s: S) -> Result { - s.serialize_str("http://www.w3.org/2001/XMLSchema-instance") -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Value(#[serde(rename = "$value")] pub String); - -impl From<&str> for Value { - fn from(s: &str) -> Value { - Value(s.to_string()) - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct IntValue(#[serde(rename = "$value")] pub i64); +pub use garage_api_common::xml::{to_xml_with_header, xmlns_tag, xmlns_xsi_tag, IntValue, Value}; #[derive(Debug, Serialize, PartialEq, Eq)] pub struct Bucket { @@ -378,6 +347,7 @@ pub struct AccessControlPolicy { #[cfg(test)] mod tests { use super::*; + use crate::error::Error as ApiError; use garage_util::time::*;