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
11fn 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
25fn 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 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 } 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 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 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 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 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 let good = "5d41402abc4b2a76b9719d911017c592";
186 assert_eq!(etag_matches(good, b"hello"), Some(true));
187 assert_eq!(etag_matches(&format!("\"{good}\""), b"hello"), Some(true));
189 assert_eq!(
191 etag_matches("00000000000000000000000000000000", b"hello"),
192 Some(false)
193 );
194 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}