Skip to main content

git_ark/
s3.rs

1use crate::config::{AwsSecrets, S3Config};
2use crate::store::ObjectStore;
3use anyhow::{anyhow, bail, Context, Result};
4use rusty_s3::actions::ListObjectsV2;
5use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle};
6use std::io::Read;
7use std::time::Duration;
8
9const SIGN_TTL: Duration = Duration::from_secs(300);
10
11/// Format a ureq error for logging WITHOUT leaking the (presigned) request URL.
12///
13/// `ureq::Error`'s `Display` writes the request URL for both variants
14/// (`Status` via `response.get_url()`, `Transport` via its own `url` field),
15/// and every URL we sign carries `X-Amz-Credential=<access key id>` plus a
16/// replayable `X-Amz-Signature`. Never format `{e}` directly on an error
17/// from a presigned request — use this instead.
18fn s3_err(e: ureq::Error) -> String {
19    match e {
20        ureq::Error::Status(code, _resp) => format!("HTTP {code}"),
21        ureq::Error::Transport(t) => format!("transport error: {}", t.kind()),
22    }
23}
24
25/// Compare an S3 `ETag` response header against the MD5 of the uploaded body.
26///
27/// Returns `None` when the ETag is not a plain MD5 — SSE-KMS and multipart
28/// uploads produce ETags that are *not* the object's MD5 (multipart ETags look
29/// like `"<hex>-<partcount>"`), so a mismatch there is expected and must never
30/// be treated as corruption. Only when the ETag is exactly 32 hex chars can we
31/// verify it, and only then do we return `Some(true/false)`.
32fn etag_matches(etag: &str, body: &[u8]) -> Option<bool> {
33    let e = etag.trim().trim_matches('"').to_ascii_lowercase();
34    if e.len() != 32 || !e.bytes().all(|b| b.is_ascii_hexdigit()) {
35        return None;
36    }
37    Some(format!("{:x}", md5::compute(body)) == e)
38}
39
40pub struct S3ObjectStore {
41    bucket: Bucket,
42    creds: Credentials,
43}
44
45impl S3ObjectStore {
46    pub fn new(cfg: &S3Config, creds: &AwsSecrets) -> Result<Self> {
47        // Endpoint precedence: config → env (back-compat for tests) → real AWS.
48        let endpoint_override = cfg
49            .endpoint
50            .clone()
51            .or_else(|| std::env::var("GIT_ARK_S3_ENDPOINT").ok());
52        let endpoint = match &endpoint_override {
53            Some(e) => e.clone(),
54            None => format!("https://s3.{}.amazonaws.com", cfg.region),
55        };
56        let style = if endpoint_override.is_some() {
57            UrlStyle::Path // MinIO wants path-style
58        } else {
59            UrlStyle::VirtualHost
60        };
61        let bucket = Bucket::new(
62            endpoint.parse().context("parsing S3 endpoint URL")?,
63            style,
64            cfg.bucket.clone(),
65            cfg.region.clone(),
66        )
67        .map_err(|e| anyhow!("bucket config: {e}"))?;
68        // A non-empty session_token means SSO/STS temporary creds (used by
69        // `restore` on a trusted machine); static keys otherwise.
70        let creds = match creds.session_token.as_deref().filter(|t| !t.is_empty()) {
71            Some(token) => Credentials::new_with_token(
72                creds.access_key_id.clone(),
73                creds.secret_access_key.clone(),
74                token.to_string(),
75            ),
76            None => Credentials::new(creds.access_key_id.clone(), creds.secret_access_key.clone()),
77        };
78        Ok(Self { bucket, creds })
79    }
80}
81
82impl ObjectStore for S3ObjectStore {
83    fn put(&self, key: &str, body: &[u8]) -> Result<()> {
84        let action = self.bucket.put_object(Some(&self.creds), key);
85        let url = action.sign(SIGN_TTL);
86        let resp = ureq::put(url.as_str())
87            .send_bytes(body)
88            .map_err(|e| anyhow!("PutObject {key} failed: {}", s3_err(e)))?;
89        if resp.status() >= 300 {
90            bail!("PutObject {key}: HTTP {}", resp.status());
91        }
92        // Verify the upload against the returned ETag when it's a plain MD5.
93        // `None` (SSE-KMS/multipart) and `Some(true)` proceed; only a proven
94        // mismatch aborts — an undetected corrupt backup is unrecoverable.
95        if let Some(etag) = resp.header("etag") {
96            if etag_matches(etag, body) == Some(false) {
97                bail!(
98                    "PutObject {key}: upload integrity check failed \
99                     (ETag != MD5) — possible corruption"
100                );
101            }
102        }
103        Ok(())
104    }
105
106    fn get(&self, key: &str) -> Result<Vec<u8>> {
107        let action = self.bucket.get_object(Some(&self.creds), key);
108        let url = action.sign(SIGN_TTL);
109        let resp = ureq::get(url.as_str())
110            .call()
111            .map_err(|e| anyhow!("GetObject {key} failed: {}", s3_err(e)))?;
112        if resp.status() >= 300 {
113            bail!("GetObject {key}: HTTP {}", resp.status());
114        }
115        let mut buf = Vec::new();
116        resp.into_reader().read_to_end(&mut buf)?;
117        Ok(buf)
118    }
119
120    fn list(&self, prefix: &str) -> Result<Vec<String>> {
121        // A single ListObjectsV2 response holds at most 1000 keys; loop on the
122        // continuation token until the listing is no longer truncated so we
123        // never silently drop history entries past the first page.
124        let mut keys = Vec::new();
125        let mut continuation: Option<String> = None;
126        loop {
127            let mut action = self.bucket.list_objects_v2(Some(&self.creds));
128            action.with_prefix(prefix);
129            if let Some(token) = continuation.clone() {
130                action.with_continuation_token(token);
131            }
132            let url = action.sign(SIGN_TTL);
133            let resp = ureq::get(url.as_str())
134                .call()
135                .map_err(|e| anyhow!("ListObjectsV2 {prefix} failed: {}", s3_err(e)))?;
136            if resp.status() >= 300 {
137                bail!("ListObjectsV2 {prefix}: HTTP {}", resp.status());
138            }
139            let text = resp.into_string()?;
140            let parsed = ListObjectsV2::parse_response(&text)
141                .map_err(|e| anyhow!("parsing ListObjectsV2 response: {e}"))?;
142            keys.extend(parsed.contents.into_iter().map(|o| o.key));
143            match parsed.next_continuation_token {
144                Some(token) if !token.is_empty() => continuation = Some(token),
145                _ => break,
146            }
147        }
148        Ok(keys)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn errors_do_not_leak_presigned_url() {
158        // Unreachable endpoint → transport error path.
159        std::env::set_var("GIT_ARK_S3_ENDPOINT", "http://127.0.0.1:1");
160        let cfg = S3Config {
161            bucket: "b".into(),
162            region: "us-east-1".into(),
163            prefix: "git-ark".into(),
164            endpoint: None,
165        };
166        let creds = AwsSecrets {
167            access_key_id: "AKIAEXAMPLELEAKCHECK".into(),
168            secret_access_key: "s".into(),
169            session_token: None,
170        };
171        let store = S3ObjectStore::new(&cfg, &creds).unwrap();
172        let err = store.put("git-ark/x/latest.age", b"data").unwrap_err();
173        std::env::remove_var("GIT_ARK_S3_ENDPOINT");
174        let msg = format!("{err:#}");
175        assert!(!msg.contains("X-Amz"), "leaked signed query params: {msg}");
176        assert!(
177            !msg.contains("AKIAEXAMPLELEAKCHECK"),
178            "leaked access key id: {msg}"
179        );
180    }
181
182    #[test]
183    fn etag_matches_verifies_plain_md5_only() {
184        // md5("hello") = 5d41402abc4b2a76b9719d911017c592
185        let good = "5d41402abc4b2a76b9719d911017c592";
186        assert_eq!(etag_matches(good, b"hello"), Some(true));
187        // S3 wraps the ETag in double quotes — must still verify.
188        assert_eq!(etag_matches(&format!("\"{good}\""), b"hello"), Some(true));
189        // A valid-shaped 32-hex ETag that doesn't match → proven corruption.
190        assert_eq!(
191            etag_matches("00000000000000000000000000000000", b"hello"),
192            Some(false)
193        );
194        // Multipart / SSE-KMS ETags are not 32-hex → unverifiable (None).
195        assert_eq!(
196            etag_matches("\"d41d8cd98f00b204e9800998ecf8427e-2\"", b"hello"),
197            None
198        );
199        assert_eq!(etag_matches("not-a-hash", b"hello"), None);
200    }
201}