1#![allow(dead_code)]
8
9use anyhow::{anyhow, bail, Context, Result};
10use rusty_s3::actions::ListObjectsV2;
11use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle};
12use std::fs::File;
13use std::io::Read;
14use std::path::Path;
15use std::time::Duration;
16
17const OP_TTL: Duration = Duration::from_secs(60);
20
21pub struct Store {
22 bucket: Bucket,
23 creds: Credentials,
24}
25
26impl Store {
27 pub fn new(bucket: &str, region: &str, endpoint: Option<&str>) -> Result<Self> {
33 let (endpoint, style) = match endpoint {
34 Some(e) => (e.to_string(), UrlStyle::Path), None => (
36 format!("https://s3.{region}.amazonaws.com"),
37 UrlStyle::VirtualHost,
38 ),
39 };
40 let bucket = Bucket::new(
41 endpoint.parse().context("parsing S3 endpoint URL")?,
42 style,
43 bucket.to_string(),
44 region.to_string(),
45 )
46 .map_err(|e| anyhow!("bucket config: {e}"))?;
47 let secrets = crate::secrets::Secrets::load()?;
51 let creds = Credentials::new(secrets.access_key_id, secrets.secret_access_key);
52 Ok(Self { bucket, creds })
53 }
54
55 pub fn presign_get(&self, key: &str, ttl: Duration) -> String {
58 sign_get(&self.bucket, &self.creds, key, ttl)
59 }
60
61 pub fn put_object(&self, key: &str, body: &[u8]) -> Result<()> {
64 let url = self.bucket.put_object(Some(&self.creds), key).sign(OP_TTL);
65 let resp = ureq::put(url.as_str())
66 .send_bytes(body)
67 .map_err(|e| anyhow!("PutObject {key} failed: {}", s3_err(e)))?;
68 if resp.status() >= 300 {
69 bail!("PutObject {key}: HTTP {}", resp.status());
70 }
71 Ok(())
72 }
73
74 pub fn delete_object(&self, key: &str) -> Result<()> {
76 let url = self
77 .bucket
78 .delete_object(Some(&self.creds), key)
79 .sign(OP_TTL);
80 let resp = ureq::delete(url.as_str())
81 .call()
82 .map_err(|e| anyhow!("DeleteObject {key} failed: {}", s3_err(e)))?;
83 if resp.status() >= 300 {
84 bail!("DeleteObject {key}: HTTP {}", resp.status());
85 }
86 Ok(())
87 }
88
89 pub fn list(&self, prefix: &str) -> Result<Vec<String>> {
92 let mut keys = Vec::new();
93 let mut continuation: Option<String> = None;
94 loop {
95 let mut action = self.bucket.list_objects_v2(Some(&self.creds));
96 action.with_prefix(prefix);
97 if let Some(token) = continuation.clone() {
98 action.with_continuation_token(token);
99 }
100 let url = action.sign(OP_TTL);
101 let resp = ureq::get(url.as_str())
102 .call()
103 .map_err(|e| anyhow!("ListObjectsV2 failed: {}", s3_err(e)))?;
104 if resp.status() >= 300 {
105 bail!("ListObjectsV2: HTTP {}", resp.status());
106 }
107 let text = resp.into_string()?;
108 let parsed = ListObjectsV2::parse_response(&text)
109 .map_err(|e| anyhow!("parsing ListObjectsV2 response: {e}"))?;
110 keys.extend(parsed.contents.into_iter().map(|o| o.key));
111 match parsed.next_continuation_token {
112 Some(token) if !token.is_empty() => continuation = Some(token),
113 _ => break,
114 }
115 }
116 Ok(keys)
117 }
118
119 pub fn put_file(
125 &self,
126 key: &str,
127 path: &Path,
128 progress: &dyn crate::progress::Progress,
129 ) -> Result<u64> {
130 let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
131 let total = file.metadata()?.len();
132 let url = self.bucket.put_object(Some(&self.creds), key).sign(OP_TTL);
133 let reader = ProgressReader {
134 inner: file,
135 uploaded: 0,
136 total,
137 progress,
138 };
139 let resp = ureq::put(url.as_str())
140 .set("Content-Length", &total.to_string())
141 .send(reader)
142 .map_err(|e| anyhow!("PutObject {key} failed: {}", s3_err(e)))?;
143 if resp.status() >= 300 {
144 bail!("PutObject {key}: HTTP {}", resp.status());
145 }
146 Ok(total)
147 }
148}
149
150struct ProgressReader<'a, R> {
153 inner: R,
154 uploaded: u64,
155 total: u64,
156 progress: &'a dyn crate::progress::Progress,
157}
158
159impl<R: Read> Read for ProgressReader<'_, R> {
160 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
161 let n = self.inner.read(buf)?;
162 self.uploaded += n as u64;
163 self.progress.bytes(self.uploaded, self.total);
164 Ok(n)
165 }
166}
167
168fn sign_get(bucket: &Bucket, creds: &Credentials, key: &str, ttl: Duration) -> String {
171 bucket.get_object(Some(creds), key).sign(ttl).to_string()
172}
173
174fn s3_err(e: ureq::Error) -> String {
177 match e {
178 ureq::Error::Status(code, _) => format!("HTTP {code}"),
179 ureq::Error::Transport(t) => t.kind().to_string(),
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn presign_get_signs_a_url_with_key_and_expiry() {
189 let bucket = Bucket::new(
190 "https://s3.us-east-1.amazonaws.com".parse().unwrap(),
191 UrlStyle::VirtualHost,
192 "dove-shares-example".to_string(),
193 "us-east-1".to_string(),
194 )
195 .unwrap();
196 let creds = Credentials::new("AKIAEXAMPLE", "secretexample");
197 let url = sign_get(
198 &bucket,
199 &creds,
200 "abc123/report.pdf",
201 Duration::from_secs(3600),
202 );
203 assert!(url.contains("report.pdf"), "key not in URL: {url}");
204 assert!(
205 url.contains("X-Amz-Expires=3600"),
206 "expiry not in URL: {url}"
207 );
208 assert!(url.contains("X-Amz-Signature="), "not signed: {url}");
209 }
210
211 #[test]
212 fn progress_reader_reports_cumulative_bytes() {
213 use std::cell::Cell;
214
215 struct Recorder {
218 last: Cell<u64>,
219 }
220 impl crate::progress::Progress for Recorder {
221 fn step(&self, _: &str) {}
222 fn done(&self, _: &str) {}
223 fn field(&self, _: &str, _: &str) {}
224 fn bytes(&self, uploaded: u64, _total: u64) {
225 self.last.set(uploaded);
226 }
227 }
228
229 let recorder = Recorder { last: Cell::new(0) };
230 let mut r = ProgressReader {
231 inner: std::io::Cursor::new(vec![0u8; 100]),
232 uploaded: 0,
233 total: 100,
234 progress: &recorder,
235 };
236 let mut buf = [0u8; 30];
237 let mut total = 0;
238 loop {
239 let n = r.read(&mut buf).unwrap();
240 if n == 0 {
241 break;
242 }
243 total += n;
244 }
245 drop(r); assert_eq!(total, 100);
247 assert_eq!(recorder.last.get(), 100);
248 }
249
250 #[test]
251 fn s3_err_does_not_leak_signed_params() {
252 let err = ureq::get("http://127.0.0.1:1/x?X-Amz-Signature=leak")
255 .call()
256 .unwrap_err();
257 let msg = s3_err(err);
258 assert!(!msg.contains("X-Amz"), "leaked signed params: {msg}");
259 }
260}