Skip to main content

yuzu_source_s3/
lib.rs

1//! Generic S3 `ObjectSource` for `yuzu-data` — reads objects from any
2//! S3-compatible store (Cloudflare R2, AWS S3, MinIO, GCS-S3) over HTTPS with
3//! SigV4-presigned GETs. Endpoint is configurable, so nothing here is
4//! Cloudflare-specific; the container points it at R2, OSS users at anything.
5//!
6//! `yuzu-data` stays pure (trait + `LocalSource`); cloud access lives here.
7
8use std::time::Duration;
9
10use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle};
11use yuzu_data::error::DataError;
12use yuzu_data::{ObjectSink, ObjectSource};
13
14/// How long a presigned GET URL stays valid — generous, single-shot use.
15const SIGN_TTL: Duration = Duration::from_secs(300);
16
17/// An `ObjectSource` backed by an S3-compatible bucket.
18pub struct S3Source {
19    bucket: Bucket,
20    creds: Credentials,
21}
22
23impl S3Source {
24    /// `endpoint` is the host root (no bucket), e.g.
25    /// `https://<acct>.r2.cloudflarestorage.com`. Path-style addressing, so the
26    /// bucket is appended to the path (works with custom endpoints).
27    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    /// Build from `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` /
46    /// `S3_SECRET_ACCESS_KEY` (+ optional `S3_REGION`, default `auto`).
47    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            &region,
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                // Per-symbol gzip files are small, but lift the default body cap
69                // so a large universe file can't get rejected mid-load.
70                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    /// Minimal stub S3: serves "hi" for any key, 404 for keys containing
105    /// "missing". Handles exactly `n` connections then exits.
106    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    /// Serves `body` once with a `Content-Encoding: gzip` header — to prove the
130    /// client does NOT transparently decompress (objects are `.csv.gz` and the
131    /// loaders gunzip them; auto-decompress would corrupt that).
132    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        // Raw gzip bytes must come back verbatim (magic 0x1f 0x8b intact), NOT decompressed.
169        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); // existing stub: 200 for non-"missing" keys
176        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    /// A bound-then-closed port: connections are refused, so both GET and PUT hit
190    /// the non-404 error arms (transport error, not a status code).
191    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); // free the port so connects are refused
195        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        // These S3_* vars are used by no other test in this crate, so mutating the
214        // process environment here is safe within this test binary.
215        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        // Missing required var → Err naming the var.
225        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        // S3_REGION left unset → defaults to "auto".
235        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}