1use std::time::Duration;
9
10use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle};
11use yuzu_data::error::DataError;
12use yuzu_data::{ObjectSink, ObjectSource};
13
14const SIGN_TTL: Duration = Duration::from_secs(300);
16
17pub struct S3Source {
19 bucket: Bucket,
20 creds: Credentials,
21}
22
23impl S3Source {
24 pub fn new(
28 endpoint: &str,
29 bucket: &str,
30 access_key: &str,
31 secret_key: &str,
32 region: &str,
33 ) -> Result<Self, DataError> {
34 let url = endpoint
35 .parse()
36 .map_err(|e| DataError::Io(format!("bad S3 endpoint {endpoint:?}: {e}")))?;
37 let bucket = Bucket::new(url, UrlStyle::Path, bucket.to_string(), region.to_string())
38 .map_err(|e| DataError::Io(format!("bad S3 bucket: {e}")))?;
39 Ok(Self {
40 bucket,
41 creds: Credentials::new(access_key, secret_key),
42 })
43 }
44
45 pub fn from_env() -> Result<Self, DataError> {
48 let var = |k: &str| std::env::var(k).map_err(|_| DataError::Io(format!("missing env {k}")));
49 let region = std::env::var("S3_REGION").unwrap_or_else(|_| "auto".to_string());
50 Self::new(
51 &var("S3_ENDPOINT")?,
52 &var("S3_BUCKET")?,
53 &var("S3_ACCESS_KEY_ID")?,
54 &var("S3_SECRET_ACCESS_KEY")?,
55 ®ion,
56 )
57 }
58}
59
60impl ObjectSource for S3Source {
61 fn get(&self, key: &str) -> Result<Option<Vec<u8>>, DataError> {
62 let url = self
63 .bucket
64 .get_object(Some(&self.creds), key)
65 .sign(SIGN_TTL);
66 match ureq::get(url.as_str()).call() {
67 Ok(resp) => {
68 let bytes = resp
71 .into_body()
72 .with_config()
73 .limit(256 * 1024 * 1024)
74 .read_to_vec()
75 .map_err(|e| DataError::Io(format!("read {key}: {e}")))?;
76 Ok(Some(bytes))
77 }
78 Err(ureq::Error::StatusCode(404)) => Ok(None),
79 Err(e) => Err(DataError::Io(format!("GET {key}: {e}"))),
80 }
81 }
82}
83
84impl ObjectSink for S3Source {
85 fn put(&self, key: &str, bytes: &[u8]) -> Result<(), DataError> {
86 let url = self
87 .bucket
88 .put_object(Some(&self.creds), key)
89 .sign(SIGN_TTL);
90 match ureq::put(url.as_str()).send(bytes) {
91 Ok(_) => Ok(()),
92 Err(e) => Err(DataError::Io(format!("PUT {key}: {e}"))),
93 }
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use std::io::{Read, Write};
101 use std::net::TcpListener;
102 use std::thread;
103
104 fn spawn_stub(n: usize) -> String {
107 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
108 let addr = listener.local_addr().unwrap();
109 thread::spawn(move || {
110 for _ in 0..n {
111 let (mut sock, _) = listener.accept().unwrap();
112 let mut buf = [0u8; 2048];
113 let read = sock.read(&mut buf).unwrap();
114 let line = String::from_utf8_lossy(&buf[..read]);
115 let first = line.lines().next().unwrap_or("");
116 let resp = if first.contains("missing") {
117 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
118 .to_string()
119 } else {
120 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"
121 .to_string()
122 };
123 sock.write_all(resp.as_bytes()).unwrap();
124 }
125 });
126 format!("http://{addr}")
127 }
128
129 fn spawn_encoded_stub(body: Vec<u8>) -> String {
133 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
134 let addr = listener.local_addr().unwrap();
135 thread::spawn(move || {
136 let (mut sock, _) = listener.accept().unwrap();
137 let mut buf = [0u8; 2048];
138 let _ = sock.read(&mut buf).unwrap();
139 let head = format!(
140 "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
141 body.len()
142 );
143 sock.write_all(head.as_bytes()).unwrap();
144 sock.write_all(&body).unwrap();
145 });
146 format!("http://{addr}")
147 }
148
149 #[test]
150 fn get_returns_bytes_and_none_on_404() {
151 let endpoint = spawn_stub(2);
152 let src = S3Source::new(&endpoint, "bucket", "ak", "sk", "auto").unwrap();
153 assert_eq!(src.get("prices/AAPL.csv.gz").unwrap(), Some(b"hi".to_vec()));
154 assert_eq!(src.get("prices/missing.csv.gz").unwrap(), None);
155 }
156
157 #[test]
158 fn get_does_not_decompress_content_encoding_gzip() {
159 use flate2::write::GzEncoder;
160 use flate2::Compression;
161 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
162 enc.write_all(b"hello,world\n").unwrap();
163 let gz = enc.finish().unwrap();
164
165 let endpoint = spawn_encoded_stub(gz.clone());
166 let src = S3Source::new(&endpoint, "bucket", "ak", "sk", "auto").unwrap();
167 let got = src.get("fundamentals/AAA.csv.gz").unwrap().unwrap();
168 assert_eq!(got, gz, "S3Source must return raw stored bytes");
170 assert_eq!(&got[..2], &[0x1f, 0x8b]);
171 }
172
173 #[test]
174 fn put_returns_ok_on_2xx() {
175 let endpoint = spawn_stub(1); let src = S3Source::new(&endpoint, "bucket", "ak", "sk", "auto").unwrap();
177 src.put("panels/close.csv.gz", b"gzip-bytes").unwrap();
178 }
179
180 #[test]
181 fn new_rejects_a_malformed_endpoint() {
182 let err = match S3Source::new("not a url", "bucket", "ak", "sk", "auto") {
183 Err(e) => e,
184 Ok(_) => panic!("expected a malformed-endpoint error"),
185 };
186 assert!(matches!(err, DataError::Io(_)));
187 }
188
189 fn dead_endpoint() -> String {
192 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
193 let addr = listener.local_addr().unwrap();
194 drop(listener); format!("http://{addr}")
196 }
197
198 #[test]
199 fn get_and_put_surface_transport_errors() {
200 let src = S3Source::new(&dead_endpoint(), "bucket", "ak", "sk", "auto").unwrap();
201 assert!(matches!(
202 src.get("prices/AAA.csv.gz"),
203 Err(DataError::Io(_))
204 ));
205 assert!(matches!(
206 src.put("panels/close.csv.gz", b"x"),
207 Err(DataError::Io(_))
208 ));
209 }
210
211 #[test]
212 fn from_env_reads_vars_and_reports_missing() {
213 for k in [
216 "S3_ENDPOINT",
217 "S3_BUCKET",
218 "S3_ACCESS_KEY_ID",
219 "S3_SECRET_ACCESS_KEY",
220 "S3_REGION",
221 ] {
222 std::env::remove_var(k);
223 }
224 assert!(matches!(
226 S3Source::from_env(),
227 Err(DataError::Io(ref m)) if m.contains("S3_ENDPOINT")
228 ));
229
230 std::env::set_var("S3_ENDPOINT", "https://example.r2.cloudflarestorage.com");
231 std::env::set_var("S3_BUCKET", "bucket");
232 std::env::set_var("S3_ACCESS_KEY_ID", "ak");
233 std::env::set_var("S3_SECRET_ACCESS_KEY", "sk");
234 S3Source::from_env().expect("from_env builds when all required vars are present");
236
237 for k in [
238 "S3_ENDPOINT",
239 "S3_BUCKET",
240 "S3_ACCESS_KEY_ID",
241 "S3_SECRET_ACCESS_KEY",
242 ] {
243 std::env::remove_var(k);
244 }
245 }
246}