Skip to content

Commit c4b75a3

Browse files
authored
feat(gateway): implement S3 read adapter (#487)
1 parent 5c31366 commit c4b75a3

5 files changed

Lines changed: 1501 additions & 11 deletions

File tree

crates/talon-backend/src/s3.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use talon_core::{
2222
BackendStore, Error, ListPage, ListedObject, ObjectId, ObjectStat, Result, Version,
2323
};
2424

25-
use crate::http::{HttpClient, HttpRequest, Method};
25+
use crate::http::{HttpClient, HttpRequest, HttpResponse, HttpStreamResponse, Method};
2626

2727
/// Percent-encode a query value.
2828
///
@@ -280,6 +280,77 @@ impl S3Backend {
280280
req
281281
}
282282

283+
fn session_headers(&self) -> Vec<(String, String)> {
284+
self.creds
285+
.session_token
286+
.as_ref()
287+
.map(|token| vec![("x-amz-security-token".to_string(), token.clone())])
288+
.unwrap_or_default()
289+
}
290+
291+
/// Execute a raw metadata request while forwarding only validated
292+
/// conditional headers supplied by a protocol adapter.
293+
pub async fn execute_head_raw(
294+
&self,
295+
obj: &ObjectId,
296+
conditions: &[(String, String)],
297+
) -> std::result::Result<HttpResponse, String> {
298+
let mut request = self.build_head(obj);
299+
request.headers.extend_from_slice(conditions);
300+
self.http.execute(self.signed(request)).await
301+
}
302+
303+
/// Execute a whole or ranged GET without buffering its response body.
304+
pub async fn execute_get_stream_raw(
305+
&self,
306+
obj: &ObjectId,
307+
range: Option<(u64, u64)>,
308+
conditions: &[(String, String)],
309+
) -> std::result::Result<HttpStreamResponse, String> {
310+
let mut headers = self.session_headers();
311+
if let Some((start, end)) = range {
312+
headers.push(("range".into(), format!("bytes={start}-{end}")));
313+
}
314+
headers.extend_from_slice(conditions);
315+
let request = HttpRequest::new(Method::Get, self.object_url(obj), headers);
316+
self.http.execute_stream(self.signed(request)).await
317+
}
318+
319+
/// Execute one raw ListObjectsV2 page.
320+
pub async fn execute_list_raw(
321+
&self,
322+
bucket: &str,
323+
prefix: &str,
324+
delimiter: Option<&str>,
325+
continuation_token: Option<&str>,
326+
max_keys: u32,
327+
encoding_type: Option<&str>,
328+
) -> std::result::Result<HttpResponse, String> {
329+
let mut query = format!("list-type=2&max-keys={}", max_keys.min(1000));
330+
if !prefix.is_empty() {
331+
query.push_str("&prefix=");
332+
query.push_str(&encode_query_value(prefix));
333+
}
334+
if let Some(delimiter) = delimiter {
335+
query.push_str("&delimiter=");
336+
query.push_str(&encode_query_value(delimiter));
337+
}
338+
if let Some(token) = continuation_token {
339+
query.push_str("&continuation-token=");
340+
query.push_str(&encode_query_value(token));
341+
}
342+
if let Some(encoding_type) = encoding_type {
343+
query.push_str("&encoding-type=");
344+
query.push_str(&encode_query_value(encoding_type));
345+
}
346+
let request = HttpRequest::new(
347+
Method::Get,
348+
format!("{}?{query}", self.bucket_url(bucket)),
349+
self.session_headers(),
350+
);
351+
self.http.execute(self.signed(request)).await
352+
}
353+
283354
fn build_streamed_put(
284355
&self,
285356
obj: &ObjectId,
@@ -636,6 +707,39 @@ mod tests {
636707
);
637708
}
638709

710+
#[tokio::test]
711+
async fn raw_list_v2_encodes_and_signs_gateway_parameters() {
712+
let http = MockHttp::new(HttpResponse {
713+
status: 200,
714+
headers: vec![],
715+
body: bytes::Bytes::new(),
716+
});
717+
let mut config = S3Config::aws("us-east-1");
718+
config.path_style = true;
719+
config.tls = false;
720+
config.endpoint = "localhost:4566".into();
721+
let s3 = S3Backend::new(config, creds(), http.clone());
722+
723+
s3.execute_list_raw(
724+
"my-bucket",
725+
"a/b",
726+
Some("/"),
727+
Some("next token"),
728+
7,
729+
Some("url"),
730+
)
731+
.await
732+
.unwrap();
733+
734+
let request = http.last.lock().unwrap().clone().unwrap();
735+
assert_eq!(request.method, Method::Get);
736+
assert_eq!(
737+
request.url,
738+
"http://localhost:4566/my-bucket?list-type=2&max-keys=7&prefix=a%2Fb&delimiter=%2F&continuation-token=next%20token&encoding-type=url"
739+
);
740+
assert!(request.header("authorization").is_some());
741+
}
742+
639743
#[tokio::test]
640744
async fn fetch_range_returns_body_on_206() {
641745
let http = MockHttp::new(HttpResponse {

crates/talon-backend/src/sigv4.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ fn canonical_query(query: &str) -> String {
7777
.split('&')
7878
.filter(|p| !p.is_empty())
7979
.map(|pair| match pair.split_once('=') {
80-
Some((k, v)) => (uri_encode_component(k), uri_encode_component(v)),
80+
Some((k, v)) => (uri_encode_query_component(k), uri_encode_query_component(v)),
8181
// A bare key still needs a trailing `=` in the canonical form.
82-
None => (uri_encode_component(pair), String::new()),
82+
None => (uri_encode_query_component(pair), String::new()),
8383
})
8484
.collect();
8585
pairs.sort();
@@ -90,14 +90,42 @@ fn canonical_query(query: &str) -> String {
9090
.join("&")
9191
}
9292

93+
/// Normalize a URL query component to exactly one layer of AWS encoding.
94+
///
95+
/// Request builders must percent-encode values to form a valid URL. SigV4
96+
/// canonicalization operates on their decoded bytes, so existing escapes are
97+
/// decoded before applying AWS's RFC 3986 encoding. A literal `+` remains a
98+
/// plus byte rather than form-urlencoded whitespace.
99+
fn uri_encode_query_component(value: &str) -> String {
100+
let bytes = value.as_bytes();
101+
let mut decoded = Vec::with_capacity(bytes.len());
102+
let mut index = 0;
103+
while index < bytes.len() {
104+
if bytes[index] == b'%'
105+
&& index + 2 < bytes.len()
106+
&& bytes[index + 1].is_ascii_hexdigit()
107+
&& bytes[index + 2].is_ascii_hexdigit()
108+
{
109+
let high = (bytes[index + 1] as char).to_digit(16).unwrap() as u8;
110+
let low = (bytes[index + 2] as char).to_digit(16).unwrap() as u8;
111+
decoded.push((high << 4) | low);
112+
index += 3;
113+
} else {
114+
decoded.push(bytes[index]);
115+
index += 1;
116+
}
117+
}
118+
uri_encode_bytes(&decoded)
119+
}
120+
93121
/// URI-encode a query key or value.
94122
///
95123
/// Unlike [`uri_encode_path`], `/` is **not** left literal: in a query
96124
/// component it is a reserved character and must be escaped, which matters for
97125
/// a listing prefix like `dir/sub`.
98-
fn uri_encode_component(s: &str) -> String {
99-
let mut out = String::with_capacity(s.len());
100-
for &b in s.as_bytes() {
126+
fn uri_encode_bytes(bytes: &[u8]) -> String {
127+
let mut out = String::with_capacity(bytes.len());
128+
for &b in bytes {
101129
match b {
102130
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
103131
out.push(b as char)
@@ -288,6 +316,10 @@ mod tests {
288316
#[test]
289317
fn canonical_query_escapes_slashes_in_values() {
290318
assert_eq!(canonical_query("prefix=dir/sub/"), "prefix=dir%2Fsub%2F");
319+
assert_eq!(
320+
canonical_query("prefix=dir%2Fsub%2F"),
321+
"prefix=dir%2Fsub%2F"
322+
);
291323
}
292324

293325
#[test]
@@ -306,8 +338,8 @@ mod tests {
306338
}
307339

308340
#[test]
309-
fn uri_encode_component_leaves_unreserved_characters_alone() {
310-
assert_eq!(uri_encode_component("aZ0-_.~"), "aZ0-_.~");
341+
fn uri_encode_query_component_leaves_unreserved_characters_alone() {
342+
assert_eq!(uri_encode_query_component("aZ0-_.~"), "aZ0-_.~");
311343
}
312344

313345
use super::*;

crates/talon-gateway/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod config;
55
mod metrics;
66
mod model;
77
mod runtime;
8+
pub mod s3;
89

910
pub use config::{GatewayConfig, GatewayConfigError, GatewayMode, GatewaySecurity};
1011
pub use metrics::GatewayMetrics;

0 commit comments

Comments
 (0)