use std::collections::HashSet;
use crate::api::list_rewrite::ListRewrite;
use crate::api::response::{ListBucketResult, ListBucketResultV1, ListCommonPrefix, ListContents};
use crate::error::ProxyError;
use crate::types::BucketConfig;
pub(crate) struct ListXmlParams<'a> {
pub bucket_name: &'a str,
pub client_prefix: &'a str,
pub delimiter: &'a str,
pub max_keys: usize,
pub is_truncated: bool,
pub key_count: usize,
pub start_after: &'a Option<String>,
pub continuation_token: &'a Option<String>,
pub next_continuation_token: Option<String>,
pub encoding_type: &'a Option<String>,
}
pub struct ListQueryParams {
pub prefix: String,
pub delimiter: String,
pub max_keys: usize,
pub continuation_token: Option<String>,
pub start_after: Option<String>,
pub encoding_type: Option<String>,
pub marker: Option<String>,
pub is_v2: bool,
}
pub fn parse_list_query_params(raw_query: Option<&str>) -> ListQueryParams {
let mut prefix = None;
let mut delimiter = None;
let mut max_keys = None;
let mut continuation_token = None;
let mut start_after = None;
let mut encoding_type = None;
let mut marker = None;
let mut is_v2 = false;
if let Some(q) = raw_query {
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
match k.as_ref() {
"prefix" => prefix = Some(v.into_owned()),
"delimiter" => delimiter = Some(v.into_owned()),
"max-keys" => max_keys = Some(v.into_owned()),
"continuation-token" => continuation_token = Some(v.into_owned()),
"start-after" => start_after = Some(v.into_owned()),
"encoding-type" => encoding_type = Some(v.into_owned()),
"marker" => marker = Some(v.into_owned()),
"list-type" if v.as_ref() == "2" => {
is_v2 = true;
}
_ => {}
}
}
}
ListQueryParams {
prefix: prefix.unwrap_or_default(),
delimiter: delimiter.unwrap_or_default(),
max_keys: max_keys
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
.min(1000),
continuation_token,
start_after,
encoding_type,
marker,
is_v2,
}
}
pub(crate) fn build_list_prefix(config: &BucketConfig, client_prefix: &str) -> String {
match &config.backend_prefix {
Some(prefix) => {
let bp = prefix.trim_end_matches('/');
if bp.is_empty() {
client_prefix.to_string()
} else {
let mut full_prefix = String::with_capacity(bp.len() + 1 + client_prefix.len());
full_prefix.push_str(bp);
full_prefix.push('/');
full_prefix.push_str(client_prefix);
full_prefix
}
}
None => client_prefix.to_string(),
}
}
pub(crate) fn build_list_xml(
params: &ListXmlParams<'_>,
list_result: &object_store::ListResult,
config: &BucketConfig,
list_rewrite: Option<&ListRewrite>,
) -> Result<String, ProxyError> {
let backend_prefix = config
.backend_prefix
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let strip_prefix = if backend_prefix.is_empty() {
String::new()
} else {
format!("{}/", backend_prefix)
};
let common_prefix_set: HashSet<&object_store::path::Path> =
list_result.common_prefixes.iter().collect();
let full_list_prefix = format!("{}{}", strip_prefix, params.client_prefix);
let list_prefix_trimmed = full_list_prefix.trim_end_matches('/');
let is_directory_marker = |obj: &object_store::ObjectMeta| -> bool {
obj.size == 0
&& (common_prefix_set.contains(&obj.location)
|| obj.location.as_ref() == backend_prefix
|| obj.location.as_ref() == list_prefix_trimmed)
};
let mut contents: Vec<ListContents> = list_result
.objects
.iter()
.filter(|obj| !is_directory_marker(obj))
.map(|obj| {
let raw_key = obj.location.to_string();
ListContents {
key: rewrite_key(&raw_key, &strip_prefix, list_rewrite),
last_modified: obj
.last_modified
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string(),
etag: obj.e_tag.as_deref().unwrap_or("\"\"").to_string(),
size: obj.size,
storage_class: "STANDARD",
}
})
.collect();
let mut common_prefixes: Vec<ListCommonPrefix> = list_result
.common_prefixes
.iter()
.map(|p| {
let raw_prefix = format!("{}/", p);
ListCommonPrefix {
prefix: rewrite_key(&raw_prefix, &strip_prefix, list_rewrite),
}
})
.collect();
let url_encode = matches!(params.encoding_type, Some(ref t) if t == "url");
let encode = |s: String| -> String {
if url_encode {
const S3_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
percent_encoding::utf8_percent_encode(&s, S3_ENCODE_SET).to_string()
} else {
s
}
};
let prefix_value = match list_rewrite {
Some(rewrite) if !rewrite.add_prefix.is_empty() => {
format!("{}{}", rewrite.add_prefix, params.client_prefix)
}
_ => params.client_prefix.to_string(),
};
if url_encode {
for item in &mut contents {
item.key = encode(std::mem::take(&mut item.key));
}
for cp in &mut common_prefixes {
cp.prefix = encode(std::mem::take(&mut cp.prefix));
}
}
Ok(ListBucketResult {
xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
name: params.bucket_name.to_string(),
prefix: encode(prefix_value),
delimiter: encode(params.delimiter.to_string()),
encoding_type: params.encoding_type.clone(),
max_keys: params.max_keys,
is_truncated: params.is_truncated,
key_count: params.key_count,
start_after: params.start_after.as_ref().map(|s| encode(s.clone())),
continuation_token: params.continuation_token.clone(),
next_continuation_token: params.next_continuation_token.clone(),
contents,
common_prefixes,
}
.to_xml())
}
pub(crate) struct ListXmlParamsV1<'a> {
pub bucket_name: &'a str,
pub client_prefix: &'a str,
pub delimiter: &'a str,
pub max_keys: usize,
pub is_truncated: bool,
pub marker: &'a str,
pub next_marker: Option<String>,
pub encoding_type: &'a Option<String>,
}
pub(crate) fn build_list_xml_v1(
params: &ListXmlParamsV1<'_>,
list_result: &object_store::ListResult,
config: &BucketConfig,
list_rewrite: Option<&ListRewrite>,
) -> Result<String, ProxyError> {
let backend_prefix = config
.backend_prefix
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let strip_prefix = if backend_prefix.is_empty() {
String::new()
} else {
format!("{}/", backend_prefix)
};
let common_prefix_set: HashSet<&object_store::path::Path> =
list_result.common_prefixes.iter().collect();
let full_list_prefix = format!("{}{}", strip_prefix, params.client_prefix);
let list_prefix_trimmed = full_list_prefix.trim_end_matches('/');
let is_directory_marker = |obj: &object_store::ObjectMeta| -> bool {
obj.size == 0
&& (common_prefix_set.contains(&obj.location)
|| obj.location.as_ref() == backend_prefix
|| obj.location.as_ref() == list_prefix_trimmed)
};
let mut contents: Vec<ListContents> = list_result
.objects
.iter()
.filter(|obj| !is_directory_marker(obj))
.map(|obj| {
let raw_key = obj.location.to_string();
ListContents {
key: rewrite_key(&raw_key, &strip_prefix, list_rewrite),
last_modified: obj
.last_modified
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string(),
etag: obj.e_tag.as_deref().unwrap_or("\"\"").to_string(),
size: obj.size,
storage_class: "STANDARD",
}
})
.collect();
let mut common_prefixes: Vec<ListCommonPrefix> = list_result
.common_prefixes
.iter()
.map(|p| {
let raw_prefix = format!("{}/", p);
ListCommonPrefix {
prefix: rewrite_key(&raw_prefix, &strip_prefix, list_rewrite),
}
})
.collect();
let url_encode = matches!(params.encoding_type, Some(ref t) if t == "url");
let encode = |s: String| -> String {
if url_encode {
const S3_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
percent_encoding::utf8_percent_encode(&s, S3_ENCODE_SET).to_string()
} else {
s
}
};
let prefix_value = match list_rewrite {
Some(rewrite) if !rewrite.add_prefix.is_empty() => {
format!("{}{}", rewrite.add_prefix, params.client_prefix)
}
_ => params.client_prefix.to_string(),
};
if url_encode {
for item in &mut contents {
item.key = encode(std::mem::take(&mut item.key));
}
for cp in &mut common_prefixes {
cp.prefix = encode(std::mem::take(&mut cp.prefix));
}
}
Ok(ListBucketResultV1 {
xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
name: params.bucket_name.to_string(),
prefix: encode(prefix_value),
delimiter: encode(params.delimiter.to_string()),
encoding_type: params.encoding_type.clone(),
max_keys: params.max_keys,
is_truncated: params.is_truncated,
marker: params.marker.to_string(),
next_marker: params.next_marker.clone(),
contents,
common_prefixes,
}
.to_xml())
}
fn rewrite_key(raw: &str, strip_prefix: &str, list_rewrite: Option<&ListRewrite>) -> String {
let key = if !strip_prefix.is_empty() {
raw.strip_prefix(strip_prefix).unwrap_or(raw)
} else {
raw
};
if let Some(rewrite) = list_rewrite {
let key = if !rewrite.strip_prefix.is_empty() {
key.strip_prefix(rewrite.strip_prefix.as_str())
.unwrap_or(key)
} else {
key
};
if !rewrite.add_prefix.is_empty() {
return if key.is_empty() || key.starts_with('/') || rewrite.add_prefix.ends_with('/') {
format!("{}{}", rewrite.add_prefix, key)
} else {
format!("{}/{}", rewrite.add_prefix, key)
};
}
return key.to_string();
}
key.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use object_store::{path::Path, ListResult, ObjectMeta};
fn make_list_result(keys: &[&str], common_prefixes: &[&str]) -> ListResult {
ListResult {
objects: keys
.iter()
.map(|k| ObjectMeta {
location: Path::from(*k),
last_modified: Utc::now(),
size: 100,
e_tag: Some("\"abc\"".to_string()),
version: None,
})
.collect(),
common_prefixes: common_prefixes.iter().map(|p| Path::from(*p)).collect(),
}
}
fn make_config(backend_prefix: Option<&str>) -> BucketConfig {
BucketConfig {
name: "test-bucket".to_string(),
backend_type: "s3".to_string(),
backend_prefix: backend_prefix.map(|s| s.to_string()),
anonymous_access: false,
allowed_roles: vec![],
backend_options: Default::default(),
}
}
#[test]
fn test_prefix_element_includes_add_prefix() {
let config = make_config(None);
let list_result = make_list_result(&["subdir/file.parquet"], &[]);
let rewrite = ListRewrite {
strip_prefix: String::new(),
add_prefix: "product/".to_string(),
};
let params = ListXmlParams {
bucket_name: "account",
client_prefix: "subdir/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, Some(&rewrite)).unwrap();
assert!(
xml.contains("<Prefix>product/subdir/</Prefix>"),
"Expected <Prefix>product/subdir/</Prefix> but got: {}",
xml
);
assert!(xml.contains("<Key>product/subdir/file.parquet</Key>"));
}
#[test]
fn test_prefix_element_without_rewrite() {
let config = make_config(None);
let list_result = make_list_result(&["file.parquet"], &[]);
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "some-prefix/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(xml.contains("<Prefix>some-prefix/</Prefix>"));
}
#[test]
fn test_common_prefixes_include_add_prefix() {
let config = make_config(None);
let list_result = make_list_result(&[], &["subdir"]);
let rewrite = ListRewrite {
strip_prefix: String::new(),
add_prefix: "product/".to_string(),
};
let params = ListXmlParams {
bucket_name: "account",
client_prefix: "",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 0,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, Some(&rewrite)).unwrap();
assert!(
xml.contains("<Prefix>product/subdir/</Prefix>"),
"Expected common prefix to include add_prefix but got: {}",
xml
);
}
#[test]
fn test_encoding_type_url_encodes_keys_and_prefixes() {
let config = make_config(None);
let list_result = make_list_result(&["dir/file with spaces.txt"], &["dir/sub dir"]);
let encoding_type = Some("url".to_string());
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "dir/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 2,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &encoding_type,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
xml.contains("<EncodingType>url</EncodingType>"),
"Missing EncodingType element: {}",
xml
);
assert!(
xml.contains("<Key>dir/file%20with%20spaces.txt</Key>"),
"Key not encoded correctly: {}",
xml
);
assert!(
xml.contains("<Prefix>dir/sub%20dir/</Prefix>"),
"CommonPrefix not encoded correctly: {}",
xml
);
assert!(
xml.contains("<Prefix>dir/</Prefix>")
|| xml.contains("<Prefix>dir/sub%20dir/</Prefix>"),
"Prefix not encoded correctly: {}",
xml
);
assert!(
xml.contains("<Delimiter>/</Delimiter>"),
"Delimiter should not encode '/': {}",
xml
);
}
#[test]
fn test_encoding_type_url_encodes_special_chars() {
let config = make_config(None);
let list_result = make_list_result(&["test_file(3).png"], &[]);
let encoding_type = Some("url".to_string());
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &encoding_type,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
xml.contains("<Key>test_file%283%29.png</Key>"),
"Expected S3-style encoding of parens: {}",
xml
);
}
#[test]
fn test_no_encoding_type_leaves_keys_raw() {
let config = make_config(None);
let list_result = make_list_result(&["dir/file with spaces.txt"], &[]);
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "dir/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
!xml.contains("<EncodingType>"),
"EncodingType should not be present: {}",
xml
);
assert!(
xml.contains("<Key>dir/file with spaces.txt</Key>"),
"Key should be raw: {}",
xml
);
}
#[test]
fn test_parse_list_query_params_encoding_type() {
let params = parse_list_query_params(Some("list-type=2&encoding-type=url&prefix=test/"));
assert_eq!(params.encoding_type, Some("url".to_string()));
assert_eq!(params.prefix, "test/");
let params = parse_list_query_params(Some("list-type=2&prefix=test/"));
assert_eq!(params.encoding_type, None);
}
#[test]
fn test_parse_list_query_params_v1_vs_v2() {
let params = parse_list_query_params(Some("list-type=2&prefix=test/"));
assert!(params.is_v2);
assert_eq!(params.marker, None);
let params = parse_list_query_params(Some("prefix=test/&marker=key123"));
assert!(!params.is_v2);
assert_eq!(params.marker, Some("key123".to_string()));
let params = parse_list_query_params(Some("list-type=1&marker=abc"));
assert!(!params.is_v2);
assert_eq!(params.marker, Some("abc".to_string()));
let params = parse_list_query_params(None);
assert!(!params.is_v2);
assert_eq!(params.marker, None);
}
#[test]
fn test_build_list_xml_v1_basic() {
let config = make_config(None);
let list_result = make_list_result(&["photos/image.jpg"], &["photos/thumbs"]);
let params = ListXmlParamsV1 {
bucket_name: "my-bucket",
client_prefix: "photos/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
marker: "photos/abc.jpg",
next_marker: None,
encoding_type: &None,
};
let xml = build_list_xml_v1(¶ms, &list_result, &config, None).unwrap();
assert!(
xml.contains("<Marker>photos/abc.jpg</Marker>"),
"Missing Marker: {xml}"
);
assert!(xml.contains("<Name>my-bucket</Name>"));
assert!(xml.contains("<Prefix>photos/</Prefix>"));
assert!(xml.contains("<Key>photos/image.jpg</Key>"));
assert!(xml.contains("<CommonPrefixes><Prefix>photos/thumbs/</Prefix></CommonPrefixes>"));
assert!(
!xml.contains("<KeyCount>"),
"V1 should not have KeyCount: {xml}"
);
assert!(
!xml.contains("<StartAfter>"),
"V1 should not have StartAfter: {xml}"
);
assert!(
!xml.contains("<ContinuationToken>"),
"V1 should not have ContinuationToken: {xml}"
);
assert!(
!xml.contains("<NextMarker>"),
"NextMarker should be absent when not truncated: {xml}"
);
}
#[test]
fn test_build_list_xml_v1_truncated_with_next_marker() {
let config = make_config(None);
let list_result = make_list_result(&["a.txt", "b.txt"], &[]);
let params = ListXmlParamsV1 {
bucket_name: "bucket",
client_prefix: "",
delimiter: "/",
max_keys: 2,
is_truncated: true,
marker: "",
next_marker: Some("b.txt".to_string()),
encoding_type: &None,
};
let xml = build_list_xml_v1(¶ms, &list_result, &config, None).unwrap();
assert!(xml.contains("<IsTruncated>true</IsTruncated>"));
assert!(
xml.contains("<NextMarker>b.txt</NextMarker>"),
"Expected NextMarker: {xml}"
);
assert!(xml.contains("<Marker></Marker>") || xml.contains("<Marker/>"));
}
fn make_list_result_with_sizes(
objects: &[(&str, u64)],
common_prefixes: &[&str],
) -> ListResult {
ListResult {
objects: objects
.iter()
.map(|(k, size)| ObjectMeta {
location: Path::from(*k),
last_modified: Utc::now(),
size: *size,
e_tag: Some("\"abc\"".to_string()),
version: None,
})
.collect(),
common_prefixes: common_prefixes.iter().map(|p| Path::from(*p)).collect(),
}
}
#[test]
fn test_directory_markers_filtered_from_v2_contents() {
let config = make_config(None);
let list_result =
make_list_result_with_sizes(&[("photos", 0), ("readme.txt", 42)], &["photos"]);
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
!xml.contains("<Key>photos</Key>"),
"Directory marker should be filtered out: {xml}"
);
assert!(
xml.contains("<Key>readme.txt</Key>"),
"Real file should remain: {xml}"
);
assert!(
xml.contains("<Prefix>photos/</Prefix>"),
"Common prefix should remain: {xml}"
);
}
#[test]
fn test_zero_byte_file_not_filtered_when_no_matching_prefix() {
let config = make_config(None);
let list_result = make_list_result_with_sizes(&[("empty.txt", 0)], &[]);
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
xml.contains("<Key>empty.txt</Key>"),
"Zero-byte file without matching prefix should be kept: {xml}"
);
}
#[test]
fn test_directory_markers_filtered_from_v1_contents() {
let config = make_config(None);
let list_result =
make_list_result_with_sizes(&[("photos", 0), ("readme.txt", 42)], &["photos"]);
let params = ListXmlParamsV1 {
bucket_name: "my-bucket",
client_prefix: "",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
marker: "",
next_marker: None,
encoding_type: &None,
};
let xml = build_list_xml_v1(¶ms, &list_result, &config, None).unwrap();
assert!(
!xml.contains("<Key>photos</Key>"),
"Directory marker should be filtered out in V1: {xml}"
);
assert!(
xml.contains("<Key>readme.txt</Key>"),
"Real file should remain in V1: {xml}"
);
}
#[test]
fn test_directory_marker_at_list_prefix_filtered_v2() {
let config = make_config(Some("harvard-lil"));
let list_result = make_list_result_with_sizes(
&[
("harvard-lil/staging-gov-data", 0),
("harvard-lil/staging-gov-data/data/file.parquet", 1000),
],
&["harvard-lil/staging-gov-data/data"],
);
let params = ListXmlParams {
bucket_name: "harvard-lil",
client_prefix: "staging-gov-data/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
!xml.contains("<Key>staging-gov-data</Key>"),
"Directory marker at list prefix should be filtered out: {xml}"
);
assert!(
xml.contains("<Key>staging-gov-data/data/file.parquet</Key>"),
"Real file should remain: {xml}"
);
assert!(
xml.contains("<Prefix>staging-gov-data/data/</Prefix>"),
"Common prefix should remain: {xml}"
);
}
#[test]
fn test_directory_marker_at_list_prefix_filtered_v1() {
let config = make_config(Some("harvard-lil"));
let list_result = make_list_result_with_sizes(
&[
("harvard-lil/staging-gov-data", 0),
("harvard-lil/staging-gov-data/data/file.parquet", 1000),
],
&["harvard-lil/staging-gov-data/data"],
);
let params = ListXmlParamsV1 {
bucket_name: "harvard-lil",
client_prefix: "staging-gov-data/",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
marker: "",
next_marker: None,
encoding_type: &None,
};
let xml = build_list_xml_v1(¶ms, &list_result, &config, None).unwrap();
assert!(
!xml.contains("<Key>staging-gov-data</Key>"),
"Directory marker at list prefix should be filtered out in V1: {xml}"
);
assert!(
xml.contains("<Key>staging-gov-data/data/file.parquet</Key>"),
"Real file should remain in V1: {xml}"
);
}
#[test]
fn test_nonzero_byte_object_matching_prefix_not_filtered() {
let config = make_config(None);
let list_result = make_list_result_with_sizes(&[("photos", 100)], &["photos"]);
let params = ListXmlParams {
bucket_name: "my-bucket",
client_prefix: "",
delimiter: "/",
max_keys: 1000,
is_truncated: false,
key_count: 1,
start_after: &None,
continuation_token: &None,
next_continuation_token: None,
encoding_type: &None,
};
let xml = build_list_xml(¶ms, &list_result, &config, None).unwrap();
assert!(
xml.contains("<Key>photos</Key>"),
"Non-zero-byte object should not be filtered: {xml}"
);
}
}