use std::time::Duration;
use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle};
use yuzu_data::error::DataError;
use yuzu_data::{ObjectSink, ObjectSource};
const SIGN_TTL: Duration = Duration::from_secs(300);
pub struct S3Source {
bucket: Bucket,
creds: Credentials,
}
impl S3Source {
pub fn new(
endpoint: &str,
bucket: &str,
access_key: &str,
secret_key: &str,
region: &str,
) -> Result<Self, DataError> {
let url = endpoint
.parse()
.map_err(|e| DataError::Io(format!("bad S3 endpoint {endpoint:?}: {e}")))?;
let bucket = Bucket::new(url, UrlStyle::Path, bucket.to_string(), region.to_string())
.map_err(|e| DataError::Io(format!("bad S3 bucket: {e}")))?;
Ok(Self {
bucket,
creds: Credentials::new(access_key, secret_key),
})
}
pub fn from_env() -> Result<Self, DataError> {
let var = |k: &str| std::env::var(k).map_err(|_| DataError::Io(format!("missing env {k}")));
let region = std::env::var("S3_REGION").unwrap_or_else(|_| "auto".to_string());
Self::new(
&var("S3_ENDPOINT")?,
&var("S3_BUCKET")?,
&var("S3_ACCESS_KEY_ID")?,
&var("S3_SECRET_ACCESS_KEY")?,
®ion,
)
}
}
impl ObjectSource for S3Source {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, DataError> {
let url = self
.bucket
.get_object(Some(&self.creds), key)
.sign(SIGN_TTL);
match ureq::get(url.as_str()).call() {
Ok(resp) => {
let bytes = resp
.into_body()
.with_config()
.limit(256 * 1024 * 1024)
.read_to_vec()
.map_err(|e| DataError::Io(format!("read {key}: {e}")))?;
Ok(Some(bytes))
}
Err(ureq::Error::StatusCode(404)) => Ok(None),
Err(e) => Err(DataError::Io(format!("GET {key}: {e}"))),
}
}
}
impl ObjectSink for S3Source {
fn put(&self, key: &str, bytes: &[u8]) -> Result<(), DataError> {
let url = self
.bucket
.put_object(Some(&self.creds), key)
.sign(SIGN_TTL);
match ureq::put(url.as_str()).send(bytes) {
Ok(_) => Ok(()),
Err(e) => Err(DataError::Io(format!("PUT {key}: {e}"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn spawn_stub(n: usize) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
for _ in 0..n {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 2048];
let read = sock.read(&mut buf).unwrap();
let line = String::from_utf8_lossy(&buf[..read]);
let first = line.lines().next().unwrap_or("");
let resp = if first.contains("missing") {
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string()
} else {
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"
.to_string()
};
sock.write_all(resp.as_bytes()).unwrap();
}
});
format!("http://{addr}")
}
fn spawn_encoded_stub(body: Vec<u8>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).unwrap();
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
sock.write_all(head.as_bytes()).unwrap();
sock.write_all(&body).unwrap();
});
format!("http://{addr}")
}
#[test]
fn get_returns_bytes_and_none_on_404() {
let endpoint = spawn_stub(2);
let src = S3Source::new(&endpoint, "bucket", "ak", "sk", "auto").unwrap();
assert_eq!(src.get("prices/AAPL.csv.gz").unwrap(), Some(b"hi".to_vec()));
assert_eq!(src.get("prices/missing.csv.gz").unwrap(), None);
}
#[test]
fn get_does_not_decompress_content_encoding_gzip() {
use flate2::write::GzEncoder;
use flate2::Compression;
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(b"hello,world\n").unwrap();
let gz = enc.finish().unwrap();
let endpoint = spawn_encoded_stub(gz.clone());
let src = S3Source::new(&endpoint, "bucket", "ak", "sk", "auto").unwrap();
let got = src.get("fundamentals/AAA.csv.gz").unwrap().unwrap();
assert_eq!(got, gz, "S3Source must return raw stored bytes");
assert_eq!(&got[..2], &[0x1f, 0x8b]);
}
#[test]
fn put_returns_ok_on_2xx() {
let endpoint = spawn_stub(1); let src = S3Source::new(&endpoint, "bucket", "ak", "sk", "auto").unwrap();
src.put("panels/close.csv.gz", b"gzip-bytes").unwrap();
}
#[test]
fn new_rejects_a_malformed_endpoint() {
let err = match S3Source::new("not a url", "bucket", "ak", "sk", "auto") {
Err(e) => e,
Ok(_) => panic!("expected a malformed-endpoint error"),
};
assert!(matches!(err, DataError::Io(_)));
}
fn dead_endpoint() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener); format!("http://{addr}")
}
#[test]
fn get_and_put_surface_transport_errors() {
let src = S3Source::new(&dead_endpoint(), "bucket", "ak", "sk", "auto").unwrap();
assert!(matches!(
src.get("prices/AAA.csv.gz"),
Err(DataError::Io(_))
));
assert!(matches!(
src.put("panels/close.csv.gz", b"x"),
Err(DataError::Io(_))
));
}
#[test]
fn from_env_reads_vars_and_reports_missing() {
for k in [
"S3_ENDPOINT",
"S3_BUCKET",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"S3_REGION",
] {
std::env::remove_var(k);
}
assert!(matches!(
S3Source::from_env(),
Err(DataError::Io(ref m)) if m.contains("S3_ENDPOINT")
));
std::env::set_var("S3_ENDPOINT", "https://example.r2.cloudflarestorage.com");
std::env::set_var("S3_BUCKET", "bucket");
std::env::set_var("S3_ACCESS_KEY_ID", "ak");
std::env::set_var("S3_SECRET_ACCESS_KEY", "sk");
S3Source::from_env().expect("from_env builds when all required vars are present");
for k in [
"S3_ENDPOINT",
"S3_BUCKET",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
] {
std::env::remove_var(k);
}
}
}