1use futures_util::future::BoxFuture;
31
32use crate::error::{Error, ErrorKind};
33use crate::region::Region;
34
35pub trait BuildServices: Send + Sync {
37 fn caller_account(&self) -> BoxFuture<'_, Result<String, Error>>;
39
40 fn put_object<'a>(
42 &'a self,
43 bucket: &'a str,
44 key: &'a str,
45 bytes: Vec<u8>,
46 ) -> BoxFuture<'a, Result<(), Error>>;
47}
48
49pub struct SignedBuildServices {
51 region: Region,
52 credentials: aws_credential_types::provider::SharedCredentialsProvider,
53 http: reqwest::Client,
54}
55
56impl std::fmt::Debug for SignedBuildServices {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("SignedBuildServices")
61 .field("region", &self.region)
62 .finish_non_exhaustive()
63 }
64}
65
66impl SignedBuildServices {
67 pub async fn new(region: Region) -> Result<Self, Error> {
72 Ok(Self {
73 credentials: super::transport::default_credentials(®ion).await?,
74 http: super::transport::http_client(std::time::Duration::from_secs(15 * 60))?,
75 region,
76 })
77 }
78
79 async fn send(
80 &self,
81 operation: &str,
82 mut request: http::Request<Vec<u8>>,
83 service: &str,
84 settings: aws_sigv4::http_request::SigningSettings,
85 ) -> Result<(u16, Vec<u8>), Error> {
86 let credentials =
87 super::transport::resolve_credentials(&self.credentials, operation).await?;
88 super::transport::sign_for(&mut request, &credentials, &self.region, service, settings)?;
89 let url = request.uri().to_string();
90 let (parts, body) = request.into_parts();
91 let request = http::Request::from_parts(parts, reqwest::Body::from(body));
92 let request = reqwest::Request::try_from(request).map_err(|error| {
93 Error::new(
94 ErrorKind::Unexpected,
95 format!("could not convert the signed {operation} request: {error}"),
96 )
97 .with_source(error)
98 })?;
99 let response = self.http.execute(request).await.map_err(|error| {
100 Error::new(
101 ErrorKind::Retryable,
102 format!("the {operation} request to {url} did not complete: {error}"),
103 )
104 .with_source(error)
105 })?;
106 let status = response.status().as_u16();
107 let body = response.bytes().await.map_err(|error| {
108 Error::new(
109 ErrorKind::Retryable,
110 format!("could not read the {operation} response body: {error}"),
111 )
112 .with_source(error)
113 })?;
114 Ok((status, body.to_vec()))
115 }
116
117 async fn caller_account_once(&self) -> Result<String, Error> {
118 let body = b"Action=GetCallerIdentity&Version=2011-06-15".to_vec();
119 let request = http::Request::builder()
120 .method("POST")
121 .uri(sts_endpoint(&self.region))
122 .header(
123 "content-type",
124 "application/x-www-form-urlencoded; charset=utf-8",
125 )
126 .body(body)
127 .map_err(|error| {
128 Error::new(
129 ErrorKind::Unexpected,
130 format!("could not build the GetCallerIdentity request: {error}"),
131 )
132 })?;
133 let (status, body) = self
134 .send(
135 "GetCallerIdentity",
136 request,
137 "sts",
138 aws_sigv4::http_request::SigningSettings::default(),
139 )
140 .await?;
141 if status == 200 {
142 return account_from_sts(&body);
143 }
144 Err(xml_failure("GetCallerIdentity", status, &body))
145 }
146
147 async fn put_object_once(&self, bucket: &str, key: &str, bytes: &[u8]) -> Result<(), Error> {
148 let request = http::Request::builder()
149 .method("PUT")
150 .uri(s3_object_url(bucket, key, &self.region))
151 .header("content-type", "application/zip")
152 .body(bytes.to_vec())
153 .map_err(|error| {
154 Error::new(
155 ErrorKind::Unexpected,
156 format!("could not build the PutObject request: {error}"),
157 )
158 })?;
159 let (status, body) = self
160 .send("PutObject", request, "s3", s3_signing_settings())
161 .await?;
162 if status == 200 {
163 return Ok(());
164 }
165 Err(xml_failure(
166 &format!("PutObject s3://{bucket}/{key}"),
167 status,
168 &body,
169 ))
170 }
171}
172
173async fn with_retry<T, F, Fut>(attempt: F) -> Result<T, Error>
176where
177 F: FnMut() -> Fut,
178 Fut: std::future::Future<Output = Result<T, Error>>,
179{
180 use backon::{ExponentialBuilder, Retryable};
181
182 attempt
183 .retry(
184 ExponentialBuilder::default()
185 .with_jitter()
186 .with_min_delay(std::time::Duration::from_millis(200))
187 .with_max_delay(std::time::Duration::from_secs(20))
188 .with_max_times(5),
189 )
190 .when(Error::retryable)
191 .await
192}
193
194impl BuildServices for SignedBuildServices {
195 fn caller_account(&self) -> BoxFuture<'_, Result<String, Error>> {
196 Box::pin(with_retry(|| self.caller_account_once()))
197 }
198
199 fn put_object<'a>(
200 &'a self,
201 bucket: &'a str,
202 key: &'a str,
203 bytes: Vec<u8>,
204 ) -> BoxFuture<'a, Result<(), Error>> {
205 Box::pin(async move { with_retry(|| self.put_object_once(bucket, key, &bytes)).await })
206 }
207}
208
209fn sts_endpoint(region: &Region) -> String {
211 format!("https://sts.{}.amazonaws.com/", region.as_str())
212}
213
214pub(crate) fn s3_object_url(bucket: &str, key: &str, region: &Region) -> String {
218 let path: Vec<String> = key
219 .split('/')
220 .map(super::transport::paths::encode_segment)
221 .collect();
222 let path = path.join("/");
223 if bucket.contains('.') {
224 format!(
225 "https://s3.{}.amazonaws.com/{bucket}/{path}",
226 region.as_str()
227 )
228 } else {
229 format!(
230 "https://{bucket}.s3.{}.amazonaws.com/{path}",
231 region.as_str()
232 )
233 }
234}
235
236pub(crate) fn s3_signing_settings() -> aws_sigv4::http_request::SigningSettings {
239 use aws_sigv4::http_request::{
240 PayloadChecksumKind, PercentEncodingMode, SigningSettings, UriPathNormalizationMode,
241 };
242 let mut settings = SigningSettings::default();
243 settings.percent_encoding_mode = PercentEncodingMode::Single;
244 settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled;
245 settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
246 settings
247}
248
249fn xml_text<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
251 let open = format!("<{tag}>");
252 let close = format!("</{tag}>");
253 let start = body.find(&open)? + open.len();
254 let end = body[start..].find(&close)? + start;
255 Some(body[start..end].trim())
256}
257
258pub(crate) fn account_from_sts(body: &[u8]) -> Result<String, Error> {
260 let text = String::from_utf8_lossy(body);
261 match xml_text(&text, "Account") {
262 Some(account) if account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit()) => {
263 Ok(account.to_string())
264 }
265 _ => Err(Error::new(
266 ErrorKind::Platform,
267 format!(
268 "GetCallerIdentity answered 200 with no twelve-digit Account: {}",
269 text.chars().take(512).collect::<String>()
270 ),
271 )),
272 }
273}
274
275pub(crate) fn xml_failure(operation: &str, status: u16, body: &[u8]) -> Error {
278 let text = String::from_utf8_lossy(body);
279 let code = xml_text(&text, "Code").unwrap_or("").to_string();
280 let message = xml_text(&text, "Message").unwrap_or("").to_string();
281 let said = if code.is_empty() && message.is_empty() {
282 "the service sent no error code".to_string()
283 } else {
284 format!("{code}: {message}")
285 };
286 let (kind, remedy) = match (status, code.as_str()) {
287 (301, _) | (_, "PermanentRedirect") | (_, "AuthorizationHeaderMalformed") => (
288 ErrorKind::Precondition,
289 format!(
290 "The bucket is in another region{}. The artifact bucket must be in the \
291 sandbox's region, where the image build reads it.",
292 xml_text(&text, "Region")
293 .map(|region| format!(" ({region})"))
294 .unwrap_or_default()
295 ),
296 ),
297 (404, _) | (_, "NoSuchBucket") => (
298 ErrorKind::Precondition,
299 "The bucket does not exist in this account and region.".to_string(),
300 ),
301 (403, _) => (
302 ErrorKind::Credentials,
303 "The identity is not allowed this call: an upload needs s3:PutObject on the \
304 bucket and key prefix."
305 .to_string(),
306 ),
307 (429, _) | (_, "SlowDown") | (_, "Throttling") | (_, "ThrottlingException") => (
308 ErrorKind::Retryable,
309 "Throttled; retry the identical request.".to_string(),
310 ),
311 (status, _) if status >= 500 => (
312 ErrorKind::Retryable,
313 "A service fault; retry the identical request.".to_string(),
314 ),
315 _ => (ErrorKind::Platform, String::new()),
316 };
317 Error::new(
318 kind,
319 format!("{operation} failed with HTTP {status}: {said}. {remedy}")
320 .trim_end()
321 .to_string(),
322 )
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
332 fn the_object_url_names_the_bucket_and_the_encoded_key() {
333 assert_eq!(
334 s3_object_url(
335 "agentd-conformance",
336 "harbor/task-0123456789ab/artifact.zip",
337 &Region::UsEast1
338 ),
339 "https://agentd-conformance.s3.us-east-1.amazonaws.com/harbor/task-0123456789ab/artifact.zip"
340 );
341 assert_eq!(
342 s3_object_url("my.bucket", "a b/c+d.zip", &Region::EuWest1),
343 "https://s3.eu-west-1.amazonaws.com/my.bucket/a%20b/c%2Bd.zip"
344 );
345 }
346
347 #[test]
350 fn the_account_is_read_from_the_sts_answer() {
351 let body =
352 br#"<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
353 <GetCallerIdentityResult>
354 <Arn>arn:aws:sts::741448939267:assumed-role/Admin/me</Arn>
355 <UserId>AROAEXAMPLE:me</UserId>
356 <Account>741448939267</Account>
357 </GetCallerIdentityResult>
358 <ResponseMetadata><RequestId>x</RequestId></ResponseMetadata>
359</GetCallerIdentityResponse>"#;
360 assert_eq!(account_from_sts(body).expect("an account"), "741448939267");
361 account_from_sts(b"<Account>12</Account>").expect_err("not twelve digits");
362 account_from_sts(b"<html>").expect_err("no account");
363 }
364
365 #[test]
368 fn a_failed_call_names_the_code_and_is_classified() {
369 let error = xml_failure(
370 "PutObject s3://b/k",
371 403,
372 b"<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>",
373 );
374 assert_eq!(error.kind(), ErrorKind::Credentials);
375 assert!(error.to_string().contains("AccessDenied"), "{error}");
376 assert!(error.to_string().contains("s3:PutObject"), "{error}");
377
378 let error = xml_failure(
379 "PutObject s3://b/k",
380 301,
381 b"<Error><Code>PermanentRedirect</Code><Message>m</Message></Error>",
382 );
383 assert_eq!(error.kind(), ErrorKind::Precondition);
384 assert!(error.to_string().contains("another region"), "{error}");
385
386 let error = xml_failure(
387 "PutObject",
388 404,
389 b"<Error><Code>NoSuchBucket</Code></Error>",
390 );
391 assert_eq!(error.kind(), ErrorKind::Precondition);
392
393 for status in [500, 503] {
394 assert!(
395 xml_failure("PutObject", status, b"").retryable(),
396 "{status}"
397 );
398 }
399 assert!(xml_failure("PutObject", 503, b"<Error><Code>SlowDown</Code></Error>").retryable());
400 assert!(
401 !xml_failure(
402 "PutObject",
403 400,
404 b"<Error><Code>InvalidRequest</Code></Error>"
405 )
406 .retryable()
407 );
408 }
409
410 #[test]
414 fn an_upload_is_signed_for_s3_with_the_payload_hash() {
415 let credentials =
416 aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
417 let mut request = http::Request::builder()
418 .method("PUT")
419 .uri(s3_object_url("bucket", "k/artifact.zip", &Region::UsEast1))
420 .body(b"zip bytes".to_vec())
421 .expect("a request");
422 crate::control::transport::sign_for(
423 &mut request,
424 &credentials,
425 &Region::UsEast1,
426 "s3",
427 s3_signing_settings(),
428 )
429 .expect("signs");
430 let headers = request.headers();
431 let authorization = headers["authorization"].to_str().expect("ascii");
432 assert!(
433 authorization.contains("/us-east-1/s3/aws4_request"),
434 "{authorization}"
435 );
436 assert!(
437 authorization.contains("x-amz-content-sha256"),
438 "{authorization}"
439 );
440 let digest = {
441 use sha2::{Digest as _, Sha256};
442 const_hex::encode(Sha256::digest(b"zip bytes"))
443 };
444 assert_eq!(headers["x-amz-content-sha256"], digest.as_str());
445 }
446}