1use crate::backends::common::{CommonBuilderConfig, CommonConfig, RequestConfig};
5use crate::backends::{Page, PageRequest, run_paginated};
6use crate::{
7 errors::*,
8 update::{Release, ReleaseAsset, ReleaseUpdate, Releases},
9 version::bump_is_greater,
10};
11use log::debug;
12use quick_xml::Reader;
13use quick_xml::events::Event;
14use regex::Regex;
15use std::path::PathBuf;
16use std::sync::LazyLock;
17use std::time::Duration;
18
19static ASSET_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
25 Regex::new(r"(?i)(?P<prefix>.*/)*(?P<name>.+)-[v]{0,1}(?P<version>\d+\.\d+\.\d+)-.+")
26 .expect("the S3 asset-key regex is a valid compile-time literal")
27});
28
29const DEFAULT_MAX_KEYS: u16 = 1000;
32
33#[cfg(feature = "s3-auth")]
35const DEFAULT_SIGNATURE_TTL_SECS: u64 = 300;
36
37fn clamp_max_keys(max_keys: u16) -> u16 {
40 max_keys.clamp(1, DEFAULT_MAX_KEYS)
41}
42
43#[cfg(feature = "s3-auth")]
45const MAX_SIGNATURE_TTL_SECS: u64 = 604_800;
46
47#[cfg(feature = "s3-auth")]
50fn clamp_signature_ttl(ttl: Duration) -> Duration {
51 Duration::from_secs(ttl.as_secs().clamp(1, MAX_SIGNATURE_TTL_SECS))
52}
53
54#[cfg(feature = "s3-auth")]
58pub use auth::AccessKey;
59
60#[allow(clippy::upper_case_acronyms)]
63#[derive(Clone, Debug, Default)]
64#[non_exhaustive]
65pub enum Endpoint {
66 #[default]
68 S3,
69 S3DualStack,
71 GCS,
73 DigitalOceanSpaces,
75 Generic(String),
78}
79
80impl From<&str> for Endpoint {
81 fn from(value: &str) -> Self {
82 Self::Generic(value.to_owned())
83 }
84}
85
86impl From<String> for Endpoint {
87 fn from(value: String) -> Self {
88 Self::Generic(value)
89 }
90}
91
92fn endpoint_requires_region(endpoint: &Endpoint) -> bool {
95 matches!(
96 endpoint,
97 Endpoint::S3 | Endpoint::S3DualStack | Endpoint::DigitalOceanSpaces
98 )
99}
100
101fn check_endpoint_region(endpoint: &Endpoint, region: &Option<String>) -> Result<()> {
104 if endpoint_requires_region(endpoint) && region.is_none() {
105 return Err(Error::MissingField { field: "region" });
106 }
107 Ok(())
108}
109
110#[derive(Clone, Debug)]
112#[must_use]
113pub struct ReleaseListBuilder {
114 endpoint: Endpoint,
115 bucket_name: Option<String>,
116 asset_prefix: Option<String>,
117 target: Option<String>,
118 region: Option<String>,
119 max_keys: u16,
120 #[cfg(feature = "s3-auth")]
121 signature_ttl: Duration,
122 #[cfg(feature = "s3-auth")]
123 access_key: Option<auth::AccessKey>,
124 request: RequestConfig,
125}
126
127impl ReleaseListBuilder {
128 pub fn bucket_name(&mut self, name: impl Into<String>) -> &mut Self {
130 self.bucket_name = Some(name.into());
131 self
132 }
133
134 pub fn max_keys(&mut self, max_keys: u16) -> &mut Self {
138 self.max_keys = clamp_max_keys(max_keys);
139 self
140 }
141
142 #[cfg(feature = "s3-auth")]
145 pub fn signature_ttl(&mut self, ttl: Duration) -> &mut Self {
146 self.signature_ttl = clamp_signature_ttl(ttl);
147 self
148 }
149
150 pub fn asset_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
157 self.asset_prefix = Some(prefix.into());
158 self
159 }
160
161 pub fn region(&mut self, region: impl Into<String>) -> &mut Self {
168 self.region = Some(region.into());
169 self
170 }
171
172 pub fn endpoint(&mut self, endpoint: impl Into<Endpoint>) -> &mut Self {
179 self.endpoint = endpoint.into();
180 self
181 }
182
183 pub fn filter_target(&mut self, target: impl Into<String>) -> &mut Self {
191 self.target = Some(target.into());
192 self
193 }
194
195 #[cfg(feature = "s3-auth")]
196 pub fn access_key(&mut self, access_key: impl Into<auth::AccessKey>) -> &mut Self {
198 self.access_key = Some(access_key.into());
199 self
200 }
201
202 request_config_setters!(request);
203
204 pub fn build(&self) -> Result<ReleaseList> {
206 let mut request = self.request.clone();
207 request.build_client();
208 request.check()?;
209 check_endpoint_region(&self.endpoint, &self.region)?;
210 Ok(ReleaseList {
211 endpoint: self.endpoint.clone(),
212 bucket_name: if let Some(ref name) = self.bucket_name {
213 name.to_owned()
214 } else {
215 return Err(Error::MissingField {
216 field: "bucket_name",
217 });
218 },
219 region: self.region.clone(),
220 asset_prefix: self.asset_prefix.clone(),
221 target: self.target.clone(),
222 max_keys: self.max_keys,
223 #[cfg(feature = "s3-auth")]
224 signature_ttl: self.signature_ttl,
225 #[cfg(feature = "s3-auth")]
226 access_key: self.access_key.clone(),
227 request,
228 })
229 }
230}
231
232#[derive(Clone, Debug)]
235pub struct ReleaseList {
236 endpoint: Endpoint,
237 bucket_name: String,
238 asset_prefix: Option<String>,
239 target: Option<String>,
240 region: Option<String>,
241 max_keys: u16,
242 #[cfg(feature = "s3-auth")]
243 signature_ttl: Duration,
244 #[cfg(feature = "s3-auth")]
245 access_key: Option<auth::AccessKey>,
246 request: RequestConfig,
247}
248
249impl ReleaseList {
250 pub fn configure() -> ReleaseListBuilder {
252 ReleaseListBuilder {
253 endpoint: Endpoint::default(),
254 bucket_name: None,
255 asset_prefix: None,
256 target: None,
257 region: None,
258 max_keys: DEFAULT_MAX_KEYS,
259 #[cfg(feature = "s3-auth")]
260 signature_ttl: Duration::from_secs(DEFAULT_SIGNATURE_TTL_SECS),
261 #[cfg(feature = "s3-auth")]
262 access_key: None,
263 request: RequestConfig::default(),
264 }
265 }
266
267 pub fn fetch(&self) -> Result<Releases> {
274 let plan = s3_listing_plan(
275 &self.endpoint,
276 &self.bucket_name,
277 &self.region,
278 &self.asset_prefix,
279 self.max_keys,
280 #[cfg(feature = "s3-auth")]
281 self.signature_ttl,
282 #[cfg(feature = "s3-auth")]
283 &self.access_key,
284 )?;
285 let releases = run_paginated(plan, &self.request)?;
286 let releases = match self.target {
287 None => releases,
288 Some(ref target) => releases
289 .into_iter()
290 .filter(|r| r.has_target_asset(target))
291 .collect::<Vec<_>>(),
292 };
293 Ok(Releases::from_listing(releases))
294 }
295
296 #[cfg(feature = "async")]
298 pub async fn fetch_async(&self) -> Result<Releases> {
299 let plan = s3_listing_plan(
300 &self.endpoint,
301 &self.bucket_name,
302 &self.region,
303 &self.asset_prefix,
304 self.max_keys,
305 #[cfg(feature = "s3-auth")]
306 self.signature_ttl,
307 #[cfg(feature = "s3-auth")]
308 &self.access_key,
309 )?;
310 let releases = crate::backends::run_paginated_async(plan, &self.request).await?;
311 let releases = match self.target {
312 None => releases,
313 Some(ref target) => releases
314 .into_iter()
315 .filter(|r| r.has_target_asset(target))
316 .collect::<Vec<_>>(),
317 };
318 Ok(Releases::from_listing(releases))
319 }
320}
321
322#[derive(Clone, Debug)]
327#[must_use]
328pub struct UpdateBuilder {
329 endpoint: Endpoint,
330 bucket_name: Option<String>,
331 asset_prefix: Option<String>,
332 region: Option<String>,
333 max_keys: u16,
334 #[cfg(feature = "s3-auth")]
335 signature_ttl: Duration,
336 #[cfg(feature = "s3-auth")]
337 access_key: Option<auth::AccessKey>,
338 common: CommonBuilderConfig,
339}
340
341impl Default for UpdateBuilder {
342 fn default() -> Self {
343 Self {
344 endpoint: Endpoint::default(),
345 bucket_name: None,
346 asset_prefix: None,
347 region: None,
348 max_keys: DEFAULT_MAX_KEYS,
349 #[cfg(feature = "s3-auth")]
350 signature_ttl: Duration::from_secs(DEFAULT_SIGNATURE_TTL_SECS),
351 #[cfg(feature = "s3-auth")]
352 access_key: None,
353 common: CommonBuilderConfig::default(),
354 }
355 }
356}
357
358impl UpdateBuilder {
360 pub fn new() -> Self {
362 Default::default()
363 }
364
365 pub fn max_keys(&mut self, max_keys: u16) -> &mut Self {
369 self.max_keys = clamp_max_keys(max_keys);
370 self
371 }
372
373 #[cfg(feature = "s3-auth")]
376 pub fn signature_ttl(&mut self, ttl: Duration) -> &mut Self {
377 self.signature_ttl = clamp_signature_ttl(ttl);
378 self
379 }
380
381 pub fn endpoint(&mut self, endpoint: impl Into<Endpoint>) -> &mut Self {
388 self.endpoint = endpoint.into();
389 self
390 }
391
392 pub fn bucket_name(&mut self, name: impl Into<String>) -> &mut Self {
394 self.bucket_name = Some(name.into());
395 self
396 }
397
398 pub fn asset_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
404 self.asset_prefix = Some(prefix.into());
405 self
406 }
407
408 pub fn region(&mut self, region: impl Into<String>) -> &mut Self {
415 self.region = Some(region.into());
416 self
417 }
418
419 #[cfg(feature = "s3-auth")]
420 pub fn access_key(&mut self, access_key: impl Into<auth::AccessKey>) -> &mut Self {
422 self.access_key = Some(access_key.into());
423 self
424 }
425
426 impl_common_builder_setters!(no_auth_token);
427
428 fn build_update(&self) -> Result<Update> {
429 check_endpoint_region(&self.endpoint, &self.region)?;
430 Ok(Update {
431 endpoint: self.endpoint.clone(),
432 bucket_name: if let Some(ref name) = self.bucket_name {
433 name.to_owned()
434 } else {
435 return Err(Error::MissingField {
436 field: "bucket_name",
437 });
438 },
439 region: self.region.clone(),
440 max_keys: self.max_keys,
441 #[cfg(feature = "s3-auth")]
442 signature_ttl: self.signature_ttl,
443 #[cfg(feature = "s3-auth")]
444 access_key: self.access_key.clone(),
445 asset_prefix: self.asset_prefix.clone(),
446 common: self.common.build()?,
447 })
448 }
449
450 pub fn build(&self) -> Result<Update> {
455 self.build_update()
456 }
457
458 #[cfg(feature = "async")]
464 pub fn build_async(&self) -> Result<AsyncUpdate> {
465 Ok(AsyncUpdate(self.build_update()?))
466 }
467}
468
469#[derive(Debug)]
471#[non_exhaustive]
472pub struct Update {
473 endpoint: Endpoint,
474 bucket_name: String,
475 asset_prefix: Option<String>,
476 region: Option<String>,
477 max_keys: u16,
478 #[cfg(feature = "s3-auth")]
479 signature_ttl: Duration,
480 #[cfg(feature = "s3-auth")]
481 access_key: Option<auth::AccessKey>,
482 common: CommonConfig,
483}
484
485impl Update {
486 pub fn configure() -> UpdateBuilder {
488 UpdateBuilder::new()
489 }
490
491 fn listing_plan(&self) -> Result<PageRequest<Release>> {
494 s3_listing_plan(
495 &self.endpoint,
496 &self.bucket_name,
497 &self.region,
498 &self.asset_prefix,
499 self.max_keys,
500 #[cfg(feature = "s3-auth")]
501 self.signature_ttl,
502 #[cfg(feature = "s3-auth")]
503 &self.access_key,
504 )
505 }
506
507 fn fetch_releases(&self) -> Result<Vec<Release>> {
509 run_paginated(self.listing_plan()?, &self.common.request)
510 }
511
512 #[cfg(feature = "async")]
514 async fn fetch_releases_async(&self) -> Result<Vec<Release>> {
515 crate::backends::run_paginated_async(self.listing_plan()?, &self.common.request).await
516 }
517}
518
519fn pick_latest(releases: &[Release]) -> Result<Release> {
521 let rel = releases.iter().max_by(|x, y| {
525 crate::version::cmp_releases_newest_first(x.version(), y.version()).reverse()
526 });
527 match rel {
528 Some(r) => Ok(r.clone()),
529 None => Err(Error::NoReleaseFound { target: None }),
530 }
531}
532
533fn sort_newer(releases: Vec<Release>, current_version: &str) -> Vec<Release> {
536 let mut releases = releases
537 .into_iter()
538 .filter(|r| bump_is_greater(current_version, r.version()).unwrap_or(false))
539 .collect::<Vec<_>>();
540 releases.sort_by(|x, y| crate::version::cmp_releases_newest_first(x.version(), y.version()));
542 releases
543}
544
545fn find_version(releases: &[Release], ver: &str) -> Result<Release> {
550 let ver = ver.trim_start_matches('v');
551 match releases.iter().find(|x| x.version() == ver) {
552 Some(r) => Ok(r.clone()),
553 None => Err(Error::NoReleaseFound { target: None }),
554 }
555}
556
557impl crate::update::sealed::Sealed for Update {}
558
559impl ReleaseUpdate for Update {
560 fn get_latest_release(&self) -> Result<Releases> {
561 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
562 let release = pick_latest(&self.fetch_releases()?)?;
563 Ok(Releases::new(vec![release], current_version))
564 }
565
566 fn get_newer_releases(&self) -> Result<Releases> {
567 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
568 let releases = sort_newer(self.fetch_releases()?, ¤t_version);
569 Ok(Releases::new(releases, current_version))
570 }
571
572 fn get_release_version(&self, ver: &str) -> Result<Release> {
573 find_version(&self.fetch_releases()?, ver)
574 }
575}
576
577impl_sync_update_verbs!(Update);
578
579#[cfg(feature = "async")]
586#[derive(Debug)]
587pub struct AsyncUpdate(Update);
588
589#[cfg(feature = "async")]
590impl_async_update_verbs!(AsyncUpdate);
591
592impl_update_config_accessors!(Update);
593
594#[cfg(feature = "async")]
595impl crate::update::AsyncReleaseUpdate for Update {
596 async fn get_latest_release_async(&self) -> Result<Releases> {
597 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
598 let release = pick_latest(&self.fetch_releases_async().await?)?;
599 Ok(Releases::new(vec![release], current_version))
600 }
601
602 async fn get_newer_releases_async(&self) -> Result<Releases> {
603 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
604 let releases = sort_newer(self.fetch_releases_async().await?, ¤t_version);
605 Ok(Releases::new(releases, current_version))
606 }
607
608 async fn get_release_version_async(&self, ver: &str) -> Result<Release> {
609 find_version(&self.fetch_releases_async().await?, ver)
610 }
611}
612
613#[cfg(feature = "s3-auth")]
615mod auth {
616 use crate::errors::*;
617 use hmac::{Hmac, KeyInit, Mac};
618 use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, utf8_percent_encode};
619 use sha2::{Digest, Sha256};
620 use std::{
621 borrow::Cow,
622 time::{SystemTime, UNIX_EPOCH},
623 };
624 use time::OffsetDateTime;
625 use url::Url;
626
627 #[derive(Clone, Debug)]
635 #[non_exhaustive]
636 pub struct AccessKey {
637 pub access_key_id: String,
638 pub secret_access_key: String,
639 }
640
641 impl AccessKey {
642 pub fn new(access_key_id: impl Into<String>, secret_access_key: impl Into<String>) -> Self {
646 Self {
647 access_key_id: access_key_id.into(),
648 secret_access_key: secret_access_key.into(),
649 }
650 }
651 }
652
653 impl From<(&str, &str)> for AccessKey {
654 fn from(value: (&str, &str)) -> Self {
655 Self {
656 access_key_id: value.0.to_owned(),
657 secret_access_key: value.1.to_owned(),
658 }
659 }
660 }
661
662 impl From<(String, String)> for AccessKey {
663 fn from(value: (String, String)) -> Self {
664 Self {
665 access_key_id: value.0,
666 secret_access_key: value.1,
667 }
668 }
669 }
670
671 const URI_ENCODE: &AsciiSet = &NON_ALPHANUMERIC
674 .remove(b'-')
675 .remove(b'.')
676 .remove(b'_')
677 .remove(b'~');
678
679 const URI_ENCODE_KEEP_SLASH: &AsciiSet = &URI_ENCODE.remove(b'/');
682
683 fn uri_encode(input: &str, encode_slash: bool) -> PercentEncode<'_> {
686 let set = if encode_slash {
687 URI_ENCODE
688 } else {
689 URI_ENCODE_KEEP_SLASH
690 };
691 utf8_percent_encode(input, set)
692 }
693
694 fn sig_base_url(url: &Url) -> String {
698 let mut base = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
699 if let Some(port) = url.port() {
700 base.push_str(&format!(":{port}"));
701 }
702 base.push_str(url.path());
703 base
704 }
705
706 fn hex_sha256(data: &[u8]) -> String {
707 let hash = Sha256::digest(data);
708 hash.iter().map(|b| format!("{b:02x}")).collect()
709 }
710
711 fn hmac_sha256(key: &[u8], data: &[u8]) -> Result<Vec<u8>> {
712 let mut mac = Hmac::<Sha256>::new_from_slice(key)?;
713 mac.update(data);
714 Ok(mac.finalize().into_bytes().to_vec())
715 }
716
717 fn derive_signing_key(secret: &str, date_stamp: &str, region: &str) -> Result<Vec<u8>> {
718 let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date_stamp.as_bytes())?;
719 let k_region = hmac_sha256(&k_date, region.as_bytes())?;
720 let k_service = hmac_sha256(&k_region, b"s3")?;
721 hmac_sha256(&k_service, b"aws4_request")
722 }
723
724 fn format_timestamp(secs: u64) -> Result<(String, String)> {
725 let dt = OffsetDateTime::from_unix_timestamp(secs as i64)?;
726 let date_stamp = format!("{:04}{:02}{:02}", dt.year(), dt.month() as u8, dt.day());
727 let amz_date = format!(
728 "{date_stamp}T{:02}{:02}{:02}Z",
729 dt.hour(),
730 dt.minute(),
731 dt.second()
732 );
733 Ok((date_stamp, amz_date))
734 }
735
736 pub fn s3_signature_v4(
737 url_str: &str,
738 region: &Option<String>,
739 access_key: &Option<AccessKey>,
740 ttl_secs: u64,
741 ) -> Result<String> {
742 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
743 s3_signature_v4_at(url_str, region, access_key, ttl_secs, now_secs)
744 }
745
746 #[cfg(test)]
750 pub(super) struct SigV4Parts {
751 pub canonical_request: String,
752 pub string_to_sign: String,
753 pub signing_key: Vec<u8>,
754 pub signature: String,
755 pub signed_url: String,
756 }
757
758 fn s3_signature_v4_at(
764 url_str: &str,
765 region: &Option<String>,
766 access_key: &Option<AccessKey>,
767 ttl_secs: u64,
768 now_secs: u64,
769 ) -> Result<String> {
770 let (access_key_id, secret_access_key) = match access_key {
771 Some(access_key) => (&access_key.access_key_id, &access_key.secret_access_key),
772 None => return Ok(url_str.to_owned()),
773 };
774 let url = Url::parse(url_str)?;
775 let host = url.host_str().ok_or_else(|| {
776 Error::S3Auth(Box::new(crate::errors::MessageError(format!(
777 "Cannot extract host from {:?}",
778 url_str
779 ))))
780 })?;
781 let canonical_uri = if url.path().is_empty() {
782 "/"
783 } else {
784 url.path()
785 };
786
787 let (date_stamp, amz_date) = format_timestamp(now_secs)?;
788
789 let region = region.as_deref().unwrap_or("us-east-1");
790
791 let credential_scope = format!("{date_stamp}/{region}/s3/aws4_request");
792
793 let mut params: Vec<_> = url.query_pairs().collect();
795
796 params.extend([
797 (
798 Cow::Borrowed("X-Amz-Algorithm"),
799 Cow::Borrowed("AWS4-HMAC-SHA256"),
800 ),
801 (
802 Cow::Borrowed("X-Amz-Credential"),
803 Cow::Owned(format!("{access_key_id}/{credential_scope}")),
804 ),
805 (Cow::Borrowed("X-Amz-Date"), Cow::Borrowed(&amz_date)),
806 (
807 Cow::Borrowed("X-Amz-Expires"),
808 Cow::Owned(ttl_secs.to_string()),
809 ),
810 (Cow::Borrowed("X-Amz-SignedHeaders"), Cow::Borrowed("host")),
811 ]);
812 params.sort_by(|a, b| a.0.cmp(&b.0));
813
814 let canonical_qs: String = params
815 .iter()
816 .map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true)))
817 .collect::<Vec<_>>()
818 .join("&");
819
820 let canonical_request =
826 format!("GET\n{canonical_uri}\n{canonical_qs}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD",);
827
828 let string_to_sign = format!(
829 "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
830 hex_sha256(canonical_request.as_bytes())
831 );
832
833 let signing_key = derive_signing_key(secret_access_key, &date_stamp, region)?;
834 let signature: String = hmac_sha256(&signing_key, string_to_sign.as_bytes())?
835 .iter()
836 .map(|b| format!("{b:02x}"))
837 .collect();
838
839 let base = sig_base_url(&url);
840 Ok(format!("{base}?{canonical_qs}&X-Amz-Signature={signature}"))
841 }
842
843 #[cfg(test)]
848 pub(super) fn s3_signature_v4_parts(
849 url_str: &str,
850 region: &Option<String>,
851 access_key: &AccessKey,
852 ttl_secs: u64,
853 now_secs: u64,
854 ) -> Result<SigV4Parts> {
855 let access_key_id = &access_key.access_key_id;
856 let secret_access_key = &access_key.secret_access_key;
857 let url = Url::parse(url_str)?;
858 let host = url.host_str().ok_or_else(|| {
859 Error::S3Auth(Box::new(crate::errors::MessageError(format!(
860 "Cannot extract host from {:?}",
861 url_str
862 ))))
863 })?;
864 let canonical_uri = if url.path().is_empty() {
865 "/"
866 } else {
867 url.path()
868 };
869
870 let (date_stamp, amz_date) = format_timestamp(now_secs)?;
871 let region = region.as_deref().unwrap_or("us-east-1");
872 let credential_scope = format!("{date_stamp}/{region}/s3/aws4_request");
873
874 let mut params: Vec<_> = url.query_pairs().collect();
875 params.extend([
876 (
877 Cow::Borrowed("X-Amz-Algorithm"),
878 Cow::Borrowed("AWS4-HMAC-SHA256"),
879 ),
880 (
881 Cow::Borrowed("X-Amz-Credential"),
882 Cow::Owned(format!("{access_key_id}/{credential_scope}")),
883 ),
884 (Cow::Borrowed("X-Amz-Date"), Cow::Borrowed(&amz_date)),
885 (
886 Cow::Borrowed("X-Amz-Expires"),
887 Cow::Owned(ttl_secs.to_string()),
888 ),
889 (Cow::Borrowed("X-Amz-SignedHeaders"), Cow::Borrowed("host")),
890 ]);
891 params.sort_by(|a, b| a.0.cmp(&b.0));
892
893 let canonical_qs: String = params
894 .iter()
895 .map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true)))
896 .collect::<Vec<_>>()
897 .join("&");
898
899 let canonical_request =
900 format!("GET\n{canonical_uri}\n{canonical_qs}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD",);
901
902 let string_to_sign = format!(
903 "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
904 hex_sha256(canonical_request.as_bytes())
905 );
906
907 let signing_key = derive_signing_key(secret_access_key, &date_stamp, region)?;
908 let signature: String = hmac_sha256(&signing_key, string_to_sign.as_bytes())?
909 .iter()
910 .map(|b| format!("{b:02x}"))
911 .collect();
912
913 let signed_url = s3_signature_v4_at(
914 url_str,
915 &Some(region.to_owned()),
916 &Some(access_key.clone()),
917 ttl_secs,
918 now_secs,
919 )?;
920
921 Ok(SigV4Parts {
922 canonical_request,
923 string_to_sign,
924 signing_key,
925 signature,
926 signed_url,
927 })
928 }
929
930 #[cfg(test)]
931 mod sigv4_vectors {
932 use super::{
945 AccessKey, derive_signing_key, hex_sha256, hmac_sha256, s3_signature_v4_at,
946 s3_signature_v4_parts, uri_encode,
947 };
948
949 #[test]
960 fn signing_key_chain_matches_aws_documented_iam_key_bytes() {
961 let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
962 let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), b"20150830").unwrap();
963 let k_region = hmac_sha256(&k_date, b"us-east-1").unwrap();
964 let k_service = hmac_sha256(&k_region, b"iam").unwrap();
965 let k_signing = hmac_sha256(&k_service, b"aws4_request").unwrap();
966 let hex: String = k_signing.iter().map(|b| format!("{b:02x}")).collect();
967 assert_eq!(
969 hex, "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9",
970 "the HMAC derivation chain must reproduce AWS's documented iam signing key"
971 );
972 let s3_key = derive_signing_key(secret, "20150830", "us-east-1").unwrap();
975 let s3_via_chain =
976 hmac_sha256(&hmac_sha256(&k_region, b"s3").unwrap(), b"aws4_request").unwrap();
977 assert_eq!(
978 s3_key, s3_via_chain,
979 "derive_signing_key(service=s3) must equal the same chain with the s3 service link"
980 );
981 assert_ne!(
982 s3_key, k_signing,
983 "the s3 service link must diverge from the iam key after kRegion"
984 );
985 }
986
987 const EXAMPLE_ACCESS_KEY_ID: &str = "AKIAIOSFODNN7EXAMPLE";
991 const EXAMPLE_SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
992
993 const EXAMPLE_NOW_SECS: u64 = 1369353600;
996
997 #[test]
1001 fn spaced_object_key_is_single_encoded_and_consistent() {
1002 let key = AccessKey::new(EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET);
1003 let parts = s3_signature_v4_parts(
1004 "https://examplebucket.s3.amazonaws.com/my key.txt",
1005 &Some("us-east-1".to_owned()),
1006 &key,
1007 86400,
1008 EXAMPLE_NOW_SECS,
1009 )
1010 .unwrap();
1011 assert!(
1012 parts.canonical_request.contains("/my%20key.txt"),
1013 "canonical URI must be single-encoded: {}",
1014 parts.canonical_request
1015 );
1016 assert!(
1017 !parts.canonical_request.contains("%2520"),
1018 "canonical URI must not be double-encoded: {}",
1019 parts.canonical_request
1020 );
1021 assert!(
1022 parts.signed_url.contains("/my%20key.txt?"),
1023 "signed URL wire path must be single-encoded to match the canonical URI: {}",
1024 parts.signed_url
1025 );
1026 }
1027
1028 #[test]
1044 fn aws_get_object_presigned_example_known_answer() {
1045 let key = AccessKey::new(EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET);
1046 let parts = s3_signature_v4_parts(
1047 "https://examplebucket.s3.amazonaws.com/test.txt",
1048 &Some("us-east-1".to_owned()),
1049 &key,
1050 86400,
1051 EXAMPLE_NOW_SECS,
1052 )
1053 .unwrap();
1054
1055 let expected_canonical_request = "GET\n\
1057 /test.txt\n\
1058 X-Amz-Algorithm=AWS4-HMAC-SHA256&\
1059 X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
1060 X-Amz-Date=20130524T000000Z&\
1061 X-Amz-Expires=86400&\
1062 X-Amz-SignedHeaders=host\n\
1063 host:examplebucket.s3.amazonaws.com\n\
1064 \n\
1065 host\n\
1066 UNSIGNED-PAYLOAD";
1067 assert_eq!(
1068 parts.canonical_request, expected_canonical_request,
1069 "canonical request must match the AWS GET-object presigned example verbatim"
1070 );
1071
1072 let expected_string_to_sign = "AWS4-HMAC-SHA256\n\
1075 20130524T000000Z\n\
1076 20130524/us-east-1/s3/aws4_request\n\
1077 3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04";
1078 assert_eq!(
1079 parts.string_to_sign, expected_string_to_sign,
1080 "string-to-sign must match the AWS GET-object presigned example verbatim"
1081 );
1082
1083 let expected_signature =
1086 "3ed0be64024db54d5574a27da223529635c383f911f80e636f0ccc13890053d2";
1087 assert_eq!(
1088 parts.signature, expected_signature,
1089 "final SigV4 signature must equal the known-answer value for the GET-object \
1090 presigned example inputs"
1091 );
1092
1093 assert!(
1096 parts
1097 .signed_url
1098 .contains("X-Amz-Signature=3ed0be64024db54d5574a27da223529635c383f911f80e636f0ccc13890053d2"),
1099 "the presigned URL must carry the known-answer signature, got: {}",
1100 parts.signed_url
1101 );
1102 assert!(
1103 parts.signed_url.starts_with(
1104 "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256"
1105 ),
1106 "the presigned URL must preserve the base path and lead with the algorithm param, \
1107 got: {}",
1108 parts.signed_url
1109 );
1110
1111 let via_signer = s3_signature_v4_at(
1114 "https://examplebucket.s3.amazonaws.com/test.txt",
1115 &Some("us-east-1".to_owned()),
1116 &Some(key),
1117 86400,
1118 EXAMPLE_NOW_SECS,
1119 )
1120 .unwrap();
1121 assert_eq!(
1122 via_signer, parts.signed_url,
1123 "the test-parts helper must reproduce the real signer's URL byte-for-byte"
1124 );
1125
1126 assert_eq!(
1130 parts.signing_key.len(),
1131 32,
1132 "signing key is a 32-byte HMAC key"
1133 );
1134 let recomputed: String =
1135 super::hmac_sha256(&parts.signing_key, parts.string_to_sign.as_bytes())
1136 .unwrap()
1137 .iter()
1138 .map(|b| format!("{b:02x}"))
1139 .collect();
1140 assert_eq!(
1141 recomputed, parts.signature,
1142 "HMAC(surfaced signing_key, surfaced string_to_sign) must equal the signature"
1143 );
1144 }
1145
1146 #[test]
1155 fn signing_key_derivation_chain_is_deterministic_and_32_bytes() {
1156 let key = derive_signing_key(EXAMPLE_SECRET, "20130524", "us-east-1").unwrap();
1158 assert_eq!(key.len(), 32, "an HMAC-SHA256 signing key is 32 bytes");
1159 let again = derive_signing_key(EXAMPLE_SECRET, "20130524", "us-east-1").unwrap();
1161 assert_eq!(key, again, "signing-key derivation must be deterministic");
1162 assert_ne!(
1164 key,
1165 derive_signing_key(EXAMPLE_SECRET, "20130525", "us-east-1").unwrap(),
1166 "a different date must change the signing key"
1167 );
1168 assert_ne!(
1169 key,
1170 derive_signing_key(EXAMPLE_SECRET, "20130524", "us-west-2").unwrap(),
1171 "a different region must change the signing key"
1172 );
1173 }
1174
1175 #[test]
1184 fn signing_key_reproduces_documented_signature() {
1185 use hmac::{Hmac, KeyInit, Mac};
1186 use sha2::Sha256;
1187
1188 let signing_key = derive_signing_key(EXAMPLE_SECRET, "20130524", "us-east-1").unwrap();
1189 let string_to_sign = "AWS4-HMAC-SHA256\n\
1190 20130524T000000Z\n\
1191 20130524/us-east-1/s3/aws4_request\n\
1192 3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04";
1193 let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key).unwrap();
1194 mac.update(string_to_sign.as_bytes());
1195 let signature: String = mac
1196 .finalize()
1197 .into_bytes()
1198 .iter()
1199 .map(|b| format!("{b:02x}"))
1200 .collect();
1201 assert_eq!(
1202 signature, "3ed0be64024db54d5574a27da223529635c383f911f80e636f0ccc13890053d2",
1203 "derive_signing_key + HMAC(string_to_sign) must reproduce the GET-object \
1204 known-answer signature"
1205 );
1206 }
1207
1208 #[test]
1214 fn sha256_of_canonical_request_matches_documented_digest() {
1215 let canonical_request = "GET\n\
1216 /test.txt\n\
1217 X-Amz-Algorithm=AWS4-HMAC-SHA256&\
1218 X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
1219 X-Amz-Date=20130524T000000Z&\
1220 X-Amz-Expires=86400&\
1221 X-Amz-SignedHeaders=host\n\
1222 host:examplebucket.s3.amazonaws.com\n\
1223 \n\
1224 host\n\
1225 UNSIGNED-PAYLOAD";
1226 assert_eq!(
1227 hex_sha256(canonical_request.as_bytes()),
1228 "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04",
1229 "SHA256 of the documented canonical request must equal AWS's documented digest"
1230 );
1231 }
1232
1233 #[test]
1240 fn percent_encoding_follows_aws_uriencode_rules() {
1241 assert_eq!(
1243 uri_encode("AZaz09-._~", true).to_string(),
1244 "AZaz09-._~",
1245 "the AWS unreserved set must never be encoded"
1246 );
1247 assert_eq!(
1249 uri_encode("a b+c=d", true).to_string(),
1250 "a%20b%2Bc%3Dd",
1251 "reserved characters must be uppercase-hex %-encoded"
1252 );
1253 assert_eq!(
1255 uri_encode("/path/to/my key.txt", false).to_string(),
1256 "/path/to/my%20key.txt",
1257 "in path mode the slash is kept and only other reserved chars are encoded"
1258 );
1259 assert_eq!(
1262 uri_encode("a/b", true).to_string(),
1263 "a%2Fb",
1264 "in query mode the slash must be encoded as %2F"
1265 );
1266 }
1267
1268 #[test]
1275 fn credential_scope_and_expires_formatting() {
1276 let key = AccessKey::new(EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET);
1277 let parts = s3_signature_v4_parts(
1278 "https://examplebucket.s3.amazonaws.com/test.txt",
1279 &Some("us-east-1".to_owned()),
1280 &key,
1281 86400,
1282 EXAMPLE_NOW_SECS,
1283 )
1284 .unwrap();
1285 assert!(
1287 parts
1288 .string_to_sign
1289 .contains("20130524/us-east-1/s3/aws4_request"),
1290 "credential scope must be <date>/<region>/s3/aws4_request, got: {}",
1291 parts.string_to_sign
1292 );
1293 assert!(
1295 parts.canonical_request.contains(
1296 "X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"
1297 ),
1298 "the canonical query credential must percent-encode the scope slashes, got: {}",
1299 parts.canonical_request
1300 );
1301 assert!(
1303 parts.canonical_request.contains("X-Amz-Expires=86400"),
1304 "X-Amz-Expires must carry the requested TTL verbatim, got: {}",
1305 parts.canonical_request
1306 );
1307 assert!(
1308 parts.signed_url.contains("X-Amz-Expires=86400"),
1309 "the signed URL must carry the requested expiry, got: {}",
1310 parts.signed_url
1311 );
1312 }
1313 }
1314}
1315
1316#[allow(clippy::too_many_arguments)]
1321fn build_s3_api_url(
1322 endpoint: &Endpoint,
1323 bucket_name: &str,
1324 region: &Option<String>,
1325 asset_prefix: &Option<String>,
1326 max_keys: u16,
1327 continuation_token: Option<&str>,
1328 #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1329 #[cfg(feature = "s3-auth")] access_key: &Option<auth::AccessKey>,
1330) -> Result<(String, String)> {
1331 let prefix = match asset_prefix {
1332 Some(prefix) => format!("&prefix={}", urlencoding::encode(prefix)),
1333 None => "".to_string(),
1334 };
1335 let continuation = match continuation_token {
1336 Some(token) => format!("&continuation-token={}", urlencoding::encode(token)),
1337 None => "".to_string(),
1338 };
1339
1340 let region_result = region
1341 .as_ref()
1342 .ok_or(Error::MissingField { field: "region" });
1343
1344 let download_base_url = match endpoint {
1345 Endpoint::S3 => format!(
1346 "https://{}.s3.{}.amazonaws.com/",
1347 bucket_name, region_result?
1348 ),
1349 Endpoint::S3DualStack => format!(
1350 "https://{}.s3.dualstack.{}.amazonaws.com/",
1351 bucket_name, region_result?
1352 ),
1353 Endpoint::DigitalOceanSpaces => format!(
1354 "https://{}.{}.digitaloceanspaces.com/",
1355 bucket_name, region_result?
1356 ),
1357 Endpoint::GCS => format!("https://storage.googleapis.com/{}/", bucket_name),
1358 Endpoint::Generic(endpoint) if endpoint.ends_with('/') => endpoint.clone(),
1361 Endpoint::Generic(endpoint) => format!("{}/", endpoint),
1362 };
1363
1364 let api_url = format!(
1365 "{}?list-type=2&max-keys={}{}{}",
1366 download_base_url, max_keys, prefix, continuation
1367 );
1368
1369 #[cfg(feature = "s3-auth")]
1370 let api_url = auth::s3_signature_v4(&api_url, region, access_key, signature_ttl.as_secs())?;
1371
1372 Ok((download_base_url, api_url))
1373}
1374
1375#[allow(clippy::too_many_arguments)]
1379fn s3_listing_plan(
1380 endpoint: &Endpoint,
1381 bucket_name: &str,
1382 region: &Option<String>,
1383 asset_prefix: &Option<String>,
1384 max_keys: u16,
1385 #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1386 #[cfg(feature = "s3-auth")] access_key: &Option<auth::AccessKey>,
1387) -> Result<PageRequest<Release>> {
1388 let endpoint = endpoint.clone();
1390 let bucket_name = bucket_name.to_owned();
1391 let region = region.clone();
1392 let asset_prefix = asset_prefix.clone();
1393 #[cfg(feature = "s3-auth")]
1394 let access_key = access_key.clone();
1395
1396 s3_page(
1397 endpoint,
1398 bucket_name,
1399 region,
1400 asset_prefix,
1401 max_keys,
1402 None,
1403 #[cfg(feature = "s3-auth")]
1404 signature_ttl,
1405 #[cfg(feature = "s3-auth")]
1406 access_key,
1407 )
1408}
1409
1410#[allow(clippy::too_many_arguments)]
1414fn s3_page(
1415 endpoint: Endpoint,
1416 bucket_name: String,
1417 region: Option<String>,
1418 asset_prefix: Option<String>,
1419 max_keys: u16,
1420 continuation_token: Option<String>,
1421 #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1422 #[cfg(feature = "s3-auth")] access_key: Option<auth::AccessKey>,
1423) -> Result<PageRequest<Release>> {
1424 let (download_base_url, api_url) = build_s3_api_url(
1425 &endpoint,
1426 &bucket_name,
1427 ®ion,
1428 &asset_prefix,
1429 max_keys,
1430 continuation_token.as_deref(),
1431 #[cfg(feature = "s3-auth")]
1432 signature_ttl,
1433 #[cfg(feature = "s3-auth")]
1434 &access_key,
1435 )?;
1436 debug!("using api url: {:?}", api_url);
1437
1438 Ok(PageRequest {
1439 url: api_url,
1440 headers: Default::default(),
1441 parse: Box::new(move |body, _resp_headers| {
1442 let (items, next_token) = parse_s3_response(
1443 body,
1444 &download_base_url,
1445 #[cfg(feature = "s3-auth")]
1446 ®ion,
1447 #[cfg(feature = "s3-auth")]
1448 signature_ttl,
1449 #[cfg(feature = "s3-auth")]
1450 &access_key,
1451 )?;
1452 let next = match next_token {
1455 Some(token) => Some(s3_page(
1456 endpoint,
1457 bucket_name,
1458 region,
1459 asset_prefix,
1460 max_keys,
1461 Some(token),
1462 #[cfg(feature = "s3-auth")]
1463 signature_ttl,
1464 #[cfg(feature = "s3-auth")]
1465 access_key,
1466 )?),
1467 None => None,
1468 };
1469 Ok(Page {
1470 items,
1471 next,
1472 stop: false,
1473 })
1474 }),
1475 })
1476}
1477
1478fn parse_s3_response<R: std::io::BufRead>(
1484 body: R,
1485 download_base_url: &str,
1486 #[cfg(feature = "s3-auth")] region: &Option<String>,
1487 #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1488 #[cfg(feature = "s3-auth")] access_key: &Option<auth::AccessKey>,
1489) -> Result<(Vec<Release>, Option<String>)> {
1490 let mut reader = Reader::from_reader(body);
1491 reader.config_mut().trim_text(true);
1492
1493 enum Tag {
1495 Contents,
1496 Key,
1497 LastModified,
1498 IsTruncated,
1499 NextContinuationToken,
1500 Other,
1501 }
1502
1503 let mut current_tag = Tag::Other;
1504 let mut current_release: Option<Release> = None;
1505 let mut is_truncated = false;
1506 let mut next_continuation_token: Option<String> = None;
1507 let regex = &*ASSET_KEY_REGEX;
1510
1511 let mut buf = Vec::new();
1513 let mut releases: Vec<Release> = vec![];
1514 loop {
1515 match reader.read_event_into(&mut buf) {
1516 Ok(Event::Start(ref e)) => match e.name().into_inner() {
1517 b"Contents" => {
1518 current_tag = Tag::Contents;
1519 if let Some(release) = current_release {
1520 add_to_releases_list(&mut releases, release);
1521 }
1522 current_release = None;
1523 }
1524 b"Key" => current_tag = Tag::Key,
1525 b"LastModified" => current_tag = Tag::LastModified,
1526 b"IsTruncated" => current_tag = Tag::IsTruncated,
1527 b"NextContinuationToken" => current_tag = Tag::NextContinuationToken,
1528 _ => current_tag = Tag::Other,
1529 },
1530 Ok(Event::Text(e)) => {
1531 if let Ok(txt) = e.decode().map(|r| r.into_owned()) {
1533 match current_tag {
1534 Tag::Key => {
1535 let p = PathBuf::from(&txt);
1536 let exe_name = match p.file_name().map(|v| v.to_str()) {
1537 Some(Some(v)) => v,
1538 _ => &txt,
1539 };
1540
1541 if let Some(captures) = regex.captures(&txt) {
1542 let release = current_release.get_or_insert(Release::default());
1543 release.name = std::sync::Arc::from(captures["name"].to_string());
1544 release.version = std::sync::Arc::from(
1545 captures["version"].trim_start_matches('v').to_string(),
1546 );
1547 let download_url = format!("{}{}", download_base_url, txt);
1548
1549 #[cfg(feature = "s3-auth")]
1550 let download_url = auth::s3_signature_v4(
1551 &download_url,
1552 region,
1553 access_key,
1554 signature_ttl.as_secs(),
1555 )?;
1556
1557 release.assets = vec![ReleaseAsset::new(exe_name, download_url)];
1558 debug!("Matched release: {:?}", release);
1559 } else {
1560 debug!("Regex mismatch: {:?}", txt);
1561 }
1562 }
1563 Tag::LastModified => {
1564 let release = current_release.get_or_insert(Release::default());
1565 release.date = std::sync::Arc::from(txt);
1566 }
1567 Tag::IsTruncated => {
1568 is_truncated = txt.eq_ignore_ascii_case("true");
1569 }
1570 Tag::NextContinuationToken => {
1571 next_continuation_token = Some(txt);
1572 }
1573 _ => (),
1574 }
1575 }
1576 }
1577 Ok(Event::Eof) => {
1578 if let Some(release) = current_release {
1579 add_to_releases_list(&mut releases, release);
1580 }
1581 break; }
1583 Err(e) => {
1584 return Err(Error::InvalidResponse {
1585 source: Box::new(e),
1586 });
1587 }
1588 _ => (), }
1590
1591 buf.clear();
1592 }
1593
1594 let next_token = if is_truncated {
1596 next_continuation_token
1597 } else {
1598 None
1599 };
1600 Ok((releases, next_token))
1601}
1602
1603fn add_to_releases_list(releases: &mut Vec<Release>, mut rel: Release) {
1606 if !rel.version().is_empty() && !rel.name.is_empty() {
1607 match releases
1608 .iter()
1609 .position(|curr| curr.name == rel.name && curr.version() == rel.version())
1610 {
1611 Some(index) => {
1612 rel.assets.append(&mut releases[index].assets);
1613 releases.push(rel);
1614 releases.swap_remove(index);
1615 }
1616 None => releases.push(rel),
1617 }
1618 }
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623 use super::Update;
1624 use crate::update::{Release, UpdateConfig};
1625 use std::time::Duration;
1626
1627 fn parse_s3_response<R: std::io::BufRead>(
1635 body: R,
1636 download_base_url: &str,
1637 #[cfg(feature = "s3-auth")] region: &Option<String>,
1638 #[cfg(feature = "s3-auth")] access_key: &Option<super::auth::AccessKey>,
1639 ) -> crate::errors::Result<Vec<Release>> {
1640 super::parse_s3_response(
1641 body,
1642 download_base_url,
1643 #[cfg(feature = "s3-auth")]
1644 region,
1645 #[cfg(feature = "s3-auth")]
1646 Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS),
1647 #[cfg(feature = "s3-auth")]
1648 access_key,
1649 )
1650 .map(|(releases, _next)| releases)
1651 }
1652
1653 fn list_bucket_xml(keys: &[&str]) -> String {
1655 let contents: String = keys
1656 .iter()
1657 .map(|k| {
1658 format!(
1659 "<Contents><Key>{k}</Key><LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents>"
1660 )
1661 })
1662 .collect();
1663 format!(
1664 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1665 <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1666 <Name>my-bucket</Name>{contents}</ListBucketResult>"
1667 )
1668 }
1669
1670 fn truncated_list_bucket_xml(keys: &[&str], next_token: &str) -> String {
1673 let contents: String = keys
1674 .iter()
1675 .map(|k| {
1676 format!(
1677 "<Contents><Key>{k}</Key><LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents>"
1678 )
1679 })
1680 .collect();
1681 format!(
1682 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1683 <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1684 <Name>my-bucket</Name><IsTruncated>true</IsTruncated>\
1685 <NextContinuationToken>{next_token}</NextContinuationToken>{contents}</ListBucketResult>"
1686 )
1687 }
1688
1689 #[test]
1694 fn parse_s3_response_single_release_single_asset() {
1695 let xml = list_bucket_xml(&["myapp-1.2.3-x86_64-linux"]);
1698 let releases = parse_s3_response(
1699 xml.as_bytes(),
1700 "https://bucket.s3.us-east-1.amazonaws.com/",
1701 #[cfg(feature = "s3-auth")]
1702 &None,
1703 #[cfg(feature = "s3-auth")]
1704 &None,
1705 )
1706 .unwrap();
1707 assert_eq!(releases.len(), 1, "one release parsed");
1708 let rel = &releases[0];
1709 assert_eq!(rel.name(), "myapp");
1710 assert_eq!(rel.version(), "1.2.3");
1711 assert_eq!(rel.assets.len(), 1);
1712 assert_eq!(rel.assets[0].name(), "myapp-1.2.3-x86_64-linux");
1713 assert!(
1714 rel.assets[0]
1715 .download_url()
1716 .starts_with("https://bucket.s3.us-east-1.amazonaws.com/"),
1717 "download URL uses the supplied base"
1718 );
1719 assert_eq!(rel.date(), "2024-01-01T00:00:00.000Z");
1720 }
1721
1722 #[test]
1723 fn parse_s3_response_v_prefix_stripped() {
1724 let xml = list_bucket_xml(&["myapp-v2.0.0-arm-linux"]);
1727 let releases = parse_s3_response(
1728 xml.as_bytes(),
1729 "https://bucket/",
1730 #[cfg(feature = "s3-auth")]
1731 &None,
1732 #[cfg(feature = "s3-auth")]
1733 &None,
1734 )
1735 .unwrap();
1736 assert_eq!(releases.len(), 1);
1737 assert_eq!(releases[0].version(), "2.0.0", "v-prefix must be stripped");
1738 }
1739
1740 #[test]
1741 fn parse_s3_response_multi_asset_merge() {
1742 let xml = list_bucket_xml(&["myapp-3.0.0-x86_64-linux", "myapp-3.0.0-aarch64-linux"]);
1747 let releases = parse_s3_response(
1748 xml.as_bytes(),
1749 "https://bucket/",
1750 #[cfg(feature = "s3-auth")]
1751 &None,
1752 #[cfg(feature = "s3-auth")]
1753 &None,
1754 )
1755 .unwrap();
1756 assert_eq!(releases.len(), 1, "same name+version must be merged");
1757 assert_eq!(
1758 releases[0].assets.len(),
1759 2,
1760 "both assets present after merge"
1761 );
1762 let asset_names: Vec<&str> = releases[0].assets.iter().map(|a| a.name()).collect();
1763 assert!(
1764 asset_names.contains(&"myapp-3.0.0-x86_64-linux"),
1765 "x86_64 asset present"
1766 );
1767 assert!(
1768 asset_names.contains(&"myapp-3.0.0-aarch64-linux"),
1769 "aarch64 asset present"
1770 );
1771 }
1772
1773 #[test]
1774 fn parse_s3_response_multiple_releases() {
1775 let xml = list_bucket_xml(&[
1778 "myapp-1.0.0-x86_64-linux",
1779 "myapp-2.0.0-x86_64-linux",
1780 "otherapp-1.5.0-x86_64-linux",
1781 ]);
1782 let releases = parse_s3_response(
1783 xml.as_bytes(),
1784 "https://bucket/",
1785 #[cfg(feature = "s3-auth")]
1786 &None,
1787 #[cfg(feature = "s3-auth")]
1788 &None,
1789 )
1790 .unwrap();
1791 assert_eq!(releases.len(), 3, "three distinct releases");
1792 let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1793 assert!(versions.contains(&"1.0.0"));
1794 assert!(versions.contains(&"2.0.0"));
1795 assert!(versions.contains(&"1.5.0"));
1796 }
1797
1798 #[test]
1799 fn parse_s3_response_skips_non_matching_keys() {
1800 let xml = list_bucket_xml(&[
1803 "README.txt",
1804 "myapp-1.0.0-x86_64-linux",
1805 "some/random/path/no-version",
1806 ]);
1807 let releases = parse_s3_response(
1808 xml.as_bytes(),
1809 "https://bucket/",
1810 #[cfg(feature = "s3-auth")]
1811 &None,
1812 #[cfg(feature = "s3-auth")]
1813 &None,
1814 )
1815 .unwrap();
1816 assert_eq!(releases.len(), 1, "only matching key produces a release");
1817 assert_eq!(releases[0].version(), "1.0.0");
1818 }
1819
1820 #[test]
1821 fn parse_s3_response_prefix_path_stripped_to_filename() {
1822 let xml = list_bucket_xml(&["releases/myapp-1.0.0-x86_64-linux"]);
1825 let releases = parse_s3_response(
1826 xml.as_bytes(),
1827 "https://bucket/",
1828 #[cfg(feature = "s3-auth")]
1829 &None,
1830 #[cfg(feature = "s3-auth")]
1831 &None,
1832 )
1833 .unwrap();
1834 assert_eq!(releases.len(), 1);
1835 assert_eq!(
1836 releases[0].assets[0].name(),
1837 "myapp-1.0.0-x86_64-linux",
1838 "asset name is the filename, not the full key path"
1839 );
1840 }
1841
1842 #[test]
1843 fn parse_s3_response_malformed_xml_errors() {
1844 use std::error::Error as _;
1845 let bad_xml = "this is not xml at all <<<";
1847 let result = parse_s3_response(
1848 bad_xml.as_bytes(),
1849 "https://bucket/",
1850 #[cfg(feature = "s3-auth")]
1851 &None,
1852 #[cfg(feature = "s3-auth")]
1853 &None,
1854 );
1855 let err = result.expect_err("malformed XML must return Err");
1856 assert!(
1859 matches!(err, crate::errors::Error::InvalidResponse { .. }),
1860 "malformed XML must surface as Error::InvalidResponse, got {:?}",
1861 err
1862 );
1863 assert!(
1864 err.source().is_some(),
1865 "InvalidResponse from XML parse must chain a non-None source()"
1866 );
1867 }
1868
1869 #[test]
1870 fn parse_s3_response_empty_body_returns_empty_vec() {
1871 let xml = "<?xml version=\"1.0\"?><ListBucketResult></ListBucketResult>";
1874 let releases = parse_s3_response(
1875 xml.as_bytes(),
1876 "https://bucket/",
1877 #[cfg(feature = "s3-auth")]
1878 &None,
1879 #[cfg(feature = "s3-auth")]
1880 &None,
1881 )
1882 .unwrap();
1883 assert!(releases.is_empty(), "empty bucket produces empty list");
1884 }
1885
1886 struct XmlResponse {
1890 body: Vec<u8>,
1891 }
1892
1893 impl crate::http_client::HttpResponse for XmlResponse {
1894 fn headers(&self) -> &crate::http_client::HeaderMap {
1895 Box::leak(Box::new(crate::http_client::HeaderMap::new()))
1897 }
1898 fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
1899 Box::new(std::io::Cursor::new(self.body))
1900 }
1901 }
1902
1903 #[test]
1904 fn parse_s3_response_parses_from_streaming_body_buffered() {
1905 let xml = list_bucket_xml(&["myapp-1.2.3-x86_64-linux"]);
1910 let resp: Box<dyn crate::http_client::HttpResponse> = Box::new(XmlResponse {
1911 body: xml.into_bytes(),
1912 });
1913 let reader = resp.body_buffered();
1914 let releases = parse_s3_response(
1915 reader,
1916 "https://bucket.s3.us-east-1.amazonaws.com/",
1917 #[cfg(feature = "s3-auth")]
1918 &None,
1919 #[cfg(feature = "s3-auth")]
1920 &None,
1921 )
1922 .unwrap();
1923 assert_eq!(releases.len(), 1, "one release parsed from the stream");
1924 assert_eq!(releases[0].version(), "1.2.3");
1925 assert_eq!(releases[0].assets[0].name(), "myapp-1.2.3-x86_64-linux");
1926 }
1927
1928 #[test]
1929 fn parse_s3_response_extracts_continuation_token_for_gcs_multipage_listing() {
1930 let page1 = truncated_list_bucket_xml(&["myapp-1.0.0-x86_64-linux"], "GCS-TOKEN-PAGE-2");
1937 let (releases, next_token) = super::parse_s3_response(
1938 page1.as_bytes(),
1939 "https://storage.googleapis.com/gbucket/",
1940 #[cfg(feature = "s3-auth")]
1941 &None,
1942 #[cfg(feature = "s3-auth")]
1943 Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS),
1944 #[cfg(feature = "s3-auth")]
1945 &None,
1946 )
1947 .unwrap();
1948 assert_eq!(
1949 next_token.as_deref(),
1950 Some("GCS-TOKEN-PAGE-2"),
1951 "a truncated first page must surface its NextContinuationToken so pagination continues"
1952 );
1953 assert_eq!(
1954 releases.len(),
1955 1,
1956 "the first page's release is still parsed alongside the continuation token"
1957 );
1958 assert_eq!(releases[0].version(), "1.0.0");
1959 }
1960
1961 #[test]
1962 fn add_to_releases_list_skips_entries_with_empty_name_or_version() {
1963 let mut releases = Vec::new();
1966 let empty_name = Release::builder()
1967 .name("")
1968 .version("1.0.0")
1969 .build()
1970 .unwrap();
1971 let empty_ver = Release::builder()
1972 .name("myapp")
1973 .version("")
1974 .build()
1975 .unwrap();
1976 super::add_to_releases_list(&mut releases, empty_name);
1977 super::add_to_releases_list(&mut releases, empty_ver);
1978 assert!(
1979 releases.is_empty(),
1980 "entries with empty name or version must be dropped"
1981 );
1982 }
1983
1984 use std::io::{Read as _, Write as _};
1989 use std::net::TcpListener;
1990
1991 struct Resp {
1994 status: &'static str,
1995 body: String,
1996 }
1997
1998 fn stub(responses: Vec<Resp>) -> String {
1999 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2000 let base = format!("http://{}/", listener.local_addr().unwrap());
2001 std::thread::spawn(move || {
2002 for r in responses {
2003 let (mut stream, _) = match listener.accept() {
2004 Ok(c) => c,
2005 Err(_) => return,
2006 };
2007 let mut buf = [0u8; 4096];
2008 let _ = stream.read(&mut buf);
2009 let out = format!(
2010 "HTTP/1.1 {}\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2011 r.status,
2012 r.body.len(),
2013 r.body
2014 );
2015 let _ = stream.write_all(out.as_bytes());
2016 let _ = stream.flush();
2017 }
2018 });
2019 base
2020 }
2021
2022 #[cfg(feature = "async")]
2025 fn s3_update(base_url: &str, current_version: &str) -> super::AsyncUpdate {
2026 Update::configure()
2027 .endpoint(super::Endpoint::Generic(base_url.to_owned()))
2028 .bucket_name("test-bucket")
2029 .bin_name("myapp")
2030 .current_version(current_version)
2031 .build_async()
2032 .unwrap()
2033 }
2034
2035 fn s3_update_sync(base_url: &str, current_version: &str) -> Update {
2038 Update::configure()
2039 .endpoint(super::Endpoint::Generic(base_url.to_owned()))
2040 .bucket_name("test-bucket")
2041 .bin_name("myapp")
2042 .current_version(current_version)
2043 .build()
2044 .unwrap()
2045 }
2046
2047 fn stub_capturing(
2050 responses: Vec<Resp>,
2051 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
2052 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2053 let base = format!("http://{}/", listener.local_addr().unwrap());
2054 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2055 let sink = captured.clone();
2056 std::thread::spawn(move || {
2057 for r in responses {
2058 let (mut stream, _) = match listener.accept() {
2059 Ok(c) => c,
2060 Err(_) => return,
2061 };
2062 let mut buf = [0u8; 4096];
2063 let n = stream.read(&mut buf).unwrap_or(0);
2064 let req = String::from_utf8_lossy(&buf[..n]).into_owned();
2065 sink.lock()
2066 .unwrap()
2067 .push(req.lines().next().unwrap_or("").to_string());
2068 let out = format!(
2069 "HTTP/1.1 {}\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2070 r.status,
2071 r.body.len(),
2072 r.body
2073 );
2074 let _ = stream.write_all(out.as_bytes());
2075 let _ = stream.flush();
2076 }
2077 });
2078 (base, captured)
2079 }
2080
2081 #[test]
2084 fn s3_listing_follows_continuation_token_across_two_responses() {
2085 let page1 = truncated_list_bucket_xml(
2089 &["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"],
2090 "TOKEN-PAGE-2",
2091 );
2092 let page2 = list_bucket_xml(&["myapp-3.0.0-x86_64-linux"]);
2093 let (base, captured) = stub_capturing(vec![
2094 Resp {
2095 status: "200 OK",
2096 body: page1,
2097 },
2098 Resp {
2099 status: "200 OK",
2100 body: page2,
2101 },
2102 ]);
2103 let upd = s3_update_sync(&base, "0.1.0");
2104 let releases = upd.get_newer_releases().unwrap();
2105 let mut versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2106 versions.sort_unstable();
2107 assert_eq!(
2108 versions,
2109 vec!["1.0.0", "2.0.0", "3.0.0"],
2110 "releases from both the truncated page and the continuation page must be accumulated"
2111 );
2112 let requests = captured.lock().unwrap();
2113 assert_eq!(requests.len(), 2, "the truncated listing must be followed");
2114 assert!(
2115 !requests[0].contains("continuation-token="),
2116 "the first request must not carry a continuation token"
2117 );
2118 assert!(
2119 requests[1].contains("continuation-token=TOKEN-PAGE-2"),
2120 "the second request must carry the NextContinuationToken in its query, got: {}",
2121 requests[1]
2122 );
2123 }
2124
2125 #[cfg(feature = "async")]
2126 #[tokio::test]
2127 async fn s3_listing_follows_continuation_token_across_two_responses_async() {
2128 let page1 = truncated_list_bucket_xml(
2132 &["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"],
2133 "TOKEN-PAGE-2",
2134 );
2135 let page2 = list_bucket_xml(&["myapp-3.0.0-x86_64-linux"]);
2136 let (base, captured) = stub_capturing(vec![
2137 Resp {
2138 status: "200 OK",
2139 body: page1,
2140 },
2141 Resp {
2142 status: "200 OK",
2143 body: page2,
2144 },
2145 ]);
2146 let upd = s3_update(&base, "0.1.0");
2147 let releases = upd.get_newer_releases_async().await.unwrap();
2148 let mut versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2149 versions.sort_unstable();
2150 assert_eq!(
2151 versions,
2152 vec!["1.0.0", "2.0.0", "3.0.0"],
2153 "async continuation must accumulate releases from both pages"
2154 );
2155 let requests = captured.lock().unwrap();
2156 assert_eq!(
2157 requests.len(),
2158 2,
2159 "the truncated listing must be followed over the async transport"
2160 );
2161 assert!(
2162 !requests[0].contains("continuation-token="),
2163 "the first async request must not carry a continuation token"
2164 );
2165 assert!(
2166 requests[1].contains("continuation-token=TOKEN-PAGE-2"),
2167 "the second async request must carry the NextContinuationToken, got: {}",
2168 requests[1]
2169 );
2170 }
2171
2172 #[cfg(feature = "s3-auth")]
2175 #[test]
2176 fn s3_continuation_signs_each_page_freshly_not_reusing_the_first_signature() {
2177 let page1 = truncated_list_bucket_xml(&["myapp-1.0.0-x86_64-linux"], "TOKEN-PAGE-2");
2182 let page2 = list_bucket_xml(&["myapp-2.0.0-x86_64-linux"]);
2183 let (base, captured) = stub_capturing(vec![
2184 Resp {
2185 status: "200 OK",
2186 body: page1,
2187 },
2188 Resp {
2189 status: "200 OK",
2190 body: page2,
2191 },
2192 ]);
2193 let upd = Update::configure()
2196 .endpoint(super::Endpoint::Generic(base.clone()))
2197 .bucket_name("test-bucket")
2198 .region("us-east-1")
2199 .bin_name("myapp")
2200 .current_version("0.1.0")
2201 .access_key(("AKIA", "secret"))
2202 .build()
2203 .unwrap();
2204 let releases = upd.get_newer_releases().unwrap();
2205 let mut versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2206 versions.sort_unstable();
2207 assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
2208
2209 let requests = captured.lock().unwrap();
2210 assert_eq!(requests.len(), 2, "the signed listing must be continued");
2211
2212 assert!(
2214 requests[0].contains("X-Amz-Signature="),
2215 "first signed listing request missing a signature, got: {}",
2216 requests[0]
2217 );
2218 assert!(
2219 requests[1].contains("X-Amz-Signature="),
2220 "the continuation request must be freshly signed (a valid signature), got: {}",
2221 requests[1]
2222 );
2223 assert!(
2224 requests[1].contains("continuation-token="),
2225 "the continuation request must carry the token, got: {}",
2226 requests[1]
2227 );
2228
2229 let sig = |line: &str| -> String {
2232 line.split("X-Amz-Signature=")
2233 .nth(1)
2234 .unwrap_or("")
2235 .split(['&', ' '])
2236 .next()
2237 .unwrap_or("")
2238 .to_string()
2239 };
2240 let sig0 = sig(&requests[0]);
2241 let sig1 = sig(&requests[1]);
2242 assert!(
2243 !sig0.is_empty() && !sig1.is_empty(),
2244 "both signatures present"
2245 );
2246 assert_ne!(
2247 sig0, sig1,
2248 "the continuation signature must be freshly computed for the continuation URL, \
2249 not the first request's signature reused"
2250 );
2251 }
2252
2253 #[test]
2254 fn s3_listing_stops_when_not_truncated() {
2255 let body = "<?xml version=\"1.0\"?><ListBucketResult><Name>b</Name>\
2258 <NextContinuationToken>SHOULD-NOT-FOLLOW</NextContinuationToken>\
2259 <Contents><Key>myapp-1.0.0-x86_64-linux</Key>\
2260 <LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents></ListBucketResult>"
2261 .to_string();
2262 let (base, captured) = stub_capturing(vec![Resp {
2263 status: "200 OK",
2264 body,
2265 }]);
2266 let upd = s3_update_sync(&base, "0.1.0");
2267 let releases = upd.get_newer_releases().unwrap();
2268 assert_eq!(releases.all().len(), 1);
2269 assert_eq!(
2270 captured.lock().unwrap().len(),
2271 1,
2272 "a token without IsTruncated=true must not be followed"
2273 );
2274 }
2275
2276 #[test]
2279 fn max_keys_clamps_to_one_to_one_thousand() {
2280 assert_eq!(super::clamp_max_keys(0), 1, "0 clamps up to the 1 floor");
2281 assert_eq!(super::clamp_max_keys(1), 1);
2282 assert_eq!(super::clamp_max_keys(500), 500, "in-range passes through");
2283 assert_eq!(super::clamp_max_keys(1000), 1000);
2284 assert_eq!(
2285 super::clamp_max_keys(5000),
2286 1000,
2287 "above 1000 clamps to the 1000 ceiling"
2288 );
2289 assert_eq!(super::clamp_max_keys(u16::MAX), 1000);
2290 }
2291
2292 #[cfg(feature = "s3-auth")]
2293 #[test]
2294 fn signature_ttl_clamps_to_aws_expires_range() {
2295 use std::time::Duration;
2296 assert_eq!(
2297 super::clamp_signature_ttl(Duration::from_secs(0)),
2298 Duration::from_secs(1),
2299 "0 clamps up to the 1s floor"
2300 );
2301 assert_eq!(
2302 super::clamp_signature_ttl(Duration::from_secs(300)),
2303 Duration::from_secs(300),
2304 "in-range passes through"
2305 );
2306 assert_eq!(
2307 super::clamp_signature_ttl(Duration::from_secs(1_000_000)),
2308 Duration::from_secs(604_800),
2309 "above 7d clamps to the 604800s ceiling"
2310 );
2311 }
2312
2313 #[test]
2314 fn max_keys_setter_threads_into_the_listing_query() {
2315 let (base, captured) = stub_capturing(vec![Resp {
2317 status: "200 OK",
2318 body: list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]),
2319 }]);
2320 let upd = Update::configure()
2321 .endpoint(super::Endpoint::Generic(base.clone()))
2322 .bucket_name("test-bucket")
2323 .bin_name("myapp")
2324 .current_version("0.1.0")
2325 .max_keys(250u16)
2326 .build()
2327 .unwrap();
2328 let _ = upd.get_newer_releases().unwrap();
2329 let request = captured.lock().unwrap()[0].clone();
2330 assert!(
2331 request.contains("max-keys=250"),
2332 "the configured max_keys must appear in the listing query, got: {}",
2333 request
2334 );
2335 }
2336
2337 #[test]
2338 fn max_keys_setter_clamps_in_the_query() {
2339 let (base, captured) = stub_capturing(vec![Resp {
2341 status: "200 OK",
2342 body: list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]),
2343 }]);
2344 let upd = Update::configure()
2345 .endpoint(super::Endpoint::Generic(base.clone()))
2346 .bucket_name("test-bucket")
2347 .bin_name("myapp")
2348 .current_version("0.1.0")
2349 .max_keys(5000u16)
2350 .build()
2351 .unwrap();
2352 let _ = upd.get_newer_releases().unwrap();
2353 let request = captured.lock().unwrap()[0].clone();
2354 assert!(
2355 request.contains("max-keys=1000") && !request.contains("max-keys=5000"),
2356 "an over-cap max_keys must clamp to 1000 in the query, got: {}",
2357 request
2358 );
2359 }
2360
2361 #[test]
2369 fn get_latest_release_sync_wraps_newest_and_carries_current_version() {
2370 let xml = list_bucket_xml(&[
2374 "myapp-0.9.0-x86_64-linux",
2375 "myapp-2.1.0-x86_64-linux",
2376 "myapp-1.0.0-x86_64-linux",
2377 ]);
2378 let base = stub(vec![Resp {
2379 status: "200 OK",
2380 body: xml,
2381 }]);
2382 let upd = s3_update_sync(&base, "1.0.0");
2383 let releases = upd.get_latest_release().unwrap();
2384 assert_eq!(
2385 releases.all().len(),
2386 1,
2387 "get_latest_release yields a one-element Releases"
2388 );
2389 assert_eq!(releases.latest().unwrap().version(), "2.1.0");
2390 assert!(
2391 releases.is_update_available().unwrap(),
2392 "2.1.0 > 1.0.0 via the one-element Releases pre-check"
2393 );
2394 }
2395
2396 #[test]
2397 fn get_newer_releases_sync_filters_to_newer_and_prechecks() {
2398 let xml = list_bucket_xml(&[
2401 "myapp-0.9.0-x86_64-linux",
2402 "myapp-1.0.0-x86_64-linux",
2403 "myapp-1.5.0-x86_64-linux",
2404 "myapp-2.0.0-x86_64-linux",
2405 ]);
2406 let base = stub(vec![Resp {
2407 status: "200 OK",
2408 body: xml,
2409 }]);
2410 let upd = s3_update_sync(&base, "1.0.0");
2411 let releases = upd.get_newer_releases().unwrap();
2412 let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2413 assert_eq!(
2414 versions,
2415 vec!["2.0.0", "1.5.0"],
2416 "only releases strictly newer than current, newest-first"
2417 );
2418 assert_eq!(releases.latest().unwrap().version(), "2.0.0");
2419 assert!(releases.is_update_available().unwrap());
2420 }
2421
2422 #[test]
2426 fn release_list_fetch_returns_listing_releases_without_current_version() {
2427 let xml = list_bucket_xml(&["myapp-2.0.0-x86_64-linux", "myapp-1.0.0-x86_64-linux"]);
2428 let base = stub(vec![Resp {
2429 status: "200 OK",
2430 body: xml,
2431 }]);
2432 let releases = super::ReleaseList::configure()
2433 .endpoint(super::Endpoint::Generic(base.clone()))
2434 .bucket_name("test-bucket")
2435 .build()
2436 .unwrap()
2437 .fetch()
2438 .unwrap();
2439 assert_eq!(
2440 releases.current_version(),
2441 None,
2442 "a bare s3 listing carries no current version"
2443 );
2444 assert!(
2445 matches!(
2446 releases.is_update_available(),
2447 Err(crate::errors::Error::NoCurrentVersion)
2448 ),
2449 "is_update_available() on an s3 listing must error with NoCurrentVersion, got {:?}",
2450 releases.is_update_available()
2451 );
2452 let mut versions: Vec<String> = releases
2453 .into_vec()
2454 .into_iter()
2455 .map(|r| r.version().to_string())
2456 .collect();
2457 versions.sort();
2458 assert_eq!(
2459 versions,
2460 vec!["1.0.0".to_string(), "2.0.0".to_string()],
2461 "into_vec() recovers the parsed releases"
2462 );
2463 }
2464
2465 #[cfg(feature = "async")]
2469 #[tokio::test]
2470 async fn release_list_fetch_async_returns_listing_releases_without_current_version() {
2471 let xml = list_bucket_xml(&["myapp-2.0.0-x86_64-linux", "myapp-1.0.0-x86_64-linux"]);
2472 let base = stub(vec![Resp {
2473 status: "200 OK",
2474 body: xml,
2475 }]);
2476 let releases = super::ReleaseList::configure()
2477 .endpoint(super::Endpoint::Generic(base.clone()))
2478 .bucket_name("test-bucket")
2479 .build()
2480 .unwrap()
2481 .fetch_async()
2482 .await
2483 .unwrap();
2484 assert_eq!(
2485 releases.current_version(),
2486 None,
2487 "a bare s3 listing carries no current version"
2488 );
2489 assert!(
2490 matches!(
2491 releases.is_update_available(),
2492 Err(crate::errors::Error::NoCurrentVersion)
2493 ),
2494 "is_update_available() on an s3 listing must error with NoCurrentVersion, got {:?}",
2495 releases.is_update_available()
2496 );
2497 let mut versions: Vec<String> = releases
2498 .into_vec()
2499 .into_iter()
2500 .map(|r| r.version().to_string())
2501 .collect();
2502 versions.sort();
2503 assert_eq!(
2504 versions,
2505 vec!["1.0.0".to_string(), "2.0.0".to_string()],
2506 "into_vec() recovers the parsed releases"
2507 );
2508 }
2509
2510 #[test]
2511 fn sync_is_update_available_agrees_between_paths_when_up_to_date() {
2512 let xml = || {
2516 list_bucket_xml(&[
2517 "myapp-2.0.0-x86_64-linux",
2518 "myapp-1.0.0-x86_64-linux",
2519 "myapp-0.9.0-x86_64-linux",
2520 ])
2521 };
2522
2523 let base = stub(vec![Resp {
2524 status: "200 OK",
2525 body: xml(),
2526 }]);
2527 let upd = s3_update_sync(&base, "2.0.0");
2528 let single = upd.get_latest_release().unwrap();
2529 assert_eq!(single.latest().unwrap().version(), "2.0.0");
2530 assert!(
2531 !single.is_update_available().unwrap(),
2532 "get_latest_release: newest (2.0.0) == current => not available"
2533 );
2534
2535 let base = stub(vec![Resp {
2536 status: "200 OK",
2537 body: xml(),
2538 }]);
2539 let upd = s3_update_sync(&base, "2.0.0");
2540 let list = upd.get_newer_releases().unwrap();
2541 assert!(
2542 list.all().is_empty(),
2543 "nothing strictly newer than 2.0.0 => empty list"
2544 );
2545 assert!(
2546 !list.is_update_available().unwrap(),
2547 "get_newer_releases agrees: not available"
2548 );
2549 }
2550
2551 fn assert_status_err(
2558 res: crate::errors::Result<crate::update::Releases>,
2559 expected_status: u16,
2560 ) {
2561 use crate::errors::Error;
2562 let err = match res {
2563 Err(e) => e,
2564 Ok(_) => panic!(
2565 "a non-2xx ({}) listing response must surface as Err, got Ok",
2566 expected_status
2567 ),
2568 };
2569 assert_eq!(
2570 err.http_status(),
2571 Some(expected_status),
2572 "http_status() must recover the injected status {}, got {:?}",
2573 expected_status,
2574 err
2575 );
2576 match expected_status {
2577 404 => assert!(
2578 matches!(err, Error::NotFound { .. }),
2579 "status 404 must surface as Error::NotFound, got {:?}",
2580 err
2581 ),
2582 401 | 403 => assert!(
2583 matches!(err, Error::Unauthorized { status, .. } if status == expected_status),
2584 "status {} must surface as Error::Unauthorized, got {:?}",
2585 expected_status,
2586 err
2587 ),
2588 _ => assert!(
2589 matches!(err, Error::HttpStatus { status, .. } if status == expected_status),
2590 "status {} must surface as Error::HttpStatus, got {:?}",
2591 expected_status,
2592 err
2593 ),
2594 }
2595 }
2596
2597 fn fetch_with_status(status: &'static str) -> crate::errors::Result<crate::update::Releases> {
2601 let base = stub(vec![Resp {
2602 status,
2603 body: "<Error><Code>NoSuchBucket</Code></Error>".to_string(),
2604 }]);
2605 let upd = s3_update_sync(&base, "0.1.0");
2606 upd.get_latest_release()
2607 }
2608
2609 #[test]
2610 fn fetch_404_surfaces_not_found() {
2611 assert_status_err(fetch_with_status("404 Not Found"), 404);
2616
2617 let base = stub(vec![Resp {
2620 status: "404 Not Found",
2621 body: "<Error><Code>NoSuchBucket</Code></Error>".to_string(),
2622 }]);
2623 let upd = s3_update_sync(&base, "0.1.0");
2624 assert_status_err(upd.get_newer_releases(), 404);
2625 }
2626
2627 #[test]
2628 fn fetch_401_and_403_surface_unauthorized() {
2629 assert_status_err(fetch_with_status("401 Unauthorized"), 401);
2631 assert_status_err(fetch_with_status("403 Forbidden"), 403);
2632 }
2633
2634 #[test]
2635 fn fetch_500_and_503_surface_http_status() {
2636 assert_status_err(fetch_with_status("500 Internal Server Error"), 500);
2638 assert_status_err(fetch_with_status("503 Service Unavailable"), 503);
2639 }
2640
2641 #[test]
2642 fn fetch_400_surfaces_http_status() {
2643 assert_status_err(fetch_with_status("400 Bad Request"), 400);
2646 }
2647
2648 #[test]
2649 fn get_release_version_sync_finds_exact_version() {
2650 let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"]);
2653 let base = stub(vec![Resp {
2654 status: "200 OK",
2655 body: xml,
2656 }]);
2657 let upd = s3_update_sync(&base, "0.1.0");
2658 let rel = upd.get_release_version("1.0.0").unwrap();
2659 assert_eq!(rel.version(), "1.0.0");
2660
2661 let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]);
2662 let base = stub(vec![Resp {
2663 status: "200 OK",
2664 body: xml,
2665 }]);
2666 let upd = s3_update_sync(&base, "0.1.0");
2667 assert!(
2668 matches!(
2669 upd.get_release_version("9.9.9"),
2670 Err(crate::errors::Error::NoReleaseFound { .. })
2671 ),
2672 "missing version must surface as Error::NoReleaseFound"
2673 );
2674 }
2675
2676 #[cfg(feature = "async")]
2677 #[tokio::test]
2678 async fn fetch_releases_from_s3_async_parses_xml_response() {
2679 let xml = list_bucket_xml(&["myapp-2.1.0-x86_64-linux"]);
2682 let base = stub(vec![Resp {
2683 status: "200 OK",
2684 body: xml,
2685 }]);
2686 let upd = s3_update(&base, "0.1.0");
2687 let releases = upd.get_latest_release_async().await.unwrap();
2688 let rel = releases.latest().expect("one-element Releases");
2689 assert_eq!(rel.version(), "2.1.0");
2690 assert_eq!(rel.name(), "myapp");
2691 }
2692
2693 #[cfg(feature = "async")]
2694 #[tokio::test]
2695 async fn get_newer_releases_async_filters_to_newer_only() {
2696 let xml = list_bucket_xml(&[
2699 "myapp-0.9.0-x86_64-linux",
2700 "myapp-1.0.0-x86_64-linux",
2701 "myapp-1.5.0-x86_64-linux",
2702 "myapp-2.0.0-x86_64-linux",
2703 ]);
2704 let base = stub(vec![Resp {
2705 status: "200 OK",
2706 body: xml,
2707 }]);
2708 let upd = s3_update(&base, "1.0.0");
2709 let releases = upd.get_newer_releases_async().await.unwrap();
2710 let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2711 assert_eq!(
2712 versions,
2713 vec!["2.0.0", "1.5.0"],
2714 "only releases strictly newer than current, newest-first"
2715 );
2716 }
2717
2718 #[cfg(feature = "async")]
2719 #[tokio::test]
2720 async fn get_release_version_async_finds_exact_version() {
2721 let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"]);
2724 let base = stub(vec![Resp {
2725 status: "200 OK",
2726 body: xml,
2727 }]);
2728 let upd = s3_update(&base, "0.1.0");
2729 let rel = upd.get_release_version_async("1.0.0").await.unwrap();
2730 assert_eq!(rel.version(), "1.0.0");
2731 }
2732
2733 #[cfg(feature = "async")]
2734 #[tokio::test]
2735 async fn get_release_version_async_errors_on_missing_version() {
2736 let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]);
2739 let base = stub(vec![Resp {
2740 status: "200 OK",
2741 body: xml,
2742 }]);
2743 let upd = s3_update(&base, "0.1.0");
2744 let res = upd.get_release_version_async("9.9.9").await;
2745 assert!(
2746 matches!(res, Err(crate::errors::Error::NoReleaseFound { .. })),
2747 "missing version must surface as Error::NoReleaseFound"
2748 );
2749 }
2750
2751 #[cfg(feature = "async")]
2752 #[tokio::test]
2753 async fn is_update_available_async_true_then_false() {
2754 let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"]);
2759 let base = stub(vec![Resp {
2760 status: "200 OK",
2761 body: xml.clone(),
2762 }]);
2763 let upd = s3_update(&base, "1.0.0");
2764 assert!(
2765 upd.get_latest_release_async()
2766 .await
2767 .unwrap()
2768 .is_update_available()
2769 .unwrap(),
2770 "2.0.0 > 1.0.0 => update available"
2771 );
2772
2773 let base = stub(vec![Resp {
2774 status: "200 OK",
2775 body: xml,
2776 }]);
2777 let upd = s3_update(&base, "2.0.0");
2778 assert!(
2779 !upd.get_latest_release_async()
2780 .await
2781 .unwrap()
2782 .is_update_available()
2783 .unwrap(),
2784 "2.0.0 not newer than 2.0.0 => no update"
2785 );
2786 }
2787
2788 #[cfg(feature = "async")]
2789 #[tokio::test]
2790 async fn get_latest_release_async_multi_asset_merge() {
2791 let xml = list_bucket_xml(&["myapp-3.0.0-x86_64-linux", "myapp-3.0.0-aarch64-linux"]);
2794 let base = stub(vec![Resp {
2795 status: "200 OK",
2796 body: xml,
2797 }]);
2798 let upd = s3_update(&base, "0.1.0");
2799 let releases = upd.get_latest_release_async().await.unwrap();
2800 let rel = releases.latest().expect("one-element Releases");
2801 assert_eq!(rel.version(), "3.0.0");
2802 assert_eq!(
2803 rel.assets.len(),
2804 2,
2805 "both assets present after async multi-asset merge"
2806 );
2807 }
2808
2809 fn rel(version: &str) -> Release {
2810 Release::builder().version(version).build().unwrap()
2811 }
2812
2813 #[test]
2814 fn pick_latest_returns_highest_version() {
2815 let releases = [rel("1.0.0"), rel("2.3.1"), rel("2.0.0"), rel("1.9.9")];
2816 assert_eq!(super::pick_latest(&releases).unwrap().version(), "2.3.1");
2817 }
2818
2819 #[test]
2820 fn pick_latest_errors_on_empty() {
2821 assert!(super::pick_latest(&[]).is_err());
2822 }
2823
2824 #[test]
2829 fn selection_parity_pick_latest_sort_newer_and_choose_latest_release() {
2830 let make = || {
2832 vec![
2833 rel("1.3.0"),
2834 rel("1.1.0"),
2835 rel("1.4.2"),
2836 rel("1.0.5"),
2837 rel("1.2.0"),
2838 ]
2839 };
2840
2841 assert_eq!(super::pick_latest(&make()).unwrap().version(), "1.4.2");
2843
2844 let sorted = super::sort_newer(make(), "1.0.0");
2846 assert_eq!(sorted.first().unwrap().version(), "1.4.2");
2847
2848 let chosen = crate::update::testing::choose_latest_release_for_test(make(), "1.0.0")
2851 .unwrap()
2852 .expect("a newer compatible release is chosen");
2853 assert_eq!(chosen.version(), "1.4.2");
2854
2855 let mut reversed = make();
2857 reversed.reverse();
2858 assert_eq!(super::pick_latest(&reversed).unwrap().version(), "1.4.2");
2859 let chosen_rev = crate::update::testing::choose_latest_release_for_test(reversed, "1.0.0")
2860 .unwrap()
2861 .expect("a newer compatible release is chosen");
2862 assert_eq!(chosen_rev.version(), "1.4.2");
2863 }
2864
2865 #[test]
2866 fn pick_latest_ignores_unparseable_versions() {
2867 let releases = [rel("1.0.0"), rel("not-a-version"), rel("2.1.0")];
2872 assert_eq!(super::pick_latest(&releases).unwrap().version(), "2.1.0");
2873
2874 let releases = [rel("bogus"), rel("1.5.0")];
2876 assert_eq!(super::pick_latest(&releases).unwrap().version(), "1.5.0");
2877 }
2878
2879 #[test]
2880 fn sort_newer_ignores_unparseable_versions() {
2881 let releases = vec![rel("garbage"), rel("2.0.0"), rel("1.5.0"), rel("1.0.0")];
2884 let newer = super::sort_newer(releases, "1.0.0");
2885 let versions: Vec<_> = newer.iter().map(|r| r.version()).collect();
2886 assert_eq!(versions, vec!["2.0.0", "1.5.0"]);
2887 }
2888
2889 #[cfg(feature = "s3-auth")]
2890 #[test]
2891 fn access_key_is_reexported_and_built_from_tuples() {
2892 let from_strs: super::AccessKey = ("AKIA-id", "secret").into();
2895 assert_eq!(from_strs.access_key_id, "AKIA-id");
2896 assert_eq!(from_strs.secret_access_key, "secret");
2897
2898 let from_owned: super::AccessKey = (String::from("id2"), String::from("secret2")).into();
2899 assert_eq!(from_owned.access_key_id, "id2");
2900 assert_eq!(from_owned.secret_access_key, "secret2");
2901 }
2902
2903 #[cfg(feature = "s3-auth")]
2904 #[test]
2905 fn access_key_setter_accepts_tuple_and_reexported_type() {
2906 let _ = Update::configure()
2909 .bucket_name("bucket")
2910 .region("us-east-1")
2911 .bin_name("my_bin")
2912 .current_version("0.1.0")
2913 .access_key(("id", "secret"))
2914 .build()
2915 .unwrap();
2916
2917 let key: super::AccessKey = ("id", "secret").into();
2918 let _ = Update::configure()
2919 .bucket_name("bucket")
2920 .region("us-east-1")
2921 .bin_name("my_bin")
2922 .current_version("0.1.0")
2923 .access_key(key)
2924 .build()
2925 .unwrap();
2926 }
2927
2928 #[test]
2929 fn filter_target_setter_exists_on_release_list_builder() {
2930 let _list = super::ReleaseList::configure()
2933 .bucket_name("bucket")
2934 .region("us-east-1")
2935 .filter_target("x86_64-unknown-linux-gnu")
2936 .build()
2937 .unwrap();
2938 }
2939
2940 #[test]
2941 fn release_list_build_surfaces_invalid_header() {
2942 let res = super::ReleaseList::configure()
2945 .bucket_name("bucket")
2946 .request_header("inva lid", "ok")
2947 .build();
2948 assert!(
2949 matches!(res, Err(crate::errors::Error::InvalidHeader { .. })),
2950 "invalid header must surface as Error::InvalidHeader from ReleaseList build()"
2951 );
2952 }
2953
2954 #[test]
2955 fn sort_newer_keeps_only_newer_descending() {
2956 let releases = vec![rel("0.9.0"), rel("1.5.0"), rel("1.0.0"), rel("2.0.0")];
2957 let newer = super::sort_newer(releases, "1.0.0");
2958 let versions: Vec<_> = newer.iter().map(|r| r.version()).collect();
2960 assert_eq!(versions, vec!["2.0.0", "1.5.0"]);
2961 }
2962
2963 #[test]
2964 fn find_version_matches_exact() {
2965 let releases = [rel("1.0.0"), rel("1.2.3"), rel("2.0.0")];
2966 assert_eq!(
2967 super::find_version(&releases, "1.2.3").unwrap().version(),
2968 "1.2.3"
2969 );
2970 assert!(super::find_version(&releases, "9.9.9").is_err());
2971 }
2972
2973 #[test]
2974 fn find_version_matches_v_prefixed_release_tag() {
2975 let releases = [rel("1.0.0"), rel("1.2.3"), rel("2.0.0")];
2980 assert_eq!(
2981 super::find_version(&releases, "v1.2.3").unwrap().version(),
2982 "1.2.3",
2983 "a v-prefixed release_tag must match the bare stored semver"
2984 );
2985 assert_eq!(
2986 super::find_version(&releases, "1.2.3").unwrap().version(),
2987 "1.2.3",
2988 "the bare form must keep working"
2989 );
2990 assert!(
2991 super::find_version(&releases, "v9.9.9").is_err(),
2992 "a v-prefixed tag with no matching release still errors"
2993 );
2994 }
2995
2996 fn configured() -> Update {
2997 Update::configure()
2998 .bucket_name("bucket")
2999 .asset_prefix("prefix")
3000 .region("us-east-1")
3001 .bin_name("my_bin")
3002 .target("x86_64-unknown-linux-gnu")
3003 .asset_identifier("musl")
3004 .current_version("0.1.0")
3005 .build()
3006 .unwrap()
3007 }
3008
3009 #[test]
3010 fn identifier_and_str_accessors_are_wired() {
3011 let upd = configured();
3012 assert_eq!(upd.asset_identifier(), Some("musl"));
3014 assert_eq!(upd.target(), "x86_64-unknown-linux-gnu");
3015 assert_eq!(upd.current_version(), "0.1.0");
3016 }
3017
3018 #[test]
3019 fn default_api_headers_is_a_noop() {
3020 let upd = configured();
3025 assert!(upd.api_headers(Some("bad\ntoken")).unwrap().is_empty());
3026 assert!(upd.api_headers(Some("good-token")).unwrap().is_empty());
3027 assert!(upd.api_headers(None).unwrap().is_empty());
3028 }
3029
3030 fn api_url(
3039 endpoint: super::Endpoint,
3040 bucket: &str,
3041 region: Option<&str>,
3042 prefix: Option<&str>,
3043 ) -> crate::errors::Result<(String, String)> {
3044 super::build_s3_api_url(
3045 &endpoint,
3046 bucket,
3047 ®ion.map(str::to_owned),
3048 &prefix.map(str::to_owned),
3049 super::DEFAULT_MAX_KEYS,
3050 None,
3051 #[cfg(feature = "s3-auth")]
3052 Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS),
3053 #[cfg(feature = "s3-auth")]
3054 &None,
3055 )
3056 }
3057
3058 #[test]
3059 fn build_s3_api_url_s3_endpoint_shape() {
3060 let (base, url) =
3063 api_url(super::Endpoint::S3, "my-bucket", Some("eu-west-1"), None).unwrap();
3064 assert_eq!(base, "https://my-bucket.s3.eu-west-1.amazonaws.com/");
3065 assert_eq!(
3066 url,
3067 "https://my-bucket.s3.eu-west-1.amazonaws.com/?list-type=2&max-keys=1000"
3068 );
3069 }
3070
3071 #[test]
3072 fn build_s3_api_url_dualstack_endpoint_shape() {
3073 let (base, url) =
3075 api_url(super::Endpoint::S3DualStack, "b", Some("us-east-2"), None).unwrap();
3076 assert_eq!(base, "https://b.s3.dualstack.us-east-2.amazonaws.com/");
3077 assert!(url.starts_with("https://b.s3.dualstack.us-east-2.amazonaws.com/?list-type=2"));
3078 }
3079
3080 #[test]
3081 fn build_s3_api_url_digitalocean_endpoint_shape() {
3082 let (base, url) = api_url(
3084 super::Endpoint::DigitalOceanSpaces,
3085 "space",
3086 Some("nyc3"),
3087 None,
3088 )
3089 .unwrap();
3090 assert_eq!(base, "https://space.nyc3.digitaloceanspaces.com/");
3091 assert!(url.starts_with("https://space.nyc3.digitaloceanspaces.com/?list-type=2"));
3092 }
3093
3094 #[test]
3095 fn build_s3_api_url_gcs_ignores_region_but_uses_list_type_2() {
3096 let (base, url) = api_url(super::Endpoint::GCS, "gbucket", None, None).unwrap();
3102 assert_eq!(base, "https://storage.googleapis.com/gbucket/");
3103 assert_eq!(
3104 url,
3105 "https://storage.googleapis.com/gbucket/?list-type=2&max-keys=1000"
3106 );
3107 assert!(
3108 url.contains("list-type=2"),
3109 "GCS listing must request list-type=2 so V2 NextContinuationToken pagination is used"
3110 );
3111 }
3112
3113 #[test]
3114 fn build_s3_api_url_generic_passes_endpoint_through() {
3115 let (base, url) = api_url(
3118 super::Endpoint::Generic("https://s3.example.com/bucket/".to_owned()),
3119 "ignored-bucket",
3120 None,
3121 None,
3122 )
3123 .unwrap();
3124 assert_eq!(base, "https://s3.example.com/bucket/");
3125 assert_eq!(
3126 url,
3127 "https://s3.example.com/bucket/?list-type=2&max-keys=1000"
3128 );
3129 }
3130
3131 #[test]
3132 fn build_s3_api_url_generic_normalizes_missing_trailing_slash() {
3133 let (base, url) = api_url(
3137 super::Endpoint::Generic("https://s3.example.com/bucket".to_owned()),
3138 "ignored-bucket",
3139 None,
3140 None,
3141 )
3142 .unwrap();
3143 assert_eq!(base, "https://s3.example.com/bucket/");
3144 assert_eq!(
3145 url,
3146 "https://s3.example.com/bucket/?list-type=2&max-keys=1000"
3147 );
3148 }
3149
3150 #[test]
3151 fn build_s3_api_url_appends_asset_prefix() {
3152 let (_base, with_prefix) = api_url(
3156 super::Endpoint::S3,
3157 "b",
3158 Some("us-east-1"),
3159 Some("releases/"),
3160 )
3161 .unwrap();
3162 assert!(
3163 with_prefix.ends_with("&prefix=releases%2F"),
3164 "prefix must be appended percent-encoded: {}",
3165 with_prefix
3166 );
3167 let (_base, no_prefix) =
3168 api_url(super::Endpoint::S3, "b", Some("us-east-1"), None).unwrap();
3169 assert!(
3170 !no_prefix.contains("prefix="),
3171 "no prefix segment when asset_prefix is None"
3172 );
3173 }
3174
3175 #[test]
3176 fn build_s3_api_url_missing_region_errors_for_region_endpoints() {
3177 for ep in [
3180 super::Endpoint::S3,
3181 super::Endpoint::S3DualStack,
3182 super::Endpoint::DigitalOceanSpaces,
3183 ] {
3184 let res = api_url(ep, "b", None, None);
3185 assert!(
3186 matches!(
3187 res,
3188 Err(crate::errors::Error::MissingField { field: "region" })
3189 ),
3190 "region-requiring endpoint without region must error with Error::MissingField"
3191 );
3192 }
3193 }
3194
3195 #[test]
3196 fn build_s3_api_url_generic_and_gcs_succeed_without_region() {
3197 assert!(api_url(super::Endpoint::GCS, "b", None, None).is_ok());
3200 assert!(
3201 api_url(
3202 super::Endpoint::Generic("https://s3.example.com/".to_owned()),
3203 "b",
3204 None,
3205 None
3206 )
3207 .is_ok()
3208 );
3209 }
3210
3211 #[test]
3215 fn build_errors_without_region_for_region_endpoints() {
3216 let res = Update::configure()
3217 .endpoint(super::Endpoint::S3)
3218 .bucket_name("bucket")
3219 .bin_name("bin")
3220 .current_version("0.1.0")
3221 .build();
3222 assert!(
3223 matches!(
3224 res,
3225 Err(crate::errors::Error::MissingField { field: "region" })
3226 ),
3227 "S3 endpoint without region must fail at build() with Error::MissingField"
3228 );
3229
3230 let list = super::ReleaseList::configure()
3231 .endpoint(super::Endpoint::DigitalOceanSpaces)
3232 .bucket_name("bucket")
3233 .build();
3234 assert!(
3235 matches!(
3236 list,
3237 Err(crate::errors::Error::MissingField { field: "region" })
3238 ),
3239 "ReleaseList build() must also enforce the region requirement"
3240 );
3241 }
3242
3243 #[cfg(feature = "async")]
3248 #[test]
3249 fn build_async_errors_without_region_for_region_endpoints() {
3250 let res = Update::configure()
3251 .endpoint(super::Endpoint::S3)
3252 .bucket_name("b")
3253 .bin_name("x")
3254 .current_version("0.1.0")
3255 .build_async();
3256 assert!(
3257 matches!(
3258 res,
3259 Err(crate::errors::Error::MissingField { field: "region" })
3260 ),
3261 "S3 endpoint without region must fail at build_async() with Error::MissingField, got {:?}",
3262 res.map(|_| "Ok")
3263 );
3264 }
3265
3266 #[cfg(feature = "async")]
3267 #[test]
3268 fn build_async_succeeds_without_region_for_generic_and_gcs() {
3269 assert!(
3270 Update::configure()
3271 .endpoint(super::Endpoint::GCS)
3272 .bucket_name("b")
3273 .bin_name("x")
3274 .current_version("0.1.0")
3275 .build_async()
3276 .is_ok(),
3277 "GCS endpoint needs no region at build_async()"
3278 );
3279 assert!(
3280 Update::configure()
3281 .endpoint(super::Endpoint::Generic(
3282 "https://s3.example.com/".to_owned()
3283 ))
3284 .bucket_name("b")
3285 .bin_name("x")
3286 .current_version("0.1.0")
3287 .build_async()
3288 .is_ok(),
3289 "Generic endpoint needs no region at build_async()"
3290 );
3291 }
3292
3293 #[test]
3294 fn build_succeeds_without_region_for_generic_and_gcs() {
3295 assert!(
3296 Update::configure()
3297 .endpoint(super::Endpoint::GCS)
3298 .bucket_name("bucket")
3299 .bin_name("bin")
3300 .current_version("0.1.0")
3301 .build()
3302 .is_ok()
3303 );
3304 assert!(
3305 Update::configure()
3306 .endpoint(super::Endpoint::Generic(
3307 "https://s3.example.com/".to_owned()
3308 ))
3309 .bucket_name("bucket")
3310 .bin_name("bin")
3311 .current_version("0.1.0")
3312 .build()
3313 .is_ok()
3314 );
3315 }
3316
3317 #[cfg(feature = "s3-auth")]
3323 #[test]
3324 fn s3_signature_v4_no_access_key_returns_url_unchanged() {
3325 let url = "https://b.s3.us-east-1.amazonaws.com/key?list-type=2";
3328 let out = super::auth::s3_signature_v4(url, &Some("us-east-1".into()), &None, 300).unwrap();
3329 assert_eq!(out, url, "missing access key must return the URL unchanged");
3330 }
3331
3332 #[cfg(feature = "s3-auth")]
3333 #[test]
3334 fn s3_signature_v4_signed_url_has_required_query_params() {
3335 let url = "https://b.s3.us-east-1.amazonaws.com/path/to/key";
3339 let key: super::AccessKey = ("AKIAEXAMPLE", "secretkey").into();
3340 let out =
3341 super::auth::s3_signature_v4(url, &Some("us-east-1".into()), &Some(key), 300).unwrap();
3342 assert!(out.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
3343 assert!(out.contains("X-Amz-Credential="));
3344 assert!(out.contains("X-Amz-Date="));
3345 assert!(out.contains("X-Amz-Expires=300"));
3346 assert!(out.contains("X-Amz-SignedHeaders=host"));
3347 assert!(out.contains("X-Amz-Signature="));
3348 let sig = out
3350 .rsplit("X-Amz-Signature=")
3351 .next()
3352 .expect("signature param present");
3353 assert_eq!(sig.len(), 64, "SigV4 signature is 32 bytes hex-encoded");
3354 assert!(
3355 sig.bytes().all(|b| b.is_ascii_hexdigit()),
3356 "signature must be lowercase hex"
3357 );
3358 assert!(
3362 out.contains("%2Fus-east-1%2Fs3%2Faws4_request"),
3363 "credential scope must embed region and service scope: {}",
3364 out
3365 );
3366 assert!(out.starts_with("https://b.s3.us-east-1.amazonaws.com/path/to/key?"));
3368 }
3369
3370 #[test]
3380 fn regex_build_error_maps_to_invalid_response_with_source() {
3381 use std::error::Error as _;
3382 let bad_pattern = String::from("(");
3386 let err =
3387 super::Regex::new(&bad_pattern).expect_err("an unbalanced group must fail to compile");
3388 let inner_shown = err.to_string();
3389 let mapped = crate::errors::Error::InvalidResponse {
3390 source: Box::new(err),
3391 };
3392 assert!(
3393 matches!(mapped, crate::errors::Error::InvalidResponse { .. }),
3394 "a regex build failure must route to Error::InvalidResponse, got {:?}",
3395 mapped
3396 );
3397 let chained = mapped
3398 .source()
3399 .expect("InvalidResponse from a regex error must chain a source()");
3400 assert!(
3401 chained.to_string().contains(&inner_shown),
3402 "source() must surface the underlying regex error, got: {}",
3403 chained
3404 );
3405 }
3406
3407 #[cfg(feature = "s3-auth")]
3412 #[test]
3413 fn s3_signature_v4_hostless_url_routes_to_s3auth() {
3414 let key: super::AccessKey = ("AKIA", "secret").into();
3415 let res = super::auth::s3_signature_v4("mailto:nobody@example.com", &None, &Some(key), 300);
3418 match res {
3419 Err(crate::errors::Error::S3Auth(source)) => {
3420 assert!(
3421 source.to_string().contains("mailto:nobody@example.com"),
3422 "S3Auth source must embed the offending URL, got: {}",
3423 source
3424 );
3425 }
3426 other => panic!(
3427 "a hostless signed URL must route to Error::S3Auth, got {:?}",
3428 other
3429 ),
3430 }
3431 }
3432
3433 #[cfg(feature = "s3-auth")]
3434 #[test]
3435 fn s3_signature_v4_defaults_region_to_us_east_1() {
3436 let key: super::AccessKey = ("AKIA", "secret").into();
3438 let out =
3439 super::auth::s3_signature_v4("https://b.s3.amazonaws.com/k", &None, &Some(key), 300)
3440 .unwrap();
3441 assert!(
3442 out.contains("%2Fus-east-1%2Fs3%2Faws4_request"),
3443 "absent region must default to us-east-1: {}",
3444 out
3445 );
3446 }
3447
3448 #[cfg(feature = "s3-auth")]
3451 #[test]
3452 fn signature_ttl_appears_as_the_expiry_in_signed_urls() {
3453 let key: super::AccessKey = ("AKIA", "secret").into();
3457 let region = Some("us-east-1".to_owned());
3458 let (_base, signed) = super::build_s3_api_url(
3459 &super::Endpoint::S3,
3460 "b",
3461 ®ion,
3462 &None,
3463 super::DEFAULT_MAX_KEYS,
3464 None,
3465 Duration::from_secs(7200),
3466 &Some(key),
3467 )
3468 .unwrap();
3469 assert!(
3470 signed.contains("X-Amz-Expires=7200"),
3471 "the configured signature_ttl must appear as X-Amz-Expires, got: {}",
3472 signed
3473 );
3474 assert!(
3475 !signed.contains("X-Amz-Expires=300"),
3476 "the default 300s must be overridden by the configured TTL"
3477 );
3478 }
3479
3480 #[cfg(feature = "s3-auth")]
3481 #[test]
3482 fn signature_ttl_setter_threads_into_the_built_update() {
3483 let upd = Update::configure()
3488 .endpoint(super::Endpoint::S3)
3489 .bucket_name("b")
3490 .region("us-east-1")
3491 .bin_name("myapp")
3492 .current_version("0.1.0")
3493 .access_key(("AKIA", "secret"))
3494 .signature_ttl(Duration::from_secs(600))
3495 .build_update()
3496 .unwrap();
3497 let plan = upd.listing_plan().unwrap();
3498 assert!(
3499 plan.url.contains("X-Amz-Expires=600"),
3500 "the configured signature_ttl must thread into the signed listing URL, got: {}",
3501 plan.url
3502 );
3503 }
3504
3505 #[cfg(feature = "s3-auth")]
3506 #[test]
3507 fn build_s3_api_url_signs_listing_url_when_access_key_present() {
3508 let key: super::AccessKey = ("AKIA", "secret").into();
3512 let region = Some("us-east-1".to_owned());
3513 let ttl = Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS);
3514 let (_base, signed) = super::build_s3_api_url(
3515 &super::Endpoint::S3,
3516 "b",
3517 ®ion,
3518 &None,
3519 super::DEFAULT_MAX_KEYS,
3520 None,
3521 ttl,
3522 &Some(key),
3523 )
3524 .unwrap();
3525 assert!(signed.contains("X-Amz-Signature="));
3526 assert!(signed.contains("X-Amz-Credential="));
3527 assert!(
3528 signed.contains("list-type=2"),
3529 "the original listing query must survive signing"
3530 );
3531
3532 let (_base, unsigned) = super::build_s3_api_url(
3533 &super::Endpoint::S3,
3534 "b",
3535 ®ion,
3536 &None,
3537 super::DEFAULT_MAX_KEYS,
3538 None,
3539 ttl,
3540 &None,
3541 )
3542 .unwrap();
3543 assert!(
3544 !unsigned.contains("X-Amz-Signature="),
3545 "no access key => unsigned listing url"
3546 );
3547 }
3548
3549 #[cfg(feature = "s3-auth")]
3550 #[test]
3551 fn parse_s3_response_signs_download_urls_when_access_key_present() {
3552 let xml = list_bucket_xml(&["myapp-1.2.3-x86_64-linux"]);
3556 let region = Some("us-east-1".to_owned());
3557
3558 let key: super::AccessKey = ("AKIA", "secret").into();
3559 let signed = parse_s3_response(
3560 xml.as_bytes(),
3561 "https://b.s3.us-east-1.amazonaws.com/",
3562 ®ion,
3563 &Some(key),
3564 )
3565 .unwrap();
3566 let signed_url = signed[0].assets[0].download_url();
3567 assert!(
3568 signed_url.contains("X-Amz-Signature=") && signed_url.contains("X-Amz-Credential="),
3569 "signed asset download url must carry SigV4 params: {}",
3570 signed_url
3571 );
3572
3573 let unsigned = parse_s3_response(
3574 xml.as_bytes(),
3575 "https://b.s3.us-east-1.amazonaws.com/",
3576 ®ion,
3577 &None,
3578 )
3579 .unwrap();
3580 let unsigned_url = unsigned[0].assets[0].download_url();
3581 assert!(
3582 !unsigned_url.contains("X-Amz-Signature="),
3583 "no access key => unsigned asset download url: {}",
3584 unsigned_url
3585 );
3586 assert_eq!(
3587 unsigned_url, "https://b.s3.us-east-1.amazonaws.com/myapp-1.2.3-x86_64-linux",
3588 "unsigned download url is the plain base+key"
3589 );
3590 }
3591
3592 #[test]
3593 fn api_headers_uses_the_trait_default_no_override() {
3594 let upd = configured();
3598 let headers = upd.api_headers(Some("secret")).unwrap();
3599 assert!(
3600 headers
3601 .get(crate::http_client::header::USER_AGENT)
3602 .is_none(),
3603 "the default api_headers (no override) must not set a User-Agent"
3604 );
3605 assert!(
3606 headers
3607 .get(crate::http_client::header::AUTHORIZATION)
3608 .is_none(),
3609 "the default api_headers is a no-op; s3 signs the URL instead of setting an auth header"
3610 );
3611 }
3612
3613 #[test]
3622 fn endpoint_from_str_and_string_resolve_to_generic() {
3623 let from_str: super::Endpoint = "https://minio.example.com/bucket/".into();
3625 assert!(
3626 matches!(&from_str, super::Endpoint::Generic(u) if u == "https://minio.example.com/bucket/"),
3627 "a &str must resolve to Endpoint::Generic with the URL verbatim, got {from_str:?}"
3628 );
3629 let owned = String::from("https://gcs.example.com/bucket/");
3631 let from_string: super::Endpoint = owned.into();
3632 assert!(
3633 matches!(&from_string, super::Endpoint::Generic(u) if u == "https://gcs.example.com/bucket/"),
3634 "a String must resolve to Endpoint::Generic with the URL verbatim, got {from_string:?}"
3635 );
3636 }
3637
3638 #[test]
3643 fn endpoint_setter_accepts_a_bare_str() {
3644 let upd = super::Update::configure()
3645 .bucket_name("b")
3646 .asset_prefix("p")
3647 .endpoint("https://generic.example.com/bucket/")
3648 .bin_name("app")
3649 .current_version("0.1.0")
3650 .build();
3651 assert!(
3654 upd.is_ok(),
3655 "the endpoint(&str) setter must resolve to a Generic endpoint and build, got {:?}",
3656 upd.err()
3657 );
3658 }
3659}