use crate::error::ProxyError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(rename = "Delete")]
pub(crate) struct DeleteRequest {
#[serde(default, rename = "Quiet")]
pub(crate) quiet: bool,
#[serde(default, rename = "Object")]
pub(crate) objects: Vec<DeleteObjectEntry>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct DeleteObjectEntry {
#[serde(rename = "Key")]
pub(crate) key: String,
}
impl DeleteRequest {
pub(crate) const MAX_KEYS: usize = 1000;
pub(crate) fn parse(body: &[u8]) -> Result<Self, ProxyError> {
let req: DeleteRequest = quick_xml::de::from_reader(body)
.map_err(|e| ProxyError::MalformedXml(format!("malformed delete body: {e}")))?;
if req.objects.is_empty() {
return Err(ProxyError::MalformedXml(
"delete request names no objects".into(),
));
}
if req.objects.len() > Self::MAX_KEYS {
return Err(ProxyError::MalformedXml(format!(
"delete request names {} objects, exceeding the {}-key limit",
req.objects.len(),
Self::MAX_KEYS
)));
}
Ok(req)
}
pub(crate) fn keys(&self) -> impl Iterator<Item = &str> {
self.objects.iter().map(|o| o.key.as_str())
}
}
pub(crate) fn build_backend_delete_body(backend_keys: &[String]) -> String {
#[derive(Serialize)]
#[serde(rename = "Delete")]
struct Body<'a> {
#[serde(rename = "Quiet")]
quiet: bool,
#[serde(rename = "Object")]
objects: Vec<Obj<'a>>,
}
#[derive(Serialize)]
struct Obj<'a> {
#[serde(rename = "Key")]
key: &'a str,
}
let body = Body {
quiet: false,
objects: backend_keys.iter().map(|k| Obj { key: k }).collect(),
};
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
quick_xml::se::to_string(&body).unwrap_or_default()
)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct DeleteError {
#[serde(rename = "Key")]
pub(crate) key: String,
#[serde(rename = "Code")]
pub(crate) code: String,
#[serde(rename = "Message")]
pub(crate) message: String,
}
#[derive(Debug)]
pub(crate) struct BackendOutcome {
pub(crate) deleted: Vec<String>,
pub(crate) errors: Vec<DeleteError>,
}
pub(crate) fn parse_backend_result(xml: &[u8]) -> Result<BackendOutcome, ProxyError> {
#[derive(Deserialize)]
#[serde(rename = "DeleteResult")]
struct DeleteResultXml {
#[serde(default, rename = "Deleted")]
deleted: Vec<Deleted>,
#[serde(default, rename = "Error")]
errors: Vec<DeleteError>,
}
#[derive(Deserialize)]
struct Deleted {
#[serde(rename = "Key")]
key: String,
}
let parsed: DeleteResultXml = quick_xml::de::from_reader(xml)
.map_err(|e| ProxyError::BackendError(format!("malformed delete result: {e}")))?;
Ok(BackendOutcome {
deleted: parsed.deleted.into_iter().map(|d| d.key).collect(),
errors: parsed.errors,
})
}
pub(crate) fn build_delete_result(
deleted: &[String],
errors: &[DeleteError],
quiet: bool,
) -> String {
#[derive(Serialize)]
#[serde(rename = "DeleteResult")]
struct DeleteResultXml<'a> {
#[serde(rename = "@xmlns")]
xmlns: &'static str,
#[serde(rename = "Deleted")]
deleted: Vec<Deleted<'a>>,
#[serde(rename = "Error")]
errors: &'a [DeleteError],
}
#[derive(Serialize)]
struct Deleted<'a> {
#[serde(rename = "Key")]
key: &'a str,
}
let deleted = if quiet {
Vec::new()
} else {
deleted.iter().map(|k| Deleted { key: k }).collect()
};
let result = DeleteResultXml {
xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
deleted,
errors,
};
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
quick_xml::se::to_string(&result).unwrap_or_default()
)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Object><Key>a.txt</Key></Object>
<Object><Key>nested/b.txt</Key></Object>
</Delete>"#;
#[test]
fn parses_keys_and_quiet_default_false() {
let req = DeleteRequest::parse(SAMPLE).unwrap();
assert!(!req.quiet);
let keys: Vec<_> = req.keys().collect();
assert_eq!(keys, vec!["a.txt", "nested/b.txt"]);
}
#[test]
fn parses_quiet_flag() {
let body = br#"<Delete><Quiet>true</Quiet><Object><Key>k</Key></Object></Delete>"#;
let req = DeleteRequest::parse(body).unwrap();
assert!(req.quiet);
}
#[test]
fn empty_delete_is_rejected_as_malformed_xml() {
let body = br#"<Delete></Delete>"#;
assert!(matches!(
DeleteRequest::parse(body),
Err(ProxyError::MalformedXml(_))
));
}
#[test]
fn malformed_body_is_rejected_as_malformed_xml() {
assert!(matches!(
DeleteRequest::parse(b"not xml"),
Err(ProxyError::MalformedXml(_))
));
}
#[test]
fn over_key_limit_is_rejected() {
let mut body = String::from("<Delete>");
for i in 0..=DeleteRequest::MAX_KEYS {
body.push_str(&format!("<Object><Key>k{i}</Key></Object>"));
}
body.push_str("</Delete>");
assert!(matches!(
DeleteRequest::parse(body.as_bytes()),
Err(ProxyError::MalformedXml(_))
));
let mut ok = String::from("<Delete>");
for i in 0..DeleteRequest::MAX_KEYS {
ok.push_str(&format!("<Object><Key>k{i}</Key></Object>"));
}
ok.push_str("</Delete>");
assert!(DeleteRequest::parse(ok.as_bytes()).is_ok());
}
#[test]
fn backend_body_lists_each_key_non_quiet() {
let body = build_backend_delete_body(&["p/a.txt".into(), "p/b.txt".into()]);
assert!(body.contains("<Quiet>false</Quiet>"));
assert!(body.contains("<Key>p/a.txt</Key>"));
assert!(body.contains("<Key>p/b.txt</Key>"));
}
#[test]
fn parses_backend_result_with_extra_elements() {
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Deleted><Key>p/a.txt</Key><DeleteMarker>true</DeleteMarker><VersionId>v1</VersionId></Deleted>
<Error><Key>p/c.txt</Key><Code>InternalError</Code><Message>oops</Message></Error>
</DeleteResult>"#;
let out = parse_backend_result(xml).unwrap();
assert_eq!(out.deleted, vec!["p/a.txt"]);
assert_eq!(out.errors.len(), 1);
assert_eq!(out.errors[0].key, "p/c.txt");
assert_eq!(out.errors[0].code, "InternalError");
}
#[test]
fn build_result_omits_deleted_when_quiet() {
let deleted = vec!["a.txt".to_string()];
let errors = vec![DeleteError {
key: "secret/x".into(),
code: "AccessDenied".into(),
message: "denied".into(),
}];
let verbose = build_delete_result(&deleted, &errors, false);
assert!(
verbose.contains("<DeleteResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">")
);
assert!(verbose.contains("<Deleted><Key>a.txt</Key></Deleted>"));
assert!(verbose.contains("<Key>secret/x</Key>"));
assert!(verbose.contains("<Code>AccessDenied</Code>"));
let quiet = build_delete_result(&deleted, &errors, true);
assert!(!quiet.contains("<Deleted>"));
assert!(quiet.contains("<Code>AccessDenied</Code>"));
}
#[test]
fn keys_are_xml_escaped() {
let body = build_backend_delete_body(&["a&b<c>.txt".into()]);
assert!(body.contains("a&b<c>.txt"));
assert!(!body.contains("a&b<c>.txt"));
}
}