1use std::time::Duration;
2
3use secrecy::ExposeSecret;
4use time::OffsetDateTime;
5
6use super::S3Client;
7use crate::error::{ErrorCategory, RetryClassification, S3Error};
8use crate::operation::{ObjectKey, PartNumber, PresignedUrl, UploadId};
9use crate::signing::{
10 Header, PresigningRequest, QueryParam, SigningCredentials, SigningPath, SigningScope,
11 sign_presigned,
12};
13
14impl S3Client {
15 pub async fn presigned_get(
17 &self,
18 key: &ObjectKey,
19 expires: Duration,
20 ) -> Result<PresignedUrl, S3Error> {
21 self.presign("GET", key, &[], expires).await
22 }
23
24 pub async fn presigned_put(
26 &self,
27 key: &ObjectKey,
28 expires: Duration,
29 ) -> Result<PresignedUrl, S3Error> {
30 self.presign("PUT", key, &[], expires).await
31 }
32
33 pub async fn presigned_head(
35 &self,
36 key: &ObjectKey,
37 expires: Duration,
38 ) -> Result<PresignedUrl, S3Error> {
39 self.presign("HEAD", key, &[], expires).await
40 }
41
42 pub async fn presigned_delete(
44 &self,
45 key: &ObjectKey,
46 expires: Duration,
47 ) -> Result<PresignedUrl, S3Error> {
48 self.presign("DELETE", key, &[], expires).await
49 }
50
51 pub async fn presigned_create_multipart_upload(
53 &self,
54 key: &ObjectKey,
55 expires: Duration,
56 ) -> Result<PresignedUrl, S3Error> {
57 self.presign("POST", key, &[("uploads", "")], expires).await
58 }
59
60 pub async fn presigned_upload_part(
62 &self,
63 key: &ObjectKey,
64 upload_id: &UploadId,
65 part_number: PartNumber,
66 expires: Duration,
67 ) -> Result<PresignedUrl, S3Error> {
68 let part_number = part_number.get().to_string();
69 self.presign(
70 "PUT",
71 key,
72 &[
73 ("partNumber", part_number.as_str()),
74 ("uploadId", upload_id.as_str()),
75 ],
76 expires,
77 )
78 .await
79 }
80
81 pub async fn presigned_abort_multipart_upload(
83 &self,
84 key: &ObjectKey,
85 upload_id: &UploadId,
86 expires: Duration,
87 ) -> Result<PresignedUrl, S3Error> {
88 self.presign("DELETE", key, &[("uploadId", upload_id.as_str())], expires)
89 .await
90 }
91
92 async fn presign(
93 &self,
94 method: &'static str,
95 key: &ObjectKey,
96 query: &[(&str, &str)],
97 expires: Duration,
98 ) -> Result<PresignedUrl, S3Error> {
99 let target = self.operation_target(Some(key.as_str()))?;
100 let credentials = self
101 .inner
102 .config
103 .credentials_provider()
104 .provide_credentials()
105 .await?;
106 let now = OffsetDateTime::now_utc();
107 if credentials.expires_by(now) {
108 return Err(S3Error::new(
109 ErrorCategory::Authentication,
110 "credential provider returned expired credentials",
111 RetryClassification::Never,
112 ));
113 }
114 let expires_at = time::Duration::try_from(expires)
115 .ok()
116 .and_then(|duration| now.checked_add(duration));
117 if credentials.expires_at().is_some_and(|credential_expiry| {
118 expires_at.is_none_or(|url_expiry| url_expiry >= credential_expiry)
119 }) {
120 return Err(S3Error::new(
121 ErrorCategory::Authentication,
122 "presigned URL lifetime must end before the credentials expire",
123 RetryClassification::Never,
124 ));
125 }
126 let secret = credentials.secret_access_key().expose_secret();
127 let session_token = credentials.session_token().map(ExposeSecret::expose_secret);
128 let signing_credentials = SigningCredentials::new(
129 credentials.access_key_id(),
130 secret.as_bytes(),
131 session_token,
132 );
133 let headers = [Header::new("host", target.authority())];
134 let query = query
135 .iter()
136 .map(|(name, value)| QueryParam::new(name, value))
137 .collect::<Vec<_>>();
138 let signing_request = PresigningRequest {
139 method,
140 uri_path: SigningPath::encoded(target.path_and_query()),
141 query: &query,
142 headers: &headers,
143 expires,
144 payload_hash: None,
145 };
146 let query: crate::signing::PresignedQuery = sign_presigned(
147 &signing_credentials,
148 SigningScope::new(self.inner.config.region(), "s3"),
149 &signing_request,
150 now,
151 )
152 .map_err(|error| {
153 S3Error::new(
154 ErrorCategory::Authentication,
155 "presigned URL generation failed",
156 RetryClassification::Never,
157 )
158 .with_source(error)
159 })?;
160 Ok(PresignedUrl::new(format!(
161 "{}?{}",
162 target.as_str(),
163 query.as_str()
164 )))
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use std::sync::Arc;
171
172 use super::*;
173 use crate::config::S3Config;
174 use crate::credentials::{Credentials, StaticCredentialsProvider};
175
176 #[tokio::test]
177 async fn presigned_output_requires_explicit_exposure() {
178 let credentials = Credentials::new("access", "secret", None).unwrap();
179 let config = S3Config::builder()
180 .bucket("bucket")
181 .credentials_provider(Arc::new(StaticCredentialsProvider::new(credentials)))
182 .build()
183 .unwrap();
184 let client = S3Client::new(config).unwrap();
185 let url = client
186 .presigned_get(&ObjectKey::new("a/../b").unwrap(), Duration::from_secs(60))
187 .await
188 .unwrap();
189 assert!(!format!("{url:?}").contains("X-Amz-Signature"));
190 assert!(url.expose().contains("/bucket/a/../b?"));
191 assert!(url.expose().contains("X-Amz-Signature="));
192
193 let upload_id = UploadId::new("opaque/upload+id").unwrap();
194 let multipart = client
195 .presigned_upload_part(
196 &ObjectKey::new("multipart").unwrap(),
197 &upload_id,
198 PartNumber::new(7).unwrap(),
199 Duration::from_secs(60),
200 )
201 .await
202 .unwrap();
203 assert!(multipart.expose().contains("partNumber=7"));
204 assert!(multipart.expose().contains("uploadId=opaque%2Fupload%2Bid"));
205 assert!(multipart.expose().contains("X-Amz-Signature="));
206 }
207
208 #[tokio::test]
209 async fn presigned_url_must_expire_before_session_credentials() {
210 let credentials = Credentials::with_expiration(
211 "access",
212 "secret",
213 Some("session".to_owned()),
214 Some(OffsetDateTime::now_utc() + time::Duration::minutes(5)),
215 )
216 .unwrap();
217 let config = S3Config::builder()
218 .bucket("bucket")
219 .credentials_provider(Arc::new(StaticCredentialsProvider::new(credentials)))
220 .build()
221 .unwrap();
222 let client = S3Client::new(config).unwrap();
223
224 let error = client
225 .presigned_get(&ObjectKey::new("key").unwrap(), Duration::from_secs(5 * 60))
226 .await
227 .expect_err("URL outliving credentials is rejected");
228
229 assert_eq!(error.category(), ErrorCategory::Authentication);
230 }
231}