Skip to main content

dove_core/
s3.rs

1//! The S3 layer: upload a share, sign a presigned download URL, delete, list.
2//! Built on `rusty-s3` + `ureq`, matching git-ark. Credentials come from the
3//! operator's own AWS profile (via the AWS CLI) — the simple tier signs with
4//! your credentials directly, so there's no separate IAM user and no host.
5
6// Methods are consumed by later tasks (`share` / `ls` / `revoke`).
7#![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
17/// TTL for internally-signed operation URLs (put/delete/list). Short — these
18/// are signed and used immediately, never handed out.
19const OP_TTL: Duration = Duration::from_secs(60);
20
21pub struct Store {
22    bucket: Bucket,
23    creds: Credentials,
24}
25
26impl Store {
27    /// Build the store from the resolved bucket/region/endpoint, resolving
28    /// credentials from `secrets.toml`. Takes plain fields rather than the
29    /// CLI's `Config` type: `Config` doesn't live in dove-core (it becomes the
30    /// backend registry in a later extraction step), so this constructor
31    /// stays decoupled from it — callers pass the fields they already have.
32    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), // S3-compatible → path-style
35            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        // Sign with dove's scoped IAM key (minted by `provision`), never your
48        // full account creds. It's a long-term key, so presigned URLs get the
49        // full requested lifetime (SSO/temp creds would cap it short).
50        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    /// A presigned GET URL for `key`, valid for `ttl`. This is the link handed
56    /// to a recipient; `ttl` is capped at 7 days by the caller (SigV4 limit).
57    pub fn presign_get(&self, key: &str, ttl: Duration) -> String {
58        sign_get(&self.bucket, &self.creds, key, ttl)
59    }
60
61    /// Upload `body` to `key`. (Whole-object PUT; multipart streaming for very
62    /// large files is a follow-up that pairs with the chunked-encryption work.)
63    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    /// Delete `key` — how `dove revoke` kills a share early (the link then 404s).
75    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    /// All object keys under `prefix`, following continuation tokens so listings
90    /// past the first 1000 aren't silently dropped.
91    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    /// Stream a file to `key`, reporting cumulative bytes uploaded via
120    /// `progress.bytes(uploaded, total)` as it goes. Content-Length is set
121    /// from the file size, so S3 gets a single sized PUT and the body streams
122    /// rather than buffering the whole file in memory. Returns the total byte
123    /// count.
124    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
150/// A `Read` that reports cumulative bytes read through `Progress::bytes` — how
151/// upload progress is driven, without the S3 layer knowing about the UI.
152struct 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
168/// Sign a presigned GET URL. Free function so it's unit-testable with fixed
169/// credentials, without resolving anything from the environment.
170fn sign_get(bucket: &Bucket, creds: &Credentials, key: &str, ttl: Duration) -> String {
171    bucket.get_object(Some(creds), key).sign(ttl).to_string()
172}
173
174/// Format a ureq error WITHOUT leaking the signed request URL (which carries
175/// `X-Amz-Credential` / `X-Amz-Signature`). Never interpolate a signed URL.
176fn 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        // A tiny test `Progress` that just records the last `bytes(uploaded, _)`
216        // call — standing in for the closure this used to drive directly.
217        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); // release the borrow on `recorder`
246        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        // A real transport error to an unreachable endpoint whose URL carries
253        // signed params — s3_err must not surface them.
254        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}