mirror of
https://github.com/kharonsec/garage.git
synced 2026-04-25 20:44:55 +02:00
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
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1549,6 +1549,7 @@ dependencies = [
|
||||
"nom",
|
||||
"opentelemetry",
|
||||
"pin-project",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ pub mod generic_server;
|
||||
pub mod helpers;
|
||||
pub mod router_macros;
|
||||
pub mod signature;
|
||||
pub mod xml;
|
||||
|
||||
197
src/api/common/xml/cors.rs
Normal file
197
src/api/common/xml/cors.rs
Normal file
@@ -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<CorsRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct CorsRule {
|
||||
#[serde(rename = "ID", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "MaxAgeSeconds", skip_serializing_if = "Option::is_none")]
|
||||
pub max_age_seconds: Option<IntValue>,
|
||||
#[serde(rename = "AllowedOrigin")]
|
||||
pub allowed_origins: Vec<Value>,
|
||||
#[serde(rename = "AllowedMethod")]
|
||||
pub allowed_methods: Vec<Value>,
|
||||
#[serde(rename = "AllowedHeader", default)]
|
||||
pub allowed_headers: Vec<Value>,
|
||||
#[serde(rename = "ExposeHeader", default)]
|
||||
pub expose_headers: Vec<Value>,
|
||||
}
|
||||
|
||||
#[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<Vec<GarageCorsRule>, 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::<Method>()
|
||||
.ok_or_bad_request("Invalid CORSRule method")?;
|
||||
}
|
||||
for header in self
|
||||
.allowed_headers
|
||||
.iter()
|
||||
.chain(self.expose_headers.iter())
|
||||
{
|
||||
header
|
||||
.0
|
||||
.parse::<HeaderName>()
|
||||
.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::<Vec<String>>();
|
||||
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::<Vec<Value>>()
|
||||
};
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<CORSRule>
|
||||
<AllowedOrigin>http://www.example.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>PUT</AllowedMethod>
|
||||
<AllowedMethod>POST</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<ID>qsdfjklm</ID>
|
||||
<MaxAgeSeconds>12345</MaxAgeSeconds>
|
||||
<AllowedOrigin>https://perdu.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<ExposeHeader>*</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>"#;
|
||||
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));
|
||||
}
|
||||
}
|
||||
337
src/api/common/xml/lifecycle.rs
Normal file
337
src/api/common/xml/lifecycle.rs
Normal file
@@ -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<LifecycleRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct LifecycleRule {
|
||||
#[serde(rename = "ID", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "Status")]
|
||||
pub status: Value,
|
||||
#[serde(rename = "Filter", default, skip_serializing_if = "Option::is_none")]
|
||||
pub filter: Option<Filter>,
|
||||
#[serde(
|
||||
rename = "Expiration",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub expiration: Option<Expiration>,
|
||||
#[serde(
|
||||
rename = "AbortIncompleteMultipartUpload",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub abort_incomplete_mpu: Option<AbortIncompleteMpu>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct Filter {
|
||||
#[serde(rename = "And", skip_serializing_if = "Option::is_none")]
|
||||
pub and: Option<Box<Filter>>,
|
||||
#[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<Value>,
|
||||
#[serde(
|
||||
rename = "ObjectSizeGreaterThan",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub size_gt: Option<IntValue>,
|
||||
#[serde(rename = "ObjectSizeLessThan", skip_serializing_if = "Option::is_none")]
|
||||
pub size_lt: Option<IntValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Expiration {
|
||||
#[serde(rename = "Days", skip_serializing_if = "Option::is_none")]
|
||||
pub days: Option<IntValue>,
|
||||
#[serde(rename = "Date", skip_serializing_if = "Option::is_none")]
|
||||
pub at_date: Option<Value>,
|
||||
}
|
||||
|
||||
#[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<Vec<GarageLifecycleRule>, &'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<GarageLifecycleRule, &'static str> {
|
||||
let enabled = match self.status.0.as_str() {
|
||||
"Enabled" => true,
|
||||
"Disabled" => false,
|
||||
_ => return Err("invalid value for <Status>"),
|
||||
};
|
||||
|
||||
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<T>(x: &Option<T>) -> 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<GarageLifecycleFilter, &'static str> {
|
||||
if self.count() > 0 && self.and.is_some() {
|
||||
Err("Filter tag cannot contain both <And> and another condition")
|
||||
} else if let Some(and) = self.and {
|
||||
if and.and.is_some() {
|
||||
return Err("Nested <And> tags");
|
||||
}
|
||||
Ok(and.internal_into_garage_lifecycle_filter())
|
||||
} else if self.count() > 1 {
|
||||
Err("Multiple Filter conditions must be wrapped in an <And> 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<Self> {
|
||||
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<GarageLifecycleExpiration, &'static str> {
|
||||
match (self.days, self.at_date) {
|
||||
(Some(_), Some(_)) => Err("cannot have both <Days> and <Date> in <Expiration>"),
|
||||
(None, None) => Err("<Expiration> must contain either <Days> or <Date>"),
|
||||
(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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Rule>
|
||||
<ID>id1</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>documents/</Prefix>
|
||||
</Filter>
|
||||
<AbortIncompleteMultipartUpload>
|
||||
<DaysAfterInitiation>7</DaysAfterInitiation>
|
||||
</AbortIncompleteMultipartUpload>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>id2</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<And>
|
||||
<Prefix>logs/</Prefix>
|
||||
<ObjectSizeGreaterThan>1000000</ObjectSizeGreaterThan>
|
||||
</And>
|
||||
</Filter>
|
||||
<Expiration>
|
||||
<Days>365</Days>
|
||||
</Expiration>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#;
|
||||
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));
|
||||
}
|
||||
}
|
||||
45
src/api/common/xml/mod.rs
Normal file
45
src/api/common/xml/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
pub mod cors;
|
||||
pub mod lifecycle;
|
||||
pub mod website;
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
pub fn to_xml_with_header<T: Serialize>(x: &T) -> Result<String, quick_xml::se::SeError> {
|
||||
use quick_xml::se::{self, EmptyElementHandling, QuoteLevel};
|
||||
|
||||
let mut xml = r#"<?xml version="1.0" encoding="UTF-8"?>"#.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<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/")
|
||||
}
|
||||
|
||||
pub fn xmlns_xsi_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
|
||||
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);
|
||||
378
src/api/common/xml/website.rs
Normal file
378
src/api/common/xml/website.rs
Normal file
@@ -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<Key>,
|
||||
#[serde(rename = "IndexDocument")]
|
||||
pub index_document: Option<Suffix>,
|
||||
#[serde(rename = "RedirectAllRequestsTo")]
|
||||
pub redirect_all_requests_to: Option<Target>,
|
||||
#[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<RoutingRule>,
|
||||
}
|
||||
|
||||
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<Condition>,
|
||||
#[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<Value>,
|
||||
}
|
||||
|
||||
#[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<IntValue>,
|
||||
#[serde(rename = "KeyPrefixEquals", skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Redirect {
|
||||
#[serde(rename = "HostName", skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<Value>,
|
||||
#[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<Value>,
|
||||
#[serde(rename = "HttpRedirectCode", skip_serializing_if = "Option::is_none")]
|
||||
pub http_redirect_code: Option<IntValue>,
|
||||
#[serde(
|
||||
rename = "ReplaceKeyPrefixWith",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub replace_prefix: Option<Value>,
|
||||
#[serde(rename = "ReplaceKeyWith", skip_serializing_if = "Option::is_none")]
|
||||
pub replace_full: Option<Value>,
|
||||
}
|
||||
|
||||
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<WebsiteConfig, Error> {
|
||||
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<bool, Error> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<ErrorDocument>
|
||||
<Key>my-error-doc</Key>
|
||||
</ErrorDocument>
|
||||
<IndexDocument>
|
||||
<Suffix>my-index</Suffix>
|
||||
</IndexDocument>
|
||||
<RedirectAllRequestsTo>
|
||||
<HostName>garage.tld</HostName>
|
||||
<Protocol>https</Protocol>
|
||||
</RedirectAllRequestsTo>
|
||||
<RoutingRules>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
|
||||
<KeyPrefixEquals>prefix1</KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HostName>gara.ge</HostName>
|
||||
<Protocol>http</Protocol>
|
||||
<HttpRedirectCode>303</HttpRedirectCode>
|
||||
<ReplaceKeyPrefixWith>prefix2</ReplaceKeyPrefixWith>
|
||||
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<KeyPrefixEquals></KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HttpRedirectCode>404</HttpRedirectCode>
|
||||
<ReplaceKeyWith>missing</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
</RoutingRules>
|
||||
</WebsiteConfiguration>"#;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<Response<ResBody>, 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,
|
||||
|
||||
@@ -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<Response<ResBody>, 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<CorsRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct CorsRule {
|
||||
#[serde(rename = "ID", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "MaxAgeSeconds", skip_serializing_if = "Option::is_none")]
|
||||
pub max_age_seconds: Option<IntValue>,
|
||||
#[serde(rename = "AllowedOrigin")]
|
||||
pub allowed_origins: Vec<Value>,
|
||||
#[serde(rename = "AllowedMethod")]
|
||||
pub allowed_methods: Vec<Value>,
|
||||
#[serde(rename = "AllowedHeader", default)]
|
||||
pub allowed_headers: Vec<Value>,
|
||||
#[serde(rename = "ExposeHeader", default)]
|
||||
pub expose_headers: Vec<Value>,
|
||||
}
|
||||
|
||||
#[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<Vec<GarageCorsRule>, 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::<Method>()
|
||||
.ok_or_bad_request("Invalid CORSRule method")?;
|
||||
}
|
||||
for header in self
|
||||
.allowed_headers
|
||||
.iter()
|
||||
.chain(self.expose_headers.iter())
|
||||
{
|
||||
header
|
||||
.0
|
||||
.parse::<HeaderName>()
|
||||
.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::<Vec<String>>();
|
||||
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::<Vec<Value>>()
|
||||
};
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<CORSRule>
|
||||
<AllowedOrigin>http://www.example.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>PUT</AllowedMethod>
|
||||
<AllowedMethod>POST</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<ID>qsdfjklm</ID>
|
||||
<MaxAgeSeconds>12345</MaxAgeSeconds>
|
||||
<AllowedOrigin>https://perdu.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<ExposeHeader>*</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>"#;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Response<ResBody>, 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<LifecycleRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct LifecycleRule {
|
||||
#[serde(rename = "ID", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "Status")]
|
||||
pub status: Value,
|
||||
#[serde(rename = "Filter", default, skip_serializing_if = "Option::is_none")]
|
||||
pub filter: Option<Filter>,
|
||||
#[serde(
|
||||
rename = "Expiration",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub expiration: Option<Expiration>,
|
||||
#[serde(
|
||||
rename = "AbortIncompleteMultipartUpload",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub abort_incomplete_mpu: Option<AbortIncompleteMpu>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct Filter {
|
||||
#[serde(rename = "And", skip_serializing_if = "Option::is_none")]
|
||||
pub and: Option<Box<Filter>>,
|
||||
#[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<Value>,
|
||||
#[serde(
|
||||
rename = "ObjectSizeGreaterThan",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub size_gt: Option<IntValue>,
|
||||
#[serde(rename = "ObjectSizeLessThan", skip_serializing_if = "Option::is_none")]
|
||||
pub size_lt: Option<IntValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Expiration {
|
||||
#[serde(rename = "Days", skip_serializing_if = "Option::is_none")]
|
||||
pub days: Option<IntValue>,
|
||||
#[serde(rename = "Date", skip_serializing_if = "Option::is_none")]
|
||||
pub at_date: Option<Value>,
|
||||
}
|
||||
|
||||
#[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<Vec<GarageLifecycleRule>, &'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<GarageLifecycleRule, &'static str> {
|
||||
let enabled = match self.status.0.as_str() {
|
||||
"Enabled" => true,
|
||||
"Disabled" => false,
|
||||
_ => return Err("invalid value for <Status>"),
|
||||
};
|
||||
|
||||
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<T>(x: &Option<T>) -> 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<GarageLifecycleFilter, &'static str> {
|
||||
if self.count() > 0 && self.and.is_some() {
|
||||
Err("Filter tag cannot contain both <And> and another condition")
|
||||
} else if let Some(and) = self.and {
|
||||
if and.and.is_some() {
|
||||
return Err("Nested <And> tags");
|
||||
}
|
||||
Ok(and.internal_into_garage_lifecycle_filter())
|
||||
} else if self.count() > 1 {
|
||||
Err("Multiple Filter conditions must be wrapped in an <And> 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<Self> {
|
||||
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<GarageLifecycleExpiration, &'static str> {
|
||||
match (self.days, self.at_date) {
|
||||
(Some(_), Some(_)) => Err("cannot have both <Days> and <Date> in <Expiration>"),
|
||||
(None, None) => Err("<Expiration> must contain either <Days> or <Date>"),
|
||||
(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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Rule>
|
||||
<ID>id1</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>documents/</Prefix>
|
||||
</Filter>
|
||||
<AbortIncompleteMultipartUpload>
|
||||
<DaysAfterInitiation>7</DaysAfterInitiation>
|
||||
</AbortIncompleteMultipartUpload>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>id2</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<And>
|
||||
<Prefix>logs/</Prefix>
|
||||
<ObjectSizeGreaterThan>1000000</ObjectSizeGreaterThan>
|
||||
</And>
|
||||
</Filter>
|
||||
<Expiration>
|
||||
<Days>365</Days>
|
||||
</Expiration>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Key>,
|
||||
#[serde(rename = "IndexDocument")]
|
||||
pub index_document: Option<Suffix>,
|
||||
#[serde(rename = "RedirectAllRequestsTo")]
|
||||
pub redirect_all_requests_to: Option<Target>,
|
||||
#[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<RoutingRule>,
|
||||
}
|
||||
|
||||
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<Condition>,
|
||||
#[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<Value>,
|
||||
}
|
||||
|
||||
#[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<IntValue>,
|
||||
#[serde(rename = "KeyPrefixEquals", skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Redirect {
|
||||
#[serde(rename = "HostName", skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<Value>,
|
||||
#[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<Value>,
|
||||
#[serde(rename = "HttpRedirectCode", skip_serializing_if = "Option::is_none")]
|
||||
pub http_redirect_code: Option<IntValue>,
|
||||
#[serde(
|
||||
rename = "ReplaceKeyPrefixWith",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub replace_prefix: Option<Value>,
|
||||
#[serde(rename = "ReplaceKeyWith", skip_serializing_if = "Option::is_none")]
|
||||
pub replace_full: Option<Value>,
|
||||
}
|
||||
|
||||
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<WebsiteConfig, Error> {
|
||||
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<bool, Error> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<ErrorDocument>
|
||||
<Key>my-error-doc</Key>
|
||||
</ErrorDocument>
|
||||
<IndexDocument>
|
||||
<Suffix>my-index</Suffix>
|
||||
</IndexDocument>
|
||||
<RedirectAllRequestsTo>
|
||||
<HostName>garage.tld</HostName>
|
||||
<Protocol>https</Protocol>
|
||||
</RedirectAllRequestsTo>
|
||||
<RoutingRules>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
|
||||
<KeyPrefixEquals>prefix1</KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HostName>gara.ge</HostName>
|
||||
<Protocol>http</Protocol>
|
||||
<HttpRedirectCode>303</HttpRedirectCode>
|
||||
<ReplaceKeyPrefixWith>prefix2</ReplaceKeyPrefixWith>
|
||||
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<KeyPrefixEquals></KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HttpRedirectCode>404</HttpRedirectCode>
|
||||
<ReplaceKeyWith>missing</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
</RoutingRules>
|
||||
</WebsiteConfiguration>"#;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T: Serialize>(x: &T) -> Result<String, ApiError> {
|
||||
let mut xml = r#"<?xml version="1.0" encoding="UTF-8"?>"#.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<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/")
|
||||
}
|
||||
|
||||
pub fn xmlns_xsi_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
|
||||
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::*;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user