Skip to main content

s3_wire/client/
presign.rs

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    /// Generates a presigned GET URL whose standard formatting is redacted.
16    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    /// Generates a presigned PUT URL whose standard formatting is redacted.
25    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    /// Generates a presigned HEAD URL whose standard formatting is redacted.
34    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    /// Generates a presigned DELETE URL whose standard formatting is redacted.
43    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    /// Generates a presigned request that initiates a multipart upload.
52    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    /// Generates a presigned multipart part-upload URL.
61    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    /// Generates a presigned multipart abort URL.
82    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}