Skip to content

Commit 7e7b487

Browse files
authored
fix: directory marker support (#22)
1 parent d9b9ee6 commit 7e7b487

1 file changed

Lines changed: 281 additions & 0 deletions

File tree

crates/core/src/api/list.rs

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
//!
33
//! Extracted from `proxy.rs` to keep the gateway focused on orchestration.
44
5+
use std::collections::HashSet;
6+
57
use crate::api::list_rewrite::ListRewrite;
68
use crate::api::response::{ListBucketResult, ListBucketResultV1, ListCommonPrefix, ListContents};
79
use crate::error::ProxyError;
@@ -127,9 +129,37 @@ pub(crate) fn build_list_xml(
127129
format!("{}/", backend_prefix)
128130
};
129131

132+
// Filter out S3 directory marker objects — 0-byte objects created by the
133+
// S3 console (or similar tools) to represent "folders". These have a
134+
// trailing `/` in their key which `object_store::Path` strips, causing
135+
// them to leak into results. We detect them in three ways:
136+
//
137+
// 1. The marker's path matches a common prefix (e.g. key `photos/` →
138+
// Path("photos") collides with CommonPrefix "photos").
139+
// 2. The marker's path equals the backend prefix itself (e.g. the root
140+
// directory marker for the entire backend prefix).
141+
// 3. The marker's path equals the full listing prefix
142+
// (backend_prefix + client_prefix, minus trailing `/`). This is the
143+
// most common real-world case: listing "harvard-lil/staging-gov-data/"
144+
// returns a 0-byte key "harvard-lil/staging-gov-data/" which
145+
// object_store converts to Path("harvard-lil/staging-gov-data").
146+
let common_prefix_set: HashSet<&object_store::path::Path> =
147+
list_result.common_prefixes.iter().collect();
148+
149+
let full_list_prefix = format!("{}{}", strip_prefix, params.client_prefix);
150+
let list_prefix_trimmed = full_list_prefix.trim_end_matches('/');
151+
152+
let is_directory_marker = |obj: &object_store::ObjectMeta| -> bool {
153+
obj.size == 0
154+
&& (common_prefix_set.contains(&obj.location)
155+
|| obj.location.as_ref() == backend_prefix
156+
|| obj.location.as_ref() == list_prefix_trimmed)
157+
};
158+
130159
let mut contents: Vec<ListContents> = list_result
131160
.objects
132161
.iter()
162+
.filter(|obj| !is_directory_marker(obj))
133163
.map(|obj| {
134164
let raw_key = obj.location.to_string();
135165
ListContents {
@@ -238,9 +268,24 @@ pub(crate) fn build_list_xml_v1(
238268
format!("{}/", backend_prefix)
239269
};
240270

271+
// Filter out S3 directory marker objects (see build_list_xml for details).
272+
let common_prefix_set: HashSet<&object_store::path::Path> =
273+
list_result.common_prefixes.iter().collect();
274+
275+
let full_list_prefix = format!("{}{}", strip_prefix, params.client_prefix);
276+
let list_prefix_trimmed = full_list_prefix.trim_end_matches('/');
277+
278+
let is_directory_marker = |obj: &object_store::ObjectMeta| -> bool {
279+
obj.size == 0
280+
&& (common_prefix_set.contains(&obj.location)
281+
|| obj.location.as_ref() == backend_prefix
282+
|| obj.location.as_ref() == list_prefix_trimmed)
283+
};
284+
241285
let mut contents: Vec<ListContents> = list_result
242286
.objects
243287
.iter()
288+
.filter(|obj| !is_directory_marker(obj))
244289
.map(|obj| {
245290
let raw_key = obj.location.to_string();
246291
ListContents {
@@ -694,4 +739,240 @@ mod tests {
694739
);
695740
assert!(xml.contains("<Marker></Marker>") || xml.contains("<Marker/>"));
696741
}
742+
743+
/// Create a ListResult with objects that have explicit sizes, for testing
744+
/// directory marker filtering.
745+
fn make_list_result_with_sizes(
746+
objects: &[(&str, u64)],
747+
common_prefixes: &[&str],
748+
) -> ListResult {
749+
ListResult {
750+
objects: objects
751+
.iter()
752+
.map(|(k, size)| ObjectMeta {
753+
location: Path::from(*k),
754+
last_modified: Utc::now(),
755+
size: *size,
756+
e_tag: Some("\"abc\"".to_string()),
757+
version: None,
758+
})
759+
.collect(),
760+
common_prefixes: common_prefixes.iter().map(|p| Path::from(*p)).collect(),
761+
}
762+
}
763+
764+
#[test]
765+
fn test_directory_markers_filtered_from_v2_contents() {
766+
let config = make_config(None);
767+
// Simulate what object_store returns: a 0-byte "photos" object
768+
// (from S3 key "photos/") alongside the "photos" common prefix,
769+
// plus a real file.
770+
let list_result =
771+
make_list_result_with_sizes(&[("photos", 0), ("readme.txt", 42)], &["photos"]);
772+
773+
let params = ListXmlParams {
774+
bucket_name: "my-bucket",
775+
client_prefix: "",
776+
delimiter: "/",
777+
max_keys: 1000,
778+
is_truncated: false,
779+
key_count: 1,
780+
start_after: &None,
781+
continuation_token: &None,
782+
next_continuation_token: None,
783+
encoding_type: &None,
784+
};
785+
786+
let xml = build_list_xml(&params, &list_result, &config, None).unwrap();
787+
788+
// The directory marker should NOT appear in Contents
789+
assert!(
790+
!xml.contains("<Key>photos</Key>"),
791+
"Directory marker should be filtered out: {xml}"
792+
);
793+
// The real file should still be present
794+
assert!(
795+
xml.contains("<Key>readme.txt</Key>"),
796+
"Real file should remain: {xml}"
797+
);
798+
// The common prefix should still be present
799+
assert!(
800+
xml.contains("<Prefix>photos/</Prefix>"),
801+
"Common prefix should remain: {xml}"
802+
);
803+
}
804+
805+
#[test]
806+
fn test_zero_byte_file_not_filtered_when_no_matching_prefix() {
807+
let config = make_config(None);
808+
// A 0-byte file that does NOT match any common prefix should be kept.
809+
let list_result = make_list_result_with_sizes(&[("empty.txt", 0)], &[]);
810+
811+
let params = ListXmlParams {
812+
bucket_name: "my-bucket",
813+
client_prefix: "",
814+
delimiter: "/",
815+
max_keys: 1000,
816+
is_truncated: false,
817+
key_count: 1,
818+
start_after: &None,
819+
continuation_token: &None,
820+
next_continuation_token: None,
821+
encoding_type: &None,
822+
};
823+
824+
let xml = build_list_xml(&params, &list_result, &config, None).unwrap();
825+
826+
assert!(
827+
xml.contains("<Key>empty.txt</Key>"),
828+
"Zero-byte file without matching prefix should be kept: {xml}"
829+
);
830+
}
831+
832+
#[test]
833+
fn test_directory_markers_filtered_from_v1_contents() {
834+
let config = make_config(None);
835+
let list_result =
836+
make_list_result_with_sizes(&[("photos", 0), ("readme.txt", 42)], &["photos"]);
837+
838+
let params = ListXmlParamsV1 {
839+
bucket_name: "my-bucket",
840+
client_prefix: "",
841+
delimiter: "/",
842+
max_keys: 1000,
843+
is_truncated: false,
844+
marker: "",
845+
next_marker: None,
846+
encoding_type: &None,
847+
};
848+
849+
let xml = build_list_xml_v1(&params, &list_result, &config, None).unwrap();
850+
851+
assert!(
852+
!xml.contains("<Key>photos</Key>"),
853+
"Directory marker should be filtered out in V1: {xml}"
854+
);
855+
assert!(
856+
xml.contains("<Key>readme.txt</Key>"),
857+
"Real file should remain in V1: {xml}"
858+
);
859+
}
860+
861+
#[test]
862+
fn test_directory_marker_at_list_prefix_filtered_v2() {
863+
// Reproduces the real-world bug from source-cooperative/source.coop#245:
864+
//
865+
// Backend bucket: us-west-2.opendata.source.coop
866+
// backend_prefix: "harvard-lil/" (the account-level prefix)
867+
// Client lists with prefix: "staging-gov-data/"
868+
// Full S3 prefix sent: "harvard-lil/staging-gov-data/"
869+
//
870+
// S3 returns a 0-byte key "harvard-lil/staging-gov-data/" in Contents
871+
// (the directory marker). object_store::Path strips the trailing slash,
872+
// giving location "harvard-lil/staging-gov-data". After strip_prefix
873+
// ("harvard-lil/"), this becomes "staging-gov-data" — a phantom file
874+
// that should not appear in the listing.
875+
let config = make_config(Some("harvard-lil"));
876+
let list_result = make_list_result_with_sizes(
877+
&[
878+
("harvard-lil/staging-gov-data", 0),
879+
("harvard-lil/staging-gov-data/data/file.parquet", 1000),
880+
],
881+
&["harvard-lil/staging-gov-data/data"],
882+
);
883+
884+
let params = ListXmlParams {
885+
bucket_name: "harvard-lil",
886+
client_prefix: "staging-gov-data/",
887+
delimiter: "/",
888+
max_keys: 1000,
889+
is_truncated: false,
890+
key_count: 1,
891+
start_after: &None,
892+
continuation_token: &None,
893+
next_continuation_token: None,
894+
encoding_type: &None,
895+
};
896+
897+
let xml = build_list_xml(&params, &list_result, &config, None).unwrap();
898+
899+
// The directory marker should NOT appear in Contents
900+
assert!(
901+
!xml.contains("<Key>staging-gov-data</Key>"),
902+
"Directory marker at list prefix should be filtered out: {xml}"
903+
);
904+
// The real file should still appear (with prefix stripped)
905+
assert!(
906+
xml.contains("<Key>staging-gov-data/data/file.parquet</Key>"),
907+
"Real file should remain: {xml}"
908+
);
909+
// The common prefix should still appear
910+
assert!(
911+
xml.contains("<Prefix>staging-gov-data/data/</Prefix>"),
912+
"Common prefix should remain: {xml}"
913+
);
914+
}
915+
916+
#[test]
917+
fn test_directory_marker_at_list_prefix_filtered_v1() {
918+
// Same scenario as V2 test above, but for ListObjectsV1.
919+
let config = make_config(Some("harvard-lil"));
920+
let list_result = make_list_result_with_sizes(
921+
&[
922+
("harvard-lil/staging-gov-data", 0),
923+
("harvard-lil/staging-gov-data/data/file.parquet", 1000),
924+
],
925+
&["harvard-lil/staging-gov-data/data"],
926+
);
927+
928+
let params = ListXmlParamsV1 {
929+
bucket_name: "harvard-lil",
930+
client_prefix: "staging-gov-data/",
931+
delimiter: "/",
932+
max_keys: 1000,
933+
is_truncated: false,
934+
marker: "",
935+
next_marker: None,
936+
encoding_type: &None,
937+
};
938+
939+
let xml = build_list_xml_v1(&params, &list_result, &config, None).unwrap();
940+
941+
assert!(
942+
!xml.contains("<Key>staging-gov-data</Key>"),
943+
"Directory marker at list prefix should be filtered out in V1: {xml}"
944+
);
945+
assert!(
946+
xml.contains("<Key>staging-gov-data/data/file.parquet</Key>"),
947+
"Real file should remain in V1: {xml}"
948+
);
949+
}
950+
951+
#[test]
952+
fn test_nonzero_byte_object_matching_prefix_not_filtered() {
953+
let config = make_config(None);
954+
// An object with size > 0 that happens to match a common prefix
955+
// should NOT be filtered — only 0-byte markers are filtered.
956+
let list_result = make_list_result_with_sizes(&[("photos", 100)], &["photos"]);
957+
958+
let params = ListXmlParams {
959+
bucket_name: "my-bucket",
960+
client_prefix: "",
961+
delimiter: "/",
962+
max_keys: 1000,
963+
is_truncated: false,
964+
key_count: 1,
965+
start_after: &None,
966+
continuation_token: &None,
967+
next_continuation_token: None,
968+
encoding_type: &None,
969+
};
970+
971+
let xml = build_list_xml(&params, &list_result, &config, None).unwrap();
972+
973+
assert!(
974+
xml.contains("<Key>photos</Key>"),
975+
"Non-zero-byte object should not be filtered: {xml}"
976+
);
977+
}
697978
}

0 commit comments

Comments
 (0)